srcpack 0.1.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/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/bundle.d.ts +37 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +189620 -0
- package/dist/config.d.ts +45 -0
- package/dist/gdrive.d.ts +33 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +187079 -0
- package/dist/init.d.ts +1 -0
- package/dist/src/bundle.d.ts +37 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/config.d.ts +45 -0
- package/dist/src/gdrive.d.ts +33 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/init.d.ts +1 -0
- package/dist/tests/bundle.test.d.ts +1 -0
- package/dist/tests/cli.test.d.ts +1 -0
- package/dist/tests/config.test.d.ts +1 -0
- package/package.json +74 -0
- package/src/bundle.ts +237 -0
- package/src/cli.ts +266 -0
- package/src/config.ts +107 -0
- package/src/gdrive.ts +200 -0
- package/src/index.ts +4 -0
- package/src/init.ts +144 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { mkdir } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
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";
|
|
9
|
+
import { runInit } from "./init.ts";
|
|
10
|
+
|
|
11
|
+
interface BundleOutput {
|
|
12
|
+
name: string;
|
|
13
|
+
outfile: string;
|
|
14
|
+
result: BundleResult;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sumLines(result: BundleResult): number {
|
|
18
|
+
return result.index.reduce((sum, entry) => sum + entry.lines, 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatNumber(n: number): string {
|
|
22
|
+
return n.toLocaleString("en-US");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function plural(n: number, singular: string, pluralForm?: string): string {
|
|
26
|
+
return n === 1 ? singular : (pluralForm ?? singular + "s");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function main() {
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
|
|
32
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
33
|
+
console.log(`
|
|
34
|
+
srcpack - Bundle and upload tool
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
npx srcpack Bundle all, upload if configured
|
|
38
|
+
npx srcpack web api Bundle specific bundles only
|
|
39
|
+
npx srcpack --dry-run Bundle without upload (preview)
|
|
40
|
+
npx srcpack --init Interactive config setup
|
|
41
|
+
npx srcpack login Authenticate with Google Drive
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
--dry-run Preview bundles without uploading
|
|
45
|
+
--init Create configuration file
|
|
46
|
+
-h, --help Show this help message
|
|
47
|
+
`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (args.includes("--init")) {
|
|
52
|
+
await runInit();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (args.includes("login")) {
|
|
57
|
+
await runLogin();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dryRun = args.includes("--dry-run");
|
|
62
|
+
const requestedBundles = args.filter((arg) => !arg.startsWith("-"));
|
|
63
|
+
|
|
64
|
+
const config = await loadConfig();
|
|
65
|
+
|
|
66
|
+
if (!config) {
|
|
67
|
+
console.error(
|
|
68
|
+
"No configuration found. Run `npx srcpack --init` to create one.",
|
|
69
|
+
);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Determine which bundles to process
|
|
74
|
+
const bundleNames = requestedBundles.length
|
|
75
|
+
? requestedBundles
|
|
76
|
+
: Object.keys(config.bundles);
|
|
77
|
+
|
|
78
|
+
// Validate requested bundle names exist
|
|
79
|
+
for (const name of bundleNames) {
|
|
80
|
+
if (!(name in config.bundles)) {
|
|
81
|
+
console.error(`Unknown bundle: ${name}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (bundleNames.length === 0) {
|
|
87
|
+
console.log("No bundles configured.");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const cwd = process.cwd();
|
|
92
|
+
const outputs: BundleOutput[] = [];
|
|
93
|
+
|
|
94
|
+
// Process all bundles
|
|
95
|
+
for (const name of bundleNames) {
|
|
96
|
+
const bundleConfig = config.bundles[name]!;
|
|
97
|
+
const result = await bundleOne(name, bundleConfig, cwd);
|
|
98
|
+
const outfile = getOutfile(bundleConfig, name, config.outDir);
|
|
99
|
+
outputs.push({ name, outfile, result });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Calculate column widths for aligned output
|
|
103
|
+
const maxNameLen = Math.max(...outputs.map((o) => o.name.length));
|
|
104
|
+
const maxFilesLen = Math.max(
|
|
105
|
+
...outputs.map((o) => formatNumber(o.result.index.length).length),
|
|
106
|
+
);
|
|
107
|
+
const maxLinesLen = Math.max(
|
|
108
|
+
...outputs.map((o) => formatNumber(sumLines(o.result)).length),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Print each bundle
|
|
112
|
+
console.log();
|
|
113
|
+
for (const { name, outfile, result } of outputs) {
|
|
114
|
+
const fileCount = result.index.length;
|
|
115
|
+
const lineCount = sumLines(result);
|
|
116
|
+
const outPath = join(cwd, outfile);
|
|
117
|
+
|
|
118
|
+
const nameCol = name.padEnd(maxNameLen);
|
|
119
|
+
const filesCol = formatNumber(fileCount).padStart(maxFilesLen);
|
|
120
|
+
const linesCol = formatNumber(lineCount).padStart(maxLinesLen);
|
|
121
|
+
|
|
122
|
+
if (dryRun) {
|
|
123
|
+
console.log(
|
|
124
|
+
` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")}`,
|
|
125
|
+
);
|
|
126
|
+
for (const entry of result.index) {
|
|
127
|
+
console.log(` ${entry.path}`);
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
131
|
+
await Bun.write(outPath, result.content);
|
|
132
|
+
console.log(
|
|
133
|
+
` ${nameCol} ${filesCol} ${plural(fileCount, "file")} ${linesCol} ${plural(lineCount, "line")} → ${outfile}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Print summary
|
|
139
|
+
const totalFiles = outputs.reduce((sum, o) => sum + o.result.index.length, 0);
|
|
140
|
+
const totalLines = outputs.reduce((sum, o) => sum + sumLines(o.result), 0);
|
|
141
|
+
const bundleWord = plural(outputs.length, "bundle");
|
|
142
|
+
const fileWord = plural(totalFiles, "file");
|
|
143
|
+
const lineWord = plural(totalLines, "line");
|
|
144
|
+
|
|
145
|
+
console.log();
|
|
146
|
+
if (dryRun) {
|
|
147
|
+
console.log(
|
|
148
|
+
`Dry run: ${outputs.length} ${bundleWord}, ${formatNumber(totalFiles)} ${fileWord}, ${formatNumber(totalLines)} ${lineWord}`,
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
console.log(
|
|
152
|
+
`Done: ${outputs.length} ${bundleWord}, ${formatNumber(totalFiles)} ${fileWord}, ${formatNumber(totalLines)} ${lineWord}`,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Handle upload if configured
|
|
156
|
+
if (config.upload) {
|
|
157
|
+
const uploads = Array.isArray(config.upload)
|
|
158
|
+
? config.upload
|
|
159
|
+
: [config.upload];
|
|
160
|
+
|
|
161
|
+
for (const uploadConfig of uploads) {
|
|
162
|
+
if (isGdriveConfigured(uploadConfig)) {
|
|
163
|
+
await handleGdriveUpload(uploadConfig, outputs);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isGdriveConfigured(config: UploadConfig): boolean {
|
|
171
|
+
return (
|
|
172
|
+
config.provider === "gdrive" &&
|
|
173
|
+
Boolean(config.clientId) &&
|
|
174
|
+
Boolean(config.clientSecret)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function getGdriveConfig(config: {
|
|
179
|
+
upload?: UploadConfig | UploadConfig[];
|
|
180
|
+
}): UploadConfig | null {
|
|
181
|
+
if (!config.upload) return null;
|
|
182
|
+
const uploads = Array.isArray(config.upload)
|
|
183
|
+
? config.upload
|
|
184
|
+
: [config.upload];
|
|
185
|
+
return uploads.find(isGdriveConfigured) ?? null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function runLogin(): Promise<void> {
|
|
189
|
+
const config = await loadConfig();
|
|
190
|
+
|
|
191
|
+
if (!config) {
|
|
192
|
+
console.error(
|
|
193
|
+
"No configuration found. Run `npx srcpack --init` to create one.",
|
|
194
|
+
);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const uploadConfig = getGdriveConfig(config);
|
|
199
|
+
if (!uploadConfig) {
|
|
200
|
+
console.error("No Google Drive upload configured.");
|
|
201
|
+
console.error(
|
|
202
|
+
"Add upload config with clientId and clientSecret to your srcpack.config.ts",
|
|
203
|
+
);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
console.log("Opening browser for authentication...");
|
|
209
|
+
await login(uploadConfig);
|
|
210
|
+
console.log("Login successful. Tokens saved to ~/.srcpack/tokens.json");
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error instanceof OAuthError) {
|
|
213
|
+
console.error(`OAuth error: ${error.error}`);
|
|
214
|
+
if (error.error_description) {
|
|
215
|
+
console.error(` ${error.error_description}`);
|
|
216
|
+
}
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function handleGdriveUpload(
|
|
224
|
+
uploadConfig: UploadConfig,
|
|
225
|
+
_outputs: BundleOutput[],
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
try {
|
|
228
|
+
await ensureAuthenticated(uploadConfig);
|
|
229
|
+
|
|
230
|
+
// TODO: Upload bundles to Google Drive using tokens
|
|
231
|
+
console.log();
|
|
232
|
+
console.log("Authenticated with Google Drive.");
|
|
233
|
+
if (uploadConfig.folder) {
|
|
234
|
+
console.log(`Ready to upload to folder: ${uploadConfig.folder}`);
|
|
235
|
+
}
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error instanceof OAuthError) {
|
|
238
|
+
console.error(`OAuth error: ${error.error}`);
|
|
239
|
+
if (error.error_description) {
|
|
240
|
+
console.error(` ${error.error_description}`);
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function getOutfile(
|
|
249
|
+
bundleConfig: BundleConfig,
|
|
250
|
+
name: string,
|
|
251
|
+
outDir: string,
|
|
252
|
+
): string {
|
|
253
|
+
if (
|
|
254
|
+
typeof bundleConfig === "object" &&
|
|
255
|
+
!Array.isArray(bundleConfig) &&
|
|
256
|
+
bundleConfig.outfile
|
|
257
|
+
) {
|
|
258
|
+
return bundleConfig.outfile;
|
|
259
|
+
}
|
|
260
|
+
return join(outDir, `${name}.txt`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
main().catch((err) => {
|
|
264
|
+
console.error(err);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { cosmiconfig } from "cosmiconfig";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
export function expandPath(p: string): string {
|
|
9
|
+
if (p.startsWith("~/")) {
|
|
10
|
+
return join(homedir(), p.slice(2));
|
|
11
|
+
}
|
|
12
|
+
return p;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const PatternsSchema = z.union([
|
|
16
|
+
z.string().min(1),
|
|
17
|
+
z.array(z.string().min(1)).min(1),
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const BundleConfigSchema = z.union([
|
|
21
|
+
z.string().min(1), // "src/**/*"
|
|
22
|
+
z.array(z.string().min(1)).min(1), // ["src/**/*", "!src/specs"]
|
|
23
|
+
z.object({
|
|
24
|
+
include: PatternsSchema,
|
|
25
|
+
outfile: z.string().optional(),
|
|
26
|
+
index: z.boolean().default(true), // Include index header in output
|
|
27
|
+
}),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const UploadConfigSchema = z.object({
|
|
31
|
+
provider: z.literal("gdrive"),
|
|
32
|
+
folder: z.string().optional(),
|
|
33
|
+
clientId: z.string().min(1),
|
|
34
|
+
clientSecret: z.string().min(1),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const ConfigSchema = z.object({
|
|
38
|
+
outDir: z.string().default(".srcpack"),
|
|
39
|
+
upload: z
|
|
40
|
+
.union([UploadConfigSchema, z.array(UploadConfigSchema).min(1)])
|
|
41
|
+
.optional(),
|
|
42
|
+
bundles: z.record(z.string(), BundleConfigSchema),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export type UploadConfig = z.infer<typeof UploadConfigSchema>;
|
|
46
|
+
export type BundleConfig = z.infer<typeof BundleConfigSchema>;
|
|
47
|
+
export type BundleConfigInput = z.input<typeof BundleConfigSchema>;
|
|
48
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
49
|
+
export type ConfigInput = z.input<typeof ConfigSchema>;
|
|
50
|
+
|
|
51
|
+
export function defineConfig(config: ConfigInput): ConfigInput {
|
|
52
|
+
return config;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class ConfigError extends Error {
|
|
56
|
+
constructor(message: string) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "ConfigError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseConfig(value: unknown): Config {
|
|
63
|
+
const result = ConfigSchema.safeParse(value);
|
|
64
|
+
if (!result.success) {
|
|
65
|
+
const issue = result.error.issues[0]!;
|
|
66
|
+
const path = issue.path.join(".");
|
|
67
|
+
const message = path ? `${path}: ${issue.message}` : issue.message;
|
|
68
|
+
throw new ConfigError(message);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const config = result.data;
|
|
72
|
+
config.outDir = expandPath(config.outDir);
|
|
73
|
+
|
|
74
|
+
for (const bundle of Object.values(config.bundles)) {
|
|
75
|
+
if (
|
|
76
|
+
typeof bundle === "object" &&
|
|
77
|
+
!Array.isArray(bundle) &&
|
|
78
|
+
bundle.outfile
|
|
79
|
+
) {
|
|
80
|
+
bundle.outfile = expandPath(bundle.outfile);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return config;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const explorer = cosmiconfig("srcpack", {
|
|
88
|
+
searchPlaces: [
|
|
89
|
+
"srcpack.config.ts", // Primary: full TypeScript support with Bun
|
|
90
|
+
"srcpack.config.js", // Fallback for JS-only projects
|
|
91
|
+
"package.json", // Zero-file option via "srcpack" field
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export async function loadConfig(searchFrom?: string): Promise<Config | null> {
|
|
96
|
+
const result = await explorer.search(searchFrom);
|
|
97
|
+
if (!result) return null;
|
|
98
|
+
return parseConfig(result.config);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function loadConfigFromFile(
|
|
102
|
+
filepath: string,
|
|
103
|
+
): Promise<Config | null> {
|
|
104
|
+
const result = await explorer.load(filepath);
|
|
105
|
+
if (!result) return null;
|
|
106
|
+
return parseConfig(result.config);
|
|
107
|
+
}
|
package/src/gdrive.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { getAuthCode, OAuthError } from "oauth-callback";
|
|
7
|
+
import type { UploadConfig } from "./config.ts";
|
|
8
|
+
|
|
9
|
+
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
10
|
+
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
11
|
+
const SCOPES = ["https://www.googleapis.com/auth/drive.file"];
|
|
12
|
+
const REDIRECT_URI = "http://localhost:3000/callback";
|
|
13
|
+
const TOKENS_PATH = join(homedir(), ".srcpack", "tokens.json");
|
|
14
|
+
|
|
15
|
+
export interface Tokens {
|
|
16
|
+
access_token: string;
|
|
17
|
+
refresh_token?: string;
|
|
18
|
+
expires_at?: number; // Unix timestamp in ms
|
|
19
|
+
token_type: string;
|
|
20
|
+
scope: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TokenResponse {
|
|
24
|
+
access_token: string;
|
|
25
|
+
refresh_token?: string;
|
|
26
|
+
expires_in: number;
|
|
27
|
+
token_type: string;
|
|
28
|
+
scope: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
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> {
|
|
36
|
+
try {
|
|
37
|
+
const data = await readFile(TOKENS_PATH, "utf-8");
|
|
38
|
+
return JSON.parse(data) as Tokens;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Saves tokens to disk for later use.
|
|
46
|
+
*/
|
|
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));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Removes stored tokens from disk.
|
|
54
|
+
*/
|
|
55
|
+
export async function clearTokens(): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
await unlink(TOKENS_PATH);
|
|
58
|
+
} catch {
|
|
59
|
+
// Ignore if file doesn't exist
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Checks if tokens are expired or about to expire (within 5 minutes).
|
|
65
|
+
*/
|
|
66
|
+
function isExpired(tokens: Tokens): boolean {
|
|
67
|
+
if (!tokens.expires_at) return false;
|
|
68
|
+
const buffer = 5 * 60 * 1000; // 5 minutes
|
|
69
|
+
return Date.now() >= tokens.expires_at - buffer;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Refreshes an expired access token using the refresh token.
|
|
74
|
+
*/
|
|
75
|
+
async function refreshAccessToken(
|
|
76
|
+
refreshToken: string,
|
|
77
|
+
config: UploadConfig,
|
|
78
|
+
): Promise<Tokens> {
|
|
79
|
+
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
82
|
+
body: new URLSearchParams({
|
|
83
|
+
refresh_token: refreshToken,
|
|
84
|
+
client_id: config.clientId,
|
|
85
|
+
client_secret: config.clientSecret,
|
|
86
|
+
grant_type: "refresh_token",
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
const error = await response.text();
|
|
92
|
+
throw new Error(`Token refresh failed: ${error}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const data = (await response.json()) as TokenResponse;
|
|
96
|
+
const tokens: Tokens = {
|
|
97
|
+
access_token: data.access_token,
|
|
98
|
+
refresh_token: refreshToken, // Keep existing refresh token
|
|
99
|
+
expires_at: Date.now() + data.expires_in * 1000,
|
|
100
|
+
token_type: data.token_type,
|
|
101
|
+
scope: data.scope,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
await saveTokens(tokens);
|
|
105
|
+
return tokens;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Gets valid tokens, refreshing if necessary.
|
|
110
|
+
* Returns null if no tokens exist or refresh fails.
|
|
111
|
+
*/
|
|
112
|
+
export async function getValidTokens(
|
|
113
|
+
config: UploadConfig,
|
|
114
|
+
): Promise<Tokens | null> {
|
|
115
|
+
const tokens = await loadTokens();
|
|
116
|
+
if (!tokens) return null;
|
|
117
|
+
|
|
118
|
+
if (isExpired(tokens) && tokens.refresh_token) {
|
|
119
|
+
try {
|
|
120
|
+
return await refreshAccessToken(tokens.refresh_token, config);
|
|
121
|
+
} catch {
|
|
122
|
+
return null; // Refresh failed, need to re-login
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return tokens;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Performs OAuth login flow - opens browser for user consent.
|
|
131
|
+
* Stores tokens on success for future use.
|
|
132
|
+
*/
|
|
133
|
+
export async function login(config: UploadConfig): Promise<Tokens> {
|
|
134
|
+
const authUrl =
|
|
135
|
+
GOOGLE_AUTH_URL +
|
|
136
|
+
"?" +
|
|
137
|
+
new URLSearchParams({
|
|
138
|
+
client_id: config.clientId,
|
|
139
|
+
redirect_uri: REDIRECT_URI,
|
|
140
|
+
response_type: "code",
|
|
141
|
+
scope: SCOPES.join(" "),
|
|
142
|
+
access_type: "offline",
|
|
143
|
+
prompt: "consent",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const result = await getAuthCode({
|
|
147
|
+
authorizationUrl: authUrl,
|
|
148
|
+
port: 3000,
|
|
149
|
+
timeout: 300000, // 5 minutes
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!result.code) {
|
|
153
|
+
throw new Error("No authorization code received");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Exchange code for tokens
|
|
157
|
+
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
160
|
+
body: new URLSearchParams({
|
|
161
|
+
code: result.code,
|
|
162
|
+
client_id: config.clientId,
|
|
163
|
+
client_secret: config.clientSecret,
|
|
164
|
+
redirect_uri: REDIRECT_URI,
|
|
165
|
+
grant_type: "authorization_code",
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
const error = await response.text();
|
|
171
|
+
throw new Error(`Token exchange failed: ${error}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const data = (await response.json()) as TokenResponse;
|
|
175
|
+
const tokens: Tokens = {
|
|
176
|
+
access_token: data.access_token,
|
|
177
|
+
refresh_token: data.refresh_token,
|
|
178
|
+
expires_at: Date.now() + data.expires_in * 1000,
|
|
179
|
+
token_type: data.token_type,
|
|
180
|
+
scope: data.scope,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
await saveTokens(tokens);
|
|
184
|
+
return tokens;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Ensures we have valid tokens - loads existing or triggers login.
|
|
189
|
+
*/
|
|
190
|
+
export async function ensureAuthenticated(
|
|
191
|
+
config: UploadConfig,
|
|
192
|
+
): Promise<Tokens> {
|
|
193
|
+
const tokens = await getValidTokens(config);
|
|
194
|
+
if (tokens) return tokens;
|
|
195
|
+
|
|
196
|
+
console.log("Authentication required. Opening browser...");
|
|
197
|
+
return login(config);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export { OAuthError };
|
package/src/index.ts
ADDED