srcpack 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "srcpack",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Zero-config CLI for bundling code into LLM-optimized context files",
5
5
  "keywords": [
6
6
  "llm",
@@ -31,6 +31,10 @@
31
31
  "license": "MIT",
32
32
  "homepage": "https://kriasoft.com/srcpack/",
33
33
  "repository": "github:kriasoft/srcpack",
34
+ "bugs": "https://github.com/kriasoft/srcpack/issues",
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ },
34
38
  "type": "module",
35
39
  "types": "./dist/index.d.ts",
36
40
  "exports": {
@@ -48,13 +52,13 @@
48
52
  "schema.json"
49
53
  ],
50
54
  "scripts": {
51
- "build": "bun build ./src/cli.ts ./src/index.ts --outdir ./dist --target bun && tsc",
52
- "typecheck": "tsc -p tsconfig.check.json",
55
+ "build": "bun build ./src/cli.ts ./src/index.ts --outdir ./dist --target node && tsc",
53
56
  "check": "tsc -p tsconfig.check.json",
54
57
  "test": "bun test tests/unit/ tests/e2e/",
55
58
  "test:unit": "bun test tests/unit/",
56
59
  "test:e2e": "bun test tests/e2e/",
57
- "test:login": "bun test --env-file .env.local tests/manual/",
60
+ "test:login": "bun test --env-file .env.local tests/manual/login.test.ts",
61
+ "test:upload": "bun test --env-file .env.local tests/manual/upload.test.ts",
58
62
  "test:all": "bun test --env-file .env.local tests/",
59
63
  "test:watch": "bun test tests/unit/ tests/e2e/ --watch",
60
64
  "docs:dev": "vitepress dev",
@@ -64,16 +68,23 @@
64
68
  },
65
69
  "dependencies": {
66
70
  "@clack/prompts": "^0.11.0",
71
+ "@googleapis/drive": "^20.0.0",
67
72
  "cosmiconfig": "^9.0.0",
73
+ "fast-glob": "^3.3.3",
74
+ "google-auth-library": "^10.5.0",
68
75
  "ignore": "^7.0.5",
69
76
  "oauth-callback": "^1.2.5",
77
+ "ora": "^9.0.0",
78
+ "picomatch": "^4.0.2",
70
79
  "zod": "^4.3.5"
71
80
  },
72
81
  "devDependencies": {
73
82
  "@types/bun": "^1.3.6",
83
+ "@types/picomatch": "^4.0.2",
74
84
  "gh-pages": "^6.3.0",
75
85
  "prettier": "^3.8.0",
76
86
  "typescript": "^5.9.3",
77
- "vitepress": "^2.0.0-alpha.15"
87
+ "vitepress": "^2.0.0-alpha.15",
88
+ "vitepress-plugin-llms": "^1.10.0"
78
89
  }
79
90
  }
package/src/bundle.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
 
3
+ import { open, readFile, stat } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
- import { Glob } from "bun";
5
+ import { glob } from "fast-glob";
6
+ import picomatch from "picomatch";
5
7
  import ignore, { type Ignore } from "ignore";
6
8
  import type { BundleConfigInput } from "./config.ts";
7
9
 
@@ -9,12 +11,17 @@ import type { BundleConfigInput } from "./config.ts";
9
11
  const BINARY_CHECK_SIZE = 8192;
10
12
 
11
13
  async function isBinary(filePath: string): Promise<boolean> {
12
- const file = Bun.file(filePath);
13
- const size = file.size;
14
- if (size === 0) return false;
14
+ const stats = await stat(filePath);
15
+ if (stats.size === 0) return false;
15
16
 
16
- const chunk = await file.slice(0, Math.min(size, BINARY_CHECK_SIZE)).bytes();
17
- return chunk.includes(0);
17
+ const fd = await open(filePath, "r");
18
+ try {
19
+ const buffer = Buffer.alloc(Math.min(stats.size, BINARY_CHECK_SIZE));
20
+ await fd.read(buffer, 0, buffer.length, 0);
21
+ return buffer.includes(0);
22
+ } finally {
23
+ await fd.close();
24
+ }
18
25
  }
