gas-fetch 1.0.0
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 +139 -0
- package/index.js +260 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# gas-fetch
|
|
2
|
+
|
|
3
|
+
CLI tool to fetch [Google Apps Script](https://developers.google.com/apps-script) web apps, with automatic Google Workspace authentication.
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
Google Apps Script web apps deployed within a Workspace organization (e.g. `script.google.com/a/macros/example.com/...`) require Google authentication. This makes them inaccessible from `curl`, scripts, or AI agents — even if the user has a valid account.
|
|
8
|
+
|
|
9
|
+
**gas-fetch** solves this by using a real browser session (via [Playwright](https://playwright.dev/)) with a persistent profile. You log in once, and subsequent calls reuse the session automatically — including handling daily session expiry by auto-clicking the account selector.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g gas-fetch
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Chromium is installed automatically via the `postinstall` script. If it doesn't run, install it manually:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx playwright install chromium
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Install from source
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/user/gas-fetch.git
|
|
27
|
+
cd gas-fetch
|
|
28
|
+
npm install
|
|
29
|
+
npm link # makes `gas` command available globally
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
### 1. First-time login
|
|
35
|
+
|
|
36
|
+
Open a browser window and log in with your Google account:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
gas https://script.google.com/.../exec --login
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Complete the login in the browser. The session is saved to `~/.config/gas-fetch/profiles/` — you only need to do this once (or when your session fully expires).
|
|
43
|
+
|
|
44
|
+
### 2. Fetch your web app
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# GET request — output goes to stdout
|
|
48
|
+
gas https://script.google.com/.../exec
|
|
49
|
+
|
|
50
|
+
# With query parameters
|
|
51
|
+
gas https://script.google.com/.../exec query=newer_than:1d limit=5
|
|
52
|
+
|
|
53
|
+
# POST request with JSON body (triggers doPost in your GAS)
|
|
54
|
+
gas https://script.google.com/.../exec --post '{"action":"search","q":"hello"}'
|
|
55
|
+
|
|
56
|
+
# Pipe to jq
|
|
57
|
+
gas https://script.google.com/.../exec | jq '.items[0]'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 3. Use the environment variable
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
export GAS_URL="https://script.google.com/.../exec"
|
|
64
|
+
gas # uses GAS_URL
|
|
65
|
+
gas query=newer_than:3d # uses GAS_URL with params
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## How It Works
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
gas Playwright (headless) GAS Web App
|
|
72
|
+
| | |
|
|
73
|
+
|-- launch browser ---------->| |
|
|
74
|
+
| |-- navigate to GAS URL ----->|
|
|
75
|
+
| | |
|
|
76
|
+
| |<-- 302 redirect ------------|
|
|
77
|
+
| | |
|
|
78
|
+
| [session valid?] |
|
|
79
|
+
| yes: continue |
|
|
80
|
+
| no: auto-click account selector |
|
|
81
|
+
| | |
|
|
82
|
+
| |<-- 200 response ------------|
|
|
83
|
+
|<-- stdout: response body ----| |
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- **First run (`--login`)**: Opens a visible browser for manual Google login. Session cookies are saved to a local profile directory.
|
|
87
|
+
- **Subsequent runs**: Launches a headless browser that reuses the saved session. If the session has expired (e.g. daily Workspace policy), it automatically clicks through the account selector.
|
|
88
|
+
- **Full re-login**: If the session is completely invalid (password change, etc.), run with `--login` again.
|
|
89
|
+
|
|
90
|
+
## Options
|
|
91
|
+
|
|
92
|
+
| Option | Description |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `<url>` | GAS web app URL (or set `GAS_URL` env var) |
|
|
95
|
+
| `key=value` | Query parameters passed to the web app |
|
|
96
|
+
| `--login` | Open a visible browser for manual login |
|
|
97
|
+
| `--post <json>` | Send a POST request with JSON body |
|
|
98
|
+
| `--profile <dir>` | Custom browser profile directory |
|
|
99
|
+
| `--timeout <ms>` | Navigation timeout (default: 30000) |
|
|
100
|
+
| `-h, --help` | Show help |
|
|
101
|
+
|
|
102
|
+
## Environment Variables
|
|
103
|
+
|
|
104
|
+
| Variable | Description |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `GAS_URL` | Default GAS web app URL |
|
|
107
|
+
| `GAS_FETCH_PROFILES` | Custom base directory for browser profiles (default: `~/.config/gas-fetch/profiles/`) |
|
|
108
|
+
|
|
109
|
+
## Browser Profiles
|
|
110
|
+
|
|
111
|
+
Each GAS URL gets its own browser profile directory (derived from a hash of the URL), so different web apps don't share sessions. Profiles are stored in `~/.config/gas-fetch/profiles/` by default.
|
|
112
|
+
|
|
113
|
+
To see where your profiles are stored:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
ls ~/.config/gas-fetch/profiles/
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
To reset a session, simply delete the corresponding profile directory and run `--login` again.
|
|
120
|
+
|
|
121
|
+
## Use with AI Agents
|
|
122
|
+
|
|
123
|
+
gas-fetch is designed to be called by AI agents or automation scripts. Status messages go to **stderr**, and only the web app response goes to **stdout**:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# In a script or agent tool
|
|
127
|
+
RESULT=$(gas "$GAS_URL" 2>/dev/null)
|
|
128
|
+
echo "$RESULT" | jq .
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Limitations
|
|
132
|
+
|
|
133
|
+
- Requires a Chromium browser (installed automatically via Playwright)
|
|
134
|
+
- Session cookies expire based on your Workspace admin policy — gas-fetch handles daily account-selector re-auth automatically, but a full re-login requires `--login`
|
|
135
|
+
- Not suitable for high-frequency concurrent calls (each invocation launches a browser)
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { chromium } = require("playwright");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
|
|
9
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
10
|
+
process.stderr.write(`gas - Fetch Google Apps Script web apps from CLI (gas-fetch)
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
gas <url> [options] [key=value ...]
|
|
14
|
+
|
|
15
|
+
Arguments:
|
|
16
|
+
<url> GAS web app URL (required, or set GAS_URL env var)
|
|
17
|
+
key=value Query parameters to pass to the web app
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--login Open a visible browser for manual login
|
|
21
|
+
--post <json> Send a POST request with JSON body (triggers doPost)
|
|
22
|
+
--profile <dir> Custom browser profile directory
|
|
23
|
+
--timeout <ms> Navigation timeout in milliseconds (default: 30000)
|
|
24
|
+
-h, --help Show this help
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
gas https://script.google.com/.../exec --login
|
|
28
|
+
gas https://script.google.com/.../exec query=newer_than:1d
|
|
29
|
+
gas https://script.google.com/.../exec --post '{"action":"send"}'
|
|
30
|
+
GAS_URL=https://script.google.com/.../exec gas
|
|
31
|
+
`);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const loginMode = args.includes("--login");
|
|
36
|
+
|
|
37
|
+
function getArgValue(flag) {
|
|
38
|
+
const idx = args.indexOf(flag);
|
|
39
|
+
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const customProfile = getArgValue("--profile");
|
|
43
|
+
const postBody = getArgValue("--post");
|
|
44
|
+
const timeout = Number(getArgValue("--timeout")) || 30000;
|
|
45
|
+
|
|
46
|
+
// First non-flag argument that looks like a GAS URL
|
|
47
|
+
const gasUrl =
|
|
48
|
+
args.find((a) => a.startsWith("https://script.google.com/")) ||
|
|
49
|
+
process.env.GAS_URL;
|
|
50
|
+
|
|
51
|
+
if (!gasUrl) {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
"ERROR: No GAS URL provided. Pass it as first argument or set GAS_URL env var.\n" +
|
|
54
|
+
"Run gas-fetch --help for usage.\n"
|
|
55
|
+
);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Derive a stable profile dir from the URL so different apps don't share sessions
|
|
60
|
+
const urlHash = crypto
|
|
61
|
+
.createHash("md5")
|
|
62
|
+
.update(gasUrl)
|
|
63
|
+
.digest("hex")
|
|
64
|
+
.slice(0, 8);
|
|
65
|
+
const defaultProfileBase =
|
|
66
|
+
process.env.GAS_FETCH_PROFILES ||
|
|
67
|
+
path.join(process.env.HOME || require("os").homedir(), ".config", "gas-fetch", "profiles");
|
|
68
|
+
const profileDir = customProfile || path.join(defaultProfileBase, urlHash);
|
|
69
|
+
|
|
70
|
+
// Extract workspace domain from URL for account selector (if present)
|
|
71
|
+
// e.g. https://script.google.com/a/macros/example.com/s/.../exec
|
|
72
|
+
const domainMatch = gasUrl.match(/\/a\/macros\/([^/]+)\//);
|
|
73
|
+
const workspaceDomain = domainMatch ? domainMatch[1] : null;
|
|
74
|
+
|
|
75
|
+
// Collect key=value pairs as query params (skip flags and their values)
|
|
76
|
+
const flagsWithValues = new Set(["--profile", "--post", "--timeout"]);
|
|
77
|
+
const skipNext = new Set();
|
|
78
|
+
const params = {};
|
|
79
|
+
for (let i = 0; i < args.length; i++) {
|
|
80
|
+
const a = args[i];
|
|
81
|
+
if (skipNext.has(i)) continue;
|
|
82
|
+
if (flagsWithValues.has(a)) {
|
|
83
|
+
skipNext.add(i + 1);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (a.startsWith("--") || a.startsWith("https://")) continue;
|
|
87
|
+
if (a.includes("=")) {
|
|
88
|
+
const [k, ...v] = a.split("=");
|
|
89
|
+
params[k] = v.join("=");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function log(...msg) {
|
|
94
|
+
process.stderr.write(msg.join(" ") + "\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function main() {
|
|
98
|
+
const browser = await chromium.launchPersistentContext(profileDir, {
|
|
99
|
+
headless: !loginMode,
|
|
100
|
+
channel: "chromium",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const page = browser.pages()[0] || (await browser.newPage());
|
|
104
|
+
|
|
105
|
+
let url = gasUrl;
|
|
106
|
+
if (Object.keys(params).length > 0) {
|
|
107
|
+
url += "?" + new URLSearchParams(params).toString();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
log("gas-fetch: navigating...");
|
|
111
|
+
await page.goto(url, { waitUntil: "networkidle", timeout });
|
|
112
|
+
|
|
113
|
+
const currentUrl = page.url();
|
|
114
|
+
|
|
115
|
+
if (isGASPage(currentUrl)) {
|
|
116
|
+
log("gas-fetch: authenticated");
|
|
117
|
+
} else if (isAccountChooser(currentUrl)) {
|
|
118
|
+
log("gas-fetch: account chooser detected, auto-selecting...");
|
|
119
|
+
await autoSelectAccount(page);
|
|
120
|
+
} else if (isLoginPage(currentUrl)) {
|
|
121
|
+
if (!loginMode) {
|
|
122
|
+
log("gas-fetch: login required — run with --login first");
|
|
123
|
+
await browser.close();
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
log("gas-fetch: please log in in the browser window...");
|
|
127
|
+
await waitForGASPage(page);
|
|
128
|
+
log("gas-fetch: login successful");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// In login mode, verify the response looks valid; if not, let user fix it
|
|
132
|
+
if (loginMode && isGASPage(page.url())) {
|
|
133
|
+
const text = await page.locator("body").innerText().catch(() => "");
|
|
134
|
+
if (isErrorPage(text)) {
|
|
135
|
+
log("gas-fetch: WARNING — page loaded but looks like an error:");
|
|
136
|
+
log("gas-fetch: " + text.substring(0, 200));
|
|
137
|
+
log("gas-fetch: the browser will stay open — please navigate or re-login manually.");
|
|
138
|
+
log("gas-fetch: once the correct page loads, it will be detected automatically.");
|
|
139
|
+
await waitForGASPage(page, true);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Handle POST: re-navigate via fetch() inside the authenticated browser context
|
|
144
|
+
if (postBody && isGASPage(page.url())) {
|
|
145
|
+
log("gas-fetch: sending POST...");
|
|
146
|
+
const result = await page.evaluate(
|
|
147
|
+
async ({ url, body }) => {
|
|
148
|
+
const res = await fetch(url, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "Content-Type": "text/plain;charset=utf-8" },
|
|
151
|
+
body,
|
|
152
|
+
redirect: "follow",
|
|
153
|
+
});
|
|
154
|
+
return res.text();
|
|
155
|
+
},
|
|
156
|
+
{ url: gasUrl, body: postBody }
|
|
157
|
+
);
|
|
158
|
+
process.stdout.write(result);
|
|
159
|
+
await browser.close();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!isGASPage(page.url())) {
|
|
164
|
+
log("gas-fetch: failed to reach GAS page — " + page.url());
|
|
165
|
+
await browser.close();
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const body = await page.locator("body").innerText().catch(() => "");
|
|
170
|
+
process.stdout.write(body.trim());
|
|
171
|
+
|
|
172
|
+
await browser.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function autoSelectAccount(page) {
|
|
176
|
+
const selectors = workspaceDomain
|
|
177
|
+
? [
|
|
178
|
+
`[data-identifier*="${workspaceDomain}"]`,
|
|
179
|
+
`[data-email*="${workspaceDomain}"]`,
|
|
180
|
+
]
|
|
181
|
+
: [];
|
|
182
|
+
selectors.push("li[data-identifier]", "div[data-authuser]");
|
|
183
|
+
|
|
184
|
+
for (const selector of selectors) {
|
|
185
|
+
const el = page.locator(selector);
|
|
186
|
+
if ((await el.count()) > 0) {
|
|
187
|
+
log("gas-fetch: clicking", selector);
|
|
188
|
+
await el.first().click();
|
|
189
|
+
await page.waitForLoadState("networkidle");
|
|
190
|
+
if (isGASPage(page.url())) return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (loginMode) {
|
|
195
|
+
log("gas-fetch: auto-select failed — please click your account in the browser");
|
|
196
|
+
await waitForGASPage(page);
|
|
197
|
+
} else {
|
|
198
|
+
log("gas-fetch: auto-select failed — run with --login to handle manually");
|
|
199
|
+
await page.context().close();
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function waitForGASPage(page, mustBeValid = false) {
|
|
205
|
+
try {
|
|
206
|
+
// eslint-disable-next-line no-constant-condition
|
|
207
|
+
while (true) {
|
|
208
|
+
await page.waitForTimeout(1000);
|
|
209
|
+
if (!isGASPage(page.url())) continue;
|
|
210
|
+
if (!mustBeValid) break;
|
|
211
|
+
// In strict mode, also verify the content isn't an error page
|
|
212
|
+
const text = await page.locator("body").innerText().catch(() => "");
|
|
213
|
+
if (!isErrorPage(text)) break;
|
|
214
|
+
}
|
|
215
|
+
await page.waitForLoadState("networkidle");
|
|
216
|
+
} catch (e) {
|
|
217
|
+
if (e.message.includes("closed")) {
|
|
218
|
+
log("gas-fetch: browser was closed before login completed");
|
|
219
|
+
}
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isGASPage(url) {
|
|
225
|
+
return (
|
|
226
|
+
(url.includes("script.google.com") && url.includes("/exec")) ||
|
|
227
|
+
url.includes("script.googleusercontent.com")
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isAccountChooser(url) {
|
|
232
|
+
return url.includes("AccountChooser") || url.includes("accountchooser");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isLoginPage(url) {
|
|
236
|
+
return (
|
|
237
|
+
url.includes("accounts.google.com") ||
|
|
238
|
+
url.includes("ServiceLogin") ||
|
|
239
|
+
url.includes("signin")
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isErrorPage(text) {
|
|
244
|
+
const errorPatterns = [
|
|
245
|
+
"不存在",
|
|
246
|
+
"does not exist",
|
|
247
|
+
"not found",
|
|
248
|
+
"404",
|
|
249
|
+
"drive.google.com/start",
|
|
250
|
+
"云端硬盘",
|
|
251
|
+
"Google Drive",
|
|
252
|
+
];
|
|
253
|
+
const lower = text.toLowerCase();
|
|
254
|
+
return errorPatterns.some((p) => lower.includes(p.toLowerCase()));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
main().catch((e) => {
|
|
258
|
+
log("gas-fetch: ERROR:", e.message);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gas-fetch",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool to fetch Google Apps Script web apps with automatic Workspace authentication",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gas": "index.js",
|
|
8
|
+
"gas-fetch": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"postinstall": "npx playwright install chromium"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"google-apps-script",
|
|
15
|
+
"gas",
|
|
16
|
+
"workspace",
|
|
17
|
+
"cli",
|
|
18
|
+
"fetch",
|
|
19
|
+
"playwright"
|
|
20
|
+
],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"type": "commonjs",
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"playwright": "^1.58.2"
|
|
31
|
+
}
|
|
32
|
+
}
|