@juppytt/fws 0.1.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/.claude/settings.local.json +72 -0
- package/README.md +126 -0
- package/bin/fws-cli.sh +4 -0
- package/bin/fws.ts +421 -0
- package/docs/cli-reference.md +211 -0
- package/docs/gws-support.md +276 -0
- package/package.json +28 -0
- package/src/config/rewrite-cache.ts +73 -0
- package/src/index.ts +3 -0
- package/src/proxy/mitm.ts +285 -0
- package/src/server/app.ts +26 -0
- package/src/server/middleware.ts +38 -0
- package/src/server/routes/calendar.ts +483 -0
- package/src/server/routes/control.ts +151 -0
- package/src/server/routes/drive.ts +342 -0
- package/src/server/routes/gmail.ts +758 -0
- package/src/server/routes/people.ts +239 -0
- package/src/server/routes/sheets.ts +242 -0
- package/src/server/routes/tasks.ts +191 -0
- package/src/store/index.ts +24 -0
- package/src/store/seed.ts +313 -0
- package/src/store/types.ts +225 -0
- package/src/util/id.ts +9 -0
- package/test/calendar.test.ts +227 -0
- package/test/drive.test.ts +153 -0
- package/test/gmail.test.ts +215 -0
- package/test/gws-validation.test.ts +883 -0
- package/test/helpers/harness.ts +109 -0
- package/test/snapshot.test.ts +80 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(gws --help)",
|
|
5
|
+
"Bash(gws gmail:*)",
|
|
6
|
+
"Bash(gws auth:*)",
|
|
7
|
+
"Bash(gws schema:*)",
|
|
8
|
+
"Bash(npm list:*)",
|
|
9
|
+
"Bash(mockoon-cli --help)",
|
|
10
|
+
"Bash(GOOGLE_WORKSPACE_CLI_LOG=gws=debug gws gmail users messages list --params '{\"userId\":\"me\",\"maxResults\":1}')",
|
|
11
|
+
"Bash(GOOGLE_WORKSPACE_CLI_LOG=gws=trace gws gmail users messages list --params '{\"userId\":\"me\",\"maxResults\":1}')",
|
|
12
|
+
"Bash(gws)",
|
|
13
|
+
"Bash(env)",
|
|
14
|
+
"Bash(HTTPS_PROXY=http://localhost:9999 GOOGLE_WORKSPACE_CLI_TOKEN=fake gws gmail users messages list --params '{\"userId\":\"me\",\"maxResults\":1}')",
|
|
15
|
+
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(d.get\\('rootUrl','N/A'\\)\\); print\\(d.get\\('baseUrl','N/A'\\)\\); print\\(d.get\\('basePath','N/A'\\)\\); print\\(d.get\\('servicePath','N/A'\\)\\)\")",
|
|
16
|
+
"Bash(cp ~/.config/gws/cache/gmail_v1.json /tmp/gmail_v1_backup.json)",
|
|
17
|
+
"Bash(python3 -c ':*)",
|
|
18
|
+
"Bash(npm --version)",
|
|
19
|
+
"Bash(tsc --version)",
|
|
20
|
+
"Bash(ls:*)",
|
|
21
|
+
"Bash(python3:*)",
|
|
22
|
+
"Bash(npm install:*)",
|
|
23
|
+
"Bash(npx tsc:*)",
|
|
24
|
+
"Bash(npx vitest:*)",
|
|
25
|
+
"Bash(npx tsx:*)",
|
|
26
|
+
"Bash(git add:*)",
|
|
27
|
+
"Bash(git commit -m ':*)",
|
|
28
|
+
"Bash(chmod +x:*)",
|
|
29
|
+
"Bash(npm link:*)",
|
|
30
|
+
"Bash(fws --help)",
|
|
31
|
+
"Bash(fws drive:*)",
|
|
32
|
+
"Bash(fws server:*)",
|
|
33
|
+
"Bash(export GOOGLE_WORKSPACE_CLI_CONFIG_DIR=/home/juhee/.local/share/fws/config)",
|
|
34
|
+
"Bash(export GOOGLE_WORKSPACE_CLI_TOKEN=fake)",
|
|
35
|
+
"Bash(curl -s http://localhost:4100/__fws/status)",
|
|
36
|
+
"Bash(kill %1)",
|
|
37
|
+
"Bash(kill 1088227)",
|
|
38
|
+
"Bash(gws drive:*)",
|
|
39
|
+
"Bash(GOOGLE_WORKSPACE_CLI_LOG=gws=debug gws gmail +triage --max 2)",
|
|
40
|
+
"Bash(GOOGLE_WORKSPACE_CLI_LOG=gws=trace gws gmail +triage --max 1)",
|
|
41
|
+
"Bash(kill 1093361)",
|
|
42
|
+
"Bash(wait)",
|
|
43
|
+
"Bash(cp /home/juhee/.config/gws/accounts.json /home/juhee/.local/share/fws/config/)",
|
|
44
|
+
"Bash(node -e ':*)",
|
|
45
|
+
"Bash(xargs kill:*)",
|
|
46
|
+
"Bash(HTTPS_PROXY=http://localhost:19999 gws gmail +triage --max 1)",
|
|
47
|
+
"Bash(HTTPS_PROXY=http://localhost:19999 gws gmail users messages list --params '{\"userId\":\"me\",\"maxResults\":1}')",
|
|
48
|
+
"Bash(find /home/juhee/headless-clawd/projects/agent-dfi/gws-src -name \"*.rs\" -exec grep -l \"config\\\\|Config\" {} \\\\;)",
|
|
49
|
+
"Bash(node -e \"const tls = require\\('tls'\\); const crypto = require\\('crypto'\\); console.log\\('crypto available:', !!crypto.generateKeyPairSync\\);\")",
|
|
50
|
+
"Bash(export HTTPS_PROXY=http://localhost:4101)",
|
|
51
|
+
"Bash(export SSL_CERT_FILE=/home/juhee/.local/share/fws/certs/ca.crt)",
|
|
52
|
+
"Bash(fws gmail:*)",
|
|
53
|
+
"Bash(gws calendar:*)",
|
|
54
|
+
"Bash(echo \"EXIT: $?\")",
|
|
55
|
+
"Bash(gh repo:*)",
|
|
56
|
+
"Bash(git:*)",
|
|
57
|
+
"Bash(gh issue create --repo pinchbench/skill --title 'Proposal: GWS-powered user tasks using fws \\(Fake Google Workspace\\)' --body ':*)",
|
|
58
|
+
"Bash(gws tasks:*)",
|
|
59
|
+
"Bash(./scripts/run.sh --suite task_26_gws_email_triage --model anthropic/claude-sonnet-4 --no-upload -v)",
|
|
60
|
+
"Bash(./scripts/run.sh --suite task_26_gws_email_triage --model openai/gpt-4o --no-upload -v)",
|
|
61
|
+
"Bash(./scripts/run.sh --suite task_26_gws_email_triage --model openai/gpt-4.1 --no-upload -v)",
|
|
62
|
+
"Bash(./scripts/run.sh --suite task_16_email_triage --model openai/gpt-4.1 --no-upload -v)",
|
|
63
|
+
"Bash(grep:*)",
|
|
64
|
+
"Bash(gh pr create --repo pinchbench/skill --title 'Fix judge API: use max_completion_tokens for OpenAI/OpenRouter' --body ':*)",
|
|
65
|
+
"Bash(npm whoami:*)",
|
|
66
|
+
"Bash(npm view:*)",
|
|
67
|
+
"Bash(mock-gws --help)",
|
|
68
|
+
"Bash(mock-gws gmail:*)",
|
|
69
|
+
"Bash(npm publish:*)"
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# fws — Fake Google Workspace
|
|
2
|
+
|
|
3
|
+
A local mock server that redirects the `gws` CLI, enabling Google Workspace testing without OAuth authentication.
|
|
4
|
+
|
|
5
|
+
Built with [Claude Code](https://claude.ai/code).
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
`gws` sends API requests to the `rootUrl` defined in its discovery cache (`~/.config/gws/cache/*.json`). fws rewrites these URLs to `http://localhost:4100/` and sets `GOOGLE_WORKSPACE_CLI_TOKEN=fake` to bypass auth.
|
|
10
|
+
|
|
11
|
+
For helper commands (`+triage`, `+send`, `+reply`, etc.) that hardcode `googleapis.com` URLs, fws runs a MITM CONNECT proxy on port 4101 that intercepts HTTPS traffic and forwards it to the local mock server.
|
|
12
|
+
|
|
13
|
+
All data lives **in memory**. When the server stops, everything is lost unless you save a snapshot first. Use `fws snapshot save` to persist state.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install
|
|
19
|
+
npm link # makes `fws` command available globally
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Start the server (runs in background)
|
|
26
|
+
fws server start
|
|
27
|
+
|
|
28
|
+
# Set env vars (printed by the command above)
|
|
29
|
+
export GOOGLE_WORKSPACE_CLI_CONFIG_DIR=~/.local/share/fws/config
|
|
30
|
+
export GOOGLE_WORKSPACE_CLI_TOKEN=fake
|
|
31
|
+
export HTTPS_PROXY=http://localhost:4101
|
|
32
|
+
export SSL_CERT_FILE=~/.local/share/fws/certs/ca.crt
|
|
33
|
+
|
|
34
|
+
# Try some commands
|
|
35
|
+
gws gmail users messages list --params '{"userId":"me"}'
|
|
36
|
+
gws gmail +triage
|
|
37
|
+
gws calendar events list --params '{"calendarId":"primary"}'
|
|
38
|
+
gws drive files list
|
|
39
|
+
gws tasks tasklists list
|
|
40
|
+
gws sheets spreadsheets get --params '{"spreadsheetId":"sheet001"}'
|
|
41
|
+
gws people people connections list --params '{"resourceName":"people/me","personFields":"names"}'
|
|
42
|
+
|
|
43
|
+
# When done
|
|
44
|
+
fws server stop
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The server starts with sample seed data (5 emails, 4 calendar events, 5 drive files, 2 tasks, 1 spreadsheet, 2 contacts) so you can try gws commands immediately.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
### Proxy mode (one-shot)
|
|
52
|
+
|
|
53
|
+
Starts a temporary server, runs gws, exits. No separate server needed.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
fws gmail users messages list --params '{"userId":"me"}'
|
|
57
|
+
fws gmail +triage
|
|
58
|
+
fws calendar calendarList list
|
|
59
|
+
fws drive about get --params '{"fields":"*"}'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Server mode (persistent)
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
fws server start # Start in background
|
|
66
|
+
fws server status # Check if running
|
|
67
|
+
fws server stop # Stop
|
|
68
|
+
fws server start --foreground # Run in foreground (for debugging)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Setup (add data to running server)
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
fws setup gmail add-message --from alice@corp.com --subject "Meeting" --body "See you at 3pm"
|
|
75
|
+
fws setup calendar add-event --summary "Team sync" --start 2026-04-08T15:00:00 --duration 1h
|
|
76
|
+
fws setup drive add-file --name "report.pdf" --mimeType application/pdf
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Snapshots
|
|
80
|
+
|
|
81
|
+
Data is in-memory only. Save before stopping the server if you need to keep it.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
fws snapshot save my-scenario # Save current state to disk
|
|
85
|
+
fws snapshot load my-scenario # Restore saved state into running server
|
|
86
|
+
fws snapshot list # List saved snapshots
|
|
87
|
+
fws snapshot delete my-scenario # Delete a snapshot
|
|
88
|
+
fws reset # Reset to default seed data
|
|
89
|
+
fws reset --snapshot my-scenario # Reset to a specific snapshot
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Snapshots are stored in `~/.local/share/fws/snapshots/` (override with `FWS_DATA_DIR`).
|
|
93
|
+
|
|
94
|
+
## Default seed data
|
|
95
|
+
|
|
96
|
+
| Service | Data |
|
|
97
|
+
|----------|------|
|
|
98
|
+
| Gmail | 5 messages (3 inbox, 1 sent, 1 read), system labels + "Projects" user label |
|
|
99
|
+
| Calendar | 4 events (Daily Standup, Q3 Planning, 1:1, Team Lunch) |
|
|
100
|
+
| Drive | 5 files (docs, spreadsheet, image, folder) |
|
|
101
|
+
| Tasks | 1 task list with 2 tasks (1 pending, 1 completed) |
|
|
102
|
+
| Sheets | 1 spreadsheet ("Budget 2026") |
|
|
103
|
+
| People | 2 contacts (Alice, Bob), 1 contact group |
|
|
104
|
+
|
|
105
|
+
## API support
|
|
106
|
+
|
|
107
|
+
Gmail (28/79 + 5 helpers), Calendar (21/37), Drive (18/57), Tasks (14/14), Sheets (7/17), People (16/24). 135 tests, 89 gws CLI validated.
|
|
108
|
+
|
|
109
|
+
See [docs/gws-support.md](docs/gws-support.md) for the full endpoint-by-endpoint table.
|
|
110
|
+
|
|
111
|
+
## Documentation
|
|
112
|
+
|
|
113
|
+
- [docs/cli-reference.md](docs/cli-reference.md) — Full CLI reference with all flags, HTTP API equivalents, and examples
|
|
114
|
+
- [docs/gws-support.md](docs/gws-support.md) — Endpoint-by-endpoint support table
|
|
115
|
+
|
|
116
|
+
## Structure
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
bin/fws.ts CLI entry point
|
|
120
|
+
src/server/routes/ Gmail, Calendar, Drive, and control API routes
|
|
121
|
+
src/store/ In-memory data store + seed data
|
|
122
|
+
src/config/ Discovery cache URL rewriting
|
|
123
|
+
src/proxy/ MITM proxy for helper commands
|
|
124
|
+
test/ Vitest tests (with gws CLI validation)
|
|
125
|
+
docs/ API support documentation
|
|
126
|
+
```
|
package/bin/fws-cli.sh
ADDED
package/bin/fws.ts
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { createApp } from '../src/server/app.js';
|
|
4
|
+
import { resetStore, loadStore, serializeStore, deserializeStore } from '../src/store/index.js';
|
|
5
|
+
import { generateConfigDir } from '../src/config/rewrite-cache.js';
|
|
6
|
+
import { generateCACert, startMitmProxy } from '../src/proxy/mitm.js';
|
|
7
|
+
import { spawn, execFile } from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import type { Server } from 'node:http';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_PORT = 4100;
|
|
14
|
+
const DEFAULT_PROXY_PORT = 4101;
|
|
15
|
+
|
|
16
|
+
function getDataDir(): string {
|
|
17
|
+
return process.env.FWS_DATA_DIR || path.join(os.homedir(), '.local', 'share', 'fws');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getSnapshotsDir(): string {
|
|
21
|
+
return path.join(getDataDir(), 'snapshots');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getServerInfoPath(): string {
|
|
25
|
+
return path.join(getDataDir(), 'server.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function ensureDir(dir: string): Promise<void> {
|
|
29
|
+
await fs.mkdir(dir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const program = new Command();
|
|
33
|
+
program
|
|
34
|
+
.name('fws')
|
|
35
|
+
.description('Fake Google Workspace — local mock server for gws CLI testing')
|
|
36
|
+
.version('0.1.0');
|
|
37
|
+
|
|
38
|
+
// === Server commands ===
|
|
39
|
+
const serverCmd = program.command('server');
|
|
40
|
+
|
|
41
|
+
serverCmd
|
|
42
|
+
.command('start')
|
|
43
|
+
.description('Start the mock server in the background')
|
|
44
|
+
.option('-p, --port <port>', 'Port number', String(DEFAULT_PORT))
|
|
45
|
+
.option('-s, --snapshot <name>', 'Load a snapshot on start')
|
|
46
|
+
.option('--foreground', 'Run in foreground (used internally)')
|
|
47
|
+
.action(async (opts) => {
|
|
48
|
+
const port = parseInt(opts.port);
|
|
49
|
+
|
|
50
|
+
if (opts.foreground) {
|
|
51
|
+
// Actually run the server (called by the background spawner below)
|
|
52
|
+
if (opts.snapshot) {
|
|
53
|
+
const snapshotPath = path.join(getSnapshotsDir(), opts.snapshot, 'store.json');
|
|
54
|
+
try {
|
|
55
|
+
const data = await fs.readFile(snapshotPath, 'utf-8');
|
|
56
|
+
loadStore(deserializeStore(data));
|
|
57
|
+
} catch {
|
|
58
|
+
console.error(`Snapshot not found: ${opts.snapshot}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const configDir = path.join(getDataDir(), 'config');
|
|
64
|
+
await generateConfigDir(port, configDir);
|
|
65
|
+
|
|
66
|
+
// Generate CA cert for MITM proxy
|
|
67
|
+
const { caPath } = await generateCACert(getDataDir());
|
|
68
|
+
|
|
69
|
+
const app = createApp();
|
|
70
|
+
const server: Server = await new Promise((resolve) => {
|
|
71
|
+
const s = app.listen(port, () => resolve(s));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Start MITM proxy for helper commands (+triage, +send, etc.)
|
|
75
|
+
const proxyPort = port + 1;
|
|
76
|
+
const proxyServer = startMitmProxy(port, proxyPort);
|
|
77
|
+
|
|
78
|
+
await ensureDir(getDataDir());
|
|
79
|
+
await fs.writeFile(getServerInfoPath(), JSON.stringify({
|
|
80
|
+
port, proxyPort, pid: process.pid, caPath,
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
const shutdown = () => {
|
|
84
|
+
server.close();
|
|
85
|
+
proxyServer.close();
|
|
86
|
+
fs.unlink(getServerInfoPath()).catch(() => {});
|
|
87
|
+
process.exit(0);
|
|
88
|
+
};
|
|
89
|
+
process.on('SIGINT', shutdown);
|
|
90
|
+
process.on('SIGTERM', shutdown);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Kill existing server if any
|
|
95
|
+
try {
|
|
96
|
+
const info = JSON.parse(await fs.readFile(getServerInfoPath(), 'utf-8'));
|
|
97
|
+
try {
|
|
98
|
+
process.kill(info.pid, 'SIGTERM');
|
|
99
|
+
await new Promise(r => setTimeout(r, 300));
|
|
100
|
+
} catch {}
|
|
101
|
+
await fs.unlink(getServerInfoPath()).catch(() => {});
|
|
102
|
+
} catch {}
|
|
103
|
+
|
|
104
|
+
// Spawn the server as a detached background process
|
|
105
|
+
const configDir = path.join(getDataDir(), 'config');
|
|
106
|
+
await ensureDir(getDataDir());
|
|
107
|
+
await generateConfigDir(port, configDir);
|
|
108
|
+
|
|
109
|
+
const logFile = path.join(getDataDir(), 'server.log');
|
|
110
|
+
// Truncate log on each start so it's fresh
|
|
111
|
+
const logFd = await fs.open(logFile, 'w');
|
|
112
|
+
|
|
113
|
+
const args = ['server', 'start', '--foreground', '-p', String(port)];
|
|
114
|
+
if (opts.snapshot) args.push('-s', opts.snapshot);
|
|
115
|
+
|
|
116
|
+
const tsxPath = path.join(import.meta.dirname, '..', 'node_modules', '.bin', 'tsx');
|
|
117
|
+
const scriptPath = path.join(import.meta.dirname, 'fws.ts');
|
|
118
|
+
|
|
119
|
+
const child = spawn(tsxPath, [scriptPath, ...args], {
|
|
120
|
+
detached: true,
|
|
121
|
+
stdio: ['ignore', logFd.fd, logFd.fd],
|
|
122
|
+
});
|
|
123
|
+
child.unref();
|
|
124
|
+
|
|
125
|
+
// Retry health check a few times (server needs time to start)
|
|
126
|
+
let started = false;
|
|
127
|
+
for (let i = 0; i < 10; i++) {
|
|
128
|
+
await new Promise(r => setTimeout(r, 300));
|
|
129
|
+
try {
|
|
130
|
+
const res = await fetch(`http://localhost:${port}/__fws/status`);
|
|
131
|
+
if (res.ok) {
|
|
132
|
+
started = true;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await logFd.close();
|
|
139
|
+
|
|
140
|
+
if (started) {
|
|
141
|
+
// Read server info to get caPath and proxyPort
|
|
142
|
+
const serverInfo = JSON.parse(await fs.readFile(getServerInfoPath(), 'utf-8').catch(() => '{}'));
|
|
143
|
+
const proxyPort = serverInfo.proxyPort || port + 1;
|
|
144
|
+
const caPath = serverInfo.caPath || path.join(getDataDir(), 'certs', 'ca.crt');
|
|
145
|
+
|
|
146
|
+
console.log(`fws server started on port ${port} (pid ${child.pid})\n`);
|
|
147
|
+
console.log(`To use with gws:\n`);
|
|
148
|
+
console.log(` export GOOGLE_WORKSPACE_CLI_CONFIG_DIR=${configDir}`);
|
|
149
|
+
console.log(` export GOOGLE_WORKSPACE_CLI_TOKEN=fake`);
|
|
150
|
+
console.log(` export HTTPS_PROXY=http://localhost:${proxyPort}`);
|
|
151
|
+
console.log(` export SSL_CERT_FILE=${caPath}\n`);
|
|
152
|
+
console.log(`Then try:\n`);
|
|
153
|
+
console.log(` gws gmail users messages list --params '{"userId":"me"}'`);
|
|
154
|
+
console.log(` gws gmail +triage`);
|
|
155
|
+
console.log(` gws calendar events list --params '{"calendarId":"primary"}'`);
|
|
156
|
+
console.log(` gws drive files list\n`);
|
|
157
|
+
console.log(`Stop with: fws server stop`);
|
|
158
|
+
} else {
|
|
159
|
+
const log = await fs.readFile(logFile, 'utf-8').catch(() => '');
|
|
160
|
+
console.error('Failed to start server.');
|
|
161
|
+
if (log.trim()) {
|
|
162
|
+
console.error('\nServer log:\n' + log);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
serverCmd
|
|
168
|
+
.command('stop')
|
|
169
|
+
.description('Stop the running mock server')
|
|
170
|
+
.action(async () => {
|
|
171
|
+
try {
|
|
172
|
+
const info = JSON.parse(await fs.readFile(getServerInfoPath(), 'utf-8'));
|
|
173
|
+
process.kill(info.pid, 'SIGTERM');
|
|
174
|
+
await fs.unlink(getServerInfoPath());
|
|
175
|
+
console.log(`Stopped fws server (pid ${info.pid})`);
|
|
176
|
+
} catch {
|
|
177
|
+
console.log('No running server found');
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
serverCmd
|
|
182
|
+
.command('status')
|
|
183
|
+
.description('Show server status')
|
|
184
|
+
.action(async () => {
|
|
185
|
+
try {
|
|
186
|
+
const info = JSON.parse(await fs.readFile(getServerInfoPath(), 'utf-8'));
|
|
187
|
+
try {
|
|
188
|
+
process.kill(info.pid, 0);
|
|
189
|
+
console.log(`Server running on port ${info.port} (pid ${info.pid})`);
|
|
190
|
+
} catch {
|
|
191
|
+
console.log('Server info exists but process is not running');
|
|
192
|
+
await fs.unlink(getServerInfoPath()).catch(() => {});
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
console.log('No server running');
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// === Snapshot commands ===
|
|
200
|
+
const snapshotCmd = program.command('snapshot');
|
|
201
|
+
|
|
202
|
+
snapshotCmd
|
|
203
|
+
.command('save <name>')
|
|
204
|
+
.description('Save current server state as a snapshot')
|
|
205
|
+
.option('-p, --port <port>', 'Server port', String(DEFAULT_PORT))
|
|
206
|
+
.action(async (name, opts) => {
|
|
207
|
+
const port = parseInt(opts.port);
|
|
208
|
+
const res = await fetch(`http://localhost:${port}/__fws/snapshot/save`, { method: 'POST' });
|
|
209
|
+
const data = await res.text();
|
|
210
|
+
|
|
211
|
+
const snapshotDir = path.join(getSnapshotsDir(), name);
|
|
212
|
+
await ensureDir(snapshotDir);
|
|
213
|
+
await fs.writeFile(path.join(snapshotDir, 'store.json'), data);
|
|
214
|
+
console.log(`Snapshot saved: ${name}`);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
snapshotCmd
|
|
218
|
+
.command('load <name>')
|
|
219
|
+
.description('Load a snapshot into the running server')
|
|
220
|
+
.option('-p, --port <port>', 'Server port', String(DEFAULT_PORT))
|
|
221
|
+
.action(async (name, opts) => {
|
|
222
|
+
const port = parseInt(opts.port);
|
|
223
|
+
const snapshotPath = path.join(getSnapshotsDir(), name, 'store.json');
|
|
224
|
+
const data = await fs.readFile(snapshotPath, 'utf-8');
|
|
225
|
+
|
|
226
|
+
await fetch(`http://localhost:${port}/__fws/snapshot/load`, {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: { 'Content-Type': 'application/json' },
|
|
229
|
+
body: data,
|
|
230
|
+
});
|
|
231
|
+
console.log(`Snapshot loaded: ${name}`);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
snapshotCmd
|
|
235
|
+
.command('list')
|
|
236
|
+
.description('List available snapshots')
|
|
237
|
+
.action(async () => {
|
|
238
|
+
const dir = getSnapshotsDir();
|
|
239
|
+
try {
|
|
240
|
+
const entries = await fs.readdir(dir);
|
|
241
|
+
if (entries.length === 0) {
|
|
242
|
+
console.log('No snapshots');
|
|
243
|
+
} else {
|
|
244
|
+
for (const name of entries) {
|
|
245
|
+
console.log(` ${name}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
console.log('No snapshots');
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
snapshotCmd
|
|
254
|
+
.command('delete <name>')
|
|
255
|
+
.description('Delete a snapshot')
|
|
256
|
+
.action(async (name) => {
|
|
257
|
+
const snapshotDir = path.join(getSnapshotsDir(), name);
|
|
258
|
+
await fs.rm(snapshotDir, { recursive: true, force: true });
|
|
259
|
+
console.log(`Snapshot deleted: ${name}`);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// === Setup commands ===
|
|
263
|
+
const setupCmd = program.command('setup');
|
|
264
|
+
const setupGmail = setupCmd.command('gmail');
|
|
265
|
+
|
|
266
|
+
setupGmail
|
|
267
|
+
.command('add-message')
|
|
268
|
+
.description('Add a message to the mailbox')
|
|
269
|
+
.requiredOption('--from <email>', 'From address')
|
|
270
|
+
.option('--to <email>', 'To address')
|
|
271
|
+
.option('--subject <text>', 'Subject line')
|
|
272
|
+
.option('--body <text>', 'Message body')
|
|
273
|
+
.option('--labels <list>', 'Comma-separated label IDs', 'INBOX,UNREAD')
|
|
274
|
+
.option('-p, --port <port>', 'Server port', String(DEFAULT_PORT))
|
|
275
|
+
.action(async (opts) => {
|
|
276
|
+
const port = parseInt(opts.port);
|
|
277
|
+
const res = await fetch(`http://localhost:${port}/__fws/setup/gmail/message`, {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: { 'Content-Type': 'application/json' },
|
|
280
|
+
body: JSON.stringify({
|
|
281
|
+
from: opts.from,
|
|
282
|
+
to: opts.to,
|
|
283
|
+
subject: opts.subject,
|
|
284
|
+
body: opts.body,
|
|
285
|
+
labels: opts.labels.split(','),
|
|
286
|
+
}),
|
|
287
|
+
});
|
|
288
|
+
const data = await res.json();
|
|
289
|
+
console.log(`Message added: ${data.id}`);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const setupCalendar = setupCmd.command('calendar');
|
|
293
|
+
|
|
294
|
+
setupCalendar
|
|
295
|
+
.command('add-event')
|
|
296
|
+
.description('Add an event to a calendar')
|
|
297
|
+
.requiredOption('--summary <text>', 'Event title')
|
|
298
|
+
.requiredOption('--start <datetime>', 'Start time (ISO 8601)')
|
|
299
|
+
.option('--duration <dur>', 'Duration (e.g. 30m, 1h, 2h)', '1h')
|
|
300
|
+
.option('--calendar <id>', 'Calendar ID', 'primary')
|
|
301
|
+
.option('--location <text>', 'Location')
|
|
302
|
+
.option('--attendees <list>', 'Comma-separated attendee emails')
|
|
303
|
+
.option('-p, --port <port>', 'Server port', String(DEFAULT_PORT))
|
|
304
|
+
.action(async (opts) => {
|
|
305
|
+
const port = parseInt(opts.port);
|
|
306
|
+
const res = await fetch(`http://localhost:${port}/__fws/setup/calendar/event`, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { 'Content-Type': 'application/json' },
|
|
309
|
+
body: JSON.stringify({
|
|
310
|
+
summary: opts.summary,
|
|
311
|
+
start: opts.start,
|
|
312
|
+
duration: opts.duration,
|
|
313
|
+
calendar: opts.calendar === 'primary' ? undefined : opts.calendar,
|
|
314
|
+
location: opts.location,
|
|
315
|
+
attendees: opts.attendees?.split(','),
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
const data = await res.json();
|
|
319
|
+
console.log(`Event added: ${data.id}`);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const setupDrive = setupCmd.command('drive');
|
|
323
|
+
|
|
324
|
+
setupDrive
|
|
325
|
+
.command('add-file')
|
|
326
|
+
.description('Add a file to Drive')
|
|
327
|
+
.requiredOption('--name <text>', 'File name')
|
|
328
|
+
.option('--mimeType <type>', 'MIME type', 'application/octet-stream')
|
|
329
|
+
.option('--parent <id>', 'Parent folder ID', 'root')
|
|
330
|
+
.option('--size <bytes>', 'File size in bytes')
|
|
331
|
+
.option('-p, --port <port>', 'Server port', String(DEFAULT_PORT))
|
|
332
|
+
.action(async (opts) => {
|
|
333
|
+
const port = parseInt(opts.port);
|
|
334
|
+
const res = await fetch(`http://localhost:${port}/__fws/setup/drive/file`, {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers: { 'Content-Type': 'application/json' },
|
|
337
|
+
body: JSON.stringify({
|
|
338
|
+
name: opts.name,
|
|
339
|
+
mimeType: opts.mimeType,
|
|
340
|
+
parent: opts.parent,
|
|
341
|
+
size: opts.size ? parseInt(opts.size) : undefined,
|
|
342
|
+
}),
|
|
343
|
+
});
|
|
344
|
+
const data = await res.json();
|
|
345
|
+
console.log(`File added: ${data.id}`);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// === Reset command ===
|
|
349
|
+
program
|
|
350
|
+
.command('reset')
|
|
351
|
+
.description('Reset server to seed data or a snapshot')
|
|
352
|
+
.option('-s, --snapshot <name>', 'Load this snapshot after reset')
|
|
353
|
+
.option('-p, --port <port>', 'Server port', String(DEFAULT_PORT))
|
|
354
|
+
.action(async (opts) => {
|
|
355
|
+
const port = parseInt(opts.port);
|
|
356
|
+
|
|
357
|
+
if (opts.snapshot) {
|
|
358
|
+
const snapshotPath = path.join(getSnapshotsDir(), opts.snapshot, 'store.json');
|
|
359
|
+
const data = await fs.readFile(snapshotPath, 'utf-8');
|
|
360
|
+
await fetch(`http://localhost:${port}/__fws/snapshot/load`, {
|
|
361
|
+
method: 'POST',
|
|
362
|
+
headers: { 'Content-Type': 'application/json' },
|
|
363
|
+
body: data,
|
|
364
|
+
});
|
|
365
|
+
console.log(`Reset to snapshot: ${opts.snapshot}`);
|
|
366
|
+
} else {
|
|
367
|
+
await fetch(`http://localhost:${port}/__fws/reset`, { method: 'POST' });
|
|
368
|
+
console.log('Reset to seed data');
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// === Proxy mode (default): start server, run gws, exit ===
|
|
373
|
+
// If first arg is not a known subcommand, treat as gws proxy
|
|
374
|
+
const SUBCOMMANDS = ['server', 'snapshot', 'setup', 'reset', 'help', '--help', '-h', '--version', '-V'];
|
|
375
|
+
|
|
376
|
+
async function runProxy(args: string[]): Promise<void> {
|
|
377
|
+
const port = DEFAULT_PORT;
|
|
378
|
+
const proxyPort = DEFAULT_PROXY_PORT;
|
|
379
|
+
|
|
380
|
+
// Start server in-process
|
|
381
|
+
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fws-proxy-'));
|
|
382
|
+
await generateConfigDir(port, configDir);
|
|
383
|
+
|
|
384
|
+
// Generate CA and start MITM proxy
|
|
385
|
+
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fws-proxy-data-'));
|
|
386
|
+
const { caPath } = await generateCACert(dataDir);
|
|
387
|
+
const proxyServer = startMitmProxy(port, proxyPort);
|
|
388
|
+
|
|
389
|
+
const app = createApp();
|
|
390
|
+
const server: Server = await new Promise((resolve) => {
|
|
391
|
+
const s = app.listen(port, () => resolve(s));
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const gwsPath = process.env.GWS_PATH || 'gws';
|
|
395
|
+
const env = {
|
|
396
|
+
...process.env,
|
|
397
|
+
GOOGLE_WORKSPACE_CLI_CONFIG_DIR: configDir,
|
|
398
|
+
GOOGLE_WORKSPACE_CLI_TOKEN: 'fake',
|
|
399
|
+
HTTPS_PROXY: `http://localhost:${proxyPort}`,
|
|
400
|
+
SSL_CERT_FILE: caPath,
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const child = spawn(gwsPath, args, { env, stdio: 'inherit' });
|
|
404
|
+
|
|
405
|
+
child.on('close', async (code) => {
|
|
406
|
+
server.close();
|
|
407
|
+
proxyServer.close();
|
|
408
|
+
await fs.rm(configDir, { recursive: true, force: true });
|
|
409
|
+
await fs.rm(dataDir, { recursive: true, force: true });
|
|
410
|
+
process.exit(code ?? 0);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Main entry
|
|
415
|
+
const firstArg = process.argv[2];
|
|
416
|
+
if (firstArg && !SUBCOMMANDS.includes(firstArg)) {
|
|
417
|
+
// Proxy mode
|
|
418
|
+
runProxy(process.argv.slice(2));
|
|
419
|
+
} else {
|
|
420
|
+
program.parse();
|
|
421
|
+
}
|