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.
Files changed (3) hide show
  1. package/README.md +139 -0
  2. package/index.js +260 -0
  3. 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
+ }