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/README.md +28 -25
- package/dist/bundle.d.ts +3 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +51027 -17210
- package/dist/config.d.ts +3 -3
- package/dist/gdrive.d.ts +17 -4
- package/dist/index.js +1515 -1514
- package/package.json +16 -5
- package/src/bundle.ts +52 -25
- package/src/cli.ts +105 -29
- package/src/config.ts +1 -1
- package/src/gdrive.ts +166 -24
- package/src/init.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "srcpack",
|
|
3
|
-
"version": "0.1.
|
|
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
|
|
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 {
|
|
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
|
|
13
|
-
|
|
14
|
-
if (size === 0) return false;
|
|
14
|
+
const stats = await stat(filePath);
|
|
15
|
+
if (stats.size === 0) return false;
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
-
|
|
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
|
|
82
|
+
* Check if a path matches any of the exclusion matchers
|
|
67
83
|
*/
|
|
68
|
-
function isExcluded(filePath: string,
|
|
69
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
|
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 {
|
|
8
|
-
|
|
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
|
|
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
|
|
45
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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(
|
|
212
|
-
console.log("Login successful.
|
|
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
|
-
|
|
279
|
+
outputs: BundleOutput[],
|
|
280
|
+
cwd: string,
|
|
228
281
|
): Promise<void> {
|
|
229
282
|
try {
|
|
230
283
|
await ensureAuthenticated(uploadConfig);
|
|
231
284
|
|
|
232
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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(
|
|
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
package/src/gdrive.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
38
|
-
return JSON.parse(data) as
|
|
46
|
+
const data = await readFile(CREDENTIALS_PATH, "utf-8");
|
|
47
|
+
return JSON.parse(data) as CredentialsFile;
|
|
39
48
|
} catch {
|
|
40
|
-
return
|
|
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
|
-
*
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
*
|
|
68
|
+
* Saves tokens for a specific OAuth client.
|
|
54
69
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 };
|