@libai168/dsh-tool-google-drive 0.2.0 → 0.4.0
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/README.md +5 -1
- package/README.zh.md +5 -1
- package/lib/client.js +98 -13
- package/lib/index.js +12 -12
- package/lib/types/client.d.ts +15 -1
- package/lib/types/url-security.d.ts +12 -0
- package/lib/url-security.js +217 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -34,6 +34,8 @@ Recommended read-only OAuth scopes:
|
|
|
34
34
|
- Docs read: `https://www.googleapis.com/auth/documents.readonly`
|
|
35
35
|
- Sheets read: `https://www.googleapis.com/auth/spreadsheets.readonly`
|
|
36
36
|
|
|
37
|
+
Both `baseUrl` and `tokenUrl` overrides must be absolute `http://` or `https://` root URLs. Only publicly reachable hosts are allowed: localhost, loopback, private, link-local, CGNAT, multicast, reserved/documentation/benchmark ranges, and every IANA special-purpose block are rejected, and a hostname whose DNS results contain any such address fails closed before the request is sent. Credentials, query strings, fragments, and non-root paths are not allowed.
|
|
38
|
+
|
|
37
39
|
## OAuth helper
|
|
38
40
|
|
|
39
41
|
This package includes a small no-dependency helper that generates a Google OAuth consent URL, captures the loopback callback, exchanges the authorization code, and prints a ready-to-copy Cordis config snippet with a refresh token.
|
|
@@ -71,7 +73,7 @@ The helper requests offline access with consent prompting so Google can return a
|
|
|
71
73
|
|
|
72
74
|
| Tool | Description | Write |
|
|
73
75
|
|---|---|---|
|
|
74
|
-
| `gdrive_auth_test` | Verify Google Drive credentials
|
|
76
|
+
| `gdrive_auth_test` | Verify Google Drive credentials without returning token material | no |
|
|
75
77
|
| `gdrive_list_files` | List or search Drive files by query and pagination | no |
|
|
76
78
|
| `gdrive_get_file` | Get one Drive file's metadata by file ID | no |
|
|
77
79
|
| `gdrive_list_permissions` | List file/folder/shared-drive sharing permissions with pagination | no |
|
|
@@ -115,6 +117,8 @@ gsheets_get_values({ spreadsheetId: 'spreadsheet_id', range: 'Sheet1!A1:D20' })
|
|
|
115
117
|
|
|
116
118
|
Permission and revision listings return an opaque `nextPageToken`; pass it back as `pageToken` to continue. `gdrive_list_permissions` can include shared-drive and domain-admin flags when the OAuth principal has those permissions. Permission email addresses, revision download URLs, and exported content are returned only by explicitly requested read tools; treat them as sensitive workspace data.
|
|
117
119
|
|
|
120
|
+
Export, Docs, and Sheets content is bounded in the client result layer. Export output accepts maxBytes (default 1 MiB, capped at 10 MiB); Docs accepts maxBytes/maxTabs; Sheets accepts maxRows/maxColumns/maxCells. Results include truncated and contentLength so callers can detect an imposed limit.
|
|
121
|
+
|
|
118
122
|
## Development
|
|
119
123
|
|
|
120
124
|
```sh
|
package/README.zh.md
CHANGED
|
@@ -34,6 +34,8 @@ npm install @libai168/dsh-tool-google-drive
|
|
|
34
34
|
- Docs 读取:`https://www.googleapis.com/auth/documents.readonly`
|
|
35
35
|
- Sheets 读取:`https://www.googleapis.com/auth/spreadsheets.readonly`
|
|
36
36
|
|
|
37
|
+
`baseUrl` 与 `tokenUrl` 两个覆盖都必须是绝对的 `http://` 或 `https://` 根地址。只允许公网可达主机:localhost、环回、私有、链路本地、CGNAT、组播、保留/文档/基准测试网段以及全部 IANA 特殊用途地址段都会被拒绝;DNS 结果包含任一此类地址时会在发出请求前 fail closed。不允许 credentials、query、fragment 或非根路径。
|
|
38
|
+
|
|
37
39
|
## OAuth 授权辅助脚本
|
|
38
40
|
|
|
39
41
|
本包内置一个无额外依赖的辅助脚本:生成 Google OAuth 授权 URL、监听本机回调、用授权码换取 token,并输出可直接复制到 Cordis 配置里的 `refreshToken` 片段。
|
|
@@ -71,7 +73,7 @@ npm install @libai168/dsh-tool-google-drive
|
|
|
71
73
|
|
|
72
74
|
| 工具 | 说明 | 写操作 |
|
|
73
75
|
|---|---|---|
|
|
74
|
-
| `gdrive_auth_test` | 验证 Google Drive
|
|
76
|
+
| `gdrive_auth_test` | 验证 Google Drive 凭证但不返回 token 内容 | 否 |
|
|
75
77
|
| `gdrive_list_files` | 按查询与分页列出或搜索 Drive 文件 | 否 |
|
|
76
78
|
| `gdrive_get_file` | 按文件 ID 获取 Drive 文件元数据 | 否 |
|
|
77
79
|
| `gdrive_list_permissions` | 分页列出文件、文件夹或共享云端硬盘的共享权限 | 否 |
|
|
@@ -115,6 +117,8 @@ gsheets_get_values({ spreadsheetId: 'spreadsheet_id', range: 'Sheet1!A1:D20' })
|
|
|
115
117
|
|
|
116
118
|
权限和修订列表会返回不透明的 `nextPageToken`;把它作为 `pageToken` 传回即可继续分页。`gdrive_list_permissions` 支持在 OAuth 主体具备权限时传入共享云端硬盘和域管理员参数。权限邮箱、修订下载 URL 以及导出内容只会由明确调用的只读工具返回,请将它们视为敏感工作区数据。
|
|
117
119
|
|
|
120
|
+
导出、Docs 和 Sheets 内容会在客户端结果层限额。导出支持 maxBytes(默认 1 MiB,最多 10 MiB),Docs 支持 maxBytes/maxTabs,Sheets 支持 maxRows/maxColumns/maxCells。结果包含 truncated 和 contentLength,调用方可识别是否触发限额。
|
|
121
|
+
|
|
118
122
|
## 开发
|
|
119
123
|
|
|
120
124
|
```sh
|
package/lib/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Google Drive API client with injected fetch for testability. */
|
|
2
|
+
import { assertSafeUrl, EndpointSecurityError, normalizeBaseUrl } from './url-security.js';
|
|
2
3
|
export class GoogleDriveError extends Error {
|
|
3
4
|
code;
|
|
4
5
|
constructor(message, code) {
|
|
@@ -33,6 +34,23 @@ function toJson(value) {
|
|
|
33
34
|
return '{}';
|
|
34
35
|
}
|
|
35
36
|
}
|
|
37
|
+
function clampBytes(value, fallback = 1024 * 1024, maximum = 10 * 1024 * 1024) {
|
|
38
|
+
if (!Number.isFinite(value))
|
|
39
|
+
return fallback;
|
|
40
|
+
return Math.max(1, Math.min(maximum, Math.trunc(value)));
|
|
41
|
+
}
|
|
42
|
+
function clampCount(value, fallback, maximum) {
|
|
43
|
+
if (!Number.isFinite(value))
|
|
44
|
+
return fallback;
|
|
45
|
+
return Math.max(1, Math.min(maximum, Math.trunc(value)));
|
|
46
|
+
}
|
|
47
|
+
function limitUtf8(value, maxBytes) {
|
|
48
|
+
const source = Buffer.from(value, 'utf8');
|
|
49
|
+
if (source.byteLength <= maxBytes)
|
|
50
|
+
return { value, contentLength: source.byteLength, truncated: false };
|
|
51
|
+
const limited = source.subarray(0, maxBytes).toString('utf8');
|
|
52
|
+
return { value: limited, contentLength: Buffer.byteLength(limited, 'utf8'), truncated: true };
|
|
53
|
+
}
|
|
36
54
|
function mapOwners(value) {
|
|
37
55
|
return asArray(value).map(item => {
|
|
38
56
|
const record = asRecord(item);
|
|
@@ -186,9 +204,27 @@ function mapSpreadsheet(data) {
|
|
|
186
204
|
sheets: asArray(r.sheets).map(item => mapSheetProperties(asRecord(item).properties ?? item)),
|
|
187
205
|
};
|
|
188
206
|
}
|
|
189
|
-
function mapSheetValues(data, spreadsheetId, fallbackRange = '') {
|
|
207
|
+
function mapSheetValues(data, spreadsheetId, fallbackRange = '', options = {}) {
|
|
190
208
|
const r = asRecord(data);
|
|
191
|
-
const
|
|
209
|
+
const maxRows = clampCount(options.maxRows, 1000, 10000);
|
|
210
|
+
const maxColumns = clampCount(options.maxColumns, 100, 1000);
|
|
211
|
+
const maxCells = clampCount(options.maxCells, 100000, 1000000);
|
|
212
|
+
const rawValues = asArray(r.values);
|
|
213
|
+
let truncated = rawValues.length > maxRows;
|
|
214
|
+
let remaining = maxCells;
|
|
215
|
+
const values = [];
|
|
216
|
+
for (const row of rawValues.slice(0, maxRows)) {
|
|
217
|
+
const rawRow = asArray(row);
|
|
218
|
+
if (rawRow.length > maxColumns || rawRow.length > remaining)
|
|
219
|
+
truncated = true;
|
|
220
|
+
const limitedRow = rawRow.slice(0, Math.min(maxColumns, remaining)).map(cell => cell == null ? '' : String(cell));
|
|
221
|
+
values.push(limitedRow);
|
|
222
|
+
remaining -= limitedRow.length;
|
|
223
|
+
if (remaining <= 0 && values.length < rawValues.length) {
|
|
224
|
+
truncated = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
192
228
|
return {
|
|
193
229
|
spreadsheetId,
|
|
194
230
|
range: asString(r, 'range') || fallbackRange,
|
|
@@ -196,6 +232,8 @@ function mapSheetValues(data, spreadsheetId, fallbackRange = '') {
|
|
|
196
232
|
rowCount: values.length,
|
|
197
233
|
columnCount: values.reduce((max, row) => Math.max(max, row.length), 0),
|
|
198
234
|
values,
|
|
235
|
+
contentLength: values.reduce((total, row) => total + row.length, 0),
|
|
236
|
+
truncated,
|
|
199
237
|
};
|
|
200
238
|
}
|
|
201
239
|
export class GoogleDriveClient {
|
|
@@ -207,16 +245,32 @@ export class GoogleDriveClient {
|
|
|
207
245
|
tokenUrl;
|
|
208
246
|
timeoutMs;
|
|
209
247
|
fetchImpl;
|
|
248
|
+
lookupImpl;
|
|
210
249
|
tokenCache = null;
|
|
211
250
|
constructor(options = {}) {
|
|
212
251
|
this.staticToken = options.accessToken ?? '';
|
|
213
252
|
this.clientId = options.clientId ?? '';
|
|
214
253
|
this.clientSecret = options.clientSecret ?? '';
|
|
215
254
|
this.refreshToken = options.refreshToken ?? '';
|
|
216
|
-
|
|
217
|
-
|
|
255
|
+
try {
|
|
256
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl, 'https://www.googleapis.com/drive/v3');
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
if (error instanceof EndpointSecurityError)
|
|
260
|
+
throw new GoogleDriveError(error.message, 400);
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
this.tokenUrl = normalizeBaseUrl(options.tokenUrl, 'https://oauth2.googleapis.com/token');
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (error instanceof EndpointSecurityError)
|
|
268
|
+
throw new GoogleDriveError(error.message, 400);
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
218
271
|
this.timeoutMs = options.timeoutMs ?? 15000;
|
|
219
272
|
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
273
|
+
this.lookupImpl = options.lookupImpl;
|
|
220
274
|
}
|
|
221
275
|
hasCredentials() {
|
|
222
276
|
return Boolean(this.staticToken || (this.clientId && this.clientSecret && this.refreshToken));
|
|
@@ -260,6 +314,14 @@ export class GoogleDriveClient {
|
|
|
260
314
|
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
261
315
|
const timer = this.timeoutMs > 0 ? setTimeout(() => controller.abort(), this.timeoutMs) : undefined;
|
|
262
316
|
try {
|
|
317
|
+
try {
|
|
318
|
+
await assertSafeUrl(new URL(this.tokenUrl), this.lookupImpl);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (error instanceof EndpointSecurityError)
|
|
322
|
+
throw new GoogleDriveError(error.message, 400);
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
263
325
|
const response = await this.fetchImpl(this.tokenUrl, {
|
|
264
326
|
method: 'POST',
|
|
265
327
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
@@ -296,6 +358,14 @@ export class GoogleDriveClient {
|
|
|
296
358
|
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
297
359
|
const timer = this.timeoutMs > 0 ? setTimeout(() => controller.abort(), this.timeoutMs) : undefined;
|
|
298
360
|
try {
|
|
361
|
+
try {
|
|
362
|
+
await assertSafeUrl(new URL(url), this.lookupImpl);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
if (error instanceof EndpointSecurityError)
|
|
366
|
+
throw new GoogleDriveError(error.message, 400);
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
299
369
|
const response = await this.fetchImpl(url, {
|
|
300
370
|
method,
|
|
301
371
|
headers,
|
|
@@ -318,8 +388,8 @@ export class GoogleDriveClient {
|
|
|
318
388
|
}
|
|
319
389
|
}
|
|
320
390
|
async authTest(signal) {
|
|
321
|
-
|
|
322
|
-
return { ok: true, authMethod: this.staticToken ? 'access_token' : 'refresh_token'
|
|
391
|
+
await this.getAccessToken(signal);
|
|
392
|
+
return { ok: true, authMethod: this.staticToken ? 'access_token' : 'refresh_token' };
|
|
323
393
|
}
|
|
324
394
|
async listFiles(options = {}) {
|
|
325
395
|
const data = asRecord(await this.request('GET', '/files', {
|
|
@@ -389,10 +459,20 @@ export class GoogleDriveClient {
|
|
|
389
459
|
responseType: responseEncoding === 'base64' ? 'arrayBuffer' : 'text',
|
|
390
460
|
signal: options.signal,
|
|
391
461
|
});
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
462
|
+
const maxBytes = clampBytes(options.maxBytes);
|
|
463
|
+
let content;
|
|
464
|
+
let truncated = false;
|
|
465
|
+
if (responseEncoding === 'base64') {
|
|
466
|
+
const bytes = Buffer.from(raw);
|
|
467
|
+
truncated = bytes.byteLength > maxBytes;
|
|
468
|
+
content = bytes.subarray(0, maxBytes).toString('base64');
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
const limited = limitUtf8(String(raw), maxBytes);
|
|
472
|
+
content = limited.value;
|
|
473
|
+
truncated = limited.truncated;
|
|
474
|
+
}
|
|
475
|
+
return { fileId, exportMimeType, encoding: responseEncoding, content, contentLength: content.length, truncated };
|
|
396
476
|
}
|
|
397
477
|
async listSharedDrives(options = {}) {
|
|
398
478
|
const data = asRecord(await this.request('GET', '/drives', {
|
|
@@ -432,14 +512,19 @@ export class GoogleDriveClient {
|
|
|
432
512
|
signal: options.signal,
|
|
433
513
|
});
|
|
434
514
|
const extracted = extractDocumentContent(data);
|
|
515
|
+
const limitedText = limitUtf8(extracted.text, clampBytes(options.maxBytes));
|
|
516
|
+
const maxTabs = clampCount(options.maxTabs, 100, 1000);
|
|
517
|
+
const tabs = extracted.tabs.slice(0, maxTabs);
|
|
435
518
|
const r = asRecord(data);
|
|
436
519
|
return {
|
|
437
520
|
documentId: asString(r, 'documentId') || documentId,
|
|
438
521
|
title: asString(r, 'title'),
|
|
439
522
|
revisionId: asString(r, 'revisionId'),
|
|
440
|
-
text:
|
|
523
|
+
text: limitedText.value,
|
|
441
524
|
tabCount: extracted.tabs.length,
|
|
442
|
-
tabs
|
|
525
|
+
tabs,
|
|
526
|
+
contentLength: limitedText.contentLength,
|
|
527
|
+
truncated: limitedText.truncated || extracted.tabs.length > tabs.length,
|
|
443
528
|
};
|
|
444
529
|
}
|
|
445
530
|
async getSpreadsheet(spreadsheetId, options = {}) {
|
|
@@ -462,6 +547,6 @@ export class GoogleDriveClient {
|
|
|
462
547
|
},
|
|
463
548
|
signal: options.signal,
|
|
464
549
|
});
|
|
465
|
-
return mapSheetValues(data, spreadsheetId, range);
|
|
550
|
+
return mapSheetValues(data, spreadsheetId, range, options);
|
|
466
551
|
}
|
|
467
552
|
}
|
package/lib/index.js
CHANGED
|
@@ -90,14 +90,14 @@ export function createTools(client) {
|
|
|
90
90
|
return [
|
|
91
91
|
defineTool({
|
|
92
92
|
name: 'gdrive_auth_test',
|
|
93
|
-
description: 'Verify Google Drive credentials
|
|
93
|
+
description: 'Verify Google Drive credentials without returning token material.',
|
|
94
94
|
parameters: {},
|
|
95
95
|
output: {
|
|
96
96
|
schema: {
|
|
97
97
|
type: 'object', additionalProperties: false,
|
|
98
|
-
properties: { ok: { type: 'boolean' }, reason: { type: 'string' }, authMethod: { type: 'string' }
|
|
98
|
+
properties: { ok: { type: 'boolean' }, reason: { type: 'string' }, authMethod: { type: 'string' } },
|
|
99
99
|
},
|
|
100
|
-
render: (_args, value) => value.ok ? text(
|
|
100
|
+
render: (_args, value) => value.ok ? text('authMethod: ' + value.authMethod) : text('Google Drive auth failed: ' + value.reason),
|
|
101
101
|
},
|
|
102
102
|
presentCall() { return { card: 'generic', title: 'Verify Google Drive credentials', kind: 'read' }; },
|
|
103
103
|
async execute(_args, exec) {
|
|
@@ -224,14 +224,14 @@ export function createTools(client) {
|
|
|
224
224
|
defineTool({
|
|
225
225
|
name: 'gdrive_export_file',
|
|
226
226
|
description: 'Export a Google Workspace file to text or another MIME type. Read-only operation.',
|
|
227
|
-
parameters: { fileId: { type: 'string', required: true, description: 'Google Drive file ID' }, exportMimeType: { type: 'string', description: 'Export MIME type, default text/plain' }, responseEncoding: { type: 'string', description: 'Output encoding: text or base64 (default text)' }, supportsAllDrives: { type: 'boolean', description: 'Enable shared drive support' } },
|
|
228
|
-
output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, fileId: { type: 'string' }, exportMimeType: { type: 'string' }, encoding: { type: 'string' }, content: { type: 'string' }, contentLength: { type: 'number' } } }, render: (_args, value) => value.found ? text((value.content ?? '').slice(0, 4000)) : text(value.reason ?? 'Google Drive is not configured.') },
|
|
227
|
+
parameters: { fileId: { type: 'string', required: true, description: 'Google Drive file ID' }, exportMimeType: { type: 'string', description: 'Export MIME type, default text/plain' }, responseEncoding: { type: 'string', description: 'Output encoding: text or base64 (default text)' }, maxBytes: { type: 'integer', description: 'Maximum source bytes to return, default 1 MiB, capped at 10 MiB' }, supportsAllDrives: { type: 'boolean', description: 'Enable shared drive support' } },
|
|
228
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, fileId: { type: 'string' }, exportMimeType: { type: 'string' }, encoding: { type: 'string' }, content: { type: 'string' }, contentLength: { type: 'number' }, truncated: { type: 'boolean' } } }, render: (_args, value) => value.found ? text((value.content ?? '').slice(0, 4000) + (value.truncated ? '\n[content truncated by maxBytes]' : '')) : text(value.reason ?? 'Google Drive is not configured.') },
|
|
229
229
|
presentCall(args) { return { card: 'generic', title: `Export Google Drive file ${args.fileId ?? ''}`, kind: 'read' }; },
|
|
230
230
|
async execute(args, exec) {
|
|
231
231
|
if (!client.hasCredentials())
|
|
232
232
|
return unavailable('Google Drive accessToken or refresh token credentials are not configured.');
|
|
233
233
|
try {
|
|
234
|
-
return { found: true, ...await client.exportFile(args.fileId, { exportMimeType: args.exportMimeType, responseEncoding: args.responseEncoding, supportsAllDrives: args.supportsAllDrives, signal: exec.signal }) };
|
|
234
|
+
return { found: true, ...await client.exportFile(args.fileId, { exportMimeType: args.exportMimeType, responseEncoding: args.responseEncoding, maxBytes: args.maxBytes, supportsAllDrives: args.supportsAllDrives, signal: exec.signal }) };
|
|
235
235
|
}
|
|
236
236
|
catch (error) {
|
|
237
237
|
if (error instanceof GoogleDriveError)
|
|
@@ -281,14 +281,14 @@ export function createTools(client) {
|
|
|
281
281
|
defineTool({
|
|
282
282
|
name: 'gdocs_get_document',
|
|
283
283
|
description: 'Read a Google Docs document structure and extracted text by document ID.',
|
|
284
|
-
parameters: { documentId: { type: 'string', required: true, description: 'Google Docs document ID' }, includeTabsContent: { type: 'boolean', description: 'Include tab content, default true' }, suggestionsViewMode: { type: 'string', description: 'Optional suggestions view mode' }, commentsViewMode: { type: 'string', description: 'Optional comments view mode' }, fields: { type: 'string', description: 'Optional field mask' } },
|
|
285
|
-
output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, documentId: { type: 'string' }, title: { type: 'string' }, revisionId: { type: 'string' }, text: { type: 'string' }, tabCount: { type: 'number' }, tabs: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { tabId: { type: 'string' }, title: { type: 'string' }, index: { type: 'number' }, textLength: { type: 'number' } } } } } }, render: (_args, value) => value.found ? renderDocument(value) : text(value.reason ?? 'Google Docs is not configured.') },
|
|
284
|
+
parameters: { documentId: { type: 'string', required: true, description: 'Google Docs document ID' }, includeTabsContent: { type: 'boolean', description: 'Include tab content, default true' }, maxBytes: { type: 'integer', description: 'Maximum UTF-8 document bytes, default 1 MiB, capped at 10 MiB' }, maxTabs: { type: 'integer', description: 'Maximum tab metadata records, default 100' }, suggestionsViewMode: { type: 'string', description: 'Optional suggestions view mode' }, commentsViewMode: { type: 'string', description: 'Optional comments view mode' }, fields: { type: 'string', description: 'Optional field mask' } },
|
|
285
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, documentId: { type: 'string' }, title: { type: 'string' }, revisionId: { type: 'string' }, text: { type: 'string' }, contentLength: { type: 'number' }, truncated: { type: 'boolean' }, tabCount: { type: 'number' }, tabs: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { tabId: { type: 'string' }, title: { type: 'string' }, index: { type: 'number' }, textLength: { type: 'number' } } } } } }, render: (_args, value) => value.found ? renderDocument(value) : text(value.reason ?? 'Google Docs is not configured.') },
|
|
286
286
|
presentCall(args) { return { card: 'generic', title: `Google Doc ${args.documentId ?? ''}`, kind: 'read' }; },
|
|
287
287
|
async execute(args, exec) {
|
|
288
288
|
if (!client.hasCredentials())
|
|
289
289
|
return unavailable('Google Drive accessToken or refresh token credentials are not configured.');
|
|
290
290
|
try {
|
|
291
|
-
return { found: true, ...await client.getDocument(args.documentId, { includeTabsContent: args.includeTabsContent, suggestionsViewMode: args.suggestionsViewMode, commentsViewMode: args.commentsViewMode, fields: args.fields, signal: exec.signal }) };
|
|
291
|
+
return { found: true, ...await client.getDocument(args.documentId, { includeTabsContent: args.includeTabsContent, maxBytes: args.maxBytes, maxTabs: args.maxTabs, suggestionsViewMode: args.suggestionsViewMode, commentsViewMode: args.commentsViewMode, fields: args.fields, signal: exec.signal }) };
|
|
292
292
|
}
|
|
293
293
|
catch (error) {
|
|
294
294
|
if (error instanceof GoogleDriveError)
|
|
@@ -319,14 +319,14 @@ export function createTools(client) {
|
|
|
319
319
|
defineTool({
|
|
320
320
|
name: 'gsheets_get_values',
|
|
321
321
|
description: 'Read values from one Google Sheets A1 range.',
|
|
322
|
-
parameters: { spreadsheetId: { type: 'string', required: true, description: 'Google Sheets spreadsheet ID' }, range: { type: 'string', required: true, description: 'A1 notation range, e.g. Sheet1!A1:D20' }, valueRenderOption: { type: 'string', description: 'FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA' }, dateTimeRenderOption: { type: 'string', description: 'SERIAL_NUMBER or FORMATTED_STRING' }, majorDimension: { type: 'string', description: 'ROWS or COLUMNS' } },
|
|
323
|
-
output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, spreadsheetId: { type: 'string' }, range: { type: 'string' }, majorDimension: { type: 'string' }, rowCount: { type: 'number' }, columnCount: { type: 'number' }, values: { type: 'array', items: { type: 'array', items: { type: 'string' } } } } }, render: (_args, value) => value.found ? renderValues(value) : text(value.reason ?? 'Google Sheets is not configured.') },
|
|
322
|
+
parameters: { spreadsheetId: { type: 'string', required: true, description: 'Google Sheets spreadsheet ID' }, range: { type: 'string', required: true, description: 'A1 notation range, e.g. Sheet1!A1:D20' }, valueRenderOption: { type: 'string', description: 'FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA' }, dateTimeRenderOption: { type: 'string', description: 'SERIAL_NUMBER or FORMATTED_STRING' }, majorDimension: { type: 'string', description: 'ROWS or COLUMNS' }, maxRows: { type: 'integer', description: 'Maximum rows, default 1000' }, maxColumns: { type: 'integer', description: 'Maximum columns, default 100' }, maxCells: { type: 'integer', description: 'Maximum cells, default 100000' } },
|
|
323
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: { found: { type: 'boolean' }, reason: { type: 'string' }, spreadsheetId: { type: 'string' }, range: { type: 'string' }, majorDimension: { type: 'string' }, rowCount: { type: 'number' }, columnCount: { type: 'number' }, contentLength: { type: 'number' }, truncated: { type: 'boolean' }, values: { type: 'array', items: { type: 'array', items: { type: 'string' } } } } }, render: (_args, value) => value.found ? renderValues(value) : text(value.reason ?? 'Google Sheets is not configured.') },
|
|
324
324
|
presentCall(args) { return { card: 'generic', title: `Google Sheet values ${args.range ?? ''}`, kind: 'read' }; },
|
|
325
325
|
async execute(args, exec) {
|
|
326
326
|
if (!client.hasCredentials())
|
|
327
327
|
return unavailable('Google Drive accessToken or refresh token credentials are not configured.');
|
|
328
328
|
try {
|
|
329
|
-
return { found: true, ...await client.getSheetValues(args.spreadsheetId, args.range, { valueRenderOption: args.valueRenderOption, dateTimeRenderOption: args.dateTimeRenderOption, majorDimension: args.majorDimension, signal: exec.signal }) };
|
|
329
|
+
return { found: true, ...await client.getSheetValues(args.spreadsheetId, args.range, { valueRenderOption: args.valueRenderOption, dateTimeRenderOption: args.dateTimeRenderOption, majorDimension: args.majorDimension, maxRows: args.maxRows, maxColumns: args.maxColumns, maxCells: args.maxCells, signal: exec.signal }) };
|
|
330
330
|
}
|
|
331
331
|
catch (error) {
|
|
332
332
|
if (error instanceof GoogleDriveError)
|
package/lib/types/client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Google Drive API client with injected fetch for testability. */
|
|
2
|
+
import { type LookupImpl } from './url-security.js';
|
|
2
3
|
export interface GoogleDriveClientOptions {
|
|
3
4
|
accessToken?: string;
|
|
4
5
|
clientId?: string;
|
|
@@ -8,6 +9,8 @@ export interface GoogleDriveClientOptions {
|
|
|
8
9
|
tokenUrl?: string;
|
|
9
10
|
timeoutMs?: number;
|
|
10
11
|
fetchImpl?: typeof fetch;
|
|
12
|
+
/** Test-only DNS lookup override; production uses node:dns/promises. */
|
|
13
|
+
lookupImpl?: LookupImpl;
|
|
11
14
|
}
|
|
12
15
|
export declare class GoogleDriveError extends Error {
|
|
13
16
|
readonly code: number;
|
|
@@ -85,6 +88,8 @@ export interface GoogleDocumentInfo {
|
|
|
85
88
|
text: string;
|
|
86
89
|
tabCount: number;
|
|
87
90
|
tabs: GoogleDocTabInfo[];
|
|
91
|
+
contentLength: number;
|
|
92
|
+
truncated: boolean;
|
|
88
93
|
}
|
|
89
94
|
export interface GoogleSheetPropertiesInfo {
|
|
90
95
|
sheetId: number;
|
|
@@ -111,6 +116,8 @@ export interface GoogleSheetValuesInfo {
|
|
|
111
116
|
rowCount: number;
|
|
112
117
|
columnCount: number;
|
|
113
118
|
values: string[][];
|
|
119
|
+
contentLength: number;
|
|
120
|
+
truncated: boolean;
|
|
114
121
|
}
|
|
115
122
|
export declare class GoogleDriveClient {
|
|
116
123
|
private readonly staticToken;
|
|
@@ -121,6 +128,7 @@ export declare class GoogleDriveClient {
|
|
|
121
128
|
private readonly tokenUrl;
|
|
122
129
|
private readonly timeoutMs;
|
|
123
130
|
private readonly fetchImpl;
|
|
131
|
+
private readonly lookupImpl;
|
|
124
132
|
private tokenCache;
|
|
125
133
|
constructor(options?: GoogleDriveClientOptions);
|
|
126
134
|
hasCredentials(): boolean;
|
|
@@ -130,7 +138,6 @@ export declare class GoogleDriveClient {
|
|
|
130
138
|
authTest(signal?: AbortSignal): Promise<{
|
|
131
139
|
ok: boolean;
|
|
132
140
|
authMethod: string;
|
|
133
|
-
tokenPreview: string;
|
|
134
141
|
}>;
|
|
135
142
|
listFiles(options?: {
|
|
136
143
|
query?: string;
|
|
@@ -180,6 +187,7 @@ export declare class GoogleDriveClient {
|
|
|
180
187
|
exportMimeType?: string;
|
|
181
188
|
responseEncoding?: 'text' | 'base64';
|
|
182
189
|
supportsAllDrives?: boolean;
|
|
190
|
+
maxBytes?: number;
|
|
183
191
|
signal?: AbortSignal;
|
|
184
192
|
}): Promise<{
|
|
185
193
|
fileId: string;
|
|
@@ -187,6 +195,7 @@ export declare class GoogleDriveClient {
|
|
|
187
195
|
encoding: string;
|
|
188
196
|
content: string;
|
|
189
197
|
contentLength: number;
|
|
198
|
+
truncated: boolean;
|
|
190
199
|
}>;
|
|
191
200
|
listSharedDrives(options?: {
|
|
192
201
|
pageSize?: number;
|
|
@@ -208,6 +217,8 @@ export declare class GoogleDriveClient {
|
|
|
208
217
|
suggestionsViewMode?: string;
|
|
209
218
|
commentsViewMode?: string;
|
|
210
219
|
fields?: string;
|
|
220
|
+
maxBytes?: number;
|
|
221
|
+
maxTabs?: number;
|
|
211
222
|
signal?: AbortSignal;
|
|
212
223
|
}): Promise<GoogleDocumentInfo>;
|
|
213
224
|
getSpreadsheet(spreadsheetId: string, options?: {
|
|
@@ -220,6 +231,9 @@ export declare class GoogleDriveClient {
|
|
|
220
231
|
valueRenderOption?: string;
|
|
221
232
|
dateTimeRenderOption?: string;
|
|
222
233
|
majorDimension?: string;
|
|
234
|
+
maxRows?: number;
|
|
235
|
+
maxColumns?: number;
|
|
236
|
+
maxCells?: number;
|
|
223
237
|
signal?: AbortSignal;
|
|
224
238
|
}): Promise<GoogleSheetValuesInfo>;
|
|
225
239
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { LookupAddress } from 'node:dns';
|
|
2
|
+
export type LookupImpl = (hostname: string, options: {
|
|
3
|
+
all: true;
|
|
4
|
+
}) => Promise<LookupAddress[]>;
|
|
5
|
+
export declare class EndpointSecurityError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare function isBlockedAddress(address: string): boolean;
|
|
9
|
+
/** Normalize a configured endpoint to origin + optional path prefix. */
|
|
10
|
+
export declare function normalizeBaseUrl(value: string | undefined, fallback?: string): string;
|
|
11
|
+
/** Validate the final request destination, resolving ordinary hostnames fail-closed. */
|
|
12
|
+
export declare function assertSafeUrl(url: URL, lookupImpl?: LookupImpl): Promise<void>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed destination validation for an operator-configured API endpoint.
|
|
3
|
+
*
|
|
4
|
+
* GENERATED FILE — instantiated from .verify/url-security.template.ts.
|
|
5
|
+
* Every copy of url-security.ts must stay byte-identical apart from LABEL, so
|
|
6
|
+
* the implementations cannot drift the way they did before. Behaviour that must
|
|
7
|
+
* hold across all copies is pinned by security-vectors.json in each repository.
|
|
8
|
+
*/
|
|
9
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
10
|
+
import { isIP } from 'node:net';
|
|
11
|
+
const LABEL = 'Google Drive';
|
|
12
|
+
export class EndpointSecurityError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'EndpointSecurityError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const defaultLookup = async (hostname, options) => dnsLookup(hostname, options);
|
|
19
|
+
/** Aligned with the IANA IPv4 Special-Purpose Address Registry. */
|
|
20
|
+
const IPV4_BLOCKED_RANGES = [
|
|
21
|
+
['0.0.0.0', 8], // this network / unspecified
|
|
22
|
+
['10.0.0.0', 8], // private
|
|
23
|
+
['100.64.0.0', 10], // carrier-grade NAT
|
|
24
|
+
['127.0.0.0', 8], // loopback
|
|
25
|
+
['169.254.0.0', 16], // link-local
|
|
26
|
+
['172.16.0.0', 12], // private
|
|
27
|
+
['192.0.0.0', 24], // IETF protocol assignments
|
|
28
|
+
['192.0.2.0', 24], // TEST-NET-1
|
|
29
|
+
['192.31.196.0', 24], // AS112-v4
|
|
30
|
+
['192.52.193.0', 24], // AMT
|
|
31
|
+
['192.88.99.0', 24], // deprecated 6to4 relay anycast
|
|
32
|
+
['192.168.0.0', 16], // private
|
|
33
|
+
['192.175.48.0', 24], // AS112-v4 direct delegation
|
|
34
|
+
['198.18.0.0', 15], // benchmarking
|
|
35
|
+
['198.51.100.0', 24], // TEST-NET-2
|
|
36
|
+
['203.0.113.0', 24], // TEST-NET-3
|
|
37
|
+
['224.0.0.0', 4], // multicast
|
|
38
|
+
['240.0.0.0', 4], // reserved / future use
|
|
39
|
+
];
|
|
40
|
+
/** Aligned with the IANA IPv6 Special-Purpose Address Registry. */
|
|
41
|
+
const IPV6_BLOCKED_RANGES = [
|
|
42
|
+
['::', 96], // unspecified and IPv4-compatible
|
|
43
|
+
['::ffff:0:0', 96], // IPv4-mapped
|
|
44
|
+
['64:ff9b::', 96], // well-known NAT64
|
|
45
|
+
['64:ff9b:1::', 48], // local-use NAT64
|
|
46
|
+
['100::', 64], // discard-only
|
|
47
|
+
['100:0:0:1::', 64], // dummy IPv6 prefix (RFC 9780)
|
|
48
|
+
['2001::', 23], // IETF protocol assignments: Teredo, AMT, AS112-v6, benchmarking, ORCHID/ORCHIDv2, DRiP
|
|
49
|
+
['2001:db8::', 32], // documentation
|
|
50
|
+
['2002::', 16], // 6to4
|
|
51
|
+
['2620:4f:8000::', 48], // direct delegation AS112 service
|
|
52
|
+
['3fff::', 20], // documentation (RFC 9637)
|
|
53
|
+
['5f00::', 16], // segment routing (SRv6) SIDs
|
|
54
|
+
['fc00::', 7], // unique local
|
|
55
|
+
['fe80::', 10], // link-local
|
|
56
|
+
['fec0::', 10], // deprecated site-local
|
|
57
|
+
['ff00::', 8], // multicast
|
|
58
|
+
];
|
|
59
|
+
function parseIpv4(address) {
|
|
60
|
+
const parts = address.split('.');
|
|
61
|
+
if (parts.length !== 4 || parts.some(part => !/^\d{1,3}$/.test(part)))
|
|
62
|
+
return null;
|
|
63
|
+
let value = 0n;
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
const octet = Number(part);
|
|
66
|
+
if (octet > 255)
|
|
67
|
+
return null;
|
|
68
|
+
value = (value << 8n) | BigInt(octet);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function parseIpv6(address) {
|
|
73
|
+
const normalized = address.toLowerCase();
|
|
74
|
+
if (normalized.includes('%'))
|
|
75
|
+
return null;
|
|
76
|
+
const sections = normalized.split('::');
|
|
77
|
+
if (sections.length > 2)
|
|
78
|
+
return null;
|
|
79
|
+
const parseSection = (section) => {
|
|
80
|
+
if (!section)
|
|
81
|
+
return [];
|
|
82
|
+
const parts = section.split(':');
|
|
83
|
+
const result = [];
|
|
84
|
+
for (const [index, part] of parts.entries()) {
|
|
85
|
+
if (part.includes('.')) {
|
|
86
|
+
if (index !== parts.length - 1)
|
|
87
|
+
return null;
|
|
88
|
+
const ipv4 = parseIpv4(part);
|
|
89
|
+
if (ipv4 === null)
|
|
90
|
+
return null;
|
|
91
|
+
result.push((ipv4 >> 16n).toString(16), (ipv4 & 0xffffn).toString(16));
|
|
92
|
+
}
|
|
93
|
+
else if (/^[0-9a-f]{1,4}$/.test(part)) {
|
|
94
|
+
result.push(part);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
};
|
|
102
|
+
const head = parseSection(sections[0]);
|
|
103
|
+
const tail = sections.length === 2 ? parseSection(sections[1]) : [];
|
|
104
|
+
if (!head || !tail)
|
|
105
|
+
return null;
|
|
106
|
+
const parts = [...head, ...tail];
|
|
107
|
+
if (sections.length === 1) {
|
|
108
|
+
if (parts.length !== 8)
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const zeroCount = 8 - parts.length;
|
|
113
|
+
if (zeroCount < 1)
|
|
114
|
+
return null;
|
|
115
|
+
parts.splice(head.length, 0, ...Array.from({ length: zeroCount }, () => '0'));
|
|
116
|
+
}
|
|
117
|
+
if (parts.length !== 8)
|
|
118
|
+
return null;
|
|
119
|
+
let value = 0n;
|
|
120
|
+
for (const part of parts)
|
|
121
|
+
value = (value << 16n) | BigInt(Number.parseInt(part, 16));
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
function inRange(address, network, bits, width) {
|
|
125
|
+
const hostBits = width - bits;
|
|
126
|
+
const mask = hostBits === width ? 0n : ((1n << BigInt(width)) - 1n) ^ ((1n << BigInt(hostBits)) - 1n);
|
|
127
|
+
return (address & mask) === (network & mask);
|
|
128
|
+
}
|
|
129
|
+
function isBlockedIpv4(address) {
|
|
130
|
+
const value = parseIpv4(address);
|
|
131
|
+
if (value === null)
|
|
132
|
+
return true;
|
|
133
|
+
return IPV4_BLOCKED_RANGES.some(([network, bits]) => {
|
|
134
|
+
const parsed = parseIpv4(network);
|
|
135
|
+
return parsed !== null && inRange(value, parsed, bits, 32);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function isBlockedIpv6(address) {
|
|
139
|
+
const value = parseIpv6(address);
|
|
140
|
+
if (value === null)
|
|
141
|
+
return true;
|
|
142
|
+
return IPV6_BLOCKED_RANGES.some(([network, bits]) => {
|
|
143
|
+
const parsed = parseIpv6(network);
|
|
144
|
+
return parsed !== null && inRange(value, parsed, bits, 128);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
export function isBlockedAddress(address) {
|
|
148
|
+
const family = isIP(address);
|
|
149
|
+
if (family === 4)
|
|
150
|
+
return isBlockedIpv4(address);
|
|
151
|
+
if (family === 6)
|
|
152
|
+
return isBlockedIpv6(address);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
function hostnameIsLocal(hostname) {
|
|
156
|
+
const normalized = hostname.toLowerCase().replace(/\.+$/, '');
|
|
157
|
+
return normalized === 'localhost'
|
|
158
|
+
|| normalized.endsWith('.localhost')
|
|
159
|
+
|| normalized === 'localhost.localdomain'
|
|
160
|
+
|| normalized.endsWith('.localhost.localdomain')
|
|
161
|
+
|| normalized === 'local'
|
|
162
|
+
|| normalized.endsWith('.local');
|
|
163
|
+
}
|
|
164
|
+
/** Normalize a configured endpoint to origin + optional path prefix. */
|
|
165
|
+
export function normalizeBaseUrl(value, fallback = '') {
|
|
166
|
+
const input = value ?? fallback;
|
|
167
|
+
// An empty endpoint means "not configured"; each client keeps its own behaviour.
|
|
168
|
+
if (!input)
|
|
169
|
+
return '';
|
|
170
|
+
if (typeof input !== 'string' || input.trim() !== input || /[\u0000-\u001f\u007f]/.test(input)) {
|
|
171
|
+
throw new EndpointSecurityError(LABEL + ' base URL is invalid.');
|
|
172
|
+
}
|
|
173
|
+
let url;
|
|
174
|
+
try {
|
|
175
|
+
url = new URL(input);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
throw new EndpointSecurityError(LABEL + ' base URL is invalid.');
|
|
179
|
+
}
|
|
180
|
+
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname || url.username || url.password || url.search || url.hash) {
|
|
181
|
+
throw new EndpointSecurityError(LABEL + ' base URL is invalid.');
|
|
182
|
+
}
|
|
183
|
+
const pathPrefix = url.pathname.replace(/\/+$/, '');
|
|
184
|
+
return `${url.origin}${pathPrefix}`;
|
|
185
|
+
}
|
|
186
|
+
/** Validate the final request destination, resolving ordinary hostnames fail-closed. */
|
|
187
|
+
export async function assertSafeUrl(url, lookupImpl = defaultLookup) {
|
|
188
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
189
|
+
throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
|
|
190
|
+
}
|
|
191
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '').replace(/\.+$/, '').toLowerCase();
|
|
192
|
+
if (!hostname || hostnameIsLocal(hostname)) {
|
|
193
|
+
throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
|
|
194
|
+
}
|
|
195
|
+
const literalFamily = isIP(hostname);
|
|
196
|
+
if (literalFamily) {
|
|
197
|
+
if (isBlockedAddress(hostname)) {
|
|
198
|
+
throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
let addresses;
|
|
203
|
+
try {
|
|
204
|
+
addresses = await lookupImpl(hostname, { all: true });
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
|
|
208
|
+
}
|
|
209
|
+
if (!Array.isArray(addresses) || addresses.length === 0) {
|
|
210
|
+
throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
|
|
211
|
+
}
|
|
212
|
+
for (const result of addresses) {
|
|
213
|
+
if (!result || (result.family !== 4 && result.family !== 6) || isIP(result.address) !== result.family || isBlockedAddress(result.address)) {
|
|
214
|
+
throw new EndpointSecurityError(LABEL + ' request URL was rejected by host safety policy.');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@libai168/dsh-tool-google-drive",
|
|
3
3
|
"description": "Google Workspace read-only tools for DeepSeek Harness: Drive files, permissions, revisions, Docs, Sheets, and Shared Drives",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"build": "tsc -p tsconfig.json",
|
|
33
33
|
"typecheck": "tsc --noEmit -p tsconfig.test.json && tsc --noEmit -p tsconfig.json",
|
|
34
34
|
"test": "vitest run",
|
|
35
|
-
"auth:google": "node scripts/auth-google.mjs"
|
|
35
|
+
"auth:google": "node scripts/auth-google.mjs",
|
|
36
|
+
"prepare": "npm run build"
|
|
36
37
|
},
|
|
37
38
|
"publishConfig": {
|
|
38
39
|
"access": "public"
|