@mcpher/gas-fakes 2.1.1 → 2.2.1
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/.claspignore +1 -1
- package/README.md +36 -37
- package/package.json +3 -1
- package/src/cli/app.js +1 -11
- package/src/cli/executor.js +0 -7
- package/src/cli/setup.js +142 -35
- package/src/index.js +1 -2
- package/src/services/documentapp/fakedocumentapp.js +1 -1
- package/src/services/documentapp/nrhelpers.js +6 -4
- package/src/services/formapp/fakeformitem.js +1 -3
- package/src/services/scriptapp/app.js +40 -6
- package/src/services/scriptapp/behavior.js +33 -5
- package/src/services/spreadsheetapp/fakespreadsheetapp.js +1 -1
- package/src/services/stores/app.js +11 -15
- package/src/services/xmlservice/app.js +19 -0
- package/src/services/xmlservice/fakeformat.js +53 -0
- package/src/support/auth.js +26 -6
- package/src/support/proxies.js +4 -1
- package/src/support/sxauth.js +26 -25
- package/src/support/sxretry.js +1 -1
- package/src/support/syncit.js +5 -6
- package/gasfakes.json +0 -8
package/.claspignore
CHANGED
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# <img src="./logo.png" alt="gas-fakes logo" width="50" align="top"> Run Native Apps Script code anywhere with gas-fakes
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Google Apps Script, meet Local Development.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
gas-fakes is a powerful emulation layer that lets you run Apps Script projects on Node.js as if they were native. By translating GAS service calls into granular Google API requests, it provides a secure, high-speed sandbox for local debugging and automated testing.
|
|
6
|
+
|
|
7
|
+
Built for the modern stack, it features plug-and-play containerization—allowing you to package your scripts as portable microservices or isolated workers. Coupled with automated identity management, gas-fakes handles the heavy lifting of OAuth and credential cycling, enabling your scripts to act on behalf of users or service accounts without manual intervention. It’s the missing link for building robust, scalable Google Workspace automations and AI-driven workflows.
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
## Getting started as a package user
|
|
@@ -30,44 +32,24 @@ gas-fakes -s "const files=DriveApp.getRootFolder().searchFiles('title contains
|
|
|
30
32
|
|
|
31
33
|
For details see [gas fakes cli](gas-fakes-cli.md)
|
|
32
34
|
|
|
33
|
-
###
|
|
34
|
-
|
|
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.
|
|
36
|
-
|
|
37
|
-
```json
|
|
38
|
-
{
|
|
39
|
-
"manifest": "./appsscript.json",
|
|
40
|
-
"clasp": "./.clasp.json",
|
|
41
|
-
"documentId": null,
|
|
42
|
-
"cache": "/tmp/gas-fakes/cache",
|
|
43
|
-
"properties": "/tmp/gas-fakes/properties",
|
|
44
|
-
"scriptId": "a-unique-id-for-your-local-project"
|
|
45
|
-
}
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
| property | type | default | description |
|
|
49
|
-
| ---------- | ------ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
50
|
-
| manifest | string | ./appsscript.json | the manifest path and name relative to your main module |
|
|
51
|
-
| clasp | string | ./clasp.json | where to look for an optional clasp file |
|
|
52
|
-
| documentId | string | null | a bound document id. This will allow testing of container bound script. The documentId will become your activeDocument (for the appropriate service) |
|
|
53
|
-
| cache | string | /tmp/gas-fakes/cache | gas-fakes uses a local file to emulate apps script's CacheService. This is where it should put the files |
|
|
54
|
-
| properties | string | /tmp/gas-fakes/properties | gas-fakes uses a local file to emulate apps script's PropertiesService. This is where it should put the files. You may want to put it somewhere other than /tmp to avoid accidental deletion, but don't put it in a place that'll get commited to public git repo |
|
|
55
|
-
| scriptId | string | from clasp, or some random value | If you have a clasp file, it'll pick up the scriptId from there. If not you can enter your scriptId manually, or just leave it to create a fake one. It's use for the moment is to return something useful from ScriptApp.getScriptId() and to partition the cache and properties stores |
|
|
56
|
-
|
|
57
|
-
### Troubleshooting: Missing Environment Tags
|
|
35
|
+
### Configuration
|
|
58
36
|
|
|
59
|
-
|
|
37
|
+
Configuration for your local Node environment is handled via environment variables, typically stored in a `.env` file and managed by the `gas-fakes init` process.
|
|
60
38
|
|
|
61
|
-
|
|
39
|
+
| Environment Variable | Default | Description |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `GF_MANIFEST_PATH` | `./appsscript.json` | Path to the `appsscript.json` manifest file. |
|
|
42
|
+
| `GF_CLASP_PATH` | `./.clasp.json` | Path to the `.clasp.json` file. |
|
|
43
|
+
| `GF_SCRIPT_ID` | from clasp, or random | Discovered from `.clasp.json` or generated as a random UUID during `gas-fakes init`. Used for `ScriptApp.getScriptId()` and partitioning stores. |
|
|
44
|
+
| `GF_DOCUMENT_ID` | `null` | A bound document ID for testing container-bound scripts. |
|
|
45
|
+
| `GF_CACHE_PATH` | `/tmp/gas-fakes/cache` | Path for `CacheService` local file emulation. |
|
|
46
|
+
| `GF_PROPERTIES_PATH` | `/tmp/gas-fakes/properties` | Path for `PropertiesService` local file emulation. |
|
|
47
|
+
| `GF_PLATFORM_AUTH` | `google` | Comma-separated list of backends to initialize (`google`, `ksuite`). |
|
|
48
|
+
| `AUTH_TYPE` | `dwd` | Google auth type: `dwd` (Domain-Wide Delegation) or `adc` (Application Default Credentials). |
|
|
49
|
+
| `LOG_DESTINATION` | `CONSOLE` | Logging destination: `CONSOLE`, `CLOUD`, `BOTH`, or `NONE`. |
|
|
50
|
+
| `STORE_TYPE` | `FILE` | Internal storage type for properties/cache: `FILE` (local) or `UPSTASH` (Redis). |
|
|
62
51
|
|
|
63
|
-
```bash
|
|
64
|
-
# Bind the 'Development' environment tag to your project
|
|
65
|
-
gcloud resource-manager tags bindings create \
|
|
66
|
-
--tag-value=YOUR_ORG_ID/environment/Development \
|
|
67
|
-
--parent=//cloudresourcemanager.googleapis.com/projects/YOUR_PROJECT_ID
|
|
68
|
-
```
|
|
69
52
|
|
|
70
|
-
*Note: The tag key `environment` and the value `Development` must already exist at the organization level. If they don't, you (or your admin) will need to create them first using `gcloud resource-manager tags keys create` and `gcloud resource-manager tags values create`.*
|
|
71
53
|
|
|
72
54
|
### Cloud Logging Integration
|
|
73
55
|
|
|
@@ -123,7 +105,7 @@ If you have used Logging to cloud, you can get a link to the log data like this.
|
|
|
123
105
|
console.log ('....example cloud log link for this session',Logger.__cloudLogLink)
|
|
124
106
|
```
|
|
125
107
|
|
|
126
|
-
It contains a cloud logging query that will display any logging done in this session - the filter is based on the scriptId (from
|
|
108
|
+
It contains a cloud logging query that will display any logging done in this session - the filter is based on the scriptId (from the environment), the projectId and userId (from Auth), as well as the start and end time of the session.
|
|
127
109
|
|
|
128
110
|
#### A note on .env location
|
|
129
111
|
|
|
@@ -147,6 +129,20 @@ Logger.__logDestination="BOTH"
|
|
|
147
129
|
|
|
148
130
|
Do whichever one suits you best.
|
|
149
131
|
|
|
132
|
+
### Troubleshooting: Missing Environment Tags
|
|
133
|
+
|
|
134
|
+
If you see a warning or error like `Project '...' lacks an 'environment' tag`, it means your Google Cloud Organization has a policy requiring projects to be designated with an environment tag (e.g., `Development`, `Production`).
|
|
135
|
+
|
|
136
|
+
You can ignore this, but you can resolve it if you want to keep things tidy. You need to bind an environment tag to your project. Replace `YOUR_ORG_ID` and `YOUR_PROJECT_ID` with your actual identifiers:
|
|
137
|
+
```bash
|
|
138
|
+
# Bind the 'Development' environment tag to your project
|
|
139
|
+
gcloud resource-manager tags bindings create \
|
|
140
|
+
--tag-value=YOUR_ORG_ID/environment/Development \
|
|
141
|
+
--parent=//cloudresourcemanager.googleapis.com/projects/YOUR_PROJECT_ID
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
*Note: The tag key `environment` and the value `Development` must already exist at the organization level. If they don't, you (or your admin) will need to create them first using `gcloud resource-manager tags keys create` and `gcloud resource-manager tags values create`.*
|
|
145
|
+
|
|
150
146
|
### Pushing files to GAS
|
|
151
147
|
|
|
152
148
|
There are a couple of syntactical differences between Node and Apps Script. Not in the body of the code but in how the IDE executes. The 2 main ones are
|
|
@@ -170,9 +166,12 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
|
|
|
170
166
|
- [gas fakes cli](gas-fakes-cli.md)
|
|
171
167
|
- [ksuite poc](ksuite_poc.md)
|
|
172
168
|
- [apps script - a lingua franca for workspace platforms](https://ramblings.mcpher.com/apps-script-a-lingua-franca/)
|
|
169
|
+
- [Apps Script: A ‘Lingua Franca’ for the Multi-Cloud Era](https://ramblings.mcpher.com/apps-script-with-ksuite/)
|
|
173
170
|
- [running gas-fakes on google cloud run](https://github.com/brucemcpherson/gas-fakes-containers)
|
|
174
171
|
- [running gas-fakes on google kubernetes engine](https://github.com/brucemcpherson/gas-fakes-containers)
|
|
175
172
|
- [running gas-fakes on Amazon AWS lambda](https://github.com/brucemcpherson/gas-fakes-containers)
|
|
173
|
+
- [running gas-fakes on Azure ACA](https://github.com/brucemcpherson/gas-fakes-containers)
|
|
174
|
+
- [Yes – you can run native apps script code on Azure ACA as well!](https://ramblings.mcpher.com/yes-you-can-run-native-apps-script-code-on-azure-aca-as-well/)
|
|
176
175
|
- [Yes – you can run native apps script code on AWS Lambda!](https://ramblings.mcpher.com/apps-script-on-aws-lambda/)
|
|
177
176
|
- [initial idea and thoughts](https://ramblings.mcpher.com/a-proof-of-concept-implementation-of-apps-script-environment-on-node/)
|
|
178
177
|
- [Inside the volatile world of a Google Document](https://ramblings.mcpher.com/inside-the-volatile-world-of-a-google-document/)
|
package/package.json
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
"node": ">=20.11.0"
|
|
4
4
|
},
|
|
5
5
|
"dependencies": {
|
|
6
|
+
"@azure/identity": "^4.13.0",
|
|
6
7
|
"@mcpher/fake-gasenum": "^1.0.6",
|
|
7
8
|
"@mcpher/gas-flex-cache": "^1.1.5",
|
|
9
|
+
"@microsoft/microsoft-graph-client": "^3.0.7",
|
|
8
10
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
9
11
|
"@sindresorhus/is": "^7.2.0",
|
|
10
12
|
"acorn": "^8.15.0",
|
|
@@ -39,7 +41,7 @@
|
|
|
39
41
|
},
|
|
40
42
|
"name": "@mcpher/gas-fakes",
|
|
41
43
|
"author": "bruce mcpherson",
|
|
42
|
-
"version": "2.
|
|
44
|
+
"version": "2.2.1",
|
|
43
45
|
"license": "MIT",
|
|
44
46
|
"main": "main.js",
|
|
45
47
|
"description": "An implementation of the Google Workspace Apps Script runtime: Run native App Script Code on Node and Cloud Run",
|
package/src/cli/app.js
CHANGED
|
@@ -32,11 +32,6 @@ export async function main() {
|
|
|
32
32
|
"A string containing the Google Apps Script."
|
|
33
33
|
)
|
|
34
34
|
.option("-e, --env <path>", "Path to a custom .env file.", "./.env")
|
|
35
|
-
.option(
|
|
36
|
-
"-g, --gfsettings <path>",
|
|
37
|
-
"Path to a gasfakes.json settings file.",
|
|
38
|
-
"./gasfakes.json"
|
|
39
|
-
)
|
|
40
35
|
.option("-x, --sandbox", "Run the script in a basic sandbox.")
|
|
41
36
|
.option(
|
|
42
37
|
"-w, --whitelistRead <string>",
|
|
@@ -70,7 +65,7 @@ export async function main() {
|
|
|
70
65
|
null
|
|
71
66
|
)
|
|
72
67
|
.action(async (options) => {
|
|
73
|
-
const { filename, script, env
|
|
68
|
+
const { filename, script, env } = options;
|
|
74
69
|
|
|
75
70
|
// If no script provided and no sub-command matched, show help
|
|
76
71
|
if (!filename && !script) {
|
|
@@ -94,10 +89,6 @@ export async function main() {
|
|
|
94
89
|
dotenv.config({ path: envPath, quiet: true });
|
|
95
90
|
}
|
|
96
91
|
|
|
97
|
-
const settingsPath = path.resolve(process.cwd(), gfsettings);
|
|
98
|
-
console.log(`...using gasfakes settings file in ${settingsPath}`);
|
|
99
|
-
process.env.GF_SETTINGS_PATH = settingsPath;
|
|
100
|
-
|
|
101
92
|
const sandboxConfig = buildSandboxConfig(options);
|
|
102
93
|
const useSandbox = !!options.sandbox || !!sandboxConfig;
|
|
103
94
|
|
|
@@ -124,7 +115,6 @@ export async function main() {
|
|
|
124
115
|
display: options.display,
|
|
125
116
|
useSandbox,
|
|
126
117
|
sandboxConfig,
|
|
127
|
-
gfSettings: settingsPath,
|
|
128
118
|
args,
|
|
129
119
|
gas_library,
|
|
130
120
|
});
|
package/src/cli/executor.js
CHANGED
|
@@ -156,7 +156,6 @@ export async function executeGasScript(options) {
|
|
|
156
156
|
filename,
|
|
157
157
|
script,
|
|
158
158
|
display,
|
|
159
|
-
gfSettings,
|
|
160
159
|
useSandbox,
|
|
161
160
|
sandboxConfig,
|
|
162
161
|
args,
|
|
@@ -181,12 +180,6 @@ export async function executeGasScript(options) {
|
|
|
181
180
|
);
|
|
182
181
|
}
|
|
183
182
|
|
|
184
|
-
Object.defineProperty(globalThis, "settingsPath", {
|
|
185
|
-
value: gfSettings,
|
|
186
|
-
writable: true,
|
|
187
|
-
configurable: true,
|
|
188
|
-
});
|
|
189
|
-
|
|
190
183
|
if (gas_library && gas_library.length > 0) {
|
|
191
184
|
const libs = gas_library.reduce((ar, { identifier, libScript }) => {
|
|
192
185
|
if (mainScript.includes(identifier)) {
|
package/src/cli/setup.js
CHANGED
|
@@ -3,11 +3,36 @@ import dotenv from "dotenv";
|
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import os from "os";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { execSync } from "child_process";
|
|
7
8
|
import { checkForGcloudCli, runCommandSync } from "./utils.js";
|
|
8
9
|
|
|
9
10
|
// --- Utility Functions ---
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Retrieves the scriptId and its source.
|
|
14
|
+
* @returns {{scriptId: string, source: string}}
|
|
15
|
+
*/
|
|
16
|
+
function getScriptIdInfo() {
|
|
17
|
+
if (process.env.GF_SCRIPT_ID) {
|
|
18
|
+
return { scriptId: process.env.GF_SCRIPT_ID, source: "env (GF_SCRIPT_ID)" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const claspPath = process.env.GF_CLASP_PATH || "./.clasp.json";
|
|
22
|
+
if (fs.existsSync(claspPath)) {
|
|
23
|
+
try {
|
|
24
|
+
const clasp = JSON.parse(fs.readFileSync(claspPath, "utf8"));
|
|
25
|
+
if (clasp.scriptId) {
|
|
26
|
+
return { scriptId: clasp.scriptId, source: `clasp (${claspPath})` };
|
|
27
|
+
}
|
|
28
|
+
} catch (e) {
|
|
29
|
+
// Ignore parsing errors
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { scriptId: "not set (will be random at runtime)", source: "none" };
|
|
34
|
+
}
|
|
35
|
+
|
|
11
36
|
/**
|
|
12
37
|
* Recursively searches for .env files starting from a directory.
|
|
13
38
|
* @param {string} dir - Start directory
|
|
@@ -118,7 +143,80 @@ export async function initializeConfiguration(options = {}) {
|
|
|
118
143
|
if (typeof platforms === "string") platforms = platforms.split(",");
|
|
119
144
|
responses.GF_PLATFORM_AUTH = platforms.join(",");
|
|
120
145
|
|
|
121
|
-
// --- Step 2:
|
|
146
|
+
// --- Step 2: Gas-Fakes Behavior Configuration ---
|
|
147
|
+
console.log("\n--- Configuring Gas-Fakes paths and behavior ---");
|
|
148
|
+
const gasFakesQuestions = [
|
|
149
|
+
{
|
|
150
|
+
type: "text",
|
|
151
|
+
name: "GF_MANIFEST_PATH",
|
|
152
|
+
message: "Path to appsscript.json",
|
|
153
|
+
initial: existingConfig.GF_MANIFEST_PATH || "./appsscript.json",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
type: "text",
|
|
157
|
+
name: "GF_CLASP_PATH",
|
|
158
|
+
message: "Path to .clasp.json",
|
|
159
|
+
initial: existingConfig.GF_CLASP_PATH || "./.clasp.json",
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
type: "text",
|
|
163
|
+
name: "GF_SCRIPT_ID",
|
|
164
|
+
message: (prev, values) => {
|
|
165
|
+
const claspPath = values.GF_CLASP_PATH || "./.clasp.json";
|
|
166
|
+
let hint = "";
|
|
167
|
+
if (fs.existsSync(claspPath)) {
|
|
168
|
+
try {
|
|
169
|
+
const clasp = JSON.parse(fs.readFileSync(claspPath, "utf8"));
|
|
170
|
+
if (clasp.scriptId) {
|
|
171
|
+
hint = ` (found in ${claspPath}: ${clasp.scriptId})`;
|
|
172
|
+
}
|
|
173
|
+
} catch (e) {}
|
|
174
|
+
}
|
|
175
|
+
if (!hint && !existingConfig.GF_SCRIPT_ID) {
|
|
176
|
+
hint = " (no ID found; a random one will be generated)";
|
|
177
|
+
}
|
|
178
|
+
return `Script ID (optional, overrides .clasp.json)${hint}`;
|
|
179
|
+
},
|
|
180
|
+
initial: (prev, values) => {
|
|
181
|
+
if (existingConfig.GF_SCRIPT_ID) return existingConfig.GF_SCRIPT_ID;
|
|
182
|
+
const claspPath = values.GF_CLASP_PATH || "./.clasp.json";
|
|
183
|
+
if (fs.existsSync(claspPath)) {
|
|
184
|
+
try {
|
|
185
|
+
const clasp = JSON.parse(fs.readFileSync(claspPath, "utf8"));
|
|
186
|
+
if (clasp.scriptId) return clasp.scriptId;
|
|
187
|
+
} catch (e) {}
|
|
188
|
+
}
|
|
189
|
+
return randomUUID();
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
type: "text",
|
|
194
|
+
name: "GF_DOCUMENT_ID",
|
|
195
|
+
message: "Document ID (optional, for container-bound scripts)",
|
|
196
|
+
initial: existingConfig.GF_DOCUMENT_ID || "",
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
type: "text",
|
|
200
|
+
name: "GF_CACHE_PATH",
|
|
201
|
+
message: "Cache storage path",
|
|
202
|
+
initial: existingConfig.GF_CACHE_PATH || "/tmp/gas-fakes/cache",
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
type: "text",
|
|
206
|
+
name: "GF_PROPERTIES_PATH",
|
|
207
|
+
message: "Properties storage path",
|
|
208
|
+
initial: existingConfig.GF_PROPERTIES_PATH || "/tmp/gas-fakes/properties",
|
|
209
|
+
}
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const gasFakesResponses = await prompts(gasFakesQuestions);
|
|
213
|
+
if (typeof gasFakesResponses.GF_MANIFEST_PATH === "undefined") {
|
|
214
|
+
console.log("Initialization cancelled.");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
Object.assign(responses, gasFakesResponses);
|
|
218
|
+
|
|
219
|
+
// --- Step 3: Google Workspace Configuration ---
|
|
122
220
|
if (platforms.includes("google")) {
|
|
123
221
|
console.log("\n--- Configuring Google Workspace backend ---");
|
|
124
222
|
|
|
@@ -146,18 +244,18 @@ export async function initializeConfiguration(options = {}) {
|
|
|
146
244
|
}
|
|
147
245
|
|
|
148
246
|
// Discover Scopes from appsscript.json
|
|
149
|
-
const manifestPath = path.resolve(process.cwd(),
|
|
247
|
+
const manifestPath = path.resolve(process.cwd(), responses.GF_MANIFEST_PATH);
|
|
150
248
|
let manifestScopes = [];
|
|
151
249
|
if (fs.existsSync(manifestPath)) {
|
|
152
250
|
try {
|
|
153
251
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
154
252
|
manifestScopes = manifest.oauthScopes || [];
|
|
155
|
-
console.log(`...discovered ${manifestScopes.length} scopes in
|
|
253
|
+
console.log(`...discovered ${manifestScopes.length} scopes in ${responses.GF_MANIFEST_PATH}`);
|
|
156
254
|
} catch (err) {
|
|
157
|
-
console.warn(
|
|
255
|
+
console.warn(`...warning: failed to parse ${responses.GF_MANIFEST_PATH}. Using default scopes only.`);
|
|
158
256
|
}
|
|
159
257
|
} else {
|
|
160
|
-
console.log(
|
|
258
|
+
console.log(`${responses.GF_MANIFEST_PATH} not found. Using default scopes only.`);
|
|
161
259
|
}
|
|
162
260
|
|
|
163
261
|
const DEFAULT_SCOPES_VALUES = [
|
|
@@ -184,9 +282,9 @@ export async function initializeConfiguration(options = {}) {
|
|
|
184
282
|
initial: existingConfig.GOOGLE_SERVICE_ACCOUNT_NAME || "gas-fakes-sa",
|
|
185
283
|
},
|
|
186
284
|
{
|
|
187
|
-
type:
|
|
285
|
+
type: "text",
|
|
188
286
|
name: "CLIENT_CREDENTIAL_FILE",
|
|
189
|
-
message: "Enter path to OAuth client credentials JSON (optional, required for restricted scopes)",
|
|
287
|
+
message: "Enter path to OAuth client credentials JSON (optional, required for restricted scopes with ADC)",
|
|
190
288
|
initial: existingConfig.CLIENT_CREDENTIAL_FILE || "",
|
|
191
289
|
}
|
|
192
290
|
];
|
|
@@ -199,7 +297,7 @@ export async function initializeConfiguration(options = {}) {
|
|
|
199
297
|
Object.assign(responses, googleResponses);
|
|
200
298
|
}
|
|
201
299
|
|
|
202
|
-
// --- Step
|
|
300
|
+
// --- Step 4: Infomaniak KSuite Configuration ---
|
|
203
301
|
if (platforms.includes("ksuite")) {
|
|
204
302
|
console.log("\n--- Configuring Infomaniak KSuite backend ---");
|
|
205
303
|
const ksuiteQuestions = [
|
|
@@ -224,7 +322,7 @@ export async function initializeConfiguration(options = {}) {
|
|
|
224
322
|
Object.assign(responses, ksuiteResponses);
|
|
225
323
|
}
|
|
226
324
|
|
|
227
|
-
// --- Step
|
|
325
|
+
// --- Step 5: Shared Remaining Config ---
|
|
228
326
|
const remainingQuestions = [
|
|
229
327
|
{
|
|
230
328
|
type: "toggle",
|
|
@@ -335,6 +433,9 @@ export async function authenticateUser(options = {}) {
|
|
|
335
433
|
}
|
|
336
434
|
dotenv.config({ path: envPath, quiet: true });
|
|
337
435
|
|
|
436
|
+
const { scriptId, source } = getScriptIdInfo();
|
|
437
|
+
console.log(`...using scriptId: ${scriptId} (source: ${source})`);
|
|
438
|
+
|
|
338
439
|
let platforms = (process.env.GF_PLATFORM_AUTH || "google").split(",");
|
|
339
440
|
|
|
340
441
|
// If specific backend requested via CLI, only auth that one
|
|
@@ -363,6 +464,7 @@ export async function authenticateUser(options = {}) {
|
|
|
363
464
|
|
|
364
465
|
const {
|
|
365
466
|
GOOGLE_CLOUD_PROJECT,
|
|
467
|
+
GCP_PROJECT_ID,
|
|
366
468
|
DEFAULT_SCOPES,
|
|
367
469
|
EXTRA_SCOPES,
|
|
368
470
|
CLIENT_CREDENTIAL_FILE,
|
|
@@ -371,9 +473,9 @@ export async function authenticateUser(options = {}) {
|
|
|
371
473
|
GOOGLE_SERVICE_ACCOUNT_NAME
|
|
372
474
|
} = process.env;
|
|
373
475
|
|
|
374
|
-
const projectId = GOOGLE_CLOUD_PROJECT;
|
|
476
|
+
const projectId = GOOGLE_CLOUD_PROJECT || GCP_PROJECT_ID;
|
|
375
477
|
if (!projectId) {
|
|
376
|
-
console.error("Error:
|
|
478
|
+
console.error("Error: Project ID not set. Please run 'gas-fakes init' first.");
|
|
377
479
|
continue;
|
|
378
480
|
}
|
|
379
481
|
|
|
@@ -387,33 +489,38 @@ export async function authenticateUser(options = {}) {
|
|
|
387
489
|
const driveAccessFlag = "--enable-gdrive-access";
|
|
388
490
|
const activeConfig = AC || "default";
|
|
389
491
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (fs.existsSync(clientPath)) clientFlag = `--client-id-file="${clientPath}"`;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
console.log("Revoking previous ADC credentials...");
|
|
398
|
-
try { execSync("gcloud auth application-default revoke --quiet", { stdio: "ignore", shell: true }); } catch (e) {}
|
|
492
|
+
// --- Common Google Login (Normal Auth Dialog) ---
|
|
493
|
+
console.log("Revoking previous user credentials...");
|
|
494
|
+
try { execSync("gcloud auth revoke --quiet", { stdio: "ignore", shell: true }); } catch (e) {}
|
|
495
|
+
try { execSync("gcloud auth application-default revoke --quiet", { stdio: "ignore", shell: true }); } catch (e) {}
|
|
399
496
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
497
|
+
console.log(`Setting up gcloud config: ${activeConfig}`);
|
|
498
|
+
try { execSync(`gcloud config configurations describe "${activeConfig}"`, { stdio: "ignore", shell: true }); }
|
|
499
|
+
catch (e) { runCommandSync(`gcloud config configurations create "${activeConfig}"`); }
|
|
500
|
+
runCommandSync(`gcloud config configurations activate "${activeConfig}"`);
|
|
501
|
+
|
|
502
|
+
runCommandSync(`gcloud config set project ${projectId}`);
|
|
503
|
+
runCommandSync(`gcloud config set billing/quota_project ${projectId}`);
|
|
504
|
+
|
|
505
|
+
console.log("Initiating user login...");
|
|
506
|
+
runCommandSync(`gcloud auth login ${driveAccessFlag}`);
|
|
507
|
+
|
|
508
|
+
let clientFlag = "";
|
|
509
|
+
if (CLIENT_CREDENTIAL_FILE) {
|
|
510
|
+
const clientPath = path.resolve(process.cwd(), CLIENT_CREDENTIAL_FILE);
|
|
511
|
+
if (fs.existsSync(clientPath)) {
|
|
512
|
+
console.log(`...using client credentials from ${clientPath}`);
|
|
513
|
+
clientFlag = `--client-id-file="${clientPath}"`;
|
|
514
|
+
}
|
|
414
515
|
}
|
|
415
516
|
|
|
517
|
+
console.log("Setting up Application Default Credentials (ADC)...");
|
|
518
|
+
runCommandSync(`gcloud auth application-default login --scopes="${scopes}" ${clientFlag}`);
|
|
519
|
+
runCommandSync(`gcloud auth application-default set-quota-project ${projectId}`);
|
|
520
|
+
|
|
521
|
+
// --- DWD Specific Setup (if configured) ---
|
|
416
522
|
if (AUTH_TYPE === "dwd") {
|
|
523
|
+
console.log("\n--- Performing Domain-Wide Delegation (DWD) Setup ---");
|
|
417
524
|
const current_user = execSync("gcloud config get-value account", { shell: true }).toString().trim();
|
|
418
525
|
const sa_email = `${GOOGLE_SERVICE_ACCOUNT_NAME}@${projectId}.iam.gserviceaccount.com`;
|
|
419
526
|
|
|
@@ -446,7 +553,7 @@ export async function authenticateUser(options = {}) {
|
|
|
446
553
|
}
|
|
447
554
|
|
|
448
555
|
/**
|
|
449
|
-
* Handles the 'enableAPIs' command to enable or disable
|
|
556
|
+
* Handles the 'enableAPIs' command to enable or disable required Google Cloud services based on options.
|
|
450
557
|
* @param {object} options Options object provided by commander.js.
|
|
451
558
|
*/
|
|
452
559
|
export function enableGoogleAPIs(options) {
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import './support/env-loader.js';
|
|
2
|
+
import './services/stores/app.js'
|
|
2
3
|
import './services/scriptapp/app.js'
|
|
3
4
|
import './services/driveapp/app.js'
|
|
4
5
|
import './services/logger/app.js'
|
|
@@ -27,5 +28,3 @@ import './services/slidesapp/app.js'
|
|
|
27
28
|
import './services/mimetype/app.js'
|
|
28
29
|
import './services/lock/app.js'
|
|
29
30
|
import './services/libhandlerapp/app.js'
|
|
30
|
-
// should be last
|
|
31
|
-
import './services/stores/app.js'
|
|
@@ -74,7 +74,7 @@ class FakeDocumentApp {
|
|
|
74
74
|
return this.openById(match[1]);
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
|
-
* note that this in gas-fakes uses the
|
|
77
|
+
* note that this in gas-fakes uses the GF_DOCUMENT_ID from the environment
|
|
78
78
|
* Returns the document to which the script is container-bound. To interact with document to which the script is not container-bound, use openById(id) or openByUrl(url) instead.
|
|
79
79
|
* @returns {Document}
|
|
80
80
|
*/
|
|
@@ -25,11 +25,13 @@ export const getCurrentNr = (data) => {
|
|
|
25
25
|
.filter(key => key.startsWith(shadowPrefix))
|
|
26
26
|
.reduce((p, c) => {
|
|
27
27
|
// strangly there's another level of .namedRanges property
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const nrs = data.namedRanges[c].namedRanges;
|
|
29
|
+
if (nrs.length > 1) {
|
|
30
|
+
// This can happen if a batchUpdate retries due to timeout but the first attempt actually succeeded.
|
|
31
|
+
// The duplicates will be automatically cleaned up by the caller (makeElementMap).
|
|
32
|
+
process.stderr.write(`...warning: found ${nrs.length} named ranges for ${c} - duplicates will be cleaned up\n`);
|
|
31
33
|
}
|
|
32
|
-
|
|
34
|
+
nrs.forEach(r => {
|
|
33
35
|
p.push(r)
|
|
34
36
|
})
|
|
35
37
|
return p
|
|
@@ -114,9 +114,7 @@ export class FakeFormItem {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
getId() {
|
|
117
|
-
|
|
118
|
-
const hexId = resource.questionItem?.question?.questionId || this.__itemId;
|
|
119
|
-
return Utils.fromHex(hexId);
|
|
117
|
+
return Utils.fromHex(this.__itemId);
|
|
120
118
|
}
|
|
121
119
|
|
|
122
120
|
getIndex() {
|
|
@@ -59,7 +59,7 @@ const limitMode = (mode) => {
|
|
|
59
59
|
const requireAllScopes = (mode) => {
|
|
60
60
|
limitMode(mode)
|
|
61
61
|
ensureInit()
|
|
62
|
-
return checkScopesMatch(Array.from(Auth.getAuthedScopes()
|
|
62
|
+
return checkScopesMatch(Array.from(Auth.getAuthedScopes()))
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
@@ -86,11 +86,28 @@ const checkScopesMatch = (required) => {
|
|
|
86
86
|
ensureInit()
|
|
87
87
|
const scopes = Auth.getTokenScopes()
|
|
88
88
|
|
|
89
|
+
// console.log('...DEBUG: scopes type:', typeof scopes, 'content:', scopes);
|
|
90
|
+
|
|
89
91
|
// now we're syncronous all the way
|
|
90
|
-
|
|
92
|
+
// normalize tokened scopes by removing trailing slashes.
|
|
93
|
+
// Handle both space and comma separation.
|
|
94
|
+
let scopeList = [];
|
|
95
|
+
if (Array.isArray(scopes)) {
|
|
96
|
+
scopeList = scopes;
|
|
97
|
+
} else if (typeof scopes === 'string') {
|
|
98
|
+
scopeList = scopes.split(/[ ,]/);
|
|
99
|
+
} else if (scopes && typeof scopes === 'object') {
|
|
100
|
+
// If it's a non-null object, maybe it's serializable but has a toString?
|
|
101
|
+
scopeList = String(scopes).split(/[ ,]/);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const tokened = new Set(scopeList.map(s => s.trim().replace(/\/$/, "")).filter(s => s))
|
|
91
105
|
|
|
92
106
|
// see which ones are missing
|
|
93
107
|
const missing = required.filter(s => {
|
|
108
|
+
// normalized required scope
|
|
109
|
+
const ns = s.trim().replace(/\/$/, "")
|
|
110
|
+
|
|
94
111
|
// setting this scope causes gcloud to block
|
|
95
112
|
// seem to manage without them anyway
|
|
96
113
|
const ignores = [
|
|
@@ -99,13 +116,19 @@ const checkScopesMatch = (required) => {
|
|
|
99
116
|
"https://www.googleapis.com/auth/presentations",
|
|
100
117
|
"https://www.googleapis.com/auth/forms"
|
|
101
118
|
]
|
|
102
|
-
const hasIgnore = ignores.
|
|
119
|
+
const hasIgnore = ignores.some(i => i.replace(/\/$/, "") === ns)
|
|
103
120
|
if (hasIgnore) {
|
|
104
121
|
slogger.warn('...ignoring requested scope for adc as google blocks it outside apps script' + s)
|
|
105
122
|
}
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
123
|
+
|
|
124
|
+
// a scope is satisfied if:
|
|
125
|
+
// 1. It is explicitly in the tokened set
|
|
126
|
+
// 2. It is a .readonly scope AND the base scope is in the tokened set (e.g. drive satisfy drive.readonly)
|
|
127
|
+
|
|
128
|
+
const baseNs = ns.replace(/\.readonly$/, "")
|
|
129
|
+
const isSatisfied = tokened.has(ns) || (ns.endsWith(".readonly") && tokened.has(baseNs))
|
|
130
|
+
|
|
131
|
+
return !(hasIgnore || isSatisfied)
|
|
109
132
|
})
|
|
110
133
|
|
|
111
134
|
if (missing.length) {
|
|
@@ -178,6 +201,9 @@ if (typeof globalThis[name] === typeof undefined) {
|
|
|
178
201
|
__proxies: Proxies,
|
|
179
202
|
get __registeredServices() {
|
|
180
203
|
return Proxies.getRegisteredServices()
|
|
204
|
+
},
|
|
205
|
+
get __loadedServices() {
|
|
206
|
+
return Proxies.getLoadedServices()
|
|
181
207
|
}
|
|
182
208
|
}
|
|
183
209
|
|
|
@@ -200,6 +226,12 @@ if (typeof globalThis[name] === typeof undefined) {
|
|
|
200
226
|
const handler = {
|
|
201
227
|
get(_, prop, receiver) {
|
|
202
228
|
if (prop === 'isFake') return true;
|
|
229
|
+
|
|
230
|
+
// BRIDGE: Inform LoadedRegistry about ScriptApp being loaded
|
|
231
|
+
if (prop !== '__behavior' && prop !== '__proxies') {
|
|
232
|
+
Proxies.__addLoaded(name);
|
|
233
|
+
}
|
|
234
|
+
|
|
203
235
|
const app = getApp(prop);
|
|
204
236
|
return Reflect.get(app, prop, receiver);
|
|
205
237
|
},
|
|
@@ -216,4 +248,6 @@ if (typeof globalThis[name] === typeof undefined) {
|
|
|
216
248
|
writable: false,
|
|
217
249
|
});
|
|
218
250
|
|
|
251
|
+
// Manually add ScriptApp to service registry
|
|
252
|
+
Proxies.__addService(name);
|
|
219
253
|
}
|
|
@@ -268,10 +268,38 @@ class FakeBehavior {
|
|
|
268
268
|
this.__idWhitelist = null
|
|
269
269
|
|
|
270
270
|
// individually settable services
|
|
271
|
-
|
|
272
|
-
this.__sandboxService = {}
|
|
273
|
-
|
|
274
|
-
|
|
271
|
+
// BRIDGE: Use a Proxy to dynamically handle services, even those registered later
|
|
272
|
+
this.__sandboxService = new Proxy({}, {
|
|
273
|
+
get: (target, name) => {
|
|
274
|
+
if (typeof name !== 'string' || name.startsWith('__')) return target[name]
|
|
275
|
+
|
|
276
|
+
// EXCLUSION: CacheService and PropertiesService are NOT intended to be sandboxed
|
|
277
|
+
if (name === 'CacheService' || name === 'PropertiesService') return undefined;
|
|
278
|
+
|
|
279
|
+
if (!target[name]) {
|
|
280
|
+
target[name] = newFakeSandboxService(this, name)
|
|
281
|
+
}
|
|
282
|
+
return target[name]
|
|
283
|
+
},
|
|
284
|
+
ownKeys: (target) => {
|
|
285
|
+
// When asked for keys, ensure all registered services are present in the target
|
|
286
|
+
if (globalThis.ScriptApp?.__registeredServices) {
|
|
287
|
+
globalThis.ScriptApp.__registeredServices.forEach(s => {
|
|
288
|
+
// EXCLUSION: CacheService and PropertiesService are NOT intended to be sandboxed
|
|
289
|
+
if (s !== 'CacheService' && s !== 'PropertiesService' && !target[s]) {
|
|
290
|
+
target[s] = newFakeSandboxService(this, s)
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
return Reflect.ownKeys(target)
|
|
295
|
+
},
|
|
296
|
+
getOwnPropertyDescriptor: (target, name) => {
|
|
297
|
+
if (typeof name === 'string' && !name.startsWith('__') && name !== 'CacheService' && name !== 'PropertiesService' && !target[name]) {
|
|
298
|
+
target[name] = newFakeSandboxService(this, name)
|
|
299
|
+
}
|
|
300
|
+
return Reflect.getOwnPropertyDescriptor(target, name)
|
|
301
|
+
}
|
|
302
|
+
})
|
|
275
303
|
}
|
|
276
304
|
newIdWhitelistItem(id) {
|
|
277
305
|
return newFakeIdWhitelistItem(id)
|
|
@@ -352,7 +380,7 @@ class FakeBehavior {
|
|
|
352
380
|
}
|
|
353
381
|
resetGmail() {
|
|
354
382
|
this.__createdGmailIds.clear();
|
|
355
|
-
this.
|
|
383
|
+
this.__allowedIds.clear();
|
|
356
384
|
return this;
|
|
357
385
|
}
|
|
358
386
|
resetCalendar() {
|
|
@@ -35,7 +35,7 @@ export const newFakeSpreadsheetApp = (...args) => {
|
|
|
35
35
|
*/
|
|
36
36
|
export class FakeSpreadsheetApp {
|
|
37
37
|
constructor() {
|
|
38
|
-
// in the context of gas-fakes we start with the activespreadsheet being the one mentioned in
|
|
38
|
+
// in the context of gas-fakes we start with the activespreadsheet being the one mentioned in the environment (GF_DOCUMENT_ID)
|
|
39
39
|
this.__activeSpreadsheet = null
|
|
40
40
|
const enumProps = [
|
|
41
41
|
"AutoFillSeries", // AutoFillSeries An enumeration of the types of series used to calculate auto-filled values.
|
|
@@ -19,22 +19,18 @@ let _cacheApp = null
|
|
|
19
19
|
/**
|
|
20
20
|
* @returns {FakeService}
|
|
21
21
|
*/
|
|
22
|
-
const registerApp = (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (!_app) {
|
|
29
|
-
_app = newFakeService(kind)
|
|
30
|
-
}
|
|
31
|
-
// this is the actual driveApp we'll return from the proxy
|
|
32
|
-
return _app
|
|
22
|
+
const registerApp = (name, kind) => {
|
|
23
|
+
let instance = null;
|
|
24
|
+
const getApp = () => {
|
|
25
|
+
// if it hasnt been intialized yet then do that
|
|
26
|
+
if (!instance) {
|
|
27
|
+
instance = newFakeService(kind)
|
|
33
28
|
}
|
|
34
|
-
|
|
29
|
+
// this is the actual driveApp we'll return from the proxy
|
|
30
|
+
return instance
|
|
35
31
|
}
|
|
32
|
+
Proxies.registerProxy(name, getApp)
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
registerApp('PropertiesService', 'PROPERTIES')
|
|
36
|
+
registerApp('CacheService', 'CACHE')
|
|
@@ -4,6 +4,7 @@ import { Proxies } from '../../support/proxies.js';
|
|
|
4
4
|
import { FakeDocument } from './fakedocument.js';
|
|
5
5
|
import { FakeElement } from './fakeelement.js';
|
|
6
6
|
import { FakeNamespace } from './fakenamespace.js';
|
|
7
|
+
import { FakeFormat } from './fakeformat.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Parses the given XML string and returns a Document object.
|
|
@@ -42,6 +43,22 @@ const getNamespace = (prefix, uri) => {
|
|
|
42
43
|
return new FakeNamespace(prefix, uri);
|
|
43
44
|
};
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Returns a Format object for pretty printing.
|
|
48
|
+
* @return {FakeFormat} The format object.
|
|
49
|
+
*/
|
|
50
|
+
const getPrettyFormat = () => {
|
|
51
|
+
return new FakeFormat({ pretty: true });
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns a Format object for raw output.
|
|
56
|
+
* @return {FakeFormat} The format object.
|
|
57
|
+
*/
|
|
58
|
+
const getRawFormat = () => {
|
|
59
|
+
return new FakeFormat({ pretty: false });
|
|
60
|
+
};
|
|
61
|
+
|
|
45
62
|
// Singleton app object
|
|
46
63
|
let _app = null;
|
|
47
64
|
|
|
@@ -52,6 +69,8 @@ if (typeof globalThis[name] === typeof undefined) {
|
|
|
52
69
|
_app = {
|
|
53
70
|
parse,
|
|
54
71
|
getNamespace,
|
|
72
|
+
getPrettyFormat,
|
|
73
|
+
getRawFormat,
|
|
55
74
|
toString: () => name
|
|
56
75
|
};
|
|
57
76
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { XMLBuilder } from 'fast-xml-parser';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fake Format class for XmlService
|
|
5
|
+
*/
|
|
6
|
+
export class FakeFormat {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this._options = options;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Formats the given document as an XML string.
|
|
13
|
+
* @param {FakeDocument} document The document to format.
|
|
14
|
+
* @return {string} The formatted XML string.
|
|
15
|
+
*/
|
|
16
|
+
format(document) {
|
|
17
|
+
if (!document || typeof document.getRootElement !== 'function') {
|
|
18
|
+
throw new Error("XmlService: Invalid document provided to format().");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const root = document.getRootElement();
|
|
22
|
+
const builderOptions = {
|
|
23
|
+
ignoreAttributes: false,
|
|
24
|
+
attributeNamePrefix: "@_",
|
|
25
|
+
textNodeName: "#text",
|
|
26
|
+
format: this._options.pretty || false,
|
|
27
|
+
indentBy: this._options.pretty ? " " : "",
|
|
28
|
+
suppressEmptyNode: false // GAS usually outputs <tag></tag> or <tag/>? Actually GAS uses <tag/> for empty elements usually.
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const builder = new XMLBuilder(builderOptions);
|
|
32
|
+
|
|
33
|
+
// Construct the object for building
|
|
34
|
+
// We access the internal _data property of FakeElement
|
|
35
|
+
const data = {
|
|
36
|
+
[root.getQualifiedName()]: root._data
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let xml = builder.build(data);
|
|
40
|
+
|
|
41
|
+
// GAS adds XML declaration followed by a line separator (\r\n)
|
|
42
|
+
const declaration = '<?xml version="1.0" encoding="UTF-8"?>\r\n';
|
|
43
|
+
|
|
44
|
+
if (this._options.pretty) {
|
|
45
|
+
// GAS pretty format uses \r\n and has a newline after declaration
|
|
46
|
+
// XMLBuilder uses \n, so we replace it.
|
|
47
|
+
return declaration + xml.replace(/\n/g, '\r\n') + '\r\n';
|
|
48
|
+
} else {
|
|
49
|
+
// Raw format - declaration, compact XML, and trailing newline
|
|
50
|
+
return declaration + xml + '\r\n';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/support/auth.js
CHANGED
|
@@ -67,13 +67,17 @@ const setTokenScopes = (scopes, platform = _platform) => {
|
|
|
67
67
|
const getTokenScopes = () => {
|
|
68
68
|
const id = _getIdentity();
|
|
69
69
|
if (id.tokenScopes) return id.tokenScopes;
|
|
70
|
-
if (_platform === 'ksuite') return "";
|
|
70
|
+
if (_platform === 'ksuite') return "";
|
|
71
71
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
// If we have an authClient, we might be able to discover them
|
|
73
|
+
if (id.authClient) {
|
|
74
|
+
return getAccessTokenInfo().then(info => {
|
|
75
|
+
id.tokenScopes = info.tokenInfo.scope;
|
|
76
|
+
return id.tokenScopes;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return "";
|
|
77
81
|
};
|
|
78
82
|
|
|
79
83
|
const getHashedUserId = () =>
|
|
@@ -219,6 +223,12 @@ const setAuth = async (scopes = [], mcpLoading = false) => {
|
|
|
219
223
|
return { access_token: token };
|
|
220
224
|
};
|
|
221
225
|
|
|
226
|
+
dwdClient.invalidateToken = function () {
|
|
227
|
+
this._token = null;
|
|
228
|
+
this._expiresAt = 0;
|
|
229
|
+
this.credentials = { access_token: 'dummy' };
|
|
230
|
+
};
|
|
231
|
+
|
|
222
232
|
id.authClient = dwdClient
|
|
223
233
|
}
|
|
224
234
|
} catch (error) {
|
|
@@ -290,8 +300,17 @@ export const responseSyncify = (result) => {
|
|
|
290
300
|
|
|
291
301
|
// Helper to populate identity from sxInit response
|
|
292
302
|
const setIdentity = (platform, data) => {
|
|
303
|
+
if (!data) return;
|
|
304
|
+
// slogger.warn(`...DEBUG: Auth.setIdentity for platform=${platform}. data keys=${Object.keys(data).join(',')}`);
|
|
293
305
|
const id = _getIdentity(platform);
|
|
294
306
|
Object.assign(id, data);
|
|
307
|
+
// slogger.warn(`...DEBUG: Auth.setIdentity result for platform=${platform}. id.tokenScopes=${id.tokenScopes}`);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const getAuthedScopes = () => {
|
|
311
|
+
const id = _getIdentity('google');
|
|
312
|
+
const scopes = id.tokenScopes || "";
|
|
313
|
+
return new Set((typeof scopes === 'string' ? scopes : "").split(" ").filter(s => s));
|
|
295
314
|
};
|
|
296
315
|
|
|
297
316
|
export const Auth = {
|
|
@@ -305,6 +324,7 @@ export const Auth = {
|
|
|
305
324
|
getUserId,
|
|
306
325
|
getAccessToken,
|
|
307
326
|
getTokenScopes,
|
|
327
|
+
getAuthedScopes,
|
|
308
328
|
getScriptId,
|
|
309
329
|
getDocumentId,
|
|
310
330
|
setSettings,
|
package/src/support/proxies.js
CHANGED
|
@@ -134,4 +134,7 @@ export const Proxies = {
|
|
|
134
134
|
guard,
|
|
135
135
|
blanketProxy,
|
|
136
136
|
getRegisteredServices: () => Array.from(serviceRegistry),
|
|
137
|
-
|
|
137
|
+
getLoadedServices: () => Array.from(loadedRegistry),
|
|
138
|
+
__addService: (name) => serviceRegistry.add(name),
|
|
139
|
+
__addLoaded: (name) => loadedRegistry.add(name),
|
|
140
|
+
}
|
package/src/support/sxauth.js
CHANGED
|
@@ -15,17 +15,16 @@ import { KSuiteDrive } from './ksuite/kdrive.js';
|
|
|
15
15
|
let _loggedSummary = false;
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* initialize
|
|
18
|
+
* initialize key stuff at the beginning such as manifest content and settings
|
|
19
19
|
* @param {object} p pargs
|
|
20
|
-
* @param {string} p.manifestPath where to
|
|
21
|
-
* @param {string} p.authPath import the auth code
|
|
20
|
+
* @param {string} p.manifestPath where to find the manifest by default
|
|
22
21
|
* @param {string} p.claspPath where to find the clasp file by default
|
|
23
22
|
* @param {string} p.settingsPath where to find the settings file
|
|
24
23
|
* @param {string} p.cachePath the cache files
|
|
25
24
|
* @param {string} p.propertiesPath the properties file location
|
|
26
25
|
* @param {string} p.fakeId a fake script id to use if one isnt in the settings
|
|
27
26
|
* @param {string[]} [p.platformAuth] list of platforms to authenticate
|
|
28
|
-
* @return {object} the finalized
|
|
27
|
+
* @return {object} the finalized versions of all the above
|
|
29
28
|
*/
|
|
30
29
|
export const sxInit = async ({ manifestPath, claspPath, settingsPath, cachePath, propertiesPath, fakeId, platformAuth }) => {
|
|
31
30
|
|
|
@@ -34,6 +33,7 @@ export const sxInit = async ({ manifestPath, claspPath, settingsPath, cachePath,
|
|
|
34
33
|
|
|
35
34
|
// get a file and parse if it exists
|
|
36
35
|
const getIfExists = async (file) => {
|
|
36
|
+
if (!file) return {};
|
|
37
37
|
try {
|
|
38
38
|
const content = await readFile(file, { encoding: 'utf8' })
|
|
39
39
|
return JSON.parse(content)
|
|
@@ -42,30 +42,21 @@ export const sxInit = async ({ manifestPath, claspPath, settingsPath, cachePath,
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const settings = { ..._settings }
|
|
45
|
+
const manifestFile = process.env.GF_MANIFEST_PATH || manifestPath;
|
|
46
|
+
const claspFile = process.env.GF_CLASP_PATH || claspPath;
|
|
48
47
|
|
|
49
|
-
settings.manifest = settings.manifest || manifestPath
|
|
50
|
-
settings.clasp = settings.clasp || claspPath
|
|
51
48
|
const [manifest, clasp] = await Promise.all([
|
|
52
|
-
getIfExists(
|
|
53
|
-
getIfExists(
|
|
49
|
+
getIfExists(manifestFile),
|
|
50
|
+
getIfExists(claspFile)
|
|
54
51
|
])
|
|
55
52
|
|
|
56
|
-
settings
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
await mkdir(settingsDir, { recursive: true })
|
|
65
|
-
await writeFile(settingsPath, strSet, { flag: 'w' })
|
|
66
|
-
} catch (err) {
|
|
67
|
-
syncWarn(`...unable to write settings file: ${err}`)
|
|
68
|
-
}
|
|
53
|
+
const settings = {
|
|
54
|
+
manifest: manifestFile,
|
|
55
|
+
clasp: claspFile,
|
|
56
|
+
scriptId: process.env.GF_SCRIPT_ID || clasp.scriptId || fakeId,
|
|
57
|
+
documentId: process.env.GF_DOCUMENT_ID || null,
|
|
58
|
+
cache: process.env.GF_CACHE_PATH || cachePath,
|
|
59
|
+
properties: process.env.GF_PROPERTIES_PATH || propertiesPath
|
|
69
60
|
}
|
|
70
61
|
|
|
71
62
|
const identities = {};
|
|
@@ -73,6 +64,9 @@ export const sxInit = async ({ manifestPath, claspPath, settingsPath, cachePath,
|
|
|
73
64
|
// --- Google Auth Block ---
|
|
74
65
|
if (platforms.includes('google') || platforms.includes('workspace')) {
|
|
75
66
|
try {
|
|
67
|
+
// Ensure platform is set for info discovery
|
|
68
|
+
Auth.setPlatform('google');
|
|
69
|
+
|
|
76
70
|
const scopes = manifest.oauthScopes || []
|
|
77
71
|
const mandatoryScopes = [
|
|
78
72
|
"openid",
|
|
@@ -105,7 +99,7 @@ export const sxInit = async ({ manifestPath, claspPath, settingsPath, cachePath,
|
|
|
105
99
|
activeUser,
|
|
106
100
|
effectiveUser,
|
|
107
101
|
projectId: Auth.getProjectId(),
|
|
108
|
-
tokenScopes: effectiveInfo.tokenInfo.scopes,
|
|
102
|
+
tokenScopes: effectiveInfo.tokenInfo.scopes || effectiveInfo.tokenInfo.scope,
|
|
109
103
|
authMethod: Auth.getAuthMethod('google')
|
|
110
104
|
};
|
|
111
105
|
|
|
@@ -125,6 +119,7 @@ export const sxInit = async ({ manifestPath, claspPath, settingsPath, cachePath,
|
|
|
125
119
|
syncWarn("ksuite requested in platformAuth but KSUITE_TOKEN is missing from environment.");
|
|
126
120
|
} else {
|
|
127
121
|
try {
|
|
122
|
+
Auth.setPlatform('ksuite');
|
|
128
123
|
const kDrive = new KSuiteDrive(kToken);
|
|
129
124
|
const accountId = await kDrive.getAccountId();
|
|
130
125
|
|
|
@@ -152,6 +147,10 @@ export const sxInit = async ({ manifestPath, claspPath, settingsPath, cachePath,
|
|
|
152
147
|
}
|
|
153
148
|
}
|
|
154
149
|
|
|
150
|
+
// Restore default platform context based on authorized backends
|
|
151
|
+
const defaultPlatform = platforms[0] === 'google' ? 'workspace' : platforms[0];
|
|
152
|
+
Auth.setPlatform(defaultPlatform);
|
|
153
|
+
|
|
155
154
|
// Final Summary Report (Concise, single instance)
|
|
156
155
|
if (!_loggedSummary) {
|
|
157
156
|
const summary = Object.keys(identities).map(p => {
|
|
@@ -166,7 +165,9 @@ export const sxInit = async ({ manifestPath, claspPath, settingsPath, cachePath,
|
|
|
166
165
|
}).join(', ');
|
|
167
166
|
|
|
168
167
|
if (summary) {
|
|
168
|
+
const scriptIdSource = process.env.GF_SCRIPT_ID ? 'env' : (clasp.scriptId ? 'clasp' : 'random');
|
|
169
169
|
syncLog(`...authorized backends: ${summary}`);
|
|
170
|
+
syncLog(`...using scriptId: ${settings.scriptId} (source: ${scriptIdSource})`);
|
|
170
171
|
_loggedSummary = true;
|
|
171
172
|
}
|
|
172
173
|
}
|
package/src/support/sxretry.js
CHANGED
|
@@ -36,7 +36,7 @@ export const sxRetry = async (Auth, tag, func, options = {}) => {
|
|
|
36
36
|
response = err.response;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const redoCodes = [429, 500, 503, 408, 401];
|
|
39
|
+
const redoCodes = [429, 500, 502, 503, 408, 401];
|
|
40
40
|
const networkErrorCodes = [
|
|
41
41
|
'ETIMEDOUT',
|
|
42
42
|
'ECONNRESET',
|
package/src/support/syncit.js
CHANGED
|
@@ -23,7 +23,6 @@ import { callSync } from "./workersync/synchronizer.js";
|
|
|
23
23
|
|
|
24
24
|
const manifestDefaultPath = "./appsscript.json";
|
|
25
25
|
const claspDefaultPath = "./.clasp.json";
|
|
26
|
-
const settingsDefaultPath = process.env.GF_SETTINGS_PATH || "./gasfakes.json";
|
|
27
26
|
const propertiesDefaultPath = "/tmp/gas-fakes/properties";
|
|
28
27
|
const cacheDefaultPath = "/tmp/gas-fakes/cache";
|
|
29
28
|
|
|
@@ -271,18 +270,16 @@ const fxUnzipper = ({ blob }) => {
|
|
|
271
270
|
* initialize all the stuff at the beginning such as manifest content and settings
|
|
272
271
|
* and register them all in Auth object for future reference
|
|
273
272
|
* @param {object} p pargs
|
|
274
|
-
* @param {string} p.manifestPath where to
|
|
273
|
+
* @param {string} p.manifestPath where to find the manifest by default
|
|
275
274
|
* @param {string} p.claspPath where to find the clasp file by default
|
|
276
|
-
* @param {string} p.settingsPath where to find the settings file
|
|
277
275
|
* @param {string} p.cachePath the cache files
|
|
278
276
|
* @param {string} p.propertiesPath the properties file location
|
|
279
277
|
* @param {string[]} [p.platformAuth] list of platforms to authenticate
|
|
280
|
-
* @return {object} the finalized
|
|
278
|
+
* @return {object} the finalized versions of all the above
|
|
281
279
|
*/
|
|
282
280
|
export const fxInit = ({
|
|
283
281
|
manifestPath = manifestDefaultPath,
|
|
284
282
|
claspPath = claspDefaultPath,
|
|
285
|
-
settingsPath = settingsDefaultPath,
|
|
286
283
|
cachePath = cacheDefaultPath,
|
|
287
284
|
propertiesPath = propertiesDefaultPath,
|
|
288
285
|
platformAuth
|
|
@@ -296,7 +293,6 @@ export const fxInit = ({
|
|
|
296
293
|
// because this is all run in a synced subprocess it's not an async result
|
|
297
294
|
const synced = callSync("sxInit", {
|
|
298
295
|
claspPath: resolve(claspPath),
|
|
299
|
-
settingsPath: resolve(settingsPath),
|
|
300
296
|
manifestPath: resolve(manifestPath),
|
|
301
297
|
mainDir,
|
|
302
298
|
cachePath,
|
|
@@ -317,9 +313,12 @@ export const fxInit = ({
|
|
|
317
313
|
Auth.setClasp(clasp);
|
|
318
314
|
Auth.setManifest(manifest);
|
|
319
315
|
|
|
316
|
+
// console.log(`...DEBUG: fxInit identities received keys=${Object.keys(identities || {}).join(',')}`);
|
|
317
|
+
|
|
320
318
|
// Populate all identities
|
|
321
319
|
if (identities) {
|
|
322
320
|
Object.keys(identities).forEach(p => {
|
|
321
|
+
// console.log(`...DEBUG: fxInit populating identity for ${p}. scopes=${identities[p].tokenScopes}`);
|
|
323
322
|
Auth.setIdentity(p, identities[p]);
|
|
324
323
|
});
|
|
325
324
|
}
|
package/gasfakes.json
DELETED