@mcpher/gas-fakes 1.2.24 → 1.2.25

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/gas-fakes.js CHANGED
@@ -1,1141 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // -----------------------------------------------------------------------------
4
- // IMPORTS
5
- // -----------------------------------------------------------------------------
3
+ import { main } from "./src/cli/app.js";
6
4
 
7
- import fs from "fs";
8
- import path from "path";
9
- import { spawn } from "child_process";
10
- import { Command } from "commander";
11
- import dotenv from "dotenv";
12
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
- import { z } from "zod";
15
- import { parse } from "acorn";
16
-
17
- import { Auth } from "./src/support/auth.js";
18
-
19
- // --- Import setup commands ---
20
- import {
21
- initializeConfiguration,
22
- authenticateUser,
23
- enableGoogleAPIs,
24
- } from "./setup.js";
25
-
26
- // sync the version with gas fakes code since they share a package.json
27
- import { createRequire } from "node:module";
28
- import { access } from "node:fs";
29
- const require = createRequire(import.meta.url);
30
- const pjson = require("./package.json");
31
- const VERSION = pjson.version;
32
-
33
- // -----------------------------------------------------------------------------
34
- // CONSTANTS & UTILITIES
35
- // -----------------------------------------------------------------------------
36
-
37
- const CLI_VERSION = "0.0.15";
38
- const MCP_VERSION = "0.0.5";
39
-
40
- /**
41
- * Replaces escaped newline characters ('\\n') with actual newlines,
42
- * while ignoring newlines inside string literals.
43
- * @param {string} text The script text to process.
44
- * @returns {string} The processed text.
45
- */
46
- function normalizeScriptNewlines(text) {
47
- // const regex = /("[^"]*")|('[^']*')|(`[^`]*`)|(\\n)/g;
48
- // return text.replace(regex, (match, g1, g2, g3, g4) => (g4 ? "\n" : match));
49
-
50
- // Updated the above as follows.
51
- const regex =
52
- /("(?:[^"\\]|\\.)*")|('(?:[^'\\]|\\.)*')|(`(?:[^`\\]|\\.)*`)|(\/\\[rn]\/[dgimsuy]*)|(\/\*[\s\S]*?\*\/)|(\/\/(?:(?!\\n).)*)|(\\n)/g;
53
- return text.replace(regex, (match, g1, g2, g3, g4, g5, g6, g7) => {
54
- if (g7) return "\n";
55
- return match;
56
- });
57
- }
58
-
59
- // -----------------------------------------------------------------------------
60
- // SANDBOX SCRIPT GENERATION
61
- // -----------------------------------------------------------------------------
62
-
63
- /**
64
- * Generates the GAS script snippet for whitelisting services.
65
- * @param {Array<Object>} services The whitelistServices configuration.
66
- * @returns {string[]} An array of script lines.
67
- */
68
- function generateServiceWhitelistScript(services) {
69
- if (!services || services.length === 0) return [];
70
-
71
- return services.flatMap(({ className, methodNames }, index) => {
72
- if (!className) {
73
- console.error("Error: Class name not found in whitelistServices.");
74
- process.exit(1);
75
- }
76
- const serviceVar = `service${index + 1}`;
77
- const lines = [
78
- `const ${serviceVar} = behavior.sandboxService.${className};`,
79
- ];
80
- if (methodNames && methodNames.length > 0) {
81
- const methods = methodNames.map((name) => `"${name}"`).join(", ");
82
- lines.push(`${serviceVar}.setMethodWhitelist([${methods}]);`);
83
- }
84
- return lines;
85
- });
86
- }
87
-
88
- /**
89
- * Generates the GAS script snippet for blacklisting services.
90
- * @param {string[]} services The blacklistServices configuration.
91
- * @returns {string[]} An array of script lines.
92
- */
93
- function generateServiceBlacklistScript(services) {
94
- if (!services || services.length === 0) return [];
95
- return services.map(
96
- (service) => `behavior.sandboxService.${service}.enabled = false;`
97
- );
98
- }
99
-
100
- /**
101
- * Generates the GAS script snippet for whitelisting Drive items.
102
- * @param {Array<Object>} items The whitelistItems configuration.
103
- * @returns {string} A script string.
104
- */
105
- function generateItemWhitelistScript(items) {
106
- if (!items || items.length === 0) return "";
107
-
108
- const whitelistItemsString = items
109
- .map(({ itemId = "", read = true, write = false, trash = false }) => {
110
- if (!itemId) {
111
- console.error("Error: itemId not found in whitelistItems.");
112
- process.exit(1);
113
- }
114
- return `behavior.newIdWhitelistItem("${itemId}").setRead(${read}).setWrite(${write}).setTrash(${trash})`;
115
- })
116
- .join(",\n ");
117
-
118
- return `behavior.setIdWhitelist([${whitelistItemsString}]);`;
119
- }
120
-
121
- /**
122
- * Constructs the setup script for a sandboxed environment based on configuration.
123
- * @param {object} sandboxConfig The sandbox configuration object.
124
- * @returns {string[]} An array of GAS script lines for setup.
125
- */
126
- function generateSandboxSetupScript(sandboxConfig) {
127
- const script = [
128
- "const behavior = ScriptApp.__behavior;",
129
- "behavior.sandboxMode = true;",
130
- "behavior.strictSandbox = true;",
131
- ];
132
-
133
- const { whitelistServices, blacklistServices, whitelistItems } =
134
- sandboxConfig;
135
-
136
- script.push(...generateServiceWhitelistScript(whitelistServices));
137
- script.push(...generateServiceBlacklistScript(blacklistServices));
138
-
139
- const itemWhitelist = generateItemWhitelistScript(whitelistItems);
140
- if (itemWhitelist) {
141
- script.push(itemWhitelist);
142
- }
143
-
144
- return script;
145
- }
146
-
147
- /**
148
- * Generates the final, executable script string.
149
- * @param {object} options
150
- * @param {string} options.scriptText The user's Google Apps Script.
151
- * @param {boolean} options.useSandbox Whether to enable basic sandbox mode.
152
- * @param {object} [options.sandboxConfig] Detailed sandbox configuration.
153
- * @returns {{mainScript: string, gasScript: string}} The script to be executed and the user-facing script part.
154
- */
155
- function generateExecutionScript({ scriptText, useSandbox, sandboxConfig }) {
156
- if (!scriptText || scriptText.trim() === "") {
157
- console.error("Error: Google Apps Script is empty or was not found.");
158
- process.exit(1);
159
- }
160
-
161
- let gasScriptLines = [];
162
-
163
- if (sandboxConfig) {
164
- gasScriptLines.push(...generateSandboxSetupScript(sandboxConfig));
165
- gasScriptLines.push(`\n\n${scriptText}\n\n`);
166
- gasScriptLines.push("ScriptApp.__behavior.trash();");
167
- } else if (useSandbox) {
168
- gasScriptLines.push("ScriptApp.__behavior.sandBoxMode = true;");
169
- gasScriptLines.push(`\n\n${scriptText}\n\n`);
170
- gasScriptLines.push("ScriptApp.__behavior.trash();");
171
- } else {
172
- gasScriptLines.push(scriptText);
173
- }
174
-
175
- const gasScript = gasScriptLines.join("\n");
176
- const mainScript = [
177
- "async function runGas() {",
178
- ' await import("./main.js"); // This will trigger the fxInit call',
179
- gasScript,
180
- "}",
181
- "return runGas();",
182
- ].join("\n");
183
-
184
- return { mainScript, gasScript };
185
- }
186
-
187
- // -----------------------------------------------------------------------------
188
- // SCRIPT EXECUTION
189
- // -----------------------------------------------------------------------------
190
-
191
- /**
192
- * Loads, prepares, and executes the user's Google Apps Script.
193
- * @param {object} options The processed CLI options.
194
- */
195
- async function executeGasScript(options) {
196
- const {
197
- filename,
198
- script,
199
- display,
200
- gfSettings,
201
- useSandbox,
202
- sandboxConfig,
203
- args,
204
- gas_library,
205
- } = options;
206
-
207
- let scriptText = filename ? fs.readFileSync(filename, "utf8") : script;
208
-
209
- if (scriptText) {
210
- scriptText = scriptText.replace(/\\\s*?\n/g, "\n");
211
- }
212
-
213
- let { mainScript, gasScript } = generateExecutionScript({
214
- scriptText: normalizeScriptNewlines(scriptText),
215
- useSandbox,
216
- sandboxConfig,
217
- });
218
-
219
- if (display) {
220
- console.log(
221
- `\n--- Generated GAS ---\n${gasScript}\n--- End Generated GAS ---\n`
222
- );
223
- }
224
-
225
- // Inject the settings path as a global for the script to access.
226
- Object.defineProperty(globalThis, "settingsPath", {
227
- value: gfSettings,
228
- writable: true,
229
- configurable: true,
230
- });
231
-
232
- if (gas_library && gas_library.length > 0) {
233
- const libs = gas_library.reduce((ar, { identifier, libScript }) => {
234
- if (mainScript.includes(identifier)) {
235
- ar.push(libScript);
236
- }
237
- return ar;
238
- }, []);
239
- if (libs.length > 0) {
240
- mainScript = `${libs.join("\n\n")}\n\n${mainScript}`;
241
- }
242
- }
243
-
244
- let res;
245
- if (args) {
246
- const gasFunction = new Function("args", mainScript);
247
- res = await gasFunction(args);
5
+ main().catch((error) => {
6
+ console.error("\x1b[31mAn unexpected error occurred:\x1b[0m");
7
+ if (error instanceof Error) {
8
+ console.error(error.stack);
248
9
  } else {
249
- const gasFunction = new Function(mainScript);
250
- res = await gasFunction();
251
- }
252
- if (res) {
253
- const output = typeof res == "string" ? res : JSON.stringify(res);
254
- console.log(output); // Returned value from Google Apps Script.
255
- }
256
- }
257
-
258
- // -----------------------------------------------------------------------------
259
- // MCP SERVER
260
- // -----------------------------------------------------------------------------
261
-
262
- /**
263
- * Helper: Constructs the CLI arguments array for gas-fakes execution.
264
- * Modified to return an array suitable for spawn (no shell escaping needed).
265
- * @param {object} params Configuration parameters
266
- * @returns {string[]} Array of CLI arguments
267
- */
268
- function buildCliArguments(params) {
269
- const {
270
- filename,
271
- script,
272
- args,
273
- sandbox,
274
- whitelistRead,
275
- whitelistReadWrite,
276
- whitelistReadWriteTrash,
277
- json,
278
- } = params;
279
-
280
- const cliArgs = [];
281
-
282
- // Input source
283
- if (filename) {
284
- cliArgs.push("-f", filename);
285
- }
286
- if (script) {
287
- cliArgs.push("-s", script);
288
- }
289
-
290
- // Execution arguments
291
- if (args) {
292
- cliArgs.push("-a", JSON.stringify(args));
293
- }
294
-
295
- // Sandbox & Permissions
296
- if (sandbox) cliArgs.push("-x");
297
- if (whitelistRead) cliArgs.push("-w", whitelistRead);
298
- if (whitelistReadWrite) cliArgs.push("--ww", whitelistReadWrite);
299
- if (whitelistReadWriteTrash) cliArgs.push("--wt", whitelistReadWriteTrash);
300
- if (json) cliArgs.push("-j", JSON.stringify(json));
301
-
302
- return cliArgs;
303
- }
304
-
305
- /**
306
- * Helper: Executes the gas-fakes command via child_process.spawn.
307
- * Uses spawn instead of exec to avoid shell interpretation of arguments.
308
- * @param {string[]} cliArgs Arguments to pass to the command
309
- * @returns {Promise<object>} MCP tool result object
310
- */
311
- async function runGasFakesProcess(cliArgs) {
312
- return new Promise((resolve) => {
313
- // We invoke the current node executable with the current script
314
- const child = spawn(process.execPath, [process.argv[1], ...cliArgs], {
315
- env: process.env,
316
- stdio: ["ignore", "pipe", "pipe"], // ignore stdin, capture stdout/stderr
317
- shell: false, // Important: Disable shell execution
318
- });
319
-
320
- let stdoutData = "";
321
- let stderrData = "";
322
-
323
- child.stdout.on("data", (data) => {
324
- stdoutData += data.toString();
325
- });
326
-
327
- child.stderr.on("data", (data) => {
328
- stderrData += data.toString();
329
- });
330
-
331
- child.on("close", (code) => {
332
- if (code === 0) {
333
- resolve({
334
- content: [
335
- { type: "text", text: stdoutData || "Execution finished." },
336
- ],
337
- isError: false,
338
- });
339
- } else {
340
- // If there's content in stdout, it might contain the error info from gas-fakes
341
- const output = stderrData || stdoutData || "Unknown error occurred";
342
- resolve({
343
- content: [{ type: "text", text: output }],
344
- isError: true,
345
- });
346
- }
347
- });
348
-
349
- child.on("error", (err) => {
350
- resolve({
351
- content: [{ type: "text", text: err.message }],
352
- isError: true,
353
- });
354
- });
355
- });
356
- }
357
-
358
- /**
359
- * Registers the default "run-gas-by-gas-fakes" tool.
360
- */
361
- function registerDefaultTool(server) {
362
- const schema1 = {
363
- description: [
364
- `Use this to safely run Google Apps Script in a sandbox using gas-fakes.`,
365
- `# Important`,
366
- `- Use the extension of the Google Apps Script files as \`js\`. Don't use \`gs\``,
367
- `- When providing script content, ensure functions are called (e.g., add \`sample();\`).`,
368
- ].join("\n"),
369
- inputSchema: {
370
- filename: z
371
- .string()
372
- .optional()
373
- .describe(`Path to the file containing Google Apps Script.`),
374
- script: z
375
- .string()
376
- .optional()
377
- .describe(`Direct GAS script content string.`),
378
- sandbox: z
379
- .boolean()
380
- .describe("Use to run Google Apps Script in a sandbox."),
381
- whitelistRead: z
382
- .string()
383
- .optional()
384
- .describe(
385
- "Whitelist of file IDs for readonly access (comma-separated). When the file IDs and folder IDs are used or provided, use `whiteListRead`, `whitelistReadWrite`, or `whitelistReadWriteTrash` by judging from the prompt."
386
- ),
387
- whitelistReadWrite: z
388
- .string()
389
- .optional()
390
- .describe(
391
- "Whitelist of file IDs for read/write access (comma-separated). When the file IDs and folder IDs are used or provided, use `whiteListRead`, `whitelistReadWrite`, or `whitelistReadWriteTrash` by judging from the prompt."
392
- ),
393
- whitelistReadWriteTrash: z
394
- .string()
395
- .optional()
396
- .describe(
397
- "Whitelist of file IDs for read/write/trash access (comma-separated). When the file IDs and folder IDs are used or provided, use `whiteListRead`, `whitelistReadWrite`, or `whitelistReadWriteTrash` by judging from the prompt."
398
- ),
399
- json: z
400
- .object({
401
- whitelistItems: z
402
- .array(
403
- z.object({
404
- itemId: z.string(),
405
- read: z.boolean().default(true).optional(),
406
- write: z.boolean().default(false).optional(),
407
- trash: z.boolean().default(false).optional(),
408
- })
409
- )
410
- .optional(),
411
- whitelistServices: z
412
- .array(
413
- z.object({
414
- className: z.string(),
415
- methodNames: z.array(z.string()).optional(),
416
- })
417
- )
418
- .optional(),
419
- blacklistServices: z.array(z.string()).optional(),
420
- })
421
- .optional()
422
- .describe("Advanced sandbox configuration JSON."),
423
- },
424
- };
425
-
426
- server.registerTool("run-gas-by-gas-fakes", schema1, async (args) => {
427
- if (!args.filename && !args.script) {
428
- return {
429
- content: [
430
- {
431
- type: "text",
432
- text: "Error: Either `filename` or `script` is required.",
433
- },
434
- ],
435
- isError: true,
436
- };
437
- }
438
- const cliArgs = buildCliArguments(args);
439
- return await runGasFakesProcess(cliArgs);
440
- });
441
-
442
- const schema2 = {
443
- description: [
444
- `Use this to create the tools of the MCP server using Google Apps Script as a new file. Only when the prompt that a new tool for the MCP server is required is clearly shown, use this tool. If a file \`settings.json\` or \`mcp_config.json\` or and so on for loading the MCP servers include \`--tools\` and the tool file to \`gas-fakes\` MCP server, ask the current client which you want to create a new file including the tools or you want to add the tools to the existing tool file. When a new tool file is created, show how to use the tool file as follows. When the created tool file is \`toolFile.js\`, modify the MCP server setting to \`"mcpServers": { "gas-fakes": { "command": "gas-fakes", "args": [ "mcp", "--tools", "{Absolute path}/toolFile.js" ] } }\`. Don't forget to replace \`{Absolute path}\` with the actual path.`,
445
- ].join("\n"),
446
- inputSchema: {
447
- filename: z
448
- .string()
449
- .describe(
450
- "Filename of the tool file built by Google Apps Script. The extension is required to be `.js`."
451
- ),
452
- tools: z
453
- .array(
454
- z
455
- .object({
456
- name: z.string().describe("Tool name."),
457
- schema: z
458
- .string()
459
- .describe(
460
- `JSON schema of the arguments to \`gas-script\`. This is required to be an object by creating zod. The simple sample script of Zod is \`{ description: "Use this to search files by a filename on Google Drive.", inputSchema: { filename: z.string().describe("Filename of the search file.") } }\`. Generate a JSON schema using Zod by following this sample. You are required to generate the JSON schema using Zod. This object of inputSchema will be used as "object" in Google Apps Script.`
461
- ),
462
- gas_script: z
463
- .string()
464
- .describe(
465
- `Generated Google Apps Script. Please be careful of the following rule. For example, when you generated a Google Apps Script like \`function sample(object) { script }\`, please add \`return sample(object);\` to execute the function. This is a very important point. When the prompt says "Create a function", please create a function and add a line of script for calling the function and returning the response like \`return sample(object);\`. Or, you can also create a Google Apps Script without enclosing the script with \`function sample(object) { script }\`. When you want to return the value, show the value using \`console.log\` in the script, or return the value using \`return\`. The simple sample script is \`const { filename } = object; const files = DriveApp.getFilesByName(filename); const ar = []; while (files.hasNext()) { const file = files.next(); ar.push({ filename: file.getName(), fileId: file.getId() }); } return ar;\` This tool is required to be used for creating tools as a new file. When you want to add more tools to the existing file, add them to an array \`tools\` in the file by following the specification of \`tools\`. If you cannot find the array, please create it as a new file.`
466
- ),
467
- libraries: z
468
- .array(
469
- z
470
- .string()
471
- .describe(
472
- [
473
- `Use this when Google Apps Script libraries are required to be used in the script. The sample values are as follows.`,
474
- `It supposes that the identity of the library is \`LIB\`.`,
475
- `- When the library script is the file with path, \`LIB@{filename1}\``,
476
- `- When the library script is the file direct link, \`LIB@{file URL}\``,
477
- `- When the library script is the ID (Library project key or library key or script ID or file ID) of the file on Google Drive, \`LIB@{ID}\``,
478
- ].join("\n")
479
- )
480
- )
481
- .default([])
482
- .describe(
483
- `Use this when Google Apps Script libraries are required to be used in the script.`
484
- ),
485
- })
486
- .describe("An object for each tool.")
487
- )
488
- .describe("An array including tools."),
489
- },
490
- };
491
- server.registerTool("create-new-tools", schema2, async (args) => {
492
- if (!args.filename || !args.tools) {
493
- return {
494
- content: [
495
- {
496
- type: "text",
497
- text: "Error: `filename` and `tools` are required.",
498
- },
499
- ],
500
- isError: true,
501
- };
502
- }
503
-
504
- const tool_ar = [];
505
- for (let i = 0; i < args.length; i++) {
506
- const { name, schema, gas_script, libraries } = args[i];
507
- tool_ar.push(
508
- `{ name: "${name}", schema: ${schema}, func: (object = {}) => { ${gas_library}\n\n${gas_script} }, libraries: [${libraries.join(
509
- ","
510
- )}] }`
511
- );
512
- }
513
-
514
- const tool_script = [
515
- `import { z } from "zod";`,
516
- ``,
517
- `const tools = [${tool_ar.join(", ")}];`,
518
- ].join("\n");
519
- const absolutePath = path.resolve(process.cwd(), args.filename);
520
- fs.writeFileSync(absolutePath, tool_script);
521
- return {
522
- content: [
523
- {
524
- type: "text",
525
- text: `A new file including tools for gas-fakes-mcp was successfully created as "${absolutePath}".`,
526
- },
527
- ],
528
- isError: false,
529
- };
530
- });
531
- }
532
-
533
- /**
534
- * Loads and registers custom tools from an external file.
535
- */
536
- async function registerCustomTools(server, toolsPath) {
537
- if (!toolsPath || !fs.existsSync(toolsPath)) {
538
- if (toolsPath) console.error(`No tool file: ${toolsPath}`);
539
- return;
10
+ console.error(String(error));
540
11
  }
541
-
542
- const absolutePath = path.resolve(process.cwd(), toolsPath);
543
- let toolsStr = fs.readFileSync(absolutePath, "utf8");
544
- toolsStr = toolsStr.replace(/^import.*/gm, "");
545
- const getTools = new Function("z", `${toolsStr} return tools || [];`);
546
- const tools = getTools(z);
547
-
548
- if (!tools || tools.length === 0) return;
549
-
550
- for (let i = 0; i < tools.length; i++) {
551
- const tool = tools[i];
552
-
553
- // Extend the custom tool schema with sandbox options
554
- const extendedSchema = { ...tool.schema };
555
- extendedSchema.inputSchema = {
556
- gas_args: z
557
- .object(tool.schema.inputSchema)
558
- .describe("Arguments for Google Apps Script."),
559
- sandbox: z.boolean().describe("Run in sandbox."),
560
- whitelistRead: z.string().optional().describe("Read-only whitelist IDs."),
561
- whitelistReadWrite: z
562
- .string()
563
- .optional()
564
- .describe("Read/Write whitelist IDs."),
565
- whitelistReadWriteTrash: z
566
- .string()
567
- .optional()
568
- .describe("Read/Write/Trash whitelist IDs."),
569
- json: z.any().optional().describe("Advanced sandbox JSON configuration."),
570
- };
571
-
572
- let originalFuncStr = tool.func.toString();
573
- if (tool.libraries && tool.libraries.length > 0) {
574
- const gas_library = await getLibraries({ libraries: tool.libraries });
575
-
576
- if (gas_library && gas_library.length > 0) {
577
- const libs = gas_library.reduce((ar, { identifier, libScript }) => {
578
- if (originalFuncStr.includes(identifier)) {
579
- ar.push(libScript);
580
- }
581
- return ar;
582
- }, []);
583
- if (libs.length > 0) {
584
- originalFuncStr = `(object = {}) => {\n\n${libs.join(
585
- "\n\n"
586
- )}\n\nconst main_gas_fakes = ${originalFuncStr}\n\nreturn main_gas_fakes(object);\n}`;
587
- }
588
- }
589
- }
590
-
591
- const toolHandler = async (opts) => {
592
- // Wrap the original function string to execute it with args
593
- const wrappedScript = `return (${originalFuncStr})(args)`;
594
-
595
- const cliArgs = buildCliArguments({
596
- script: wrappedScript,
597
- args: opts.gas_args,
598
- ...opts,
599
- });
600
-
601
- return await runGasFakesProcess(cliArgs);
602
- };
603
-
604
- server.registerTool(tool.name, extendedSchema, toolHandler);
605
- }
606
- }
607
-
608
- /**
609
- * Defines and runs the MCP server for gas-fakes.
610
- */
611
- async function startMcpServer(options) {
612
- const { tools } = options;
613
-
614
- const server = new McpServer({
615
- name: "gas-fakes-mcp",
616
- version: MCP_VERSION,
617
- });
618
-
619
- // Register the built-in generic runner
620
- registerDefaultTool(server);
621
-
622
- // Register dynamic custom tools if provided
623
- if (tools) {
624
- await registerCustomTools(server, tools);
625
- }
626
-
627
- const transport = new StdioServerTransport();
628
- await server.connect(transport);
629
- }
630
-
631
- // -----------------------------------------------------------------------------
632
- // CLI DEFINITION & MAIN EXECUTION
633
- // -----------------------------------------------------------------------------
634
-
635
- /**
636
- * Parses sandbox-related CLI options into a structured config object.
637
- * @param {object} options Raw options from Commander.
638
- * @returns {object | undefined} A sandbox configuration object or undefined.
639
- */
640
- function buildSandboxConfig(options) {
641
- const { json, whitelistRead, whitelistReadWrite, whitelistReadWriteTrash } =
642
- options;
643
-
644
- if (json) {
645
- try {
646
- return JSON.parse(json);
647
- } catch (err) {
648
- console.error("Error: Invalid JSON provided to --json option.");
649
- process.exit(1);
650
- }
651
- }
652
-
653
- if (whitelistRead || whitelistReadWrite || whitelistReadWriteTrash) {
654
- const config = { whitelistItems: [] };
655
- const parseWhitelist = (idString, permissions) => {
656
- if (!idString) return;
657
- idString.split(",").forEach((id) => {
658
- const trimmedId = id.trim();
659
- if (trimmedId) {
660
- config.whitelistItems.push({ itemId: trimmedId, ...permissions });
661
- }
662
- });
663
- };
664
-
665
- parseWhitelist(whitelistRead, { read: true });
666
- parseWhitelist(whitelistReadWrite, { read: true, write: true });
667
- parseWhitelist(whitelistReadWriteTrash, {
668
- read: true,
669
- write: true,
670
- trash: true,
671
- });
672
-
673
- return config;
674
- }
675
-
676
- return undefined;
677
- }
678
-
679
- // -----------------------------------------------------------------------------
680
- // PROCESS GAS LIBRARIES
681
- // -----------------------------------------------------------------------------
682
-
683
- /**
684
- * Helper function to wrap spawn in a Promise.
685
- * This captures stdout and stderr and resolves or rejects based on the exit code.
686
- */
687
- function spawnCommand(command, args) {
688
- return new Promise((resolve, reject) => {
689
- const child = spawn(command, args);
690
- let stdout = "";
691
- let stderr = "";
692
-
693
- // Collect data from stdout stream
694
- child.stdout.on("data", (data) => {
695
- stdout += data.toString();
696
- });
697
-
698
- // Collect data from stderr stream
699
- child.stderr.on("data", (data) => {
700
- stderr += data.toString();
701
- });
702
-
703
- // Handle command not found or other spawn errors
704
- child.on("error", (err) => {
705
- reject(err);
706
- });
707
-
708
- // Handle process exit
709
- child.on("close", (code) => {
710
- if (code === 0) {
711
- resolve(stdout.trim());
712
- } else {
713
- reject(
714
- new Error(stderr.trim() || `Command failed with exit code ${code}`)
715
- );
716
- }
717
- });
718
- });
719
- }
720
-
721
- async function checkForGcloudCli() {
722
- try {
723
- // args: ['--version']
724
- await spawnCommand("gcloud", ["--version"]);
725
- } catch (error) {
726
- console.error(
727
- "\n[Error] Google Cloud SDK (gcloud CLI) not found or failed to run."
728
- );
729
- console.error("Please install it by following the official instructions:");
730
- console.error("https://cloud.google.com/sdk/gcloud");
731
- // Only exit if it's strictly required to stop execution here
732
- process.exit(1);
733
- }
734
- }
735
-
736
- async function getAccessToken(pattern) {
737
- if (pattern == 1) {
738
- // Authorization pattern 1
739
- const auth = await Auth.setAuth(
740
- ["https://www.googleapis.com/auth/drive.readonly"],
741
- null,
742
- true
743
- );
744
- auth.cachedCredential = null;
745
- return await auth.getAccessToken();
746
- } else {
747
- // Authorization pattern 2
748
- await checkForGcloudCli();
749
- try {
750
- const accessToken = await spawnCommand("gcloud", [
751
- "auth",
752
- "print-access-token",
753
- ]);
754
- return accessToken;
755
- } catch (error) {
756
- console.error("\nError obtaining access token:");
757
- console.error(error.message);
758
- console.error(
759
- "Please ensure you are authenticated with gcloud CLI. Run 'gcloud auth application-default login'."
760
- );
761
- process.exit(1);
762
- }
763
- }
764
- }
765
-
766
- async function fetchScriptFileFromGoogleDrive(sourcePath, pattern = 1) {
767
- try {
768
- const accessToken = await getAccessToken(pattern);
769
- const url = `https://www.googleapis.com/drive/v3/files/${sourcePath}/export?mimeType=${encodeURIComponent(
770
- "application/vnd.google-apps.script+json"
771
- )}`;
772
-
773
- const response = await fetch(url, {
774
- headers: { authorization: `Bearer ${accessToken}` },
775
- });
776
-
777
- if (!response.ok) {
778
- throw new Error(
779
- `HTTP error ${response.status} fetching Drive ID "${sourcePath}".`
780
- );
781
- }
782
-
783
- const text = await response.json();
784
- if (text.files && text.files.length > 0) {
785
- // GAS projects can have multiple files; filter for server-side JS and join them.
786
- return text.files
787
- .filter((e) => e.type === "server_js")
788
- .map((e) => e.source)
789
- .join("\n\n");
790
- }
791
- } catch (err) {
792
- if (pattern == 1) {
793
- return await fetchScriptFileFromGoogleDrive(sourcePath, 2);
794
- }
795
-
796
- // If it wasn't a Drive ID or auth failed, fall through to error
797
- throw new Error(
798
- `Could not retrieve script from "${sourcePath}". Ensure it is a valid path, URL, or Drive ID, and that you are authenticated.`
799
- );
800
- }
801
- }
802
-
803
- /**
804
- * Fetches the source code for a library from a local file, a URL, or Google Drive.
805
- *
806
- * @param {string} sourcePath - The file path, URL, or Drive File ID of the library.
807
- * @returns {Promise<string>} The source code of the library.
808
- * @throws {Error} If the source cannot be found, HTTP fails, or auth fails.
809
- */
810
- async function fetchLibrarySource(sourcePath) {
811
- // 1. Check Local File
812
- if (fs.existsSync(sourcePath)) {
813
- return fs.readFileSync(sourcePath, "utf8");
814
- }
815
-
816
- // 2. Check URL
817
- // Note: URL.canParse requires Node.js v18.17.0+ or v20+
818
- if (URL.canParse(sourcePath)) {
819
- const response = await fetch(sourcePath);
820
- if (!response.ok) {
821
- throw new Error(`HTTP error ${response.status} fetching ${sourcePath}`);
822
- }
823
- return await response.text();
824
- }
825
-
826
- // 3. Check Google Drive (via gcloud)
827
- try {
828
- return await fetchScriptFileFromGoogleDrive(sourcePath);
829
- } catch (err) {
830
- throw new Error(
831
- `No valid source code found for "${sourcePath}". Error message: ${err.message}`
832
- );
833
- }
834
- }
835
-
836
- /**
837
- * Wraps raw library source code into a GAS-style namespace using an IIFE.
838
- * It uses AST parsing to export top-level functions and 'var' variables.
839
- *
840
- * @param {string} identifier - The library namespace identifier (e.g., "TableApp").
841
- * @param {string} source - The raw JavaScript source code.
842
- * @returns {string} The wrapped, executable JavaScript code.
843
- */
844
- function generateLibraryWrapper(identifier, source) {
845
- try {
846
- const ast = parse(source, { ecmaVersion: 2020 });
847
-
848
- // Extract top-level FunctionDeclarations and VariableDeclarations (var only)
849
- const exportNames = ast.body.reduce((names, node) => {
850
- if (node.type === "FunctionDeclaration") {
851
- names.push(node.id.name);
852
- } else if (node.type === "VariableDeclaration" && node.kind === "var") {
853
- names.push(...node.declarations.map((d) => d.id.name));
854
- }
855
- return names;
856
- }, []);
857
-
858
- if (exportNames.length === 0) {
859
- throw new Error(
860
- `No top-level functions or var variables found to export in library "${identifier}".`
861
- );
862
- }
863
-
864
- return [
865
- `var ${identifier} = (function () {`,
866
- `var ${identifier};`,
867
- source,
868
- `\n`,
869
- `if (this && this.${identifier}) { ${identifier} = this.${identifier}; }`,
870
- `return { ${exportNames.join(", ")} };`,
871
- `}).call({});`,
872
- ].join("\n");
873
-
874
- // Or, use the following script.
875
- // return [
876
- // `var ${identifier} = (function () {`,
877
- // source,
878
- // `\n`,
879
- // `return { ${exportNames.join(", ")} };`,
880
- // `})();`,
881
- // ].join("\n");
882
- } catch (err) {
883
- console.error(`Error processing library "${identifier}": ${err.message}`);
884
- process.exit(1);
885
- }
886
- }
887
-
888
- /**
889
- * Processes library arguments, fetches source code, merges duplicates,
890
- * and generates namespaced wrapper scripts.
891
- *
892
- * This function handles the format "Identifier@Source". If the same Identifier
893
- * is provided multiple times, the sources are concatenated in order.
894
- *
895
- * @param {object} options - The CLI options object.
896
- * @param {string[]} [options.libraries] - Array of library strings (e.g., ["Lib@./file.js"]).
897
- * @returns {Promise<string|null>} The combined string of all library scripts, or null if none provided.
898
- */
899
- async function getLibraries(options) {
900
- const { libraries } = options;
901
-
902
- if (!libraries || !Array.isArray(libraries) || libraries.length === 0) {
903
- return null;
904
- }
905
-
906
- // 1. Parse and Fetch (Concurrent)
907
- // We map arguments to Promises to fetch all libraries in parallel.
908
- const fetchPromises = libraries.map(async (libArg) => {
909
- const splitIndex = libArg.indexOf("@");
910
-
911
- if (splitIndex === -1) {
912
- throw new Error(
913
- `Invalid library format: "${libArg}". Expected format: 'Identifier@Source'.`
914
- );
915
- }
916
-
917
- const identifier = libArg.substring(0, splitIndex).trim();
918
- const sourcePath = libArg.substring(splitIndex + 1).trim();
919
-
920
- if (!identifier || !sourcePath) {
921
- throw new Error(
922
- `Invalid library argument: "${libArg}". Identifier or Source is missing.`
923
- );
924
- }
925
-
926
- try {
927
- const source = await fetchLibrarySource(sourcePath);
928
- return { identifier, source };
929
- } catch (err) {
930
- throw new Error(`Failed to load library "${identifier}": ${err.message}`);
931
- }
932
- });
933
-
934
- let fetchedLibs;
935
- try {
936
- fetchedLibs = await Promise.all(fetchPromises);
937
- } catch (err) {
938
- // If any fetch fails, log the error and exit
939
- console.error(`Error: ${err.message}`);
940
- process.exit(1);
941
- }
942
-
943
- // 2. Merge libraries with the same Identifier
944
- // Using a Map to maintain insertion order and group sources
945
- const mergedLibsMap = new Map();
946
-
947
- for (const { identifier, source } of fetchedLibs) {
948
- if (mergedLibsMap.has(identifier)) {
949
- const existingSource = mergedLibsMap.get(identifier);
950
- mergedLibsMap.set(identifier, existingSource + "\n\n" + source);
951
- } else {
952
- mergedLibsMap.set(identifier, source);
953
- }
954
- }
955
-
956
- // 3. Generate Wrappers
957
- // Convert the merged sources into wrapped IIFE strings
958
- const wrappedScripts = [];
959
- for (const [identifier, source] of mergedLibsMap) {
960
- wrappedScripts.push({
961
- identifier,
962
- libScript: generateLibraryWrapper(identifier, source),
963
- });
964
- }
965
-
966
- return wrappedScripts;
967
- }
968
-
969
- /**
970
- * Sets up and runs the command-line interface.
971
- */
972
- async function main() {
973
- const program = new Command();
974
-
975
- program
976
- .name("gas-fakes")
977
- .description("A CLI tool to execute Google Apps Script with fakes/mocks.")
978
- .version(
979
- VERSION,
980
- "-v, --version",
981
- "Display the current version of gas-fakes"
982
- );
983
-
984
- // Default command to execute a script
985
- program
986
- .description("Execute a Google Apps Script file or string.")
987
- .option("-f, --filename <string>", "Path to the Google Apps Script file.")
988
- .option(
989
- "-s, --script <string>",
990
- "A string containing the Google Apps Script."
991
- )
992
- .option("-e, --env <path>", "Path to a custom .env file.", "./.env")
993
- .option(
994
- "-g, --gfsettings <path>",
995
- "Path to a gasfakes.json settings file.",
996
- "./gasfakes.json"
997
- )
998
- .option("-x, --sandbox", "Run the script in a basic sandbox.")
999
- .option(
1000
- "-w, --whitelistRead <string>",
1001
- "Comma-separated file IDs for read-only access (enables sandbox)."
1002
- )
1003
- .option(
1004
- "--ww, --whitelistReadWrite <string>",
1005
- "Comma-separated file IDs for read/write access (enables sandbox)."
1006
- )
1007
- .option(
1008
- "--wt, --whitelistReadWriteTrash <string>",
1009
- "Comma-separated file IDs for read/write/trash access (enables sandbox)."
1010
- )
1011
- .option(
1012
- "-j, --json <string>",
1013
- "JSON string for advanced sandbox configuration (overrides whitelist flags)."
1014
- )
1015
- .option(
1016
- "-d, --display",
1017
- "Display the generated script before execution.",
1018
- false
1019
- )
1020
- .option(
1021
- "-a, --args <string>",
1022
- `Arguments for the function of Google Apps Script. Provide it as a JSON string. The name of the argument is "args" as a fixed name. For example, when the function of GAS is \`function sample(args) { script }\`, you can provide the arguments like \`-a '{"key": "value"}'\`.`,
1023
- null
1024
- )
1025
- .option(
1026
- "-l, --libraries <string...>",
1027
- `Libraries. You can run the Google Apps Script with libraries. When you use 2 libraries "Lib1" and "Lib" which are the identifiers of library, provide '--libraries "Lib1@{filename}" --libraries "Lib2@{file URL}"'.`,
1028
- null
1029
- )
1030
- .action(async (options) => {
1031
- const { filename, script, env, gfsettings } = options;
1032
- if (!filename && !script) {
1033
- // This action is for the default command. If a known command is passed (like 'init'), this won't run.
1034
- // We check if the command is not one of the others.
1035
- const knownCommands = program.commands.map((cmd) => cmd.name());
1036
- if (!process.argv.slice(2).some((arg) => knownCommands.includes(arg))) {
1037
- // console.error(
1038
- // "Error: You must provide a script via --filename or --script, or use a specific command (e.g., init, auth, mcp)."
1039
- // );
1040
- program.help();
1041
- process.exit(1);
1042
- }
1043
- return;
1044
- }
1045
-
1046
- // Load environment variables
1047
- const envPath = path.resolve(process.cwd(), env);
1048
- console.log(`...using env file in ${envPath}`);
1049
- dotenv.config({ path: envPath, quiet: true });
1050
-
1051
- // Load gasfakes settings
1052
- const settingsPath = path.resolve(process.cwd(), gfsettings);
1053
- console.log(`...using gasfakes settings file in ${settingsPath}`);
1054
- process.env.GF_SETTINGS_PATH = settingsPath;
1055
-
1056
- const sandboxConfig = buildSandboxConfig(options);
1057
- const useSandbox = !!options.sandbox || !!sandboxConfig;
1058
-
1059
- let args = null;
1060
- if (options.args) {
1061
- try {
1062
- args = JSON.parse(
1063
- options.args
1064
- .replace(/\\\s*?\n/g, "\\n")
1065
- .replace(/\n/g, "\\n")
1066
- .replace(/\r/g, "\\r")
1067
- );
1068
- } catch (err) {
1069
- console.error("Error: Invalid JSON provided to --args option.");
1070
- process.exit(1);
1071
- }
1072
- }
1073
-
1074
- const gas_library = await getLibraries(options);
1075
-
1076
- await executeGasScript({
1077
- filename,
1078
- script,
1079
- display: options.display,
1080
- useSandbox,
1081
- sandboxConfig,
1082
- gfSettings: settingsPath,
1083
- args,
1084
- gas_library,
1085
- });
1086
- });
1087
-
1088
- // --- Setup commands ---
1089
- program
1090
- .command("init")
1091
- .description(
1092
- "Initializes the configuration by creating or updating the .env file."
1093
- )
1094
- .option("-e, --env <path>", "Path to a custom .env file.")
1095
- .action(initializeConfiguration);
1096
-
1097
- program
1098
- .command("auth")
1099
- .description("Runs the Google Cloud authentication and authorization flow.")
1100
- .action(authenticateUser);
1101
-
1102
- program
1103
- .command("enableAPIs")
1104
- .description(
1105
- "Enables or disables required Google Cloud APIs for the project."
1106
- )
1107
- .option("--all", "Enable all default Google Cloud APIs.")
1108
- .option("--edrive", "Enable drive.googleapis.com")
1109
- .option("--ddrive", "Disable drive.googleapis.com")
1110
- .option("--esheets", "Enable sheets.googleapis.com")
1111
- .option("--dsheets", "Disable sheets.googleapis.com")
1112
- .option("--eforms", "Enable forms.googleapis.com")
1113
- .option("--dforms", "Disable forms.googleapis.com")
1114
- .option("--edocs", "Enable docs.googleapis.com")
1115
- .option("--ddocs", "Disable docs.googleapis.com")
1116
- .option("--egmail", "Enable gmail.googleapis.com")
1117
- .option("--dgmail", "Disable gmail.googleapis.com")
1118
- .option("--elogging", "Enable logging.googleapis.com")
1119
- .option("--dlogging", "Disable logging.googleapis.com")
1120
- .action(enableGoogleAPIs);
1121
-
1122
- // MCP server command
1123
- program
1124
- .command("mcp")
1125
- .description("Launch gas-fakes as an MCP server.")
1126
- .option(
1127
- "-t, --tools <string>",
1128
- "A filename of the custom MCP server tools built by Google Apps Script."
1129
- )
1130
- .action(startMcpServer);
1131
-
1132
- program.showHelpAfterError("(add --help for additional information)");
1133
-
1134
- await program.parseAsync(process.argv);
1135
- }
1136
-
1137
- // Run the main function
1138
- main().catch((err) => {
1139
- console.error("An unexpected error occurred:", err);
1140
12
  process.exit(1);
1141
13
  });