@hasna/connectors 0.3.3 → 0.3.5
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/bin/index.js +1 -1
- package/bin/mcp.js +1 -1
- package/connectors/connect-google/src/api/client.ts +13 -1
- package/connectors/connect-google/src/types/index.ts +1 -0
- package/connectors/connect-google/src/utils/config.ts +91 -0
- package/connectors/connect-googlecalendar/src/api/client.ts +13 -0
- package/connectors/connect-googlecalendar/src/utils/config.ts +51 -8
- package/connectors/connect-googlecontacts/src/api/client.ts +9 -0
- package/connectors/connect-googledocs/src/api/client.ts +13 -1
- package/connectors/connect-googledocs/src/types/index.ts +1 -0
- package/connectors/connect-googledocs/src/utils/config.ts +98 -0
- package/connectors/connect-googlesheets/src/api/client.ts +4 -0
- package/connectors/connect-googlesheets/src/utils/config.ts +51 -8
- package/connectors/connect-googletasks/src/utils/config.ts +52 -12
- package/package.json +1 -1
package/bin/index.js
CHANGED
|
@@ -6701,7 +6701,7 @@ var PRESETS = {
|
|
|
6701
6701
|
commerce: { description: "Commerce and finance", connectors: ["stripe", "shopify", "revolut", "mercury", "pandadoc"] }
|
|
6702
6702
|
};
|
|
6703
6703
|
var program2 = new Command;
|
|
6704
|
-
program2.name("connectors").description("Install API connectors for your project").version("0.3.
|
|
6704
|
+
program2.name("connectors").description("Install API connectors for your project").version("0.3.5");
|
|
6705
6705
|
program2.command("interactive", { isDefault: true }).alias("i").description("Interactive connector browser").action(() => {
|
|
6706
6706
|
if (!isTTY) {
|
|
6707
6707
|
console.log(`Non-interactive environment detected. Use a subcommand:
|
package/bin/mcp.js
CHANGED
|
@@ -20309,7 +20309,7 @@ async function getConnectorCommandHelp(name, command) {
|
|
|
20309
20309
|
loadConnectorVersions();
|
|
20310
20310
|
var server = new McpServer({
|
|
20311
20311
|
name: "connectors",
|
|
20312
|
-
version: "0.3.
|
|
20312
|
+
version: "0.3.5"
|
|
20313
20313
|
});
|
|
20314
20314
|
server.registerTool("search_connectors", {
|
|
20315
20315
|
title: "Search Connectors",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GoogleConfig, OutputFormat } from '../types';
|
|
2
2
|
import { GoogleApiError } from '../types';
|
|
3
|
+
import { getValidAccessToken } from '../utils/config';
|
|
3
4
|
|
|
4
5
|
// Google API base URLs
|
|
5
6
|
const BASE_URLS = {
|
|
@@ -21,14 +22,16 @@ export interface RequestOptions {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export class GoogleClient {
|
|
24
|
-
private
|
|
25
|
+
private accessToken: string;
|
|
25
26
|
private readonly baseUrls: Record<GoogleService, string>;
|
|
27
|
+
private readonly useAutoRefresh: boolean;
|
|
26
28
|
|
|
27
29
|
constructor(config: GoogleConfig) {
|
|
28
30
|
if (!config.accessToken) {
|
|
29
31
|
throw new Error('Access token is required');
|
|
30
32
|
}
|
|
31
33
|
this.accessToken = config.accessToken;
|
|
34
|
+
this.useAutoRefresh = config.autoRefresh !== false; // defaults to true
|
|
32
35
|
this.baseUrls = {
|
|
33
36
|
gmail: config.baseUrls?.gmail || BASE_URLS.gmail,
|
|
34
37
|
drive: config.baseUrls?.drive || BASE_URLS.drive,
|
|
@@ -58,6 +61,15 @@ export class GoogleClient {
|
|
|
58
61
|
async request<T>(service: GoogleService, path: string, options: RequestOptions = {}): Promise<T> {
|
|
59
62
|
const { method = 'GET', params, body, headers = {} } = options;
|
|
60
63
|
|
|
64
|
+
// Auto-refresh token if enabled
|
|
65
|
+
if (this.useAutoRefresh) {
|
|
66
|
+
try {
|
|
67
|
+
this.accessToken = await getValidAccessToken();
|
|
68
|
+
} catch {
|
|
69
|
+
// Fall through to use current token if refresh fails
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
61
73
|
const url = this.buildUrl(service, path, params);
|
|
62
74
|
|
|
63
75
|
const requestHeaders: Record<string, string> = {
|
|
@@ -11,6 +11,7 @@ export interface ProfileConfig {
|
|
|
11
11
|
refreshToken?: string;
|
|
12
12
|
clientId?: string;
|
|
13
13
|
clientSecret?: string;
|
|
14
|
+
expiresAt?: number;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
// Store for --profile flag override (set by CLI before commands run)
|
|
@@ -219,6 +220,96 @@ export function setRefreshToken(refreshToken: string): void {
|
|
|
219
220
|
saveProfile(config);
|
|
220
221
|
}
|
|
221
222
|
|
|
223
|
+
// ============================================
|
|
224
|
+
// Token Refresh
|
|
225
|
+
// ============================================
|
|
226
|
+
|
|
227
|
+
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
228
|
+
|
|
229
|
+
export function getClientId(): string | undefined {
|
|
230
|
+
return process.env.GOOGLE_CLIENT_ID || loadProfile().clientId;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function getClientSecret(): string | undefined {
|
|
234
|
+
return process.env.GOOGLE_CLIENT_SECRET || loadProfile().clientSecret;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Refresh the access token using the refresh token and client credentials
|
|
239
|
+
*/
|
|
240
|
+
export async function refreshAccessToken(): Promise<ProfileConfig> {
|
|
241
|
+
const profile = loadProfile();
|
|
242
|
+
const clientId = process.env.GOOGLE_CLIENT_ID || profile.clientId;
|
|
243
|
+
const clientSecret = process.env.GOOGLE_CLIENT_SECRET || profile.clientSecret;
|
|
244
|
+
const refreshToken = process.env.GOOGLE_REFRESH_TOKEN || profile.refreshToken;
|
|
245
|
+
|
|
246
|
+
if (!clientId || !clientSecret) {
|
|
247
|
+
throw new Error('OAuth client credentials not configured. Set clientId/clientSecret in profile or GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET env vars.');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!refreshToken) {
|
|
251
|
+
throw new Error('No refresh token available. Please re-authenticate.');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: {
|
|
257
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
258
|
+
},
|
|
259
|
+
body: new URLSearchParams({
|
|
260
|
+
client_id: clientId,
|
|
261
|
+
client_secret: clientSecret,
|
|
262
|
+
refresh_token: refreshToken,
|
|
263
|
+
grant_type: 'refresh_token',
|
|
264
|
+
}),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
if (!response.ok) {
|
|
268
|
+
const error = await response.json() as { error_description?: string; error?: string };
|
|
269
|
+
throw new Error(`Token refresh failed: ${error.error_description || error.error}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const data = await response.json() as { access_token: string; expires_in: number };
|
|
273
|
+
|
|
274
|
+
const updatedProfile: ProfileConfig = {
|
|
275
|
+
...profile,
|
|
276
|
+
accessToken: data.access_token,
|
|
277
|
+
refreshToken: refreshToken, // Keep original refresh token
|
|
278
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
saveProfile(updatedProfile);
|
|
282
|
+
return updatedProfile;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Get a valid access token, refreshing if necessary.
|
|
287
|
+
* Returns the current token if not expired, otherwise refreshes it.
|
|
288
|
+
*/
|
|
289
|
+
export async function getValidAccessToken(): Promise<string> {
|
|
290
|
+
// Env var override always wins (no refresh possible)
|
|
291
|
+
if (process.env.GOOGLE_ACCESS_TOKEN) {
|
|
292
|
+
return process.env.GOOGLE_ACCESS_TOKEN;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const profile = loadProfile();
|
|
296
|
+
|
|
297
|
+
if (!profile.accessToken) {
|
|
298
|
+
throw new Error('Not authenticated. Run "connect-google config set-token <token>" or set GOOGLE_ACCESS_TOKEN.');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// If we have expiry info and a refresh token, check if refresh is needed
|
|
302
|
+
if (profile.expiresAt && profile.refreshToken) {
|
|
303
|
+
// Refresh if token expires within 5 minutes
|
|
304
|
+
if (Date.now() >= profile.expiresAt - 5 * 60 * 1000) {
|
|
305
|
+
const updated = await refreshAccessToken();
|
|
306
|
+
return updated.accessToken!;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return profile.accessToken;
|
|
311
|
+
}
|
|
312
|
+
|
|
222
313
|
// ============================================
|
|
223
314
|
// Utility Functions
|
|
224
315
|
// ============================================
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GoogleCalendarConfig, OutputFormat, GoogleApiErrorResponse } from '../types';
|
|
2
2
|
import { GoogleCalendarApiError } from '../types';
|
|
3
|
+
import { isTokenExpired, setTokens } from '../utils/config';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_BASE_URL = 'https://www.googleapis.com/calendar/v3';
|
|
5
6
|
const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
@@ -141,6 +142,13 @@ export class GoogleCalendarClient {
|
|
|
141
142
|
const data = await response.json();
|
|
142
143
|
this.accessToken = data.access_token;
|
|
143
144
|
|
|
145
|
+
// Save refreshed tokens to disk
|
|
146
|
+
setTokens({
|
|
147
|
+
accessToken: data.access_token,
|
|
148
|
+
refreshToken: this.refreshToken,
|
|
149
|
+
expiresIn: data.expires_in,
|
|
150
|
+
});
|
|
151
|
+
|
|
144
152
|
return {
|
|
145
153
|
accessToken: data.access_token,
|
|
146
154
|
expiresIn: data.expires_in,
|
|
@@ -167,6 +175,11 @@ export class GoogleCalendarClient {
|
|
|
167
175
|
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
168
176
|
const { method = 'GET', params, body, headers = {} } = options;
|
|
169
177
|
|
|
178
|
+
// Auto-refresh token if expired
|
|
179
|
+
if (isTokenExpired() && this.refreshToken && this.clientId && this.clientSecret) {
|
|
180
|
+
await this.refreshAccessToken();
|
|
181
|
+
}
|
|
182
|
+
|
|
170
183
|
const url = this.buildUrl(path, params);
|
|
171
184
|
|
|
172
185
|
const requestHeaders: Record<string, string> = {
|
|
@@ -170,6 +170,49 @@ export function saveProfile(config: ProfileConfig, profile?: string): void {
|
|
|
170
170
|
writeFileSync(getProfilePath(profileName), JSON.stringify(config, null, 2));
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
// ============================================
|
|
174
|
+
// OAuth2 Credentials (Client ID/Secret) - Root credentials.json (shared across profiles)
|
|
175
|
+
// ============================================
|
|
176
|
+
|
|
177
|
+
const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
|
|
178
|
+
|
|
179
|
+
interface CredentialsConfig {
|
|
180
|
+
clientId?: string;
|
|
181
|
+
clientSecret?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function loadCredentials(): CredentialsConfig {
|
|
185
|
+
ensureConfigDir();
|
|
186
|
+
|
|
187
|
+
if (!existsSync(CREDENTIALS_FILE)) {
|
|
188
|
+
// Migration: check if credentials exist in any profile and copy to base
|
|
189
|
+
const profiles = listProfiles();
|
|
190
|
+
for (const prof of profiles) {
|
|
191
|
+
const profileConfig = loadProfile(prof);
|
|
192
|
+
if (profileConfig.clientId && profileConfig.clientSecret) {
|
|
193
|
+
const creds = {
|
|
194
|
+
clientId: profileConfig.clientId,
|
|
195
|
+
clientSecret: profileConfig.clientSecret,
|
|
196
|
+
};
|
|
197
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
198
|
+
return creds;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf-8'));
|
|
206
|
+
} catch {
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function saveCredentials(creds: CredentialsConfig): void {
|
|
212
|
+
ensureConfigDir();
|
|
213
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
214
|
+
}
|
|
215
|
+
|
|
173
216
|
// ============================================
|
|
174
217
|
// Token Management
|
|
175
218
|
// ============================================
|
|
@@ -195,23 +238,23 @@ export function setRefreshToken(refreshToken: string): void {
|
|
|
195
238
|
}
|
|
196
239
|
|
|
197
240
|
export function getClientId(): string | undefined {
|
|
198
|
-
return process.env.GOOGLE_CALENDAR_CLIENT_ID || loadProfile().clientId;
|
|
241
|
+
return process.env.GOOGLE_CALENDAR_CLIENT_ID || loadCredentials().clientId || loadProfile().clientId;
|
|
199
242
|
}
|
|
200
243
|
|
|
201
244
|
export function setClientId(clientId: string): void {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
245
|
+
const creds = loadCredentials();
|
|
246
|
+
creds.clientId = clientId;
|
|
247
|
+
saveCredentials(creds);
|
|
205
248
|
}
|
|
206
249
|
|
|
207
250
|
export function getClientSecret(): string | undefined {
|
|
208
|
-
return process.env.GOOGLE_CALENDAR_CLIENT_SECRET || loadProfile().clientSecret;
|
|
251
|
+
return process.env.GOOGLE_CALENDAR_CLIENT_SECRET || loadCredentials().clientSecret || loadProfile().clientSecret;
|
|
209
252
|
}
|
|
210
253
|
|
|
211
254
|
export function setClientSecret(clientSecret: string): void {
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
255
|
+
const creds = loadCredentials();
|
|
256
|
+
creds.clientSecret = clientSecret;
|
|
257
|
+
saveCredentials(creds);
|
|
215
258
|
}
|
|
216
259
|
|
|
217
260
|
export function setTokens(tokens: { accessToken: string; refreshToken?: string; expiresIn?: number }): void {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GoogleContactsConfig, OAuthTokenResponse, OAuthTokens } from '../types';
|
|
2
2
|
import { GoogleContactsApiError, AuthError, RateLimitError } from '../types';
|
|
3
|
+
import { saveTokens } from '../utils/config';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_BASE_URL = 'https://people.googleapis.com';
|
|
5
6
|
const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
@@ -118,6 +119,14 @@ export class GoogleContactsClient {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
this.tokens = newTokens;
|
|
122
|
+
|
|
123
|
+
// Persist refreshed tokens to disk so they survive process exit
|
|
124
|
+
saveTokens({
|
|
125
|
+
accessToken: newTokens.accessToken,
|
|
126
|
+
refreshToken: newTokens.refreshToken,
|
|
127
|
+
expiresIn: newTokens.expiresIn,
|
|
128
|
+
});
|
|
129
|
+
|
|
121
130
|
return this.tokens;
|
|
122
131
|
}
|
|
123
132
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GoogleDocsConfig, OutputFormat } from '../types';
|
|
2
2
|
import { GoogleDocsApiError } from '../types';
|
|
3
|
+
import { getValidAccessToken } from '../utils/config';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_BASE_URL = 'https://docs.googleapis.com/v1';
|
|
5
6
|
|
|
@@ -13,8 +14,9 @@ export interface RequestOptions {
|
|
|
13
14
|
|
|
14
15
|
export class GoogleDocsClient {
|
|
15
16
|
private readonly apiKey?: string;
|
|
16
|
-
private
|
|
17
|
+
private accessToken?: string;
|
|
17
18
|
private readonly baseUrl: string;
|
|
19
|
+
private readonly useAutoRefresh: boolean;
|
|
18
20
|
|
|
19
21
|
constructor(config: GoogleDocsConfig) {
|
|
20
22
|
if (!config.apiKey && !config.accessToken) {
|
|
@@ -22,6 +24,7 @@ export class GoogleDocsClient {
|
|
|
22
24
|
}
|
|
23
25
|
this.apiKey = config.apiKey;
|
|
24
26
|
this.accessToken = config.accessToken;
|
|
27
|
+
this.useAutoRefresh = config.autoRefresh !== false; // defaults to true
|
|
25
28
|
this.baseUrl = config.baseUrl || DEFAULT_BASE_URL;
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -51,6 +54,15 @@ export class GoogleDocsClient {
|
|
|
51
54
|
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
52
55
|
const { method = 'GET', params, body, headers = {} } = options;
|
|
53
56
|
|
|
57
|
+
// Auto-refresh token if enabled and using OAuth
|
|
58
|
+
if (this.accessToken && this.useAutoRefresh) {
|
|
59
|
+
try {
|
|
60
|
+
this.accessToken = await getValidAccessToken();
|
|
61
|
+
} catch {
|
|
62
|
+
// Fall through to use current token if refresh fails
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
const url = this.buildUrl(path, params);
|
|
55
67
|
|
|
56
68
|
const requestHeaders: Record<string, string> = {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
export interface GoogleDocsConfig {
|
|
9
9
|
apiKey?: string; // API key for read-only public documents
|
|
10
10
|
accessToken?: string; // OAuth2 access token for full access
|
|
11
|
+
autoRefresh?: boolean; // Enable auto-refresh of access token (default: true)
|
|
11
12
|
baseUrl?: string; // Override default base URL
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -8,6 +8,10 @@ const DEFAULT_PROFILE = 'default';
|
|
|
8
8
|
export interface ProfileConfig {
|
|
9
9
|
apiKey?: string;
|
|
10
10
|
accessToken?: string;
|
|
11
|
+
refreshToken?: string;
|
|
12
|
+
clientId?: string;
|
|
13
|
+
clientSecret?: string;
|
|
14
|
+
expiresAt?: number;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
// Store for --profile flag override (set by CLI before commands run)
|
|
@@ -191,6 +195,100 @@ export function setAccessToken(accessToken: string): void {
|
|
|
191
195
|
saveProfile(config);
|
|
192
196
|
}
|
|
193
197
|
|
|
198
|
+
// ============================================
|
|
199
|
+
// Token Refresh
|
|
200
|
+
// ============================================
|
|
201
|
+
|
|
202
|
+
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
203
|
+
|
|
204
|
+
export function getClientId(): string | undefined {
|
|
205
|
+
return process.env.GOOGLE_CLIENT_ID || loadProfile().clientId;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function getClientSecret(): string | undefined {
|
|
209
|
+
return process.env.GOOGLE_CLIENT_SECRET || loadProfile().clientSecret;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function getRefreshToken(): string | undefined {
|
|
213
|
+
return process.env.GOOGLE_REFRESH_TOKEN || loadProfile().refreshToken;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Refresh the access token using the refresh token and client credentials
|
|
218
|
+
*/
|
|
219
|
+
export async function refreshAccessToken(): Promise<ProfileConfig> {
|
|
220
|
+
const profile = loadProfile();
|
|
221
|
+
const clientId = process.env.GOOGLE_CLIENT_ID || profile.clientId;
|
|
222
|
+
const clientSecret = process.env.GOOGLE_CLIENT_SECRET || profile.clientSecret;
|
|
223
|
+
const refreshToken = process.env.GOOGLE_REFRESH_TOKEN || profile.refreshToken;
|
|
224
|
+
|
|
225
|
+
if (!clientId || !clientSecret) {
|
|
226
|
+
throw new Error('OAuth client credentials not configured. Set clientId/clientSecret in profile or GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET env vars.');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!refreshToken) {
|
|
230
|
+
throw new Error('No refresh token available. Please re-authenticate.');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers: {
|
|
236
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
237
|
+
},
|
|
238
|
+
body: new URLSearchParams({
|
|
239
|
+
client_id: clientId,
|
|
240
|
+
client_secret: clientSecret,
|
|
241
|
+
refresh_token: refreshToken,
|
|
242
|
+
grant_type: 'refresh_token',
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
const error = await response.json() as { error_description?: string; error?: string };
|
|
248
|
+
throw new Error(`Token refresh failed: ${error.error_description || error.error}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const data = await response.json() as { access_token: string; expires_in: number };
|
|
252
|
+
|
|
253
|
+
const updatedProfile: ProfileConfig = {
|
|
254
|
+
...profile,
|
|
255
|
+
accessToken: data.access_token,
|
|
256
|
+
refreshToken: refreshToken, // Keep original refresh token
|
|
257
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
saveProfile(updatedProfile);
|
|
261
|
+
return updatedProfile;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get a valid access token, refreshing if necessary.
|
|
266
|
+
* Returns the current token if not expired, otherwise refreshes it.
|
|
267
|
+
*/
|
|
268
|
+
export async function getValidAccessToken(): Promise<string> {
|
|
269
|
+
// Env var override always wins (no refresh possible)
|
|
270
|
+
if (process.env.GOOGLE_ACCESS_TOKEN) {
|
|
271
|
+
return process.env.GOOGLE_ACCESS_TOKEN;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const profile = loadProfile();
|
|
275
|
+
|
|
276
|
+
if (!profile.accessToken) {
|
|
277
|
+
throw new Error('Not authenticated. Run "connect-googledocs config set-token <token>" or set GOOGLE_ACCESS_TOKEN.');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// If we have expiry info and a refresh token, check if refresh is needed
|
|
281
|
+
if (profile.expiresAt && profile.refreshToken) {
|
|
282
|
+
// Refresh if token expires within 5 minutes
|
|
283
|
+
if (Date.now() >= profile.expiresAt - 5 * 60 * 1000) {
|
|
284
|
+
const updated = await refreshAccessToken();
|
|
285
|
+
return updated.accessToken!;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return profile.accessToken;
|
|
290
|
+
}
|
|
291
|
+
|
|
194
292
|
// ============================================
|
|
195
293
|
// Utility Functions
|
|
196
294
|
// ============================================
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GoogleSheetsConfig, OutputFormat } from '../types';
|
|
2
2
|
import { GoogleSheetsApiError } from '../types';
|
|
3
|
+
import { setAccessToken as saveAccessToken } from '../utils/config';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_BASE_URL = 'https://sheets.googleapis.com/v4';
|
|
5
6
|
|
|
@@ -89,6 +90,9 @@ export class GoogleSheetsClient {
|
|
|
89
90
|
this.accessToken = data.access_token;
|
|
90
91
|
this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
|
|
91
92
|
|
|
93
|
+
// Persist refreshed token to disk so it survives process exit
|
|
94
|
+
saveAccessToken(data.access_token, this.tokenExpiresAt);
|
|
95
|
+
|
|
92
96
|
return data.access_token;
|
|
93
97
|
}
|
|
94
98
|
|
|
@@ -191,6 +191,49 @@ export function setApiKey(apiKey: string): void {
|
|
|
191
191
|
saveProfile(config);
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// ============================================
|
|
195
|
+
// OAuth2 Credentials (Client ID/Secret) - Root credentials.json (shared across profiles)
|
|
196
|
+
// ============================================
|
|
197
|
+
|
|
198
|
+
const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
|
|
199
|
+
|
|
200
|
+
interface CredentialsConfig {
|
|
201
|
+
clientId?: string;
|
|
202
|
+
clientSecret?: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function loadCredentials(): CredentialsConfig {
|
|
206
|
+
ensureConfigDir();
|
|
207
|
+
|
|
208
|
+
if (!existsSync(CREDENTIALS_FILE)) {
|
|
209
|
+
// Migration: check if credentials exist in any profile and copy to base
|
|
210
|
+
const profiles = listProfiles();
|
|
211
|
+
for (const prof of profiles) {
|
|
212
|
+
const profileConfig = loadProfile(prof);
|
|
213
|
+
if (profileConfig.clientId && profileConfig.clientSecret) {
|
|
214
|
+
const creds = {
|
|
215
|
+
clientId: profileConfig.clientId,
|
|
216
|
+
clientSecret: profileConfig.clientSecret,
|
|
217
|
+
};
|
|
218
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
219
|
+
return creds;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
return JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf-8'));
|
|
227
|
+
} catch {
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function saveCredentials(creds: CredentialsConfig): void {
|
|
233
|
+
ensureConfigDir();
|
|
234
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
235
|
+
}
|
|
236
|
+
|
|
194
237
|
// ============================================
|
|
195
238
|
// OAuth Token Management
|
|
196
239
|
// ============================================
|
|
@@ -219,23 +262,23 @@ export function setRefreshToken(refreshToken: string): void {
|
|
|
219
262
|
}
|
|
220
263
|
|
|
221
264
|
export function getClientId(): string | undefined {
|
|
222
|
-
return process.env.GOOGLE_CLIENT_ID || loadProfile().clientId;
|
|
265
|
+
return process.env.GOOGLE_CLIENT_ID || loadCredentials().clientId || loadProfile().clientId;
|
|
223
266
|
}
|
|
224
267
|
|
|
225
268
|
export function setClientId(clientId: string): void {
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
269
|
+
const creds = loadCredentials();
|
|
270
|
+
creds.clientId = clientId;
|
|
271
|
+
saveCredentials(creds);
|
|
229
272
|
}
|
|
230
273
|
|
|
231
274
|
export function getClientSecret(): string | undefined {
|
|
232
|
-
return process.env.GOOGLE_CLIENT_SECRET || loadProfile().clientSecret;
|
|
275
|
+
return process.env.GOOGLE_CLIENT_SECRET || loadCredentials().clientSecret || loadProfile().clientSecret;
|
|
233
276
|
}
|
|
234
277
|
|
|
235
278
|
export function setClientSecret(clientSecret: string): void {
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
279
|
+
const creds = loadCredentials();
|
|
280
|
+
creds.clientSecret = clientSecret;
|
|
281
|
+
saveCredentials(creds);
|
|
239
282
|
}
|
|
240
283
|
|
|
241
284
|
export function setOAuthTokens(accessToken: string, refreshToken?: string, expiresAt?: number): void {
|
|
@@ -193,28 +193,71 @@ export function saveProfile(config: ProfileConfig, profile?: string): void {
|
|
|
193
193
|
writeFileSync(join(profileDir, 'config.json'), JSON.stringify(config, null, 2));
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
// ============================================
|
|
197
|
+
// OAuth2 Credentials (Client ID/Secret) - Root credentials.json (shared across profiles)
|
|
198
|
+
// ============================================
|
|
199
|
+
|
|
200
|
+
const CREDENTIALS_FILE = join(BASE_CONFIG_DIR, 'credentials.json');
|
|
201
|
+
|
|
202
|
+
interface CredentialsConfig {
|
|
203
|
+
clientId?: string;
|
|
204
|
+
clientSecret?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function loadCredentials(): CredentialsConfig {
|
|
208
|
+
ensureBaseConfigDir();
|
|
209
|
+
|
|
210
|
+
if (!existsSync(CREDENTIALS_FILE)) {
|
|
211
|
+
// Migration: check if credentials exist in any profile and copy to base
|
|
212
|
+
const profiles = listProfiles();
|
|
213
|
+
for (const prof of profiles) {
|
|
214
|
+
const profileConfig = loadProfile(prof);
|
|
215
|
+
if (profileConfig.clientId && profileConfig.clientSecret) {
|
|
216
|
+
const creds = {
|
|
217
|
+
clientId: profileConfig.clientId,
|
|
218
|
+
clientSecret: profileConfig.clientSecret,
|
|
219
|
+
};
|
|
220
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
221
|
+
return creds;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
return JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf-8'));
|
|
229
|
+
} catch {
|
|
230
|
+
return {};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function saveCredentials(creds: CredentialsConfig): void {
|
|
235
|
+
ensureBaseConfigDir();
|
|
236
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
237
|
+
}
|
|
238
|
+
|
|
196
239
|
// ============================================
|
|
197
240
|
// OAuth Credentials
|
|
198
241
|
// ============================================
|
|
199
242
|
|
|
200
243
|
export function getClientId(): string | undefined {
|
|
201
|
-
return process.env.GOOGLE_CLIENT_ID || loadProfile().clientId;
|
|
244
|
+
return process.env.GOOGLE_CLIENT_ID || loadCredentials().clientId || loadProfile().clientId;
|
|
202
245
|
}
|
|
203
246
|
|
|
204
247
|
export function setClientId(clientId: string): void {
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
248
|
+
const creds = loadCredentials();
|
|
249
|
+
creds.clientId = clientId;
|
|
250
|
+
saveCredentials(creds);
|
|
208
251
|
}
|
|
209
252
|
|
|
210
253
|
export function getClientSecret(): string | undefined {
|
|
211
|
-
return process.env.GOOGLE_CLIENT_SECRET || loadProfile().clientSecret;
|
|
254
|
+
return process.env.GOOGLE_CLIENT_SECRET || loadCredentials().clientSecret || loadProfile().clientSecret;
|
|
212
255
|
}
|
|
213
256
|
|
|
214
257
|
export function setClientSecret(clientSecret: string): void {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
258
|
+
const creds = loadCredentials();
|
|
259
|
+
creds.clientSecret = clientSecret;
|
|
260
|
+
saveCredentials(creds);
|
|
218
261
|
}
|
|
219
262
|
|
|
220
263
|
export function getAccessToken(): string | undefined {
|
|
@@ -248,10 +291,7 @@ export function setTokenExpiry(expiry: number): void {
|
|
|
248
291
|
}
|
|
249
292
|
|
|
250
293
|
export function setCredentials(clientId: string, clientSecret: string): void {
|
|
251
|
-
|
|
252
|
-
config.clientId = clientId;
|
|
253
|
-
config.clientSecret = clientSecret;
|
|
254
|
-
saveProfile(config);
|
|
294
|
+
saveCredentials({ clientId, clientSecret });
|
|
255
295
|
}
|
|
256
296
|
|
|
257
297
|
export function setTokens(accessToken: string, refreshToken: string | undefined, expiresIn: number): void {
|