Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:35:59 +08:00
commit 90883a4d25
287 changed files with 75058 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
# Google Cloud Console Setup Guide
## Step 1: Create Project
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Sign in with your **consulting account** (this will own the OAuth app)
3. Click **Select a project****New Project**
4. Name: `Geoffrey Google Workspace`
5. Click **Create**
## Step 2: Enable APIs
Navigate to **APIs & Services → Library** and enable each:
### Required APIs
- [ ] Gmail API
- [ ] Google Calendar API
- [ ] Google Drive API
- [ ] Google Docs API
- [ ] Google Sheets API
- [ ] Google Slides API
- [ ] Google Forms API
- [ ] Google Chat API
- [ ] Tasks API
- [ ] People API (for user info)
### Optional APIs
- [ ] Google Keep API (limited availability)
- [ ] Gemini API (if using AI features)
**Tip:** Search for each API name and click **Enable**
## Step 3: Configure OAuth Consent Screen
1. Go to **APIs & Services → OAuth consent screen**
2. Select **External** (unless all accounts are in same org)
3. Click **Create**
### App Information
- App name: `Geoffrey`
- User support email: Your consulting email
- Developer contact: Your consulting email
### Scopes
Click **Add or Remove Scopes** and add:
```
https://www.googleapis.com/auth/gmail.modify
https://www.googleapis.com/auth/calendar
https://www.googleapis.com/auth/drive
https://www.googleapis.com/auth/documents
https://www.googleapis.com/auth/spreadsheets
https://www.googleapis.com/auth/presentations
https://www.googleapis.com/auth/forms.body
https://www.googleapis.com/auth/chat.messages
https://www.googleapis.com/auth/tasks
https://www.googleapis.com/auth/userinfo.email
```
### Test Users
Add all three email addresses:
- Your PSD email
- Your personal email
- Your consulting email
**Note:** While in "Testing" mode, only these users can authorize.
## Step 4: Create OAuth Credentials
1. Go to **APIs & Services → Credentials**
2. Click **Create Credentials → OAuth client ID**
3. Application type: **Desktop app**
4. Name: `Geoffrey CLI`
5. Click **Create**
6. Copy the **Client ID** and **Client Secret**
7. Add to your iCloud secrets `.env` file:
```
~/Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/secrets/.env
```
Add these lines:
```
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
```
## Step 5: PSD Domain Allowlisting
Your PSD Google Workspace likely restricts third-party apps. To allow Geoffrey:
### If You're a Google Admin:
1. Go to [Google Admin Console](https://admin.google.com)
2. Navigate to **Security → Access and data control → API controls**
3. Click **Manage Third-Party App Access**
4. Click **Add app → OAuth App Name Or Client ID**
5. Enter your OAuth Client ID (from Step 4)
6. Select **Trusted** access
### If You Need IT Approval:
Send this to your IT team:
```
Subject: Request to Allow OAuth App for Personal Productivity Tool
I need to allowlist a personal productivity app that integrates with Google Workspace.
OAuth Client ID: [YOUR_CLIENT_ID_HERE]
Requested scopes:
- Gmail (read/send)
- Calendar (read/write)
- Drive (read/write)
- Docs/Sheets/Slides (read/write)
- Tasks (read/write)
This is a local CLI tool that runs only on my machine.
No data is sent to external servers.
Please add this client ID to the trusted apps list.
```
## Step 6: Authenticate Each Account
Once credentials are in your .env:
```bash
cd skills/google-workspace
# Install dependencies
bun install
# Authenticate each account
bun auth/oauth_setup.js psd # Will open browser, sign in with PSD account
bun auth/oauth_setup.js kh # Will open browser, sign in with personal account
bun auth/oauth_setup.js hrg # Will open browser, sign in with consulting account
# After each auth, store the tokens (copy the JSON output from oauth_setup)
bun auth/token_manager.js store psd '<tokens-json-output>'
bun auth/token_manager.js store kh '<tokens-json-output>'
bun auth/token_manager.js store hrg '<tokens-json-output>'
```
## Step 7: Verify Setup
```bash
# List stored accounts
bun auth/token_manager.js list
# Test token retrieval
bun auth/token_manager.js get psd
```
## Troubleshooting
### "Access blocked: This app's request is invalid"
- Check that redirect URI matches: `http://localhost:3000/oauth2callback`
- Verify OAuth consent screen is configured
### "Access denied" for PSD account
- App needs to be allowlisted in PSD Google Admin
- Contact IT with the client ID
### "Refresh token is null"
- Delete the app from your Google account's connected apps
- Re-run oauth_setup.js with the account
- The `prompt: 'consent'` should force a new refresh token
### Token expires quickly
- Access tokens last 1 hour
- token_manager.js auto-refreshes using the refresh token
- Refresh tokens don't expire unless revoked
## Security Notes
- Credentials stored in iCloud secrets `.env` (synced, but local to your devices)
- Tokens stored in macOS Keychain (encrypted)
- Each account has its own isolated tokens
- Revoke access anytime from Google Account → Security → Third-party apps

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env node
/**
* OAuth2 Setup Script for Google Workspace
*
* Usage: node oauth_setup.js <account-alias>
* Example: node oauth_setup.js psd
*
* This script:
* 1. Starts a local server to receive OAuth callback
* 2. Opens browser to Google consent page
* 3. Exchanges auth code for tokens
* 4. Outputs tokens as JSON (to be stored by token_manager.js)
*
* Prerequisites:
* - .env file with GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET
* - npm install googleapis open dotenv
*/
const { google } = require('googleapis');
const http = require('http');
const url = require('url');
const openModule = require('open');
const open = openModule.default || openModule;
const path = require('path');
// Load environment variables from iCloud secrets
const os = require('os');
const ENV_PATH = path.join(
os.homedir(),
'Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/secrets/.env'
);
require('dotenv').config({ path: ENV_PATH });
// Configuration
const REDIRECT_PORT = process.env.OAUTH_REDIRECT_PORT || 3000;
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/oauth2callback`;
// Full scope list for complete Google Workspace access
const SCOPES = [
// Gmail
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.compose',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.labels',
// Calendar
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/calendar.events',
// Drive
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/drive.file',
// Docs, Sheets, Slides
'https://www.googleapis.com/auth/documents',
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/presentations',
// Forms
'https://www.googleapis.com/auth/forms.body',
'https://www.googleapis.com/auth/forms.responses.readonly',
// Chat
'https://www.googleapis.com/auth/chat.spaces',
'https://www.googleapis.com/auth/chat.messages',
'https://www.googleapis.com/auth/chat.memberships.readonly',
// Tasks
'https://www.googleapis.com/auth/tasks',
// Keep (Note: Keep API has limited availability)
// 'https://www.googleapis.com/auth/keep',
// User info (to identify which account)
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
// Admin Directory (to look up user names)
'https://www.googleapis.com/auth/admin.directory.user.readonly',
];
async function main() {
const accountAlias = process.argv[2];
if (!accountAlias) {
console.error(JSON.stringify({
error: 'Missing account alias',
usage: 'node oauth_setup.js <account-alias>',
example: 'node oauth_setup.js psd'
}));
process.exit(1);
}
// Load client credentials from environment
const client_id = process.env.GOOGLE_CLIENT_ID;
const client_secret = process.env.GOOGLE_CLIENT_SECRET;
if (!client_id || !client_secret) {
console.error(JSON.stringify({
error: 'Missing credentials in .env',
instructions: [
'1. Go to Google Cloud Console',
'2. Create OAuth 2.0 Client ID (Desktop app)',
'3. Copy client ID and secret to skills/google-workspace/.env',
'4. See .env.example for format'
]
}));
process.exit(1);
}
// Create OAuth2 client
const oauth2Client = new google.auth.OAuth2(
client_id,
client_secret,
REDIRECT_URI
);
// Generate auth URL
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent', // Force to get refresh token
});
// Start local server to receive callback
const server = http.createServer(async (req, res) => {
try {
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === '/oauth2callback') {
const code = parsedUrl.query.code;
if (!code) {
res.writeHead(400);
res.end('Missing authorization code');
return;
}
// Exchange code for tokens
const { tokens } = await oauth2Client.getToken(code);
// Get user info to confirm which account
oauth2Client.setCredentials(tokens);
const oauth2 = google.oauth2({ version: 'v2', auth: oauth2Client });
const userInfo = await oauth2.userinfo.get();
// Send success response to browser
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body style="font-family: sans-serif; padding: 40px; text-align: center;">
<h1>✅ Authorization Successful</h1>
<p>Account: <strong>${userInfo.data.email}</strong></p>
<p>Alias: <strong>${accountAlias}</strong></p>
<p>You can close this window.</p>
</body>
</html>
`);
// Output tokens as JSON
console.log(JSON.stringify({
success: true,
account: accountAlias,
email: userInfo.data.email,
tokens: {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: tokens.token_type,
expiry_date: tokens.expiry_date,
}
}, null, 2));
// Close server
server.close();
}
} catch (error) {
res.writeHead(500);
res.end('Authorization failed');
console.error(JSON.stringify({
error: 'Token exchange failed',
message: error.message
}));
server.close();
process.exit(1);
}
});
server.listen(REDIRECT_PORT, async () => {
console.error(`Opening browser for ${accountAlias} account authorization...`);
console.error(`If browser doesn't open, visit: ${authUrl}`);
await open(authUrl);
});
// Timeout after 5 minutes
setTimeout(() => {
console.error(JSON.stringify({
error: 'Authorization timeout',
message: 'No response received within 5 minutes'
}));
server.close();
process.exit(1);
}, 5 * 60 * 1000);
}
main().catch(error => {
console.error(JSON.stringify({
error: 'Setup failed',
message: error.message
}));
process.exit(1);
});

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env node
/**
* Token Manager for Google Workspace
*
* Stores and retrieves OAuth tokens from macOS Keychain.
*
* Usage:
* Store: node token_manager.js store <account> '<tokens-json>'
* Retrieve: node token_manager.js get <account>
* Refresh: node token_manager.js refresh <account>
* List: node token_manager.js list
* Delete: node token_manager.js delete <account>
*
* Examples:
* node token_manager.js store psd '{"access_token":"...","refresh_token":"..."}'
* node token_manager.js get psd
* node token_manager.js refresh psd
*/
const { execSync } = require('child_process');
const { google } = require('googleapis');
const path = require('path');
// Load environment variables from iCloud secrets
const os = require('os');
const ENV_PATH = path.join(
os.homedir(),
'Library/Mobile Documents/com~apple~CloudDocs/Geoffrey/secrets/.env'
);
require('dotenv').config({ path: ENV_PATH });
const SERVICE_NAME = 'geoffrey-google-workspace';
/**
* Store tokens in Keychain
*/
function storeTokens(account, tokens) {
const tokenString = typeof tokens === 'string' ? tokens : JSON.stringify(tokens);
// Delete existing entry if present
try {
execSync(
`security delete-generic-password -s "${SERVICE_NAME}" -a "${account}" 2>/dev/null`,
{ encoding: 'utf8' }
);
} catch (e) {
// Entry might not exist, that's fine
}
// Add new entry
try {
execSync(
`security add-generic-password -s "${SERVICE_NAME}" -a "${account}" -w '${tokenString.replace(/'/g, "'\\''")}' -U`,
{ encoding: 'utf8' }
);
return { success: true, account, message: 'Tokens stored in Keychain' };
} catch (error) {
throw new Error(`Failed to store tokens: ${error.message}`);
}
}
/**
* Retrieve tokens from Keychain
*/
function getTokens(account) {
try {
const result = execSync(
`security find-generic-password -s "${SERVICE_NAME}" -a "${account}" -w`,
{ encoding: 'utf8' }
).trim();
return JSON.parse(result);
} catch (error) {
throw new Error(`No tokens found for account: ${account}`);
}
}
/**
* Refresh access token using refresh token
*/
async function refreshTokens(account) {
// Get current tokens
const tokens = getTokens(account);
if (!tokens.refresh_token) {
throw new Error('No refresh token available');
}
// Load credentials from environment
const client_id = process.env.GOOGLE_CLIENT_ID;
const client_secret = process.env.GOOGLE_CLIENT_SECRET;
if (!client_id || !client_secret) {
throw new Error('Missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET in .env');
}
// Create OAuth2 client and refresh
const oauth2Client = new google.auth.OAuth2(client_id, client_secret);
oauth2Client.setCredentials(tokens);
const { credentials: newTokens } = await oauth2Client.refreshAccessToken();
// Store updated tokens (keep refresh_token if not returned)
const updatedTokens = {
...tokens,
access_token: newTokens.access_token,
expiry_date: newTokens.expiry_date,
};
storeTokens(account, updatedTokens);
return {
success: true,
account,
access_token: newTokens.access_token,
expiry_date: newTokens.expiry_date
};
}
/**
* List all stored accounts
*/
function listAccounts() {
try {
const result = execSync(
`security dump-keychain | grep -A 4 '"svce"<blob>="${SERVICE_NAME}"' | grep '"acct"' | sed 's/.*="\\(.*\\)"/\\1/'`,
{ encoding: 'utf8' }
);
const accounts = result.trim().split('\n').filter(Boolean);
return { accounts };
} catch (error) {
return { accounts: [] };
}
}
/**
* Delete tokens for an account
*/
function deleteTokens(account) {
try {
execSync(
`security delete-generic-password -s "${SERVICE_NAME}" -a "${account}"`,
{ encoding: 'utf8' }
);
return { success: true, account, message: 'Tokens deleted' };
} catch (error) {
throw new Error(`Failed to delete tokens for ${account}: ${error.message}`);
}
}
/**
* Get an authenticated OAuth2 client for an account
*/
async function getAuthClient(account) {
const tokens = getTokens(account);
const client_id = process.env.GOOGLE_CLIENT_ID;
const client_secret = process.env.GOOGLE_CLIENT_SECRET;
if (!client_id || !client_secret) {
throw new Error('Missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET in .env');
}
const oauth2Client = new google.auth.OAuth2(client_id, client_secret);
oauth2Client.setCredentials(tokens);
// Check if token needs refresh (expires in next 5 minutes)
if (tokens.expiry_date && tokens.expiry_date < Date.now() + 5 * 60 * 1000) {
const refreshed = await refreshTokens(account);
oauth2Client.setCredentials({
...tokens,
access_token: refreshed.access_token
});
}
return oauth2Client;
}
// CLI interface
async function main() {
const [command, account, data] = process.argv.slice(2);
try {
let result;
switch (command) {
case 'store':
if (!account || !data) {
throw new Error('Usage: token_manager.js store <account> <tokens-json>');
}
result = storeTokens(account, JSON.parse(data));
break;
case 'get':
if (!account) {
throw new Error('Usage: token_manager.js get <account>');
}
result = getTokens(account);
break;
case 'refresh':
if (!account) {
throw new Error('Usage: token_manager.js refresh <account>');
}
result = await refreshTokens(account);
break;
case 'list':
result = listAccounts();
break;
case 'delete':
if (!account) {
throw new Error('Usage: token_manager.js delete <account>');
}
result = deleteTokens(account);
break;
default:
throw new Error(`Unknown command: ${command}\nCommands: store, get, refresh, list, delete`);
}
console.log(JSON.stringify(result, null, 2));
} catch (error) {
console.error(JSON.stringify({ error: error.message }));
process.exit(1);
}
}
// Only run CLI if called directly
if (require.main === module) {
main();
}
// Export for use by other scripts
module.exports = { getAuthClient, getTokens, refreshTokens, storeTokens };