19
26
 
20
27
  export interface FileEntry {
@@ -30,11 +37,15 @@ export interface BundleResult {
30
37
  }
31
38
 
32
39
  /**
33
- * Normalize BundleConfig to arrays of include/exclude patterns
40
+ * Normalize BundleConfig to arrays of include/exclude/force patterns.
41
+ * - Regular patterns: included, filtered by .gitignore
42
+ * - `!pattern`: excluded from results
43
+ * - `+pattern`: force-included, bypasses .gitignore
34
44
  */
35
45
  function normalizePatterns(config: BundleConfigInput): {
36
46
  include: string[];
37
47
  exclude: string[];
48
+ force: string[];
38
49
  } {
39
50
  let patterns: string[];
40
51
 
@@ -50,29 +61,28 @@ function normalizePatterns(config: BundleConfigInput): {
50
61
 
51
62
  const include: string[] = [];
52
63
  const exclude: string[] = [];
64
+ const force: string[] = [];
53
65
 
54
66
  for (const p of patterns) {
55
67
  if (p.startsWith("!")) {
56
68
  exclude.push(p.slice(1));
69
+ } else if (p.startsWith("+")) {
70
+ force.push(p.slice(1));
57
71
  } else {
58
72
  include.push(p);
59
73
  }
60
74
  }
61
75
 
62
- return { include, exclude };
76
+ return { include, exclude, force };
63
77
  }
64
78
 
79
+ type Matcher = (path: string) => boolean;
80
+
65
81
  /**
66
- * Check if a path matches any of the exclusion patterns
82
+ * Check if a path matches any of the exclusion matchers
67
83
  */
68
- function isExcluded(filePath: string, excludePatterns: string[]): boolean {
69
- for (const pattern of excludePatterns) {
70
- const glob = new Glob(pattern);
71
- if (glob.match(filePath)) {
72
- return true;
73
- }
74
- }
75
- return false;
84
+ function isExcluded(filePath: string, matchers: Matcher[]): boolean {
85
+ return matchers.some((match) => match(filePath));
76
86
  }
77
87
 
78
88
  /**
@@ -83,7 +93,7 @@ async function loadGitignore(cwd: string): Promise<Ignore> {
83
93
  const gitignorePath = join(cwd, ".gitignore");
84
94
 
85
95
  try {
86
- const content = await Bun.file(gitignorePath).text();
96
+ const content = await readFile(gitignorePath, "utf-8");
87
97
  ig.add(content);
88
98
  } catch {
89
99
  // No .gitignore file, return empty ignore instance
@@ -94,20 +104,37 @@ async function loadGitignore(cwd: string): Promise<Ignore> {
94
104
 
95
105
  /**
96
106
  * Resolve bundle config to a list of file paths.
97
- * Respects .gitignore patterns in the working directory.
107
+ * - Regular patterns respect .gitignore
108
+ * - Force patterns (+prefix) bypass .gitignore
109
+ * - Exclude patterns (!prefix) filter both
98
110
  */
99
111
  export async function resolvePatterns(
100
112
  config: BundleConfigInput,
101
113
  cwd: string,
102
114
  ): Promise<string[]> {
103
- const { include, exclude } = normalizePatterns(config);
115
+ const { include, exclude, force } = normalizePatterns(config);
116
+ const excludeMatchers = exclude.map((p) => picomatch(p));
104
117
  const gitignore = await loadGitignore(cwd);
105
118
  const files = new Set<string>();
106
119
 
107
- for (const pattern of include) {
108
- const glob = new Glob(pattern);
109
- for await (const match of glob.scan({ cwd, onlyFiles: true })) {
110
- if (!isExcluded(match, exclude) && !gitignore.ignores(match)) {
120
+ // Regular includes: respect .gitignore
121
+ if (include.length > 0) {
122
+ const matches = await glob(include, { cwd, onlyFiles: true, dot: true });
123
+ for (const match of matches) {
124
+ if (!isExcluded(match, excludeMatchers) && !gitignore.ignores(match)) {
125
+ const fullPath = join(cwd, match);
126
+ if (!(await isBinary(fullPath))) {
127
+ files.add(match);
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ // Force includes: bypass .gitignore
134
+ if (force.length > 0) {
135
+ const matches = await glob(force, { cwd, onlyFiles: true, dot: true });
136
+ for (const match of matches) {
137
+ if (!isExcluded(match, excludeMatchers)) {
111
138
  const fullPath = join(cwd, match);
112
139
  if (!(await isBinary(fullPath))) {
113
140
  files.add(match);
@@ -184,7 +211,7 @@ export async function createBundle(
184
211
  for (let i = 0; i < files.length; i++) {
185
212
  const filePath = files[i]!;
186
213
  const fullPath = join(cwd, filePath);
187
- const content = await Bun.file(fullPath).text();
214
+ const content = await readFile(fullPath, "utf-8");
188
215
  const lines = countLines(content);
189
216
 
190
217
  // Separator takes 1 line, then content starts on next line
package/src/cli.ts CHANGED
@@ -1,11 +1,23 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
  // SPDX-License-Identifier: MIT
3
3
 
4
- import { mkdir } from "node:fs/promises";
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
+ import ora from "ora";
6
7
  import { bundleOne, type BundleResult } from "./bundle.ts";
7
- import { loadConfig, type BundleConfig, type UploadConfig } from "./config.ts";
8
- import { ensureAuthenticated, login, OAuthError } from "./gdrive.ts";
8
+ import {
9
+ ConfigError,
10
+ loadConfig,
11
+ type BundleConfig,
12
+ type UploadConfig,
13
+ } from "./config.ts";
14
+ import {
15
+ ensureAuthenticated,
16
+ login,
17
+ OAuthError,
18
+ uploadFile,
19
+ type UploadResult,
20
+ } from "./gdrive.ts";
9
21
  import { runInit } from "./init.ts";
10
22
 
11
23
  interface BundleOutput {
@@ -36,13 +48,15 @@ srcpack - Bundle and upload tool
36
48
  Usage:
37
49
  npx srcpack Bundle all, upload if configured
38
50
  npx srcpack web api Bundle specific bundles only
39
- npx srcpack --dry-run Bundle without upload (preview)
51
+ npx srcpack --dry-run Preview bundles without writing files
52
+ npx srcpack --no-upload Bundle only, skip upload
40
53
  npx srcpack init Interactive config setup
41
54
  npx srcpack login Authenticate with Google Drive
42
55
 
43
56
  Options:
44
- --dry-run Preview bundles without uploading
45
- -h, --help Show this help message
57
+ --dry-run Preview bundles without writing files
58
+ --no-upload Skip uploading to cloud storage
59
+ -h, --help Show this help message
46
60
  `);
47
61
  return;
48
62
  }
@@ -58,6 +72,7 @@ Options:
58
72
  }
59
73
 
60
74
  const dryRun = args.includes("--dry-run");
75
+ const noUpload = args.includes("--no-upload");
61
76
  const subcommands = ["init", "login"];
62
77
  const requestedBundles = args.filter(
63
78
  (arg) => !arg.startsWith("-") && !subcommands.includes(arg),
@@ -93,14 +108,23 @@ Options:
93
108
  const cwd = process.cwd();
94
109
  const outputs: BundleOutput[] = [];
95
110
 
96
- // Process all bundles
97
- for (const name of bundleNames) {
111
+ // Process all bundles with progress
112
+ const bundleSpinner = ora({
113
+ text: `Bundling ${bundleNames[0]}...`,
114
+ color: "cyan",
115
+ }).start();
116
+
117
+ for (let i = 0; i < bundleNames.length; i++) {
118
+ const name = bundleNames[i]!;
119
+ bundleSpinner.text = `Bundling ${name}... (${i + 1}/${bundleNames.length})`;
98
120
  const bundleConfig = config.bundles[name]!;
99
121
  const result = await bundleOne(name, bundleConfig, cwd);
100
122
  const outfile = getOutfile(bundleConfig, name, config.outDir);
101
123
  outputs.push({ name, outfile, result });
102
124
  }
103
125
 
126
+ bundleSpinner.stop();
127
+
104
128
  // Calculate column widths for aligned output
105
129
  const maxNameLen = Math.max(...outputs.map((o) => o.name.length));
106
130
  const maxFilesLen = Math.max(
@@ -130,7 +154,7 @@ Options:
130
154
  }
131
155
  } else {
132
156
  await mkdir(dirname(outPath), { recursive: true });
133
- await Bun.write(outPath, result.content);
157
+ await writeFile(outPath, result.content);
134
158
  console.log(
135
159
  ` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")} → ${outfile}`,
136
160
  );
@@ -151,18 +175,18 @@ Options:
151
175
  );
152
176
  } else {
153
177
  console.log(
154
- `Done: ${outputs.length} ${bundleWord}, ${formatNumber(totalFiles)} ${fileWord}, ${formatNumber(totalLines)} ${lineWord}`,
178
+ `Bundled: ${outputs.length} ${bundleWord}, ${formatNumber(totalFiles)} ${fileWord}, ${formatNumber(totalLines)} ${lineWord}`,
155
179
  );
156
180
 
157
- // Handle upload if configured
158
- if (config.upload) {
181
+ // Handle upload if configured and not disabled
182
+ if (config.upload && !noUpload) {
159
183
  const uploads = Array.isArray(config.upload)
160
184
  ? config.upload
161
185
  : [config.upload];
162
186
 
163
187
  for (const uploadConfig of uploads) {
164
188
  if (isGdriveConfigured(uploadConfig)) {
165
- await handleGdriveUpload(uploadConfig, outputs);
189
+ await handleGdriveUpload(uploadConfig, outputs, cwd);
166
190
  }
167
191
  }
168
192
  }
@@ -188,7 +212,16 @@ function getGdriveConfig(config: {
188
212
  }
189
213
 
190
214
  async function runLogin(): Promise<void> {
191
- const config = await loadConfig();
215
+ let config;
216
+ try {
217
+ config = await loadConfig();
218
+ } catch (error) {
219
+ if (error instanceof ConfigError && error.message.includes("upload")) {
220
+ printUploadConfigHelp();
221
+ process.exit(1);
222
+ }
223
+ throw error;
224
+ }
192
225
 
193
226
  if (!config) {
194
227
  console.error(
@@ -197,19 +230,25 @@ async function runLogin(): Promise<void> {
197
230
  process.exit(1);
198
231
  }
199
232
 
200
- const uploadConfig = getGdriveConfig(config);
201
- if (!uploadConfig) {
202
- console.error("No Google Drive upload configured.");
203
- console.error(
204
- "Add upload config with clientId and clientSecret to your srcpack.config.ts",
205
- );
233
+ if (!config.upload) {
234
+ printUploadConfigHelp();
235
+ process.exit(1);
236
+ }
237
+
238
+ const uploads = Array.isArray(config.upload)
239
+ ? config.upload
240
+ : [config.upload];
241
+ const gdriveConfig = uploads.find((u) => u.provider === "gdrive");
242
+
243
+ if (!gdriveConfig) {
244
+ console.error('No upload config with provider: "gdrive" found.');
206
245
  process.exit(1);
207
246
  }
208
247
 
209
248
  try {
210
249
  console.log("Opening browser for authentication...");
211
- await login(uploadConfig);
212
- console.log("Login successful. Tokens saved to ~/.srcpack/tokens.json");
250
+ await login(gdriveConfig);
251
+ console.log("Login successful.");
213
252
  } catch (error) {
214
253
  if (error instanceof OAuthError) {
215
254
  console.error(`OAuth error: ${error.error}`);
@@ -222,22 +261,59 @@ async function runLogin(): Promise<void> {
222
261
  }
223
262
  }
224
263
 
264
+ function printUploadConfigHelp(): void {
265
+ console.error("Upload configuration incomplete or missing.");
266
+ console.error("Add to srcpack.config.ts:");
267
+ console.error(`
268
+ upload: {
269
+ provider: "gdrive",
270
+ folderId: "...", // optional - Google Drive folder ID
271
+ clientId: "...", // required - OAuth 2.0 client ID
272
+ clientSecret: "...", // required - OAuth 2.0 client secret
273
+ }
274
+ `);
275
+ }
276
+
225
277
  async function handleGdriveUpload(
226
278
  uploadConfig: UploadConfig,
227
- _outputs: BundleOutput[],
279
+ outputs: BundleOutput[],
280
+ cwd: string,
228
281
  ): Promise<void> {
229
282
  try {
230
283
  await ensureAuthenticated(uploadConfig);
231
284
 
232
- // TODO: Upload bundles to Google Drive using tokens
285
+ const uploadSpinner = ora({
286
+ text: `Uploading to Google Drive...`,
287
+ color: "cyan",
288
+ }).start();
289
+
290
+ const results: UploadResult[] = [];
291
+
292
+ for (let i = 0; i < outputs.length; i++) {
293
+ const output = outputs[i]!;
294
+ const filePath = join(cwd, output.outfile);
295
+ uploadSpinner.text = `Uploading ${output.name}... (${i + 1}/${outputs.length})`;
296
+ const result = await uploadFile(filePath, uploadConfig);
297
+ results.push(result);
298
+ }
299
+
300
+ uploadSpinner.stop();
301
+
302
+ // Print upload summary
233
303
  console.log();
234
- console.log("Authenticated with Google Drive.");
235
- if (uploadConfig.folder) {
236
- console.log(`Ready to upload to folder: ${uploadConfig.folder}`);
304
+ const uploadWord = plural(results.length, "file");
305
+ console.log(`Uploaded: ${results.length} ${uploadWord} to Google Drive`);
306
+
307
+ for (const result of results) {
308
+ if (result.webViewLink) {
309
+ console.log(` ${result.name} → ${result.webViewLink}`);
310
+ } else {
311
+ console.log(` ${result.name}`);
312
+ }
237
313
  }
238
314
  } catch (error) {
239
315
  if (error instanceof OAuthError) {
240
- console.error(`OAuth error: ${error.error}`);
316
+ console.error(`\nOAuth error: ${error.error}`);
241
317
  if (error.error_description) {
242
318
  console.error(` ${error.error_description}`);
243
319
  }
package/src/config.ts CHANGED
@@ -29,7 +29,7 @@ const BundleConfigSchema = z.union([
29
29
 
30
30
  const UploadConfigSchema = z.object({
31
31
  provider: z.literal("gdrive"),
32
- folder: z.string().optional(),
32
+ folderId: z.string().optional(),
33
33
  clientId: z.string().min(1),
34
34
  clientSecret: z.string().min(1),
35
35
  });
package/src/gdrive.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
 
3
- import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
3
+ import { createReadStream } from "node:fs";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
6
+ import { dirname, join, basename } from "node:path";
7
+ import { drive as createDrive, type drive_v3 } from "@googleapis/drive";
8
+ import { OAuth2Client } from "google-auth-library";
6
9
  import { getAuthCode, OAuthError } from "oauth-callback";
7
10
  import type { UploadConfig } from "./config.ts";
8
11
 
@@ -10,7 +13,12 @@ const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
10
13
  const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
11
14
  const SCOPES = ["https://www.googleapis.com/auth/drive.file"];
12
15
  const REDIRECT_URI = "http://localhost:3000/callback";
13
- const TOKENS_PATH = join(homedir(), ".srcpack", "tokens.json");
16
+ const CREDENTIALS_PATH = join(
17
+ homedir(),
18
+ ".config",
19
+ "srcpack",
20
+ "credentials.json",
21
+ );
14
22
 
15
23
  export interface Tokens {
16
24
  access_token: string;
@@ -28,35 +36,52 @@ interface TokenResponse {
28
36
  scope: string;
29
37
  }
30
38
 
31
- /**
32
- * Loads stored tokens from disk.
33
- * Returns null if no tokens exist or they cannot be read.
34
- */
35
- export async function loadTokens(): Promise<Tokens | null> {
39
+ // Credentials keyed by provider, then by clientId for multi-destination support
40
+ interface CredentialsFile {
41
+ gdrive?: Record<string, Tokens>;
42
+ }
43
+
44
+ async function readCredentials(): Promise<CredentialsFile> {
36
45
  try {
37
- const data = await readFile(TOKENS_PATH, "utf-8");
38
- return JSON.parse(data) as Tokens;
46
+ const data = await readFile(CREDENTIALS_PATH, "utf-8");
47
+ return JSON.parse(data) as CredentialsFile;
39
48
  } catch {
40
- return null;
49
+ return {};
41
50
  }
42
51
  }
43
52
 
53
+ async function writeCredentials(creds: CredentialsFile): Promise<void> {
54
+ await mkdir(dirname(CREDENTIALS_PATH), { recursive: true });
55
+ await writeFile(CREDENTIALS_PATH, JSON.stringify(creds, null, 2));
56
+ }
57
+
44
58
  /**
45
- * Saves tokens to disk for later use.
59
+ * Loads stored tokens for a specific OAuth client.
60
+ * Returns null if no tokens exist or they cannot be read.
46
61
  */
47
- async function saveTokens(tokens: Tokens): Promise<void> {
48
- await mkdir(dirname(TOKENS_PATH), { recursive: true });
49
- await writeFile(TOKENS_PATH, JSON.stringify(tokens, null, 2));
62
+ export async function loadTokens(config: UploadConfig): Promise<Tokens | null> {
63
+ const creds = await readCredentials();
64
+ return creds.gdrive?.[config.clientId] ?? null;
50
65
  }
51
66
 
52
67
  /**
53
- * Removes stored tokens from disk.
68
+ * Saves tokens for a specific OAuth client.
54
69
  */
55
- export async function clearTokens(): Promise<void> {
56
- try {
57
- await unlink(TOKENS_PATH);
58
- } catch {
59
- // Ignore if file doesn't exist
70
+ async function saveTokens(tokens: Tokens, config: UploadConfig): Promise<void> {
71
+ const creds = await readCredentials();
72
+ creds.gdrive ??= {};
73
+ creds.gdrive[config.clientId] = tokens;
74
+ await writeCredentials(creds);
75
+ }
76
+
77
+ /**
78
+ * Removes stored tokens for a specific OAuth client.
79
+ */
80
+ export async function clearTokens(config: UploadConfig): Promise<void> {
81
+ const creds = await readCredentials();
82
+ if (creds.gdrive?.[config.clientId]) {
83
+ delete creds.gdrive[config.clientId];
84
+ await writeCredentials(creds);
60
85
  }
61
86
  }
62
87
 
@@ -101,7 +126,7 @@ async function refreshAccessToken(
101
126
  scope: data.scope,
102
127
  };
103
128
 
104
- await saveTokens(tokens);
129
+ await saveTokens(tokens, config);
105
130
  return tokens;
106
131
  }
107
132
 
@@ -112,7 +137,7 @@ async function refreshAccessToken(
112
137
  export async function getValidTokens(
113
138
  config: UploadConfig,
114
139
  ): Promise<Tokens | null> {
115
- const tokens = await loadTokens();
140
+ const tokens = await loadTokens(config);
116
141
  if (!tokens) return null;
117
142
 
118
143
  if (isExpired(tokens) && tokens.refresh_token) {
@@ -180,7 +205,7 @@ export async function login(config: UploadConfig): Promise<Tokens> {
180
205
  scope: data.scope,
181
206
  };
182
207
 
183
- await saveTokens(tokens);
208
+ await saveTokens(tokens, config);
184
209
  return tokens;
185
210
  }
186
211
 
@@ -197,4 +222,121 @@ export async function ensureAuthenticated(
197
222
  return login(config);
198
223
  }
199
224
 
225
+ /**
226
+ * Creates an authenticated OAuth2 client from tokens.
227
+ */
228
+ function createAuthClient(tokens: Tokens, config: UploadConfig): OAuth2Client {
229
+ const client = new OAuth2Client(config.clientId, config.clientSecret);
230
+ client.setCredentials({
231
+ access_token: tokens.access_token,
232
+ refresh_token: tokens.refresh_token,
233
+ });
234
+ return client;
235
+ }
236
+
237
+ /**
238
+ * Creates an authenticated Google Drive client.
239
+ */
240
+ function createDriveClient(
241
+ tokens: Tokens,
242
+ config: UploadConfig,
243
+ ): drive_v3.Drive {
244
+ const auth = createAuthClient(tokens, config);
245
+ return createDrive({ version: "v3", auth });
246
+ }
247
+
248
+ /**
249
+ * Finds a file by name in a specific folder (or root).
250
+ * Returns the file ID if found, null otherwise.
251
+ */
252
+ async function findFile(
253
+ drive: drive_v3.Drive,
254
+ name: string,
255
+ folderId?: string,
256
+ ): Promise<string | null> {
257
+ const parent = folderId ?? "root";
258
+ const query = `name = '${name}' and '${parent}' in parents and trashed = false`;
259
+
260
+ const res = await drive.files.list({
261
+ q: query,
262
+ fields: "files(id, name)",
263
+ spaces: "drive",
264
+ });
265
+
266
+ return res.data.files?.[0]?.id ?? null;
267
+ }
268
+
269
+ export interface UploadResult {
270
+ fileId: string;
271
+ name: string;
272
+ webViewLink?: string;
273
+ }
274
+
275
+ /**
276
+ * Uploads a file to Google Drive. Updates existing file if found with same name.
277
+ */
278
+ export async function uploadFile(
279
+ filePath: string,
280
+ config: UploadConfig,
281
+ ): Promise<UploadResult> {
282
+ const tokens = await ensureAuthenticated(config);
283
+ const drive = createDriveClient(tokens, config);
284
+ const fileName = basename(filePath);
285
+
286
+ // Check if file already exists in target folder
287
+ const existingId = await findFile(drive, fileName, config.folderId);
288
+
289
+ const media = {
290
+ mimeType: "text/plain",
291
+ body: createReadStream(filePath),
292
+ };
293
+
294
+ let res: { data: drive_v3.Schema$File };
295
+
296
+ if (existingId) {
297
+ // Update existing file
298
+ res = await drive.files.update({
299
+ fileId: existingId,
300
+ media,
301
+ fields: "id, name, webViewLink",
302
+ });
303
+ } else {
304
+ // Create new file
305
+ const requestBody: drive_v3.Schema$File = {
306
+ name: fileName,
307
+ };
308
+
309
+ if (config.folderId) {
310
+ requestBody.parents = [config.folderId];
311
+ }
312
+
313
+ res = await drive.files.create({
314
+ requestBody,
315
+ media,
316
+ fields: "id, name, webViewLink",
317
+ });
318
+ }
319
+
320
+ return {
321
+ fileId: res.data.id!,
322
+ name: res.data.name!,
323
+ webViewLink: res.data.webViewLink ?? undefined,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Uploads multiple files to Google Drive.
329
+ */
330
+ export async function uploadFiles(
331
+ filePaths: string[],
332
+ config: UploadConfig,
333
+ ): Promise<UploadResult[]> {
334
+ const results: UploadResult[] = [];
335
+ for (const filePath of filePaths) {
336
+ const result = await uploadFile(filePath, config);
337
+ results.push(result);
338
+ }
339
+ return results;
340
+ }
341
+
200
342
  export { OAuthError };