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/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
@@ -0,0 +1,4 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ export { defineConfig, loadConfig, loadConfigFromFile } from "./config.ts";
4
+ export type { Config, BundleConfig } from "./config.ts";