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}