main
  1#!/usr/bin/env node
  2
  3import fs from "node:fs";
  4import os from "node:os";
  5import path from "node:path";
  6import { spawnSync } from "node:child_process";
  7import { createRequire } from "node:module";
  8
  9// --- Configuration paths ---
 10
 11export const CONFIG_DIR =
 12  process.env.GOOGLE_WORKSPACE_CONFIG_DIR ||
 13  path.join(os.homedir(), ".config", "google-workspace");
 14
 15export const CREDENTIALS_PATH =
 16  process.env.GOOGLE_WORKSPACE_CREDENTIALS ||
 17  path.join(CONFIG_DIR, "credentials.json");
 18
 19export const TOKEN_PATH =
 20  process.env.GOOGLE_WORKSPACE_TOKEN || path.join(CONFIG_DIR, "token.json");
 21
 22const SKILL_ROOT = path.join(path.dirname(new URL(import.meta.url).pathname));
 23
 24// --- Scopes ---
 25
 26export const DEFAULT_SCOPES = [
 27  "https://www.googleapis.com/auth/documents",
 28  "https://www.googleapis.com/auth/drive",
 29  "https://www.googleapis.com/auth/calendar",
 30  "https://www.googleapis.com/auth/userinfo.profile",
 31  "https://www.googleapis.com/auth/gmail.modify",
 32  "https://www.googleapis.com/auth/directory.readonly",
 33  "https://www.googleapis.com/auth/presentations",
 34  "https://www.googleapis.com/auth/spreadsheets.readonly",
 35];
 36
 37export const DEFAULT_VERSIONS = {
 38  calendar: "v3",
 39  docs: "v1",
 40  drive: "v3",
 41  gmail: "v1",
 42  people: "v1",
 43  sheets: "v4",
 44  slides: "v1",
 45};
 46
 47let runtimeDeps;
 48
 49// --- Helpers ---
 50
 51function ensureConfigDir() {
 52  fs.mkdirSync(CONFIG_DIR, { recursive: true });
 53}
 54
 55function readJson(filePath) {
 56  return JSON.parse(fs.readFileSync(filePath, "utf8"));
 57}
 58
 59function installDependencies() {
 60  const npm = process.platform === "win32" ? "npm.cmd" : "npm";
 61  console.error("ℹ️  Installing Google Workspace skill dependencies...");
 62
 63  const result = spawnSync(npm, ["install", "--no-audit", "--no-fund"], {
 64    cwd: SKILL_ROOT,
 65    stdio: "inherit",
 66  });
 67
 68  if (result.error) {
 69    throw new Error(`Failed to run npm install: ${result.error.message}`);
 70  }
 71
 72  if (result.status !== 0) {
 73    throw new Error(`npm install failed with exit code ${result.status}`);
 74  }
 75}
 76
 77function loadRuntimeDeps() {
 78  if (runtimeDeps) {
 79    return runtimeDeps;
 80  }
 81
 82  const require = createRequire(import.meta.url);
 83
 84  try {
 85    const { google } = require("googleapis");
 86    const { authenticate } = require("@google-cloud/local-auth");
 87    runtimeDeps = { google, authenticate };
 88    return runtimeDeps;
 89  } catch (error) {
 90    if (error && error.code === "MODULE_NOT_FOUND") {
 91      installDependencies();
 92      const { google } = require("googleapis");
 93      const { authenticate } = require("@google-cloud/local-auth");
 94      runtimeDeps = { google, authenticate };
 95      return runtimeDeps;
 96    }
 97    throw error;
 98  }
 99}
100
101export function getGoogleApis() {
102  return loadRuntimeDeps().google;
103}
104
105export function credentialsExist() {
106  return fs.existsSync(CREDENTIALS_PATH);
107}
108
109function tokenExists() {
110  return fs.existsSync(TOKEN_PATH);
111}
112
113function loadCredentialsFile() {
114  if (!credentialsExist()) {
115    throw new Error(
116      `Missing OAuth credentials file at ${CREDENTIALS_PATH}.\n` +
117        "Create a Google Cloud OAuth Desktop client and save the JSON there.\n" +
118        "See: https://console.cloud.google.com/apis/credentials"
119    );
120  }
121  return readJson(CREDENTIALS_PATH);
122}
123
124export function loadToken() {
125  if (!tokenExists()) {
126    return null;
127  }
128  return readJson(TOKEN_PATH);
129}
130
131function createOAuthClientFromCredentials(credentialsJson) {
132  const creds = credentialsJson.installed || credentialsJson.web;
133  if (!creds) {
134    throw new Error(
135      'Invalid credentials.json: expected an "installed" or "web" key.'
136    );
137  }
138
139  if (!creds.client_id || !creds.client_secret) {
140    throw new Error(
141      "Invalid credentials.json: missing client_id or client_secret."
142    );
143  }
144
145  const redirectUri = Array.isArray(creds.redirect_uris)
146    ? creds.redirect_uris[0]
147    : undefined;
148
149  const google = getGoogleApis();
150  return new google.auth.OAuth2(
151    creds.client_id,
152    creds.client_secret,
153    redirectUri || "http://localhost"
154  );
155}
156
157function saveToken(token) {
158  ensureConfigDir();
159  fs.writeFileSync(TOKEN_PATH, JSON.stringify(token, null, 2));
160  try {
161    fs.chmodSync(TOKEN_PATH, 0o600);
162  } catch {
163    // Non-POSIX file systems can fail chmod. Ignore.
164  }
165}
166
167export function clearToken() {
168  if (tokenExists()) {
169    fs.rmSync(TOKEN_PATH);
170  }
171}
172
173function isExpiringSoon(credentials) {
174  if (!credentials || !credentials.expiry_date) {
175    return false;
176  }
177  return credentials.expiry_date < Date.now() + 60_000;
178}
179
180async function interactiveLogin(scopes) {
181  const { authenticate } = loadRuntimeDeps();
182
183  if (!credentialsExist()) {
184    throw new Error(
185      `Missing OAuth credentials file at ${CREDENTIALS_PATH}.\n\n` +
186        "Setup instructions:\n" +
187        "1. Go to https://console.cloud.google.com/apis/credentials\n" +
188        '2. Create an OAuth 2.0 Client ID (type: "Desktop app")\n' +
189        "3. Download the JSON and save it to:\n" +
190        `   ${CREDENTIALS_PATH}\n` +
191        "4. Enable the required APIs in your Google Cloud project:\n" +
192        "   - Google Calendar API\n" +
193        "   - Google Drive API\n" +
194        "   - Gmail API\n" +
195        "   - Google Docs API\n" +
196        "   - Google Sheets API\n" +
197        "   - Google Slides API\n" +
198        "   - People API"
199    );
200  }
201
202  console.error("ℹ️  Opening browser for Google OAuth login...");
203
204  const authClient = await authenticate({
205    keyfilePath: CREDENTIALS_PATH,
206    scopes,
207  });
208
209  if (!authClient.credentials || !authClient.credentials.access_token) {
210    throw new Error("Authentication failed: no access token returned.");
211  }
212
213  saveToken(authClient.credentials);
214  return authClient;
215}
216
217async function authorizeWithADC(scopes) {
218  const google = getGoogleApis();
219  try {
220    const auth = new google.auth.GoogleAuth({ scopes });
221    const client = await auth.getClient();
222    // Verify the client works by fetching a token
223    await client.getAccessToken();
224    return client;
225  } catch {
226    return null;
227  }
228}
229
230export async function authorize(options = {}) {
231  const scopes = options.scopes || DEFAULT_SCOPES;
232  const interactive = options.interactive !== false;
233
234  loadRuntimeDeps();
235
236  // Skip ADC — gcloud ADC lacks Workspace scopes
237  // const adcClient = await authorizeWithADC(scopes);
238  // if (adcClient) {
239  //   return adcClient;
240  // }
241
242  // Fall back to custom OAuth client with stored token
243  ensureConfigDir();
244
245  const token = loadToken();
246  const client = createOAuthClientFromCredentials(loadCredentialsFile());
247
248  if (!token) {
249    if (!interactive) {
250      throw new Error(
251        `No token found at ${TOKEN_PATH}. Run: node scripts/auth.js login`
252      );
253    }
254    return interactiveLogin(scopes);
255  }
256
257  client.setCredentials(token);
258
259  if (!isExpiringSoon(client.credentials)) {
260    return client;
261  }
262
263  // Try to refresh
264  if (client.credentials.refresh_token) {
265    const refreshed = await client.refreshAccessToken();
266    const merged = {
267      ...refreshed.credentials,
268      refresh_token:
269        refreshed.credentials.refresh_token || client.credentials.refresh_token,
270    };
271    client.setCredentials(merged);
272    saveToken(merged);
273    return client;
274  }
275
276  if (!interactive) {
277    throw new Error(
278      "Token is expired and no refresh token is available. Run login again."
279    );
280  }
281
282  return interactiveLogin(scopes);
283}
284
285export function formatScopes(value) {
286  if (!value) {
287    return [];
288  }
289  if (Array.isArray(value)) {
290    return value;
291  }
292  return String(value)
293    .split(/[\s,]+/)
294    .map((part) => part.trim())
295    .filter(Boolean);
296}