@mcpher/gas-fakes 1.2.3 → 1.2.5
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
|
@@ -20,6 +20,16 @@ Collaborators should fork the repo and use the local versions of these files - s
|
|
|
20
20
|
|
|
21
21
|
Just as on Apps Script, everything is executed synchronously so you don't need to bother with handling Promises/async/await. Just write normal Apps Script code. Usually you would have an associated App Script project if that's your eventual target, but it's not essential that you do. You can get started right away on Node.
|
|
22
22
|
|
|
23
|
+
# gas-fakes-cli
|
|
24
|
+
|
|
25
|
+
Now you can run apps script code directly from your console - for example
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
gas-fakes -s "const files=DriveApp.getRootFolder().searchFiles('title contains \"Untitled\"');while (files.hasNext()) {console.log(files.next().getName())};"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For details see [gas fakes cli](gas-fakes-cli.md)
|
|
32
|
+
|
|
23
33
|
### Settings
|
|
24
34
|
|
|
25
35
|
The optional `gasfakes.json` file holds various location and behavior parameters for your local Node environment. It is not required on GAS, as you can't change anything over there. If you don't provide this file, `gas-fakes` will create one for you with sensible defaults.
|
|
@@ -134,12 +144,12 @@ There are a couple of syntactical differences between Node and Apps Script. Not
|
|
|
134
144
|
// this required on Node but not on Apps Script
|
|
135
145
|
if (ScriptApp.isFake) testFakes()
|
|
136
146
|
````
|
|
137
|
-
For inspiration on pushing modified files to the IDE, see the togas.sh bash script I use for the test suite.
|
|
147
|
+
For inspiration on pushing modified files to the IDE, see the togas.sh bash script I use for the test suite. There's also a complete push pull workflow available - see - [push test pull](pull-test-push.md)
|
|
148
|
+
|
|
138
149
|
|
|
139
150
|
## Help
|
|
140
151
|
|
|
141
152
|
As I mentioned earlier, to take this further, I'm going to need a lot of help to extend the methods and services supported - so if you feel this would be useful to you, and would like to collaborate, please ping me on bruce@mcpher.com and we'll talk.
|
|
142
|
-
|
|
143
153
|
## <img src="./logo.png" alt="gas-fakes logo" width="50" align="top"> Further Reading
|
|
144
154
|
|
|
145
155
|
- [getting started](GETTING_STARTED.md) - how to handle authentication for restricted scopes.
|
|
@@ -158,3 +168,5 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
|
|
|
158
168
|
- [sandbox](sandbox.md)
|
|
159
169
|
- [named range identity](named-range-identity.md)
|
|
160
170
|
- [adc and restricted scopes](https://ramblings.mcpher.com/how-to-allow-access-to-sensitive-scopes-with-application-default-credentials/)
|
|
171
|
+
- [push test pull](pull-test-push.md)
|
|
172
|
+
- [gas fakes cli](gas-fakes-cli.md)
|
package/gas-fakes.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cli for gas-fakes
|
|
5
|
+
* v0.0.1
|
|
6
|
+
*/
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import dotenv from 'dotenv'
|
|
10
|
+
|
|
11
|
+
const version = "0.0.2";
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name("gas-fakes")
|
|
17
|
+
.description("CLI tool for gas-fakes")
|
|
18
|
+
.version(version, "-v, --version", "display the current version");
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
program
|
|
22
|
+
.description("Execute Google Apps Script using gas-fakes.")
|
|
23
|
+
.option(
|
|
24
|
+
"-f, --filename <string>",
|
|
25
|
+
"filename of the file including Google Apps Script. When this is used, the option --script is ignored."
|
|
26
|
+
)
|
|
27
|
+
.option(
|
|
28
|
+
"-e, --env <path>",
|
|
29
|
+
"provide path to your .env file for special options."
|
|
30
|
+
)
|
|
31
|
+
.option(
|
|
32
|
+
"-g, --gfsettings <path>",
|
|
33
|
+
"provide path to your gasfakes.json file for script options."
|
|
34
|
+
)
|
|
35
|
+
.option(
|
|
36
|
+
"-s, --script <string>",
|
|
37
|
+
"provide Google Apps Script as a string. When this is used, the option --filename is ignored."
|
|
38
|
+
)
|
|
39
|
+
.option("-x, --sandbox", "run Google Apps Script in a sandbox.")
|
|
40
|
+
.option(
|
|
41
|
+
"-w, --whitelist <string>",
|
|
42
|
+
"whitelist of file IDs. Set the file IDs in comma-separated list. In this case, the files of the file IDs are used for both read and write. When this is used, the script is run in a sandbox."
|
|
43
|
+
)
|
|
44
|
+
.option(
|
|
45
|
+
"-j, --json <string>",
|
|
46
|
+
`JSON string including parameters for managing a sandbox. Enclose it with ' or ". When this is used, the option --whitelist is ignored. When this is used, the script is run in a sandbox.`
|
|
47
|
+
)
|
|
48
|
+
.option(
|
|
49
|
+
"-d, --display",
|
|
50
|
+
`display the created script for executing with gas-fakes. Default is false.`,
|
|
51
|
+
false
|
|
52
|
+
)
|
|
53
|
+
.action((options) => {
|
|
54
|
+
if (Object.keys(options).length == 0) {
|
|
55
|
+
program.help();
|
|
56
|
+
} else {
|
|
57
|
+
const { filename, script, sandbox, whitelist, json, display, env , gfsettings} = options;
|
|
58
|
+
const obj = { sandbox: !!sandbox, display };
|
|
59
|
+
if (!filename && !script) {
|
|
60
|
+
console.error(
|
|
61
|
+
"error: Provide the filename or the script of Google Apps Script."
|
|
62
|
+
);
|
|
63
|
+
process.exit();
|
|
64
|
+
}
|
|
65
|
+
if (gfsettings) {
|
|
66
|
+
obj.gfSettings= gfsettings
|
|
67
|
+
}
|
|
68
|
+
if (env) {
|
|
69
|
+
dotenv.config({ path: env })
|
|
70
|
+
}
|
|
71
|
+
if (filename) {
|
|
72
|
+
obj.filename = filename;
|
|
73
|
+
}
|
|
74
|
+
if (script) {
|
|
75
|
+
obj.script = script;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// for sandbox
|
|
79
|
+
if (whitelist) {
|
|
80
|
+
const ar = whitelist.split(",").map((e) => e.trim());
|
|
81
|
+
if (ar.length > 0) {
|
|
82
|
+
obj.whitelistItems = ar;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (json) {
|
|
86
|
+
try {
|
|
87
|
+
const temp = JSON.parse(json);
|
|
88
|
+
obj.json_sandbox = temp;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error("error: Invalid JSON.");
|
|
91
|
+
process.exit();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
loadScript(obj);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
program.showHelpAfterError("(add --help for additional information)");
|
|
99
|
+
program.parse();
|
|
100
|
+
|
|
101
|
+
function __getImportScript(o) {
|
|
102
|
+
const { scriptText, sandbox, whitelistItems, json_sandbox, gfSettings } = o;
|
|
103
|
+
if (scriptText.trim() == "") {
|
|
104
|
+
console.error("error: Google Apps Script was not found.");
|
|
105
|
+
process.exit();
|
|
106
|
+
}
|
|
107
|
+
const gasScriptAr = [];
|
|
108
|
+
if (json_sandbox) {
|
|
109
|
+
gasScriptAr.push(
|
|
110
|
+
`const behavior = ScriptApp.__behavior;`,
|
|
111
|
+
`behavior.sandboxMode = true;`,
|
|
112
|
+
`behavior.strictSandbox = true;`
|
|
113
|
+
);
|
|
114
|
+
const { whitelistItems, whitelistServices, blacklistServices } =
|
|
115
|
+
json_sandbox;
|
|
116
|
+
if (whitelistServices && whitelistServices.length > 0) {
|
|
117
|
+
const bl = whitelistServices.flatMap(({ className, methodNames }, i) => {
|
|
118
|
+
if (!className) {
|
|
119
|
+
console.error(
|
|
120
|
+
"error: Class name was not found in whitelistServices."
|
|
121
|
+
);
|
|
122
|
+
process.exit();
|
|
123
|
+
}
|
|
124
|
+
const k = `s${i + 1}`;
|
|
125
|
+
const temp = [`const ${k} = behavior.sandboxService.${className};`];
|
|
126
|
+
if (methodNames && methodNames.length > 0) {
|
|
127
|
+
temp.push(
|
|
128
|
+
`${k}.setMethodWhitelist([${methodNames.map((e) => `"${e}"`)}]);`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return temp;
|
|
132
|
+
});
|
|
133
|
+
gasScriptAr.push(...bl);
|
|
134
|
+
}
|
|
135
|
+
if (blacklistServices && blacklistServices.length > 0) {
|
|
136
|
+
const bl = blacklistServices.map(
|
|
137
|
+
(e) => `behavior.sandboxService.${e}.enabled = false;`
|
|
138
|
+
);
|
|
139
|
+
gasScriptAr.push(bl);
|
|
140
|
+
}
|
|
141
|
+
if (whitelistItems && whitelistItems.length > 0) {
|
|
142
|
+
const wl = whitelistItems
|
|
143
|
+
.map(({ itemId = "", read = true, write = false, trash = false }) => {
|
|
144
|
+
if (!itemId) {
|
|
145
|
+
console.error("error: itemId was not found in whitelistItems.");
|
|
146
|
+
process.exit();
|
|
147
|
+
}
|
|
148
|
+
return `behavior.newIdWhitelistItem("${itemId}").setRead(${read}).setWrite(${write}).setTrash(${trash})`;
|
|
149
|
+
})
|
|
150
|
+
.join(",");
|
|
151
|
+
gasScriptAr.push(`behavior.setIdWhitelist([${wl}]);`);
|
|
152
|
+
}
|
|
153
|
+
gasScriptAr.push(`\n\n${scriptText}\n\n`, `ScriptApp.__behavior.trash();`);
|
|
154
|
+
} else {
|
|
155
|
+
if (sandbox && (!whitelistItems || whitelistItems.length === 0)) {
|
|
156
|
+
gasScriptAr.push(
|
|
157
|
+
sandbox ? `ScriptApp.__behavior.sandBoxMode = true;` : "",
|
|
158
|
+
`\n\n${scriptText}\n\n`,
|
|
159
|
+
sandbox ? `ScriptApp.__behavior.trash();` : ""
|
|
160
|
+
);
|
|
161
|
+
} else if (whitelistItems && whitelistItems.length > 0) {
|
|
162
|
+
const wl = whitelistItems
|
|
163
|
+
.map((id) => `behavior.newIdWhitelistItem("${id}").setWrite(true)`)
|
|
164
|
+
.join(",");
|
|
165
|
+
gasScriptAr.push(
|
|
166
|
+
`const behavior = ScriptApp.__behavior;`,
|
|
167
|
+
`behavior.sandboxMode = true;`,
|
|
168
|
+
`behavior.strictSandbox = true;`,
|
|
169
|
+
`behavior.setIdWhitelist([${wl}]);`,
|
|
170
|
+
`\n\n${scriptText}\n\n`,
|
|
171
|
+
`ScriptApp.__behavior.trash();`
|
|
172
|
+
);
|
|
173
|
+
} else {
|
|
174
|
+
gasScriptAr.push(scriptText);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const importScriptAr = [
|
|
178
|
+
`async function runGas() {`,
|
|
179
|
+
// Pass the settings path to the init function if it's provided
|
|
180
|
+
gfSettings
|
|
181
|
+
? `const settingsPath = "${gfSettings}";`
|
|
182
|
+
: `const settingsPath = undefined;`,
|
|
183
|
+
`await import("./main.js");`, // This will trigger the fxInit call
|
|
184
|
+
...gasScriptAr,
|
|
185
|
+
`};`,
|
|
186
|
+
``,
|
|
187
|
+
`runGas();`,
|
|
188
|
+
];
|
|
189
|
+
return {
|
|
190
|
+
mainScript: importScriptAr.join("\n"),
|
|
191
|
+
gasScript: gasScriptAr.join("\n"),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function loadScript(o) {
|
|
196
|
+
const { filename, script, display } = o;
|
|
197
|
+
|
|
198
|
+
const scriptText = filename ? fs.readFileSync(filename, "utf8") : script;
|
|
199
|
+
const { mainScript, gasScript } = __getImportScript({ scriptText, ...o });
|
|
200
|
+
if (display) {
|
|
201
|
+
console.log(`--- script ---`);
|
|
202
|
+
console.log(gasScript);
|
|
203
|
+
console.log(`--- /script ---`);
|
|
204
|
+
}
|
|
205
|
+
const gasFunc = new Function(mainScript);
|
|
206
|
+
// The script needs access to the settings path variable we just created
|
|
207
|
+
Object.defineProperty(globalThis, "settingsPath", {
|
|
208
|
+
value: o.gfSettings,
|
|
209
|
+
writable: true,
|
|
210
|
+
configurable: true,
|
|
211
|
+
});
|
|
212
|
+
gasFunc();
|
|
213
|
+
}
|
package/gasfakes.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest": "./appsscript.json",
|
|
3
3
|
"clasp": "./.clasp.json",
|
|
4
|
-
"scriptId": "
|
|
4
|
+
"scriptId": "7bcfb7cc-6200-4a7d-9f33-e1a3f3d19529",
|
|
5
5
|
"documentId": null,
|
|
6
6
|
"cache": "/tmp/gas-fakes/cache",
|
|
7
7
|
"properties": "/tmp/gas-fakes/properties"
|
package/package.json
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
"@mcpher/gas-flex-cache": "^1.1.2",
|
|
8
8
|
"@sindresorhus/is": "^7.0.1",
|
|
9
9
|
"archiver": "^7.0.1",
|
|
10
|
+
"commander": "^14.0.1",
|
|
11
|
+
"dotenv": "^17.2.3",
|
|
10
12
|
"exceljs": "^4.4.0",
|
|
11
13
|
"fast-json-stable-stringify": "^2.1.0",
|
|
12
14
|
"get-stream": "^9.0.1",
|
|
@@ -26,7 +28,6 @@
|
|
|
26
28
|
},
|
|
27
29
|
"type": "module",
|
|
28
30
|
"scripts": {
|
|
29
|
-
|
|
30
31
|
"test": "node --env-file=./.env ./test/test.js",
|
|
31
32
|
"testdrive": "node --env-file=./.env ./test/testdrive.js execute",
|
|
32
33
|
"testsheetsdatavalidations": "node --env-file=./.env ./test/testsheetsdatavalidations.js execute",
|
|
@@ -74,10 +75,13 @@
|
|
|
74
75
|
},
|
|
75
76
|
"name": "@mcpher/gas-fakes",
|
|
76
77
|
"author": "bruce mcpherson",
|
|
77
|
-
"version": "1.2.
|
|
78
|
+
"version": "1.2.5",
|
|
78
79
|
"license": "MIT",
|
|
79
80
|
"main": "main.js",
|
|
80
81
|
"description": "A proof of concept implementation of Apps Script Environment on Node",
|
|
81
82
|
"repository": "github:brucemcpherson/gas-fakes",
|
|
82
|
-
"homepage": "https://ramblings.mcpher.com/a-proof-of-concept-implementation-of-apps-script-environment-on-node/"
|
|
83
|
+
"homepage": "https://ramblings.mcpher.com/a-proof-of-concept-implementation-of-apps-script-environment-on-node/",
|
|
84
|
+
"bin": {
|
|
85
|
+
"gas-fakes": "cli/gas-fakes.js"
|
|
86
|
+
}
|
|
83
87
|
}
|
|
@@ -32,6 +32,9 @@ export const getFilesIterator = ({
|
|
|
32
32
|
if (qob) {
|
|
33
33
|
const regex = /(^|\s)title(\s*=\s*|\s+contains\s+|\s*!=\s*|\s*>\s*|\s*<\s*)/gi;
|
|
34
34
|
qob = qob.map(f => {
|
|
35
|
+
if (!is.string(f)) {
|
|
36
|
+
throw new Error (`invalid parameters ${JSON.stringify(qob)}`)
|
|
37
|
+
}
|
|
35
38
|
return f.replace(regex, (match, p1, p2) => {
|
|
36
39
|
// p1 is the start-of-string or whitespace before 'title'
|
|
37
40
|
// p2 is the operator and surrounding whitespace
|
|
@@ -39,7 +42,7 @@ export const getFilesIterator = ({
|
|
|
39
42
|
});
|
|
40
43
|
})
|
|
41
44
|
}
|
|
42
|
-
|
|
45
|
+
//qob = ["name contains 'Untitled' and '0AN2ExLh4POiZUk9PVA' in parents and mimeType != 'application/vnd.google-apps.folder'"]
|
|
43
46
|
/**
|
|
44
47
|
* this generator will get chunks of matching files from the drive api
|
|
45
48
|
* and yield them 1 by 1 and handle paging if required
|
|
@@ -142,6 +145,7 @@ const fileLister = ({
|
|
|
142
145
|
}) => {
|
|
143
146
|
// enhance any already supplied query params
|
|
144
147
|
qob = Utils.arrify(qob) || []
|
|
148
|
+
qob = [...qob]
|
|
145
149
|
if (parentId) {
|
|
146
150
|
ScriptApp.__behavior.isAccessible(parentId) // will throw if not accessible
|
|
147
151
|
qob.push(`'${parentId}' in parents`)
|
package/src/support/sxauth.js
CHANGED
|
@@ -9,7 +9,7 @@ import got from 'got';
|
|
|
9
9
|
import { Auth } from './auth.js';
|
|
10
10
|
import { syncError, syncLog } from './workersync/synclogger.js';
|
|
11
11
|
import { homedir } from 'os';
|
|
12
|
-
import {access, readFile, writeFile, mkdir } from 'fs/promises';
|
|
12
|
+
import { access, readFile, writeFile, mkdir } from 'fs/promises';
|
|
13
13
|
import path from 'path'
|
|
14
14
|
|
|
15
15
|
|
|
@@ -47,10 +47,11 @@ export const sxInit = async ({ manifestPath, claspPath, settingsPath, mainDir, c
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// files are relative to this main path
|
|
50
|
+
|
|
50
51
|
const settingsFile = path.resolve(mainDir, settingsPath)
|
|
51
52
|
const settingsDir = path.dirname(settingsFile)
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
// syncLog (JSON.stringify({mainDir,settingsPath,settingsDir,settingsFile}))
|
|
54
55
|
// get the setting file if it exists
|
|
55
56
|
const _settings = await getIfExists(path.resolve(mainDir, settingsFile))
|
|
56
57
|
const settings = { ..._settings }
|
|
@@ -91,12 +92,27 @@ export const sxInit = async ({ manifestPath, claspPath, settingsPath, mainDir, c
|
|
|
91
92
|
// Initialize auth. This is async and will discover the project ID.
|
|
92
93
|
const auth = await Auth.setAuth(scopes);
|
|
93
94
|
const projectId = Auth.getProjectId();
|
|
94
|
-
|
|
95
|
+
let accessToken = null
|
|
96
|
+
|
|
97
|
+
// need to handle an expired refresh token
|
|
98
|
+
try {
|
|
99
|
+
accessToken = await auth.getAccessToken()
|
|
100
|
+
} catch (error) {
|
|
101
|
+
|
|
102
|
+
}
|
|
95
103
|
let tokenInfo = null
|
|
96
104
|
try {
|
|
97
105
|
tokenInfo = await got(`https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${accessToken}`).json()
|
|
98
106
|
} catch (err) {
|
|
99
|
-
syncError
|
|
107
|
+
syncError(`Application default credentials needs attention`)
|
|
108
|
+
if (error.code === 400 && error.message.includes('invalid_grant')) {
|
|
109
|
+
// Log a specific, actionable error for the developer/operator
|
|
110
|
+
syncError("ADC 'invalid_grant' Error: The underlying Application Default Credentials have expired or been revoked.");
|
|
111
|
+
syncError("Helpful note: in your admin console under security/access amd data control there are a couple of settings to fiddle with token life")
|
|
112
|
+
syncError("Use your settaccount.sh to reauthenticate");
|
|
113
|
+
throw new Error ('failed to get access token info : Use your settaccount.sh to reauthenticate')
|
|
114
|
+
}
|
|
115
|
+
throw error; // Re-throw any other errors
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
/// these all jst exist in this sub process so we need to send them back to parent process
|
package/src/support/syncit.js
CHANGED
|
@@ -22,7 +22,7 @@ import { callSync } from "./workersync/synchronizer.js";
|
|
|
22
22
|
|
|
23
23
|
const manifestDefaultPath = "./appsscript.json";
|
|
24
24
|
const claspDefaultPath = "./.clasp.json";
|
|
25
|
-
const settingsDefaultPath = "./gasfakes.json";
|
|
25
|
+
const settingsDefaultPath = "./gasfakes.json" || process.env.GF_SETTINGS_PATH;
|
|
26
26
|
const propertiesDefaultPath = "/tmp/gas-fakes/properties";
|
|
27
27
|
const cacheDefaultPath = "/tmp/gas-fakes/cache";
|
|
28
28
|
// note that functions like Sheets.newGridRange() etc create objects that contain get and set functions
|
|
@@ -262,7 +262,7 @@ const fxUnzipper = ({ blob }) => {
|
|
|
262
262
|
const fxInit = ({
|
|
263
263
|
manifestPath = manifestDefaultPath,
|
|
264
264
|
claspPath = claspDefaultPath,
|
|
265
|
-
settingsPath = settingsDefaultPath,
|
|
265
|
+
settingsPath = globalThis.settingsPath || settingsDefaultPath,
|
|
266
266
|
cachePath = cacheDefaultPath,
|
|
267
267
|
propertiesPath = propertiesDefaultPath,
|
|
268
268
|
} = {}) => {
|