@mcpher/gas-fakes 1.2.25 → 1.2.26

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.RU.md CHANGED
@@ -348,6 +348,9 @@ const getParentsIterator = ({
348
348
  - [gemini](gemini-observations.md) - some reflections and experiences on using gemini to help code large projects
349
349
  - [named colors](named-colors.md)
350
350
  - [sandbox](sandbox.md)
351
+ - [using apps script libraries with gas-fakes](libraries.md)
352
+ - [how libhandler works](libhandler.md)
353
+ - [article:using apps script libraries with gas-fakes](https://ramblings.mcpher.com/how-to-use-apps-script-libraries-directly-from-node/)
351
354
  - [named range identity](named-range-identity.md)
352
355
  - [adc and restricted scopes](https://ramblings.mcpher.com/how-to-allow-access-to-sensitive-scopes-with-application-default-credentials/)
353
356
  - [push test pull](pull-test-push.md)
package/README.md CHANGED
@@ -167,6 +167,9 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
167
167
  - [gemini](gemini-observations.md) - some reflections and experiences on using gemini to help code large projects
168
168
  - [named colors](named-colors.md)
169
169
  - [sandbox](sandbox.md)
170
+ - [using apps script libraries with gas-fakes](libraries.md)
171
+ - [how libhandler works](libhandler.md)
172
+ - [article:using apps script libraries with gas-fakes](https://ramblings.mcpher.com/how-to-use-apps-script-libraries-directly-from-node/)
170
173
  - [named range identity](named-range-identity.md)
171
174
  - [adc and restricted scopes](https://ramblings.mcpher.com/how-to-allow-access-to-sensitive-scopes-with-application-default-credentials/)
172
175
  - [push test pull](pull-test-push.md)
package/package.json CHANGED
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "name": "@mcpher/gas-fakes",
36
36
  "author": "bruce mcpherson",
37
- "version": "1.2.25",
37
+ "version": "1.2.26",
38
38
  "license": "MIT",
39
39
  "main": "main.js",
40
40
  "description": "A proof of concept implementation of Apps Script Environment on Node",
package/src/cli/app.js CHANGED
@@ -138,9 +138,24 @@ export async function main() {
138
138
  .command("enableAPIs")
139
139
  .description("Enables or disables required Google Cloud APIs.")
140
140
  .option("--all", "Enable all default APIs.")
141
+ // Drive
141
142
  .option("--edrive", "Enable drive.googleapis.com")
142
143
  .option("--ddrive", "Disable drive.googleapis.com")
143
- // ... (other flags implied by setup.js logic)
144
+ // Sheets
145
+ .option("--esheets", "Enable sheets.googleapis.com")
146
+ .option("--dsheets", "Disable sheets.googleapis.com")
147
+ // Forms
148
+ .option("--eforms", "Enable forms.googleapis.com")
149
+ .option("--dforms", "Disable forms.googleapis.com")
150
+ // Docs
151
+ .option("--edocs", "Enable docs.googleapis.com")
152
+ .option("--ddocs", "Disable docs.googleapis.com")
153
+ // Gmail
154
+ .option("--egmail", "Enable gmail.googleapis.com")
155
+ .option("--dgmail", "Disable gmail.googleapis.com")
156
+ // Logging
157
+ .option("--elogging", "Enable logging.googleapis.com")
158
+ .option("--dlogging", "Disable logging.googleapis.com")
144
159
  .action(enableGoogleAPIs);
145
160
 
146
161
  // --- MCP Command ---
@@ -43,6 +43,29 @@ function generateItemWhitelistScript(items) {
43
43
  return `behavior.setIdWhitelist([${whitelistItemsString}]);`;
44
44
  }
45
45
 
46
+ function generateGmailSandbox(gmailSandbox) {
47
+ if (!gmailSandbox) return [];
48
+ const { emailWhitelist, usageLimit, labelWhitelist, cleanup } = gmailSandbox;
49
+ const temp = ["const gmailSettings = behavior.sandboxService.GmailApp;"];
50
+ if (emailWhitelist && emailWhitelist.length > 0) {
51
+ temp.push(
52
+ `gmailSettings.emailWhitelist = ${JSON.stringify(emailWhitelist)};`
53
+ );
54
+ }
55
+ if (gmailSandbox.hasOwnProperty("cleanup")) {
56
+ temp.push(`gmailSettings.cleanup = ${cleanup};`);
57
+ }
58
+ if (usageLimit) {
59
+ temp.push(`gmailSettings.usageLimit = ${usageLimit};`);
60
+ }
61
+ if (labelWhitelist && labelWhitelist.length > 0) {
62
+ temp.push(
63
+ `gmailSettings.labelWhitelist = ${JSON.stringify(labelWhitelist)};`
64
+ );
65
+ }
66
+ return temp;
67
+ }
68
+
46
69
  function generateSandboxSetupScript(sandboxConfig) {
47
70
  const script = [
48
71
  "const behavior = ScriptApp.__behavior;",
@@ -50,11 +73,12 @@ function generateSandboxSetupScript(sandboxConfig) {
50
73
  "behavior.strictSandbox = true;",
51
74
  ];
52
75
 
53
- const { whitelistServices, blacklistServices, whitelistItems } =
76
+ const { whitelistServices, blacklistServices, whitelistItems, gmailSandbox } =
54
77
  sandboxConfig;
55
78
 
56
79
  script.push(...generateServiceWhitelistScript(whitelistServices));
57
80
  script.push(...generateServiceBlacklistScript(blacklistServices));
81
+ script.push(...generateGmailSandbox(gmailSandbox));
58
82
 
59
83
  const itemWhitelist = generateItemWhitelistScript(whitelistItems);
60
84
  if (itemWhitelist) {
package/src/cli/mcp.js CHANGED
@@ -157,7 +157,10 @@ function registerDefaultTool(server) {
157
157
  if (!args.filename || !args.tools) {
158
158
  return {
159
159
  content: [
160
- { type: "text", text: "Error: `filename` and `tools` are required." },
160
+ {
161
+ type: "text",
162
+ text: "Error: `filename` and `tools` are required.",
163
+ },
161
164
  ],
162
165
  isError: true,
163
166
  };
@@ -166,11 +169,9 @@ function registerDefaultTool(server) {
166
169
  const tool_ar = [];
167
170
  for (let i = 0; i < args.tools.length; i++) {
168
171
  const { name, schema, gas_script, libraries } = args.tools[i];
169
- // Note: This generation logic is complex; assuming gas_library is empty for generation context,
170
- // or simply rendering the array strings.
171
172
  tool_ar.push(
172
173
  `{ name: "${name}", schema: ${schema}, func: (object = {}) => { \n\n${gas_script} }, libraries: ${JSON.stringify(
173
- libraries
174
+ libraries || []
174
175
  )} }`
175
176
  );
176
177
  }
package/src/cli/setup.js CHANGED
@@ -2,17 +2,26 @@ import prompts from "prompts";
2
2
  import dotenv from "dotenv";
3
3
  import fs from "fs";
4
4
  import path from "path";
5
+ import os from "os";
5
6
  import { execSync } from "child_process";
6
7
  import { checkForGcloudCli, runCommandSync } from "./utils.js";
7
8
 
8
- // --- Utility: Search .env ---
9
+ // --- Utility Functions ---
10
+
11
+ /**
12
+ * Recursively searches for .env files starting from a directory.
13
+ * @param {string} dir - Start directory
14
+ * @returns {Promise<string[]>} List of found .env file paths
15
+ */
9
16
  async function findEnvFiles(dir) {
10
17
  try {
11
18
  const entries = await fs.promises.readdir(dir, { withFileTypes: true });
12
19
  const promises = entries.map((entry) => {
13
20
  const fullPath = path.join(dir, entry.name);
14
21
  if (entry.isDirectory()) {
15
- if (entry.name === "node_modules") return Promise.resolve([]);
22
+ if (entry.name === "node_modules") {
23
+ return Promise.resolve([]);
24
+ }
16
25
  return findEnvFiles(fullPath);
17
26
  } else if (entry.isFile() && entry.name === ".env") {
18
27
  return Promise.resolve(fullPath);
@@ -27,7 +36,7 @@ async function findEnvFiles(dir) {
27
36
  }
28
37
  }
29
38
 
30
- // --- Commands ---
39
+ // --- Exported Command Implementations ---
31
40
 
32
41
  export async function initializeConfiguration(options = {}) {
33
42
  let envPath;
@@ -38,7 +47,10 @@ export async function initializeConfiguration(options = {}) {
38
47
  } else {
39
48
  const foundFiles = await findEnvFiles(process.cwd());
40
49
  if (foundFiles.length > 0) {
41
- const choices = foundFiles.map((file) => ({ title: file, value: file }));
50
+ const choices = foundFiles.map((file) => ({
51
+ title: file,
52
+ value: file,
53
+ }));
42
54
  choices.push({
43
55
  title: "Create a new .env file in the current directory",
44
56
  value: "new",
@@ -56,10 +68,11 @@ export async function initializeConfiguration(options = {}) {
56
68
  return;
57
69
  }
58
70
 
59
- envPath =
60
- response.envPathSelection === "new"
61
- ? path.join(process.cwd(), ".env")
62
- : response.envPathSelection;
71
+ if (response.envPathSelection === "new") {
72
+ envPath = path.join(process.cwd(), ".env");
73
+ } else {
74
+ envPath = response.envPathSelection;
75
+ }
63
76
  } else {
64
77
  console.log(
65
78
  "No .env file found. A new one will be created in the current directory."
@@ -79,15 +92,18 @@ export async function initializeConfiguration(options = {}) {
79
92
 
80
93
  console.log("--------------------------------------------------");
81
94
  console.log("Configuring .env for gas-fakes");
95
+ console.log("Press Enter to accept the default value in brackets.");
96
+ console.log("Use Space to select/deselect scopes.");
82
97
  console.log("--------------------------------------------------");
83
98
 
84
99
  const existingExtraScopes = existingConfig.EXTRA_SCOPES
85
100
  ? existingConfig.EXTRA_SCOPES.split(",").filter((s) => s)
86
101
  : [];
102
+
87
103
  const responses = {};
88
104
 
89
- // Stage 1: Basic Info
90
- const basicInfoResponses = await prompts([
105
+ // --- Stage 1: Basic Info ---
106
+ const basicInfoQuestions = [
91
107
  {
92
108
  type: "text",
93
109
  name: "GCP_PROJECT_ID",
@@ -102,17 +118,26 @@ export async function initializeConfiguration(options = {}) {
102
118
  "Enter a test Drive file ID for authentication checks (optional)",
103
119
  initial: existingConfig.DRIVE_TEST_FILE_ID || "",
104
120
  },
105
- ]);
121
+ ];
106
122
 
107
- if (typeof basicInfoResponses.GCP_PROJECT_ID === "undefined") return;
123
+ const basicInfoResponses = await prompts(basicInfoQuestions);
124
+ if (typeof basicInfoResponses.GCP_PROJECT_ID === "undefined") {
125
+ console.log("Initialization cancelled.");
126
+ return;
127
+ }
108
128
  Object.assign(responses, basicInfoResponses);
109
129
 
110
- // Stage 2: Scopes
130
+ // --- Stage 2: Scopes ---
131
+
111
132
  const DEFAULT_SCOPES_VALUES = [
112
133
  "https://www.googleapis.com/auth/userinfo.email",
113
134
  "openid",
114
135
  "https://www.googleapis.com/auth/cloud-platform",
115
136
  ];
137
+ console.log(
138
+ "\nThe following default scopes are required for basic operations and will be enabled automatically:"
139
+ );
140
+ DEFAULT_SCOPES_VALUES.forEach((scope) => console.log(` - ${scope}`));
116
141
  responses.DEFAULT_SCOPES = DEFAULT_SCOPES_VALUES;
117
142
 
118
143
  const extraScopeQuestion = {
@@ -124,7 +149,30 @@ export async function initializeConfiguration(options = {}) {
124
149
  title: "Workspace resources",
125
150
  value: "https://www.googleapis.com/auth/drive",
126
151
  },
152
+ /*
153
+ {
154
+ title: "Sheets (full access)",
155
+ value: "https://www.googleapis.com/auth/spreadsheets",
156
+ },
157
+ {
158
+ title: "Docs (full access)",
159
+ value: "https://www.googleapis.com/auth/documents",
160
+ },
161
+ {
162
+ title: "Forms (full access)",
163
+ value: "https://www.googleapis.com/auth/forms",
164
+ },
165
+ {
166
+ title: "Gmail (send mail)",
167
+ value: "https://www.googleapis.com/auth/gmail.send",
168
+ },
169
+ {
170
+ title: "Gmail (full access)",
171
+ value: "https://www.googleapis.com/auth/gmail.modify",
172
+ },
173
+ */
127
174
  {
175
+ // actually labels are not sensitive
128
176
  title: "Gmail labels",
129
177
  value: "https://www.googleapis.com/auth/gmail.labels",
130
178
  },
@@ -133,49 +181,130 @@ export async function initializeConfiguration(options = {}) {
133
181
  title: "Gmail compose",
134
182
  value: "https://www.googleapis.com/auth/gmail.compose",
135
183
  },
184
+ {
185
+ sensitivity: "sensitive",
186
+ title: "Gmail modify",
187
+ value: "https://www.googleapis.com/auth/gmail.modify",
188
+ },
189
+ {
190
+ sensitivity: "sensitive",
191
+ title: "Gmail send",
192
+ value: "https://www.googleapis.com/auth/gmail.send",
193
+ },
136
194
  ].map((scope) => ({
137
195
  ...scope,
138
196
  title: scope.sensitivity
139
197
  ? `[${scope.sensitivity}] ${scope.title}`
140
198
  : scope.title,
199
+ // because we always need drive for ant extra scopes
141
200
  selected:
142
201
  existingExtraScopes.length > 0
143
202
  ? existingExtraScopes.includes(scope.value)
144
203
  : scope.value === "https://www.googleapis.com/auth/drive",
145
204
  })),
205
+ hint: "- Use space to select/deselect. Press Enter to submit.",
146
206
  };
147
207
 
208
+ // Check for any kind of sensitivity
209
+ const sensitiveScopesList = extraScopeQuestion.choices.filter(
210
+ (scope) => scope.sensitivity
211
+ );
212
+
148
213
  const extraScopeResponses = await prompts(extraScopeQuestion);
149
- if (typeof extraScopeResponses.EXTRA_SCOPES === "undefined") return;
214
+
215
+ if (typeof extraScopeResponses.EXTRA_SCOPES === "undefined") {
216
+ console.log("Initialization cancelled.");
217
+ return;
218
+ }
150
219
  Object.assign(responses, extraScopeResponses);
151
220
 
152
- const clientCredentialResponse = await prompts({
221
+ const selectedExtraScopes = responses.EXTRA_SCOPES || [];
222
+
223
+ const usesSensitiveScopes = sensitiveScopesList.some((s) =>
224
+ selectedExtraScopes.includes(s.value)
225
+ );
226
+
227
+ if (usesSensitiveScopes) {
228
+ console.log("\n--------------------------------------------------");
229
+ console.log(
230
+ "You have selected sensitive or restricted scopes. Google requires an OAuth client credential file for these."
231
+ );
232
+ console.log(
233
+ "See the getting started guide https://github.com/brucemcpherson/gas-fakes/blob/main/GETTING_STARTED.md for how."
234
+ );
235
+ console.log("--------------------------------------------------");
236
+ }
237
+
238
+ const clientCredentialQuestion = {
153
239
  type: "text",
154
240
  name: "CLIENT_CREDENTIAL_FILE",
155
- message: "Enter path to OAuth client credentials JSON (optional)",
241
+ message: usesSensitiveScopes
242
+ ? "Enter the path and filename for your OAuth client credentials JSON"
243
+ : "Enter path to OAuth client credentials JSON (optional)",
156
244
  initial: existingConfig.CLIENT_CREDENTIAL_FILE || "",
157
- });
245
+ validate: (input) => {
246
+ const trimmedInput = input.trim();
247
+
248
+ if (usesSensitiveScopes) {
249
+ if (trimmedInput === "") {
250
+ return "This field is required for the selected sensitive scopes.";
251
+ }
252
+ } else {
253
+ if (trimmedInput === "") {
254
+ return true;
255
+ }
256
+ }
257
+
258
+ const resolvedPath = path.resolve(process.cwd(), trimmedInput);
259
+ if (!fs.existsSync(resolvedPath)) {
260
+ return `File not found at '${resolvedPath}'. Please check the path and try again.`;
261
+ }
262
+
263
+ return true;
264
+ },
265
+ };
266
+
267
+ const clientCredentialResponse = await prompts(clientCredentialQuestion);
268
+ if (typeof clientCredentialResponse.CLIENT_CREDENTIAL_FILE === "undefined") {
269
+ console.log("Initialization cancelled.");
270
+ return;
271
+ }
158
272
  Object.assign(responses, clientCredentialResponse);
159
273
 
160
- // Stage 3: Remaining Config
161
- const remainingResponses = await prompts([
274
+ // --- Stage 3: Remaining Config ---
275
+ const defaultScopesDisplay = `\n - Default: [${responses.DEFAULT_SCOPES.join(
276
+ ", "
277
+ )}]`;
278
+ const extraScopesDisplay =
279
+ responses.EXTRA_SCOPES && responses.EXTRA_SCOPES.length > 0
280
+ ? `\n - Extra: [${responses.EXTRA_SCOPES.join(", ")}]`
281
+ : "\n - Extra: [None]";
282
+
283
+ const remainingQuestions = [
162
284
  {
163
285
  type: "toggle",
164
286
  name: "QUIET",
165
287
  message: "Run gas-fakes package in quiet mode",
166
- initial: existingConfig.QUIET === "true",
288
+ initial: existingConfig.QUIET === "true" ? true : false,
167
289
  },
168
290
  {
169
291
  type: "select",
170
292
  name: "LOG_DESTINATION",
171
- message: "Enter logging destination",
293
+ message: `Selected Scopes:${defaultScopesDisplay}${extraScopesDisplay}\n\nEnter logging destination`,
172
294
  choices: [
173
295
  { title: "CONSOLE", value: "CONSOLE" },
174
296
  { title: "CLOUD", value: "CLOUD" },
175
297
  { title: "BOTH", value: "BOTH" },
176
298
  { title: "NONE", value: "NONE" },
177
299
  ],
178
- initial: 0,
300
+ initial:
301
+ ["CONSOLE", "CLOUD", "BOTH", "NONE"].indexOf(
302
+ existingConfig.LOG_DESTINATION
303
+ ) > -1
304
+ ? ["CONSOLE", "CLOUD", "BOTH", "NONE"].indexOf(
305
+ existingConfig.LOG_DESTINATION
306
+ )
307
+ : 0,
179
308
  },
180
309
  {
181
310
  type: "select",
@@ -185,18 +314,31 @@ export async function initializeConfiguration(options = {}) {
185
314
  { title: "FILE", value: "FILE" },
186
315
  { title: "UPSTASH", value: "UPSTASH" },
187
316
  ],
188
- initial: 0,
317
+ initial:
318
+ ["FILE", "UPSTASH"].indexOf(existingConfig.STORE_TYPE?.toUpperCase()) >
319
+ -1
320
+ ? ["FILE", "UPSTASH"].indexOf(existingConfig.STORE_TYPE.toUpperCase())
321
+ : 0,
189
322
  },
190
- ]);
323
+ ];
324
+
325
+ const remainingResponses = await prompts(remainingQuestions);
326
+ if (typeof remainingResponses.LOG_DESTINATION === "undefined") {
327
+ console.log("Initialization cancelled.");
328
+ return;
329
+ }
191
330
  Object.assign(responses, remainingResponses);
192
331
 
193
- if (Array.isArray(responses.DEFAULT_SCOPES))
332
+ // Convert scope arrays to comma-separated strings for saving
333
+ if (Array.isArray(responses.DEFAULT_SCOPES)) {
194
334
  responses.DEFAULT_SCOPES = responses.DEFAULT_SCOPES.join(",");
195
- if (Array.isArray(responses.EXTRA_SCOPES))
335
+ }
336
+ if (Array.isArray(responses.EXTRA_SCOPES)) {
196
337
  responses.EXTRA_SCOPES = responses.EXTRA_SCOPES.join(",");
338
+ }
197
339
 
198
340
  if (responses.STORE_TYPE === "UPSTASH") {
199
- const upstashResponses = await prompts([
341
+ const upstashQuestions = [
200
342
  {
201
343
  type: "text",
202
344
  name: "UPSTASH_REDIS_REST_URL",
@@ -209,10 +351,23 @@ export async function initializeConfiguration(options = {}) {
209
351
  message: "Enter your Upstash Redis REST Token",
210
352
  initial: existingConfig.UPSTASH_REDIS_REST_TOKEN || "",
211
353
  },
212
- ]);
354
+ ];
355
+ const upstashResponses = await prompts(upstashQuestions);
356
+
357
+ if (typeof upstashResponses.UPSTASH_REDIS_REST_URL === "undefined") {
358
+ console.log("Initialization cancelled during Upstash configuration.");
359
+ return;
360
+ }
213
361
  Object.assign(responses, upstashResponses);
214
362
  }
215
363
 
364
+ // --- Confirmation Step ---
365
+ console.log("\n------------------ Summary ------------------");
366
+ Object.entries(responses).forEach(([key, value]) => {
367
+ if (value !== undefined) console.log(`${key}: ${value}`);
368
+ });
369
+ console.log("-------------------------------------------");
370
+
216
371
  const confirmSave = await prompts({
217
372
  type: "confirm",
218
373
  name: "save",
@@ -221,33 +376,51 @@ export async function initializeConfiguration(options = {}) {
221
376
  });
222
377
 
223
378
  if (!confirmSave.save) {
224
- console.log("Configuration discarded.");
379
+ console.log("Configuration discarded. No changes were made.");
225
380
  return;
226
381
  }
227
382
 
383
+ // --- File Writing Logic ---
384
+ console.log(`Writing configuration to "${envPath}"...`);
228
385
  const inits =
229
386
  responses.STORE_TYPE !== "UPSTASH"
230
387
  ? { UPSTASH_REDIS_REST_TOKEN: "", UPSTASH_REDIS_REST_URL: "" }
231
388
  : {};
232
389
  const finalConfig = { ...existingConfig, ...responses, ...inits };
233
390
 
391
+ console.log("\n------------------ Final output ------------------");
234
392
  const envContent = Reflect.ownKeys(finalConfig)
235
- .map((key) => `${key}="${(finalConfig[key] || "").toString().trim()}"`)
393
+ .map((key) => {
394
+ const item = finalConfig[key];
395
+ const res = `${key}="${(item.toString() || "").trim()}"`;
396
+ console.log(res);
397
+ return res;
398
+ })
236
399
  .join("\n");
237
400
 
238
401
  fs.writeFileSync(envPath, envContent + "\n", "utf8");
402
+
403
+ console.log("--------------------------------------------------");
239
404
  console.log("Setup complete. Your .env file has been updated.");
405
+ console.log("--------------------------------------------------");
240
406
  }
241
407
 
408
+ /**
409
+ * Handles the 'auth' command to authenticate with Google Cloud.
410
+ */
242
411
  export function authenticateUser() {
412
+ // First, check if gcloud CLI is available.
243
413
  checkForGcloudCli();
244
- const envPath = path.join(process.cwd(), ".env");
414
+
415
+ const rootDirectory = process.cwd();
416
+ const envPath = path.join(rootDirectory, ".env");
245
417
 
246
418
  if (!fs.existsSync(envPath)) {
247
419
  console.error(`Error: .env file not found at '${envPath}'`);
248
420
  console.error("Please run './gas-fakes.js init' first.");
249
421
  process.exit(1);
250
422
  }
423
+
251
424
  dotenv.config({ path: envPath, quiet: true });
252
425
 
253
426
  const {
@@ -263,42 +436,149 @@ export function authenticateUser() {
263
436
  process.exit(1);
264
437
  }
265
438
 
266
- let scopes =
439
+ const defaultScopes =
267
440
  DEFAULT_SCOPES ||
268
441
  "https://www.googleapis.com/auth/userinfo.email,openid,https://www.googleapis.com/auth/cloud-platform";
269
- if (EXTRA_SCOPES && EXTRA_SCOPES.length > 0) {
270
- scopes += "," + EXTRA_SCOPES;
442
+ const extraScopes =
443
+ EXTRA_SCOPES ||
444
+ "https://www.googleapis.com/auth/drive,https://www.googleapis.com/auth/spreadsheets";
445
+
446
+ let scopes = defaultScopes;
447
+ if (extraScopes && extraScopes.length > 0) {
448
+ scopes += (extraScopes.startsWith(",") ? "" : ",") + extraScopes;
271
449
  }
272
450
 
451
+ const driveAccessFlag = "--enable-gdrive-access";
452
+
453
+ console.log(`...requesting scopes ${scopes}`);
454
+
273
455
  let clientFlag = "";
274
- if (CLIENT_CREDENTIAL_FILE && fs.existsSync(CLIENT_CREDENTIAL_FILE)) {
275
- clientFlag = `--client-id-file="${CLIENT_CREDENTIAL_FILE}"`;
456
+ if (CLIENT_CREDENTIAL_FILE) {
457
+ console.log("...attempting to use enhanced client credentials");
458
+
459
+ let clientPath = CLIENT_CREDENTIAL_FILE;
460
+ if (!path.isAbsolute(clientPath)) {
461
+ clientPath = path.join(rootDirectory, clientPath);
462
+ }
463
+
464
+ if (fs.existsSync(clientPath)) {
465
+ clientFlag = `--client-id-file="${clientPath}"`;
466
+ } else {
467
+ console.error(
468
+ `Error: Client credential file specified in .env not found at '${clientPath}'`
469
+ );
470
+ process.exit(1);
471
+ }
472
+ } else {
473
+ console.log(
474
+ "\n...CLIENT_CREDENTIAL_FILE is not set. Using default Application Default Credentials (ADC)."
475
+ );
476
+ console.log(
477
+ "...if you have requested any sensitive scopes, you'll see 'This app is blocked message.'"
478
+ );
479
+ console.log(
480
+ "...To allow them see - https://github.com/brucemcpherson/gas-fakes/blob/main/GETTING_STARTED.md\n"
481
+ );
276
482
  }
277
483
 
484
+ const projectId = GCP_PROJECT_ID;
278
485
  const activeConfig = AC || "default";
279
486
 
487
+ console.log("Revoking previous credentials...");
280
488
  try {
281
489
  execSync("gcloud auth revoke --quiet", { stdio: "ignore" });
282
- } catch (e) {}
283
-
490
+ } catch (e) {
491
+ /* ignore */
492
+ }
284
493
  try {
285
- runCommandSync(`gcloud config configurations activate "${activeConfig}"`);
494
+ execSync("gcloud auth application-default revoke --quiet", {
495
+ stdio: "ignore",
496
+ });
286
497
  } catch (e) {
498
+ /* ignore */
499
+ }
500
+
501
+ console.log(`Ensuring gcloud configuration '${activeConfig}' exists...`);
502
+ try {
503
+ execSync(`gcloud config configurations describe "${activeConfig}"`, {
504
+ stdio: "ignore",
505
+ });
506
+ console.log(`Configuration '${activeConfig}' already exists.`);
507
+ } catch (error) {
508
+ console.log(`Configuration '${activeConfig}' not found. Creating it...`);
287
509
  runCommandSync(`gcloud config configurations create "${activeConfig}"`);
288
- runCommandSync(`gcloud config configurations activate "${activeConfig}"`);
289
510
  }
290
511
 
291
- runCommandSync(`gcloud config set project ${GCP_PROJECT_ID}`);
292
- runCommandSync(`gcloud auth login --enable-gdrive-access`);
512
+ console.log(`Activating gcloud configuration: ${activeConfig}`);
513
+ runCommandSync(`gcloud config configurations activate "${activeConfig}"`);
514
+
515
+ console.log(`Setting project to: ${projectId}`);
516
+ runCommandSync(`gcloud config set project ${projectId}`);
517
+ runCommandSync(`gcloud config set billing/quota_project ${projectId}`);
518
+
519
+ console.log("Initiating user login...");
520
+ runCommandSync(`gcloud auth login ${driveAccessFlag}`);
521
+
522
+ console.log("Initiating Application Default Credentials (ADC) login...");
293
523
  runCommandSync(
294
524
  `gcloud auth application-default login --scopes="${scopes}" ${clientFlag}`
295
525
  );
526
+ runCommandSync(
527
+ `gcloud auth application-default set-quota-project ${projectId}`
528
+ );
529
+
530
+ // --- Verification ---
531
+ console.log("\nVerifying configuration...");
296
532
 
533
+ const gcloudConfigDir =
534
+ process.env.CLOUDSDK_CONFIG || path.join(os.homedir(), ".config", "gcloud");
535
+ const activeConfigPath = path.join(gcloudConfigDir, "active_config");
536
+
537
+ let currentConfig = "unknown";
538
+ if (fs.existsSync(activeConfigPath)) {
539
+ currentConfig = fs.readFileSync(activeConfigPath, "utf8").trim();
540
+ } else {
541
+ console.warn(
542
+ `Warning: Could not find active_config file at ${activeConfigPath}`
543
+ );
544
+ }
545
+
546
+ const currentProject = execSync("gcloud config get project")
547
+ .toString()
548
+ .trim();
549
+ console.log(
550
+ `Active config is ${currentConfig} - project is ${currentProject}`
551
+ );
552
+
553
+ console.log("\nFetching token information...");
554
+ const userToken = execSync("gcloud auth print-access-token")
555
+ .toString()
556
+ .trim();
557
+ const appDefaultToken = execSync(
558
+ "gcloud auth application-default print-access-token"
559
+ )
560
+ .toString()
561
+ .trim();
562
+
563
+ console.log("\n...user token scopes");
564
+ runCommandSync(
565
+ `curl https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${userToken}`
566
+ );
567
+
568
+ console.log("\n...application default token scopes");
569
+ runCommandSync(
570
+ `curl https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${appDefaultToken}`
571
+ );
297
572
  console.log("\nAuthentication process finished.");
298
573
  }
299
574
 
575
+ /**
576
+ * Handles the 'enableAPIs' command to enable or disable necessary Google Cloud services based on options.
577
+ * @param {object} options Options object provided by commander.js.
578
+ */
300
579
  export function enableGoogleAPIs(options) {
301
580
  checkForGcloudCli();
581
+
302
582
  const API_SERVICES = {
303
583
  drive: "drive.googleapis.com",
304
584
  sheets: "sheets.googleapis.com",
@@ -308,22 +588,45 @@ export function enableGoogleAPIs(options) {
308
588
  logging: "logging.googleapis.com",
309
589
  };
310
590
 
311
- const enable = [];
312
- const disable = [];
313
-
591
+ const servicesToEnable = new Set();
592
+ const servicesToDisable = new Set();
314
593
  if (options.all || Object.keys(options).length === 0) {
315
- enable.push(...Object.values(API_SERVICES));
594
+ Object.values(API_SERVICES).forEach((service) =>
595
+ servicesToEnable.add(service)
596
+ );
316
597
  } else {
317
598
  for (const key in API_SERVICES) {
318
- if (options[`e${key}`]) enable.push(API_SERVICES[key]);
319
- if (options[`d${key}`]) disable.push(API_SERVICES[key]);
599
+ if (options[`e${key}`]) {
600
+ servicesToEnable.add(API_SERVICES[key]);
601
+ }
602
+ if (options[`d${key}`]) {
603
+ servicesToDisable.add(API_SERVICES[key]);
604
+ }
320
605
  }
321
606
  }
322
-
323
- if (enable.length > 0) {
324
- runCommandSync(`gcloud services enable ${enable.join(" ")}`);
607
+ if (servicesToEnable.size > 0) {
608
+ const enableList = Array.from(servicesToEnable);
609
+ console.log(`Enabling Google Cloud services: ${enableList.join(", ")}...`);
610
+ runCommandSync(`gcloud services enable ${enableList.join(" ")}`);
611
+ console.log("Services enabled successfully.");
325
612
  }
326
- if (disable.length > 0) {
327
- runCommandSync(`gcloud services disable ${disable.join(" ")}`);
613
+ if (servicesToDisable.size > 0) {
614
+ const disableList = Array.from(servicesToDisable);
615
+ console.log(
616
+ `Disabling Google Cloud services: ${disableList.join(", ")}...`
617
+ );
618
+ runCommandSync(`gcloud services disable ${disableList.join(" ")}`);
619
+ console.log("Services disabled successfully.");
620
+ }
621
+ if (
622
+ servicesToEnable.size === 0 &&
623
+ servicesToDisable.size === 0 &&
624
+ Object.keys(options).length > 0 &&
625
+ !options.all
626
+ ) {
627
+ console.log("No specific APIs were selected to enable or disable.");
628
+ console.log(
629
+ "Use '--all' to enable all default APIs, or specify flags like '--edrive' or '--ddrive'."
630
+ );
328
631
  }
329
632
  }
package/src/cli/utils.js CHANGED
@@ -5,8 +5,8 @@ const require = createRequire(import.meta.url);
5
5
  const pjson = require("../../package.json");
6
6
 
7
7
  export const VERSION = pjson.version;
8
- export const CLI_VERSION = "0.0.16"; // Kept from original logic
9
- export const MCP_VERSION = "0.0.6";
8
+ export const CLI_VERSION = "0.0.18"; // Kept from original logic
9
+ export const MCP_VERSION = "0.0.7";
10
10
 
11
11
  /**
12
12
  * Replaces escaped newline characters ('\\n') with actual newlines,
package/src/index.js CHANGED
@@ -24,5 +24,6 @@ import './services/formapp/app.js'
24
24
  import './services/slidesapp/app.js'
25
25
  import './services/mimetype/app.js'
26
26
  import './services/lock/app.js'
27
+ import './services/libhandlerapp/app.js'
27
28
  // should be last
28
29
  import './services/stores/app.js'
@@ -64,6 +64,7 @@ class FakeAdvDriveFiles {
64
64
  }
65
65
 
66
66
  /**
67
+ * TODO implement the correct download method
67
68
  * this is fairly pointless in apps script as it returns an operation, and Drive.Operations are not supported
68
69
  * TODO - look into what actually happens to the operation - it may be possible to do something with it using the operations ap directly
69
70
  * for the moment we'll just return a fake operation that looks like adv returns
@@ -193,48 +194,24 @@ class FakeAdvDriveFiles {
193
194
 
194
195
  }
195
196
 
196
- export() {
197
- return notYetImplemented()
198
- }
199
-
200
- }
197
+ export(fileId, mimeType) {
201
198
 
199
+ if (!is.nonEmptyString(fileId)) {
200
+ throw new Error(`API call to drive.files.export failed with error: Required`)
201
+ }
202
+ ScriptApp.__behavior.isAccessible(fileId, 'Drive', 'read');
203
+ const params = {
204
+ id: fileId,
205
+ mimeType,
206
+ }
202
207
 
203
- /**
204
- * tidy up a fields parameter
205
- * @param {object} p
206
- * @param {string} [p.fields=minFields] which fields to get
207
- * @return {string[]} an array of the fields required merged with the minimum fields required to support caching
208
- */
209
- const tidyFieldsFar = ({ fields = "" } = {}, mf = minFields) => {
210
- if (!is.string(fields)) {
211
- throw new Error(`invalid fields definition`, fields)
208
+ const { response, data } = Syncit.fxDriveExport(params)
209
+ return data
210
+
212
211
  }
213
212
 
214
- return Array.from(
215
- new Set((mf.split(",").concat(fields.split(","))
216
- .map(f => f.replace(/\s/g, ""))
217
- .filter(f => f)))
218
- .keys()
219
- )
220
- }
221
-
222
-
223
- /**
224
- * enhance the array of required fields by adding any propertyies already in cache
225
- * @param {object} p
226
- * @param {File} p.cachedFile meta data
227
- * @returns {string} an enhanced fields as a string with the dedupped fields already in cache
228
- */
229
- const enhanceFar = ({ cachedFile, far }) => {
230
- // we'll enhance the cache with the current value of any already fetched key by fetching it again
231
- far = cachedFile ? Array.from(new Set(far.concat(Reflect.ownKeys(cachedFile))).keys()) : far
232
-
233
- // now construct an appropriate fields arg
234
- return far.join(",")
235
213
  }
236
214
 
237
-
238
215
  /**
239
216
  * ceate/patch a file and optionally upload some data
240
217
  * update and copy are virtually the same payload
@@ -0,0 +1,9 @@
1
+ /**
2
+ * the idea here is to create an empty global entry for the singleton
3
+ * but only load it when it is actually used.
4
+ */
5
+ import { newFakeLibHandlerApp as maker} from './fakelibhandlerapp.js';
6
+ import { lazyLoaderApp } from '../common/lazyloader.js'
7
+
8
+ let _app = null;
9
+ _app = lazyLoaderApp(_app, 'LibHandlerApp', maker)
@@ -0,0 +1,39 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+ import { Auth } from '../../support/auth.js';
3
+ import { newFakeLibrary } from './fakelibrary.js';
4
+ export const newFakeLibHandler = (...args) => {
5
+ return Proxies.guard(new FakeLibHandler(...args));
6
+ };
7
+
8
+ // to keep the same pattern as other apps script services, we'll use the worker to async/sync
9
+ // the starting point is the current manifest (or another manifest if specified)
10
+ class FakeLibHandler {
11
+ constructor(manifest) {
12
+ this.__manifest = manifest
13
+ if (!this.__manifest) {
14
+ console.warn ('...manifest not found in auth and not provided - no libraries will be loaded');
15
+ }
16
+ }
17
+
18
+ get manifest() {
19
+ return this.__manifest;
20
+ }
21
+ get dependencies() {
22
+ return this.__manifest.dependencies;
23
+ }
24
+ get libraries() {
25
+ return this.dependencies?.libraries;
26
+ }
27
+ get enabledAdvancedServices() {
28
+ return this.dependencies?.enabledAdvancedServices;
29
+ }
30
+
31
+ fetchLibraries() {
32
+ const libs = this.dependencies?.libraries.map(newFakeLibrary);
33
+ return libs
34
+ }
35
+
36
+ toString() {
37
+ return 'LibHandler';
38
+ }
39
+ }
@@ -0,0 +1,54 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+ import { Auth } from '../../support/auth.js';
3
+ import { newFakeLibHandler } from './fakelibhandler.js';
4
+
5
+ export const newFakeLibHandlerApp = (...args) => {
6
+ return Proxies.guard(new FakeLibHandlerApp(...args));
7
+ };
8
+
9
+ // we ned to be able to handle multiple recursive libraries
10
+ // the default source will be the current manifest
11
+ // for now we only sipport the HEAD version of libraries
12
+ class FakeLibHandlerApp {
13
+ constructor() {
14
+ this.__libMap = new Map()
15
+ }
16
+
17
+ get libMap() {
18
+ return this.__libMap
19
+ }
20
+
21
+
22
+ load(manifest) {
23
+ manifest = manifest || Auth.getManifest();
24
+ if (!manifest) {
25
+ throw new Error('manifest not found in auth and not provided');
26
+ }
27
+ this.__libMap = new Map()
28
+
29
+ const recurseManifests = (manifest) => {
30
+ const libs = newFakeLibHandler(manifest).fetchLibraries();
31
+ libs.forEach((lib) => {
32
+ if (!this.libMap.has(lib.libraryId)) {
33
+ this.libMap.set(lib.libraryId, lib);
34
+ console.log(`...loading ${lib.libraryId} - ${lib.userSymbol}`)
35
+ if (lib.libraries) {
36
+ recurseManifests(lib.manifest)
37
+ }
38
+ }
39
+ })
40
+ }
41
+ recurseManifests(manifest)
42
+
43
+ this.libMap.forEach((lib) => {
44
+ lib.inject()
45
+ })
46
+ return this
47
+ }
48
+
49
+
50
+ toString() {
51
+ return 'LibHandlerApp';
52
+ }
53
+
54
+ }
@@ -0,0 +1,117 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+ import { parse } from 'acorn';
3
+
4
+ export const newFakeLibrary = (...args) => {
5
+ return Proxies.guard(new FakeLibrary(...args));
6
+ };
7
+
8
+ // to keep the same pattern as other apps script services, we'll use the worker to async/sync
9
+ // the starting point is the current manifest (or another manifest if specified)
10
+ class FakeLibrary {
11
+ constructor(libraryOb) {
12
+ this.__libraryOb = libraryOb;
13
+ if (!this.__libraryOb) {
14
+ throw new Error('library not provided');
15
+ }
16
+ this.__content = null;
17
+ this.__libContent = null;
18
+ this.__manifest = null;
19
+ }
20
+ get libraryOb() {
21
+ return this.__libraryOb;
22
+ }
23
+ get version() {
24
+ return this.__libraryOb.version;
25
+ }
26
+ get userSymbol() {
27
+ return this.__libraryOb.userSymbol;
28
+ }
29
+ get libraryId() {
30
+ if (!this.__libraryOb.libraryId) {
31
+ throw new Error(`libraryId not found in library ${JSON.stringify(this.__libraryOb)}`);
32
+ }
33
+ return this.__libraryOb.libraryId;
34
+ }
35
+ get libContent() {
36
+ if (!this.__libContent) {
37
+ this.__allowSandboxAccess();
38
+ const data = Drive.Files.export(
39
+ this.libraryId,
40
+ 'application/vnd.google-apps.script+json',
41
+ );
42
+ if (!data) {
43
+ throw new Error(`Library ${this.libraryId} not found`);
44
+ }
45
+ this.__libContent = JSON.parse(Utilities.newBlob(data).getDataAsString())
46
+ }
47
+ return this.__libContent
48
+ }
49
+ __allowSandboxAccess() {
50
+ if (ScriptApp.__behavior?.sandboxMode) {
51
+ ScriptApp.__behavior.addWhitelistedFile(this.libraryId);
52
+ }
53
+ }
54
+
55
+ get combinedJs() {
56
+ return this.libContent.files.filter((f) => f.type === 'server_js').map((f) => `////-- ${f.name} --\n${f.source}`).join(`\n\n`)
57
+ }
58
+
59
+ get content() {
60
+ if (!this.__content) {
61
+ const libContent = this.libContent;
62
+ this.__content = {
63
+ ...this.__libraryOb,
64
+ ...libContent,
65
+ serverJs: this.serverJs,
66
+ manifest: this.manifest,
67
+ libraries: this.libraries,
68
+ combinedJs: this.combinedJs
69
+ }
70
+ }
71
+ return this.__content
72
+ }
73
+ get wrapper() {
74
+ const ast = parse(this.combinedJs, {
75
+ ecmaVersion: 'latest',
76
+ sourceType: 'script',
77
+ allowReserved: true
78
+ });
79
+ const getBody = (ast, type) => {
80
+ return (ast.type === 'Program' ? ast.body.filter(f => f.type === type) : [])
81
+ }
82
+ const makeExports = (ast, type, accessor) => getBody(ast, type).map((f) => accessor(f))
83
+ const functions = makeExports(ast, 'FunctionDeclaration', (f) => f.id.name)
84
+ const variables = makeExports(ast, 'VariableDeclaration', (f) => f.declarations[0].id.name)
85
+ const classes = makeExports(ast, 'ClassDeclaration', (f) => f.id.name)
86
+ const exports = [...functions, ...variables, ...classes];
87
+ return `${this.combinedJs};\nreturn { ${exports.join(', ')} };`;
88
+ }
89
+
90
+ inject() {
91
+ if (!this.wrapper) {
92
+ throw new Error('wrapper not loaded');
93
+ }
94
+ globalThis[this.userSymbol] = (new Function(this.wrapper))()
95
+ return this
96
+ }
97
+
98
+ get manifest() {
99
+ if (!this.__manifest) {
100
+ const t = this.libContent?.files?.find((f) => f.type === 'json' && f.name === 'appsscript')
101
+ if (!t) {
102
+ throw new Error(`manifest not found in library ${this.libraryId}`)
103
+ }
104
+ this.__manifest = JSON.parse(t.source)
105
+ }
106
+ return this.__manifest
107
+ }
108
+ get serverJs() {
109
+ return this.libContent?.files?.filter((f) => f.type === 'server_js')
110
+ }
111
+ get libraries() {
112
+ return this.manifest?.dependencies?.libraries || null;
113
+ }
114
+ toString() {
115
+ return 'Library';
116
+ }
117
+ }
@@ -348,7 +348,7 @@ class FakeBehavior {
348
348
  return id
349
349
  }
350
350
  isAccessible(id, serviceName, accessType = 'read') {
351
- if (!is.nonEmptyString(id)) {
351
+ if (this.sandboxMode && !is.nonEmptyString(id)) {
352
352
  throw new Error(`Invalid sandbox id parameter (${id}) - must be a non-empty string`);
353
353
  }
354
354
 
@@ -139,25 +139,17 @@ export const sxStreamUpMedia = async (Auth, { resource, bytes, fields, method, m
139
139
  }
140
140
 
141
141
  }
142
-
143
- /**
144
- * sync a call to download data from drive
145
- * @param {object} p pargs
146
- * @param {string} p.id file id
147
- * @return {SxResult} from the api
148
- */
149
- export const sxDriveMedia = async (Auth, { id }) => {
150
-
142
+ const sxStreamer = async ({
143
+ params,
144
+ options = {},
145
+ method = 'get' }) => {
151
146
  // this is the node drive service
152
147
  const drive = getDriveApiClient();
153
- const streamed = await drive.files.get({
154
- fileId: id,
155
- alt: 'media'
156
- }, {
157
- responseType: 'stream'
148
+ const streamed = await drive.files[method](params, {
149
+ responseType: 'stream',
150
+ ...options
158
151
  })
159
152
  const response = responseSyncify(streamed)
160
-
161
153
  if (response.status === 200) {
162
154
  const buf = await getStreamAsBuffer(streamed.data)
163
155
  const data = Array.from(buf)
@@ -172,9 +164,37 @@ export const sxDriveMedia = async (Auth, { id }) => {
172
164
  response
173
165
  }
174
166
  }
167
+ }
168
+ /**
169
+ * sync a call to export data from drive
170
+ * @param {object} p pargs
171
+ * @param {string} p.id file id
172
+ * @return {SxResult} from the api
173
+ */
174
+ export const sxDriveExport = async (_, { id: fileId, mimeType }) => {
175
+
176
+ return sxStreamer({ params: {
177
+ fileId,
178
+ mimeType
179
+ }, method: 'export' })
180
+
181
+ }
182
+ /**
183
+ * sync a call to download data from drive
184
+ * @param {object} p pargs
185
+ * @param {string} p.id file id
186
+ * @return {SxResult} from the api
187
+ */
188
+ export const sxDriveMedia = async (_, { id: fileId }) => {
189
+
190
+ return sxStreamer({params: {
191
+ fileId,
192
+ alt: 'media'
193
+ }, method: 'get' })
175
194
 
176
195
  }
177
196
 
197
+
178
198
  export const sxDriveGet = (Auth, { id, params, options }) => {
179
199
  return sxDrive(Auth, {
180
200
  prop: "files",
@@ -333,6 +333,23 @@ const fxDriveMedia = ({ id }) => {
333
333
  id,
334
334
  });
335
335
  };
336
+ /**
337
+ * sync a call to Drive api to stream a download
338
+ * @param {object} p pargs
339
+ * @param {string} p.prop the prop of drive eg 'files' for drive.files
340
+ * @param {string} p.method the method of drive eg 'list' for drive.files.list
341
+ * @param {object} p.params the params to add to the request
342
+ * @return {DriveResponse} from the drive api
343
+ */
344
+ const fxDriveExport = ({ id, mimeType , options ={alt: 'media'}}) => {
345
+ // see issue https://issuetracker.google.com/issues/468534237
346
+ // live apps script failes without this alt option
347
+ return callSync("sxDriveExport", {
348
+ id,
349
+ mimeType,
350
+ options
351
+ });
352
+ };
336
353
 
337
354
  /**
338
355
  * a sync version of fetching
@@ -399,5 +416,5 @@ export const Syncit = {
399
416
  fxDocs,
400
417
  fxForms,
401
418
  fxGmail,
402
- // fxGetImagesFromXlsx
403
- };
419
+ fxDriveExport
420
+ }
package/testlib.sh CHANGED
@@ -1 +1,2 @@
1
- node gas-fakes -l bmFiddler@13EWG4-lPrEf34itxQhAQ7b9JEbmCBfO8uE4Mhr99CHi3Pw65oxXtq-rU -s "const sheet=SpreadsheetApp.openById('1h9IGIShgVBVUrUjjawk5MaCEQte_7t32XeEP1Z5jXKQ').getSheets()[0];const fiddler = new bmFiddler.Fiddler(sheet);console.log (fiddler.getData().slice(0, 5));"
1
+ gas-fakes -l bmFiddler@13EWG4-lPrEf34itxQhAQ7b9JEbmCBfO8uE4Mhr99CHi3Pw65oxXtq-rU \
2
+ -s "const sheet=SpreadsheetApp.openById('1h9IGIShgVBVUrUjjawk5MaCEQte_7t32XeEP1Z5jXKQ').getSheets()[0];const fiddler = new bmFiddler.Fiddler(sheet);console.log (fiddler.getData().slice(0, 5));"