repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
pi/agent/skills/google-workspace/scripts/common.js
Raw#!/usr/bin/env node
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const http = require('node:http');
const net = require('node:net');
const crypto = require('node:crypto');
const { spawn, spawnSync } = require('node:child_process');
const CONFIG_DIR =
process.env.GOOGLE_WORKSPACE_CONFIG_DIR ||
path.join(os.homedir(), '.pi', 'google-workspace');
const CREDENTIALS_PATH =
process.env.GOOGLE_WORKSPACE_CREDENTIALS ||
path.join(CONFIG_DIR, 'credentials.json');
const TOKENS_DIR =
process.env.GOOGLE_WORKSPACE_TOKENS_DIR || path.join(CONFIG_DIR, 'tokens');
const SKILL_ROOT = path.join(__dirname, '..');
const DEFAULT_CLIENT_ID =
'338689075775-o75k922vn5fdl18qergr96rp8g63e4d7.apps.googleusercontent.com';
const DEFAULT_CLOUD_FUNCTION_URL =
'https://google-workspace-extension.geminicli.com';
const REQUIRED_IDENTITY_SCOPES = [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
const DEFAULT_SCOPES = [
'https://www.googleapis.com/auth/documents',
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/calendar',
'https://www.googleapis.com/auth/chat.spaces',
'https://www.googleapis.com/auth/chat.messages',
'https://www.googleapis.com/auth/chat.memberships',
...REQUIRED_IDENTITY_SCOPES,
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/directory.readonly',
'https://www.googleapis.com/auth/presentations.readonly',
'https://www.googleapis.com/auth/spreadsheets.readonly',
];
const DEFAULT_VERSIONS = {
calendar: 'v3',
chat: 'v1',
docs: 'v1',
drive: 'v3',
gmail: 'v1',
people: 'v1',
sheets: 'v4',
slides: 'v1',
};
let runtimeDeps;
function ensureConfigDir() {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
function ensureTokensDir() {
fs.mkdirSync(TOKENS_DIR, { recursive: true });
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function installDependencies() {
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
console.error('ℹ️ Installing Google Workspace skill dependencies...');
const result = spawnSync(npm, ['install', '--no-audit', '--no-fund'], {
cwd: SKILL_ROOT,
stdio: 'inherit',
});
if (result.error) {
throw new Error(`Failed to run npm install: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(`npm install failed with exit code ${result.status}`);
}
}
function loadRuntimeDeps() {
if (runtimeDeps) {
return runtimeDeps;
}
try {
const { google } = require('googleapis');
const { authenticate } = require('@google-cloud/local-auth');
runtimeDeps = { google, authenticate };
return runtimeDeps;
} catch (error) {
if (error && error.code === 'MODULE_NOT_FOUND') {
installDependencies();
const { google } = require('googleapis');
const { authenticate } = require('@google-cloud/local-auth');
runtimeDeps = { google, authenticate };
return runtimeDeps;
}
throw error;
}
}
function getGoogleApis() {
return loadRuntimeDeps().google;
}
function getWorkspaceClientConfig() {
return {
clientId:
process.env.GOOGLE_WORKSPACE_CLIENT_ID ||
process.env.WORKSPACE_CLIENT_ID ||
DEFAULT_CLIENT_ID,
cloudFunctionUrl:
process.env.GOOGLE_WORKSPACE_CLOUD_FUNCTION_URL ||
process.env.WORKSPACE_CLOUD_FUNCTION_URL ||
DEFAULT_CLOUD_FUNCTION_URL,
};
}
function credentialsExist() {
return fs.existsSync(CREDENTIALS_PATH);
}
function normalizeEmail(email) {
const normalized = String(email || '').trim().toLowerCase();
if (!normalized) {
throw new Error('Email is required. Pass --email <account@example.com>.');
}
if (!/^[^@\s]+@[^@\s]+$/.test(normalized)) {
throw new Error(`Invalid email address: ${email}`);
}
return normalized;
}
function tokenFilenameForEmail(email) {
return `${encodeURIComponent(normalizeEmail(email))}.json`;
}
function tokenPathForEmail(email) {
return path.join(TOKENS_DIR, tokenFilenameForEmail(email));
}
function tokenExists(email) {
return fs.existsSync(tokenPathForEmail(email));
}
function loadCredentialsFile() {
if (!credentialsExist()) {
throw new Error(
`Missing OAuth credentials file at ${CREDENTIALS_PATH}. ` +
'Create a Google OAuth Desktop client and save the JSON there.',
);
}
return readJson(CREDENTIALS_PATH);
}
function loadToken(email) {
const tokenPath = tokenPathForEmail(email);
if (!fs.existsSync(tokenPath)) {
return null;
}
return readJson(tokenPath);
}
function decodeEmailFromTokenFilename(filename) {
if (!filename.endsWith('.json')) {
return null;
}
try {
return normalizeEmail(decodeURIComponent(filename.slice(0, -5)));
} catch {
return null;
}
}
function listAccounts() {
if (!fs.existsSync(TOKENS_DIR)) {
return [];
}
const results = [];
const entries = fs.readdirSync(TOKENS_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) {
continue;
}
const tokenPath = path.join(TOKENS_DIR, entry.name);
const emailFromFilename = decodeEmailFromTokenFilename(entry.name);
let token;
try {
token = readJson(tokenPath);
} catch (error) {
results.push({
email: emailFromFilename,
path: tokenPath,
error: `Failed to read token JSON: ${error.message}`,
});
continue;
}
let email = emailFromFilename;
if (token && token.__email) {
try {
email = normalizeEmail(token.__email);
} catch {
// Ignore malformed __email metadata and keep filename-derived email.
}
}
const expiryDate = token.expiry_date || null;
results.push({
email,
path: tokenPath,
authMode: token.__authMode === 'local' || token.__authMode === 'cloud'
? token.__authMode
: null,
hasAccessToken: Boolean(token.access_token),
hasRefreshToken: Boolean(token.refresh_token),
scopes: formatScopes(token.scope),
expiryDate,
expired: expiryDate ? expiryDate < Date.now() : null,
});
}
return results.sort((a, b) => {
const left = a.email || '';
const right = b.email || '';
return left.localeCompare(right);
});
}
function createOAuthClientFromCredentials(credentialsJson) {
const creds = credentialsJson.installed || credentialsJson.web;
if (!creds) {
throw new Error(
'Invalid credentials.json: expected an "installed" or "web" key.',
);
}
if (!creds.client_id || !creds.client_secret) {
throw new Error(
'Invalid credentials.json: missing client_id or client_secret.',
);
}
const redirectUri = Array.isArray(creds.redirect_uris)
? creds.redirect_uris[0]
: undefined;
const google = getGoogleApis();
return new google.auth.OAuth2(
creds.client_id,
creds.client_secret,
redirectUri || 'http://localhost',
);
}
function createCloudOAuthClient() {
const google = getGoogleApis();
const { clientId } = getWorkspaceClientConfig();
return new google.auth.OAuth2({ clientId });
}
function resolveAuthMode(token) {
const forced = process.env.GOOGLE_WORKSPACE_AUTH_MODE;
if (forced === 'local' || forced === 'cloud') {
return forced;
}
if (token && (token.__authMode === 'local' || token.__authMode === 'cloud')) {
return token.__authMode;
}
if (credentialsExist()) {
return 'local';
}
return 'cloud';
}
function saveToken(email, token, mode) {
ensureConfigDir();
ensureTokensDir();
const normalizedEmail = normalizeEmail(email);
const tokenPath = tokenPathForEmail(normalizedEmail);
const payload = {
...token,
__authMode: mode,
__email: normalizedEmail,
};
fs.writeFileSync(tokenPath, JSON.stringify(payload, null, 2));
try {
fs.chmodSync(tokenPath, 0o600);
} catch {
// Non-POSIX file systems can fail chmod. Ignore.
}
}
function clearToken(email) {
const tokenPath = tokenPathForEmail(email);
if (fs.existsSync(tokenPath)) {
fs.rmSync(tokenPath);
}
}
function isExpiringSoon(credentials) {
if (!credentials || !credentials.expiry_date) {
return false;
}
return credentials.expiry_date < Date.now() + 60_000;
}
function openUrlInBrowser(targetUrl) {
let command;
let args;
if (process.platform === 'darwin') {
command = 'open';
args = [targetUrl];
} else if (process.platform === 'win32') {
command = 'cmd';
args = ['/c', 'start', '', targetUrl];
} else {
command = 'xdg-open';
args = [targetUrl];
}
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
});
child.unref();
}
function getAvailablePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const address = server.address();
const port = address && typeof address === 'object' ? address.port : 0;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}
async function refreshViaCloudFunction(refreshToken) {
const { cloudFunctionUrl } = getWorkspaceClientConfig();
const response = await fetch(`${cloudFunctionUrl}/refreshToken`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Cloud token refresh failed: ${response.status} ${body}`);
}
return response.json();
}
function mergeWithIdentityScopes(scopes) {
return Array.from(new Set([...formatScopes(scopes), ...REQUIRED_IDENTITY_SCOPES]));
}
async function fetchAuthenticatedEmail(authClient) {
const google = getGoogleApis();
const oauth2 = google.oauth2({ version: 'v2', auth: authClient });
const response = await oauth2.userinfo.get();
const email = response?.data?.email;
if (!email) {
throw new Error(
'Authentication succeeded, but Google did not return an email address. ' +
'Ensure userinfo.email scope is granted.',
);
}
return normalizeEmail(email);
}
async function ensureExpectedEmail(authClient, expectedEmail) {
const normalizedExpectedEmail = normalizeEmail(expectedEmail);
const authenticatedEmail = await fetchAuthenticatedEmail(authClient);
if (authenticatedEmail !== normalizedExpectedEmail) {
throw new Error(
`Authenticated as ${authenticatedEmail}, but expected ${normalizedExpectedEmail}. ` +
'Sign in with the requested account and retry.',
);
}
return authenticatedEmail;
}
async function interactiveLoginLocal(scopes, email) {
const { authenticate } = loadRuntimeDeps();
console.error('ℹ️ Opening browser for Google OAuth login (local credentials)...');
const authClient = await authenticate({
keyfilePath: CREDENTIALS_PATH,
scopes: mergeWithIdentityScopes(scopes),
});
if (!authClient.credentials || !authClient.credentials.access_token) {
throw new Error('Authentication failed: no access token returned.');
}
const normalizedEmail = await ensureExpectedEmail(authClient, email);
saveToken(normalizedEmail, authClient.credentials, 'local');
return authClient;
}
async function interactiveLoginCloud(scopes, email) {
const client = createCloudOAuthClient();
const { cloudFunctionUrl } = getWorkspaceClientConfig();
const host = process.env.GOOGLE_WORKSPACE_CALLBACK_HOST || 'localhost';
const port = await getAvailablePort();
const callbackUrl = `http://${host}:${port}/oauth2callback`;
const csrfToken = crypto.randomBytes(32).toString('hex');
const statePayload = {
uri: callbackUrl,
manual: false,
csrf: csrfToken,
};
const state = Buffer.from(JSON.stringify(statePayload), 'utf8').toString(
'base64',
);
const authUrl = client.generateAuthUrl({
redirect_uri: cloudFunctionUrl,
access_type: 'offline',
scope: mergeWithIdentityScopes(scopes),
state,
prompt: 'consent',
});
console.error('ℹ️ Opening browser for Google OAuth login...');
try {
openUrlInBrowser(authUrl);
} catch {
console.error('⚠️ Could not auto-open browser. Open this URL manually:');
console.error(authUrl);
}
const loginTimeoutMs = 5 * 60 * 1000;
const credentials = await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
server.close(() => {
reject(
new Error(
'Authentication timed out after 5 minutes. Please try again.',
),
);
});
}, loginTimeoutMs);
const server = http.createServer((req, res) => {
try {
if (!req.url || !req.url.startsWith('/oauth2callback')) {
res.statusCode = 404;
res.end('Not found');
return;
}
const parsed = new URL(req.url, `http://${host}:${port}`);
const returnedState = parsed.searchParams.get('state');
if (returnedState !== csrfToken) {
res.statusCode = 400;
res.end('State mismatch.');
clearTimeout(timer);
server.close(() => {
reject(new Error('OAuth state mismatch.'));
});
return;
}
const errorCode = parsed.searchParams.get('error');
if (errorCode) {
const description =
parsed.searchParams.get('error_description') ||
'No additional details';
res.statusCode = 400;
res.end('Authentication failed.');
clearTimeout(timer);
server.close(() => {
reject(new Error(`Google OAuth error: ${errorCode}. ${description}`));
});
return;
}
const accessToken = parsed.searchParams.get('access_token');
const refreshToken = parsed.searchParams.get('refresh_token');
const scope = parsed.searchParams.get('scope');
const tokenType = parsed.searchParams.get('token_type');
const expiryDateRaw = parsed.searchParams.get('expiry_date');
if (!accessToken || !expiryDateRaw) {
res.statusCode = 400;
res.end('Authentication failed: missing tokens.');
clearTimeout(timer);
server.close(() => {
reject(
new Error('Authentication failed: callback did not include tokens.'),
);
});
return;
}
const expiryDate = Number.parseInt(expiryDateRaw, 10);
if (Number.isNaN(expiryDate)) {
res.statusCode = 400;
res.end('Authentication failed: invalid expiry date.');
clearTimeout(timer);
server.close(() => {
reject(
new Error('Authentication failed: callback expiry_date is invalid.'),
);
});
return;
}
const creds = {
access_token: accessToken,
refresh_token: refreshToken || null,
scope: scope || undefined,
token_type: tokenType || undefined,
expiry_date: expiryDate,
};
res.end('Authentication successful. You can close this tab.');
clearTimeout(timer);
server.close(() => {
resolve(creds);
});
} catch (error) {
clearTimeout(timer);
server.close(() => {
reject(error);
});
}
});
server.on('error', (error) => {
clearTimeout(timer);
reject(new Error(`OAuth callback server error: ${error.message}`));
});
server.listen(port, host, () => {
// listener started
});
});
client.setCredentials(credentials);
const normalizedEmail = await ensureExpectedEmail(client, email);
saveToken(normalizedEmail, credentials, 'cloud');
return client;
}
async function interactiveLogin(scopes, mode, email) {
if (mode === 'local') {
return interactiveLoginLocal(scopes, email);
}
return interactiveLoginCloud(scopes, email);
}
function stripInternalTokenFields(token) {
if (!token || typeof token !== 'object') {
return token;
}
const clone = { ...token };
delete clone.__authMode;
delete clone.__email;
return clone;
}
async function authorize(options = {}) {
const email = normalizeEmail(options.email);
const scopes = mergeWithIdentityScopes(options.scopes || DEFAULT_SCOPES);
const interactive = options.interactive !== false;
ensureConfigDir();
ensureTokensDir();
loadRuntimeDeps();
const token = loadToken(email);
const mode = resolveAuthMode(token);
const client =
mode === 'local'
? createOAuthClientFromCredentials(loadCredentialsFile())
: createCloudOAuthClient();
if (!token) {
if (!interactive) {
throw new Error(
`No token found for ${email} at ${tokenPathForEmail(email)}. ` +
`Run: node scripts/auth.js login --email ${email}`,
);
}
return interactiveLogin(scopes, mode, email);
}
if (token.__email) {
const tokenEmail = normalizeEmail(token.__email);
if (tokenEmail !== email) {
throw new Error(
`Token metadata mismatch. Requested ${email} but token is tagged as ${tokenEmail}. ` +
`Delete it and sign in again: node scripts/auth.js clear --email ${email}`,
);
}
}
client.setCredentials(stripInternalTokenFields(token));
if (!isExpiringSoon(client.credentials)) {
return client;
}
if (client.credentials.refresh_token) {
if (mode === 'local') {
const refreshed = await client.refreshAccessToken();
const merged = {
...refreshed.credentials,
refresh_token:
refreshed.credentials.refresh_token || client.credentials.refresh_token,
};
client.setCredentials(merged);
saveToken(email, merged, 'local');
return client;
}
const refreshed = await refreshViaCloudFunction(
client.credentials.refresh_token,
);
const merged = {
...refreshed,
refresh_token: client.credentials.refresh_token,
};
client.setCredentials(merged);
saveToken(email, merged, 'cloud');
return client;
}
if (!interactive) {
throw new Error(
`Token for ${email} is expired and no refresh token is available. ` +
`Run: node scripts/auth.js login --email ${email}`,
);
}
return interactiveLogin(scopes, mode, email);
}
function formatScopes(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value;
}
return String(value)
.split(/[\s,]+/)
.map((part) => part.trim())
.filter(Boolean);
}
module.exports = {
CONFIG_DIR,
CREDENTIALS_PATH,
TOKENS_DIR,
DEFAULT_SCOPES,
DEFAULT_VERSIONS,
REQUIRED_IDENTITY_SCOPES,
authorize,
clearToken,
credentialsExist,
formatScopes,
getGoogleApis,
getWorkspaceClientConfig,
listAccounts,
loadToken,
normalizeEmail,
resolveAuthMode,
tokenExists,
tokenPathForEmail,
};