@seanmozeik/s3up 0.3.1

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.
@@ -0,0 +1,350 @@
1
+ // src/commands/upload.ts
2
+
3
+ import path from 'node:path';
4
+ import * as p from '@clack/prompts';
5
+ import boxen from 'boxen';
6
+
7
+ import { createArchive, isDirectory } from '../lib/archive';
8
+ import { type GlobalFlags, parseUploadFlags } from '../lib/flags';
9
+ import {
10
+ checkResumableUpload,
11
+ cleanupExistingUpload,
12
+ type MultipartOptions,
13
+ uploadMultipart
14
+ } from '../lib/multipart';
15
+ import { formatBytes, formatUploadError, formatUploadSuccess } from '../lib/output';
16
+ import { loadConfig, PROVIDERS, type S3Config } from '../lib/providers';
17
+ import {
18
+ renderProgress,
19
+ runWithConcurrency,
20
+ type UploadError,
21
+ type UploadOutcome,
22
+ type UploadProgress,
23
+ type UploadResult,
24
+ uploadFile
25
+ } from '../lib/upload';
26
+ import { showBanner } from '../ui/banner';
27
+ import { boxColors, frappe, theme } from '../ui/theme';
28
+
29
+ const MULTIPART_THRESHOLD = 100 * 1024 * 1024; // 100MB
30
+
31
+ const SPEED_PRESETS = {
32
+ default: { chunkSize: 25 * 1024 * 1024, connections: 8 },
33
+ fast: { chunkSize: 5 * 1024 * 1024, connections: 16 },
34
+ slow: { chunkSize: 50 * 1024 * 1024, connections: 4 }
35
+ } as const;
36
+
37
+ interface UploadOptions {
38
+ chunkSize: number;
39
+ connections: number;
40
+ forceFast?: boolean;
41
+ }
42
+
43
+ function getUploadOptions(flags: { fast: boolean; slow: boolean }): UploadOptions {
44
+ if (flags.fast) return { ...SPEED_PRESETS.fast, forceFast: true };
45
+ if (flags.slow) return SPEED_PRESETS.slow;
46
+ return SPEED_PRESETS.default;
47
+ }
48
+
49
+ function displayResults(results: UploadOutcome[], quiet: boolean): void {
50
+ if (quiet) {
51
+ for (const r of results) {
52
+ if (r.success) {
53
+ console.log(formatUploadSuccess(r.filename, r.publicUrl, r.size, true));
54
+ } else {
55
+ console.error(formatUploadError(r.filename, r.error));
56
+ }
57
+ }
58
+ return;
59
+ }
60
+
61
+ const successes = results.filter((r): r is UploadResult => r.success);
62
+ const failures = results.filter((r): r is UploadError => !r.success);
63
+
64
+ if (successes.length > 0) {
65
+ const content = successes
66
+ .map(
67
+ (r) =>
68
+ `${theme.success('✓')} ${frappe.text(r.filename)} ${theme.dim(`(${formatBytes(r.size)})`)}\n ${theme.link(r.publicUrl)}`
69
+ )
70
+ .join('\n\n');
71
+
72
+ const box = boxen(content, {
73
+ borderColor: boxColors.success,
74
+ borderStyle: 'round',
75
+ padding: { bottom: 0, left: 1, right: 1, top: 0 },
76
+ title: `Uploaded ${successes.length} file${successes.length > 1 ? 's' : ''}`,
77
+ titleAlignment: 'left'
78
+ });
79
+ console.log(box);
80
+ }
81
+
82
+ if (failures.length > 0) {
83
+ for (const f of failures) {
84
+ p.log.error(`${f.filename}: ${f.error}`);
85
+ }
86
+ }
87
+ }
88
+
89
+ export async function upload(args: string[], globalFlags: GlobalFlags): Promise<void> {
90
+ const flags = parseUploadFlags(args);
91
+ const uploadOpts = getUploadOptions(flags);
92
+
93
+ // Load config first
94
+ const config = await loadConfig();
95
+ if (!config) {
96
+ if (globalFlags.ci) {
97
+ console.error('Error: S3UP_CONFIG not set and --ci prevents interactive setup');
98
+ process.exit(2);
99
+ }
100
+ if (!globalFlags.quiet) await showBanner();
101
+ p.log.error('S3 not configured. Run: s3up setup');
102
+ process.exit(2);
103
+ }
104
+
105
+ const providerInfo = PROVIDERS[config.provider];
106
+
107
+ // Separate files and directories
108
+ const files: { path: string; name: string; size: number; blob?: Blob }[] = [];
109
+ const directories: string[] = [];
110
+ const invalidPaths: string[] = [];
111
+
112
+ for (const filePath of flags.paths) {
113
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath);
114
+
115
+ if (await isDirectory(absolutePath)) {
116
+ directories.push(absolutePath);
117
+ } else {
118
+ const file = Bun.file(absolutePath);
119
+ const exists = await file.exists();
120
+ if (exists) {
121
+ files.push({
122
+ name: path.basename(absolutePath),
123
+ path: absolutePath,
124
+ size: file.size
125
+ });
126
+ } else {
127
+ invalidPaths.push(filePath);
128
+ }
129
+ }
130
+ }
131
+
132
+ // Create tarball from directories if any
133
+ if (directories.length > 0) {
134
+ const archive = await createArchive(directories, {
135
+ compression: flags.compression,
136
+ name: flags.as
137
+ });
138
+
139
+ // Add archive as a "file" to upload
140
+ files.push({
141
+ blob: archive.blob,
142
+ name: archive.name,
143
+ path: '', // Will use blob directly
144
+ size: archive.size
145
+ });
146
+ }
147
+
148
+ if (!globalFlags.quiet) await showBanner();
149
+
150
+ // Report invalid paths
151
+ if (invalidPaths.length > 0 && !globalFlags.quiet) {
152
+ for (const f of invalidPaths) {
153
+ p.log.warn(`Path not found: ${f}`);
154
+ }
155
+ }
156
+
157
+ if (files.length === 0) {
158
+ if (globalFlags.quiet) {
159
+ console.error('Error: No valid files to upload');
160
+ } else {
161
+ p.outro(theme.error('No valid files to upload'));
162
+ }
163
+ process.exit(1);
164
+ }
165
+
166
+ // Apply prefix to keys
167
+ const getKey = (filename: string): string => {
168
+ if (flags.prefix) {
169
+ const normalizedPrefix = flags.prefix.replace(/^\/+|\/+$/g, '');
170
+ return `${normalizedPrefix}/${filename}`;
171
+ }
172
+ return filename;
173
+ };
174
+
175
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
176
+
177
+ if (!globalFlags.quiet) {
178
+ p.intro(
179
+ frappe.text(
180
+ `Uploading ${files.length} file${files.length > 1 ? 's' : ''} to ${providerInfo.name} (${formatBytes(totalSize)})`
181
+ )
182
+ );
183
+ }
184
+
185
+ const results: UploadOutcome[] = [];
186
+
187
+ // Separate large and small files
188
+ const threshold = uploadOpts.forceFast ? uploadOpts.chunkSize : MULTIPART_THRESHOLD;
189
+ const largeFiles = files.filter((f) => f.size >= threshold && !f.blob);
190
+ const smallFiles = files.filter((f) => f.size < threshold || f.blob);
191
+
192
+ // Handle large files (one at a time for progress)
193
+ for (const file of largeFiles) {
194
+ // Check for resumable upload
195
+ const { canResume, percentComplete } = await checkResumableUpload(file.path);
196
+
197
+ if (canResume && !globalFlags.ci) {
198
+ const resume = await p.confirm({
199
+ message: `Resume incomplete upload of ${file.name}? (${percentComplete}% done)`
200
+ });
201
+
202
+ if (p.isCancel(resume)) {
203
+ if (!globalFlags.quiet) p.outro(frappe.subtext1('Cancelled'));
204
+ process.exit(0);
205
+ }
206
+
207
+ if (!resume) {
208
+ await cleanupExistingUpload(file.path, config);
209
+ }
210
+ } else if (canResume && globalFlags.ci) {
211
+ // In CI mode, always resume
212
+ }
213
+
214
+ if (!globalFlags.quiet) {
215
+ console.log(frappe.text(`\nUploading ${file.name} (${formatBytes(file.size)})...`));
216
+ }
217
+
218
+ const multipartOptions: MultipartOptions = {
219
+ chunkSize: uploadOpts.chunkSize,
220
+ connections: uploadOpts.connections
221
+ };
222
+
223
+ const key = getKey(file.name);
224
+ const result = await uploadMultipart(file.path, config, key, multipartOptions);
225
+
226
+ if (result.success) {
227
+ results.push({
228
+ filename: file.name,
229
+ publicUrl: result.publicUrl,
230
+ size: file.size,
231
+ success: true
232
+ });
233
+ } else {
234
+ results.push({
235
+ error: result.error,
236
+ filename: file.name,
237
+ success: false
238
+ });
239
+ }
240
+ }
241
+
242
+ // Handle small files (concurrent)
243
+ if (smallFiles.length > 0) {
244
+ const progress: UploadProgress = {
245
+ activeFiles: new Set(),
246
+ completed: 0,
247
+ total: smallFiles.length
248
+ };
249
+
250
+ let spinner: ReturnType<typeof p.spinner> | undefined;
251
+ if (!globalFlags.quiet) {
252
+ spinner = p.spinner();
253
+ spinner.start(renderProgress(progress));
254
+ }
255
+
256
+ const smallResults = await runWithConcurrency(
257
+ smallFiles,
258
+ uploadOpts.connections,
259
+ async (file) => {
260
+ progress.activeFiles.add(file.name);
261
+ spinner?.message(renderProgress(progress));
262
+
263
+ const key = getKey(file.name);
264
+
265
+ // Handle blob uploads (from archives)
266
+ let result: UploadOutcome;
267
+ if (file.blob) {
268
+ result = await uploadBlob(file.blob, key, file.name, config);
269
+ } else {
270
+ result = await uploadFile({ ...file, name: key }, config);
271
+ }
272
+
273
+ progress.activeFiles.delete(file.name);
274
+ progress.completed++;
275
+ spinner?.message(renderProgress(progress));
276
+
277
+ // Fix the filename in result
278
+ if (result.success) {
279
+ return { ...result, filename: file.name };
280
+ }
281
+ return { ...result, filename: file.name };
282
+ }
283
+ );
284
+
285
+ spinner?.stop(theme.success(`Uploaded ${progress.completed}/${progress.total} files`));
286
+ results.push(...smallResults);
287
+ }
288
+
289
+ if (!globalFlags.quiet) console.log();
290
+ displayResults(results, globalFlags.quiet);
291
+
292
+ const successCount = results.filter((r) => r.success).length;
293
+ const failCount = results.filter((r) => !r.success).length;
294
+
295
+ if (failCount === 0) {
296
+ if (!globalFlags.quiet) p.outro(theme.success('All files uploaded successfully!'));
297
+ process.exit(0);
298
+ } else if (successCount > 0) {
299
+ if (!globalFlags.quiet) p.outro(theme.warning(`${successCount} uploaded, ${failCount} failed`));
300
+ process.exit(4);
301
+ } else {
302
+ if (!globalFlags.quiet) p.outro(theme.error('All uploads failed'));
303
+ process.exit(1);
304
+ }
305
+ }
306
+
307
+ // Helper to upload a blob (for archives)
308
+ async function uploadBlob(
309
+ blob: Blob,
310
+ key: string,
311
+ filename: string,
312
+ config: S3Config
313
+ ): Promise<UploadOutcome> {
314
+ try {
315
+ const { getEndpoint } = await import('../lib/providers');
316
+ const endpoint = getEndpoint(config);
317
+
318
+ const response = await fetch(`s3://${config.bucket}/${key}`, {
319
+ body: blob.stream(),
320
+ headers: {
321
+ 'Content-Disposition': 'attachment'
322
+ },
323
+ method: 'PUT',
324
+ s3: {
325
+ accessKeyId: config.accessKeyId,
326
+ endpoint,
327
+ secretAccessKey: config.secretAccessKey
328
+ }
329
+ });
330
+
331
+ if (!response.ok) {
332
+ throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
333
+ }
334
+
335
+ const publicUrl = `${config.publicUrlBase}/${key}`;
336
+
337
+ return {
338
+ filename,
339
+ publicUrl,
340
+ size: blob.size,
341
+ success: true
342
+ };
343
+ } catch (err) {
344
+ return {
345
+ error: err instanceof Error ? err.message : String(err),
346
+ filename,
347
+ success: false
348
+ };
349
+ }
350
+ }
@@ -0,0 +1,51 @@
1
+ // src/index.test.ts
2
+ import { describe, expect, test } from 'bun:test';
3
+ import { $ } from 'bun';
4
+
5
+ describe('s3up CLI', () => {
6
+ test('--version shows version', async () => {
7
+ const result = await $`bun run src/index.ts --version`.text();
8
+ expect(result).toMatch(/^s3up v\d+\.\d+\.\d+/);
9
+ });
10
+
11
+ test('--help shows help', async () => {
12
+ const result = await $`bun run src/index.ts --help`.text();
13
+ expect(result).toContain('Usage:');
14
+ expect(result).toContain('upload');
15
+ expect(result).toContain('list');
16
+ expect(result).toContain('prune');
17
+ });
18
+
19
+ test('-h shows help', async () => {
20
+ const result = await $`bun run src/index.ts -h`.text();
21
+ expect(result).toContain('Usage:');
22
+ });
23
+
24
+ test('-v shows version', async () => {
25
+ const result = await $`bun run src/index.ts -v`.text();
26
+ expect(result).toMatch(/^s3up v\d+\.\d+\.\d+/);
27
+ });
28
+
29
+ test('no args shows help', async () => {
30
+ const result = await $`bun run src/index.ts`.text();
31
+ expect(result).toContain('Usage:');
32
+ });
33
+ });
34
+
35
+ describe('s3up upload validation', () => {
36
+ test('exits with error when file not found', async () => {
37
+ const proc = $`bun run src/index.ts upload nonexistent-file-12345.txt --ci`.nothrow();
38
+ const result = await proc;
39
+ // Should fail with exit code 1 (no valid files)
40
+ expect(result.exitCode).toBe(1);
41
+ });
42
+ });
43
+
44
+ describe('s3up prune validation', () => {
45
+ test('requires at least one filter', async () => {
46
+ const proc = $`bun run src/index.ts prune backups/`.nothrow();
47
+ const result = await proc;
48
+ expect(result.exitCode).toBe(1);
49
+ expect(result.stderr.toString()).toContain('--older-than or --keep-last');
50
+ });
51
+ });
package/src/index.ts ADDED
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import * as p from '@clack/prompts';
4
+ import pkg from '../package.json' with { type: 'json' };
5
+ import { list } from './commands/list';
6
+ import { prune } from './commands/prune';
7
+ import { upload } from './commands/upload';
8
+ import { parseGlobalFlags } from './lib/flags';
9
+ import { deleteConfig } from './lib/providers';
10
+ import { showBanner } from './ui/banner';
11
+ import { setup } from './ui/setup';
12
+ import { frappe, theme } from './ui/theme';
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // Teardown Command
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ async function teardown(): Promise<void> {
19
+ await showBanner();
20
+ p.intro(frappe.text('Remove stored credentials'));
21
+
22
+ const confirm = await p.confirm({
23
+ message: 'Remove all stored S3 credentials?'
24
+ });
25
+
26
+ if (p.isCancel(confirm) || !confirm) {
27
+ p.outro(frappe.subtext1('Cancelled'));
28
+ process.exit(0);
29
+ }
30
+
31
+ const s = p.spinner();
32
+ s.start('Removing credentials...');
33
+
34
+ try {
35
+ const removed = await deleteConfig();
36
+ s.stop(theme.success(`Removed ${removed} credential(s)`));
37
+ p.outro(frappe.subtext1('Done'));
38
+ } catch (err) {
39
+ s.stop(theme.error('Failed to remove credentials'));
40
+ p.log.error(err instanceof Error ? err.message : String(err));
41
+ process.exit(1);
42
+ }
43
+ }
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // Help
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ async function showHelp(): Promise<void> {
50
+ await showBanner();
51
+ console.log(frappe.text('Usage:'));
52
+ console.log(
53
+ ` ${theme.accent('s3up')} ${theme.dim('[options]')} ${frappe.text('<files...>')} Upload files`
54
+ );
55
+ console.log(
56
+ ` ${theme.accent('s3up upload')} ${theme.dim('[options]')} ${frappe.text('<files...>')} Upload files`
57
+ );
58
+ console.log(
59
+ ` ${theme.accent('s3up list')} ${theme.dim('[prefix]')} List bucket objects`
60
+ );
61
+ console.log(
62
+ ` ${theme.accent('s3up prune')} ${frappe.text('<prefix>')} ${theme.dim('[options]')} Delete old objects`
63
+ );
64
+ console.log(` ${theme.accent('s3up setup')} Configure credentials`);
65
+ console.log(` ${theme.accent('s3up teardown')} Remove credentials`);
66
+ console.log();
67
+ console.log(frappe.text('Global options:'));
68
+ console.log(` ${theme.accent('-q, --quiet')} Minimal output for scripting`);
69
+ console.log(` ${theme.accent('--ci')} Non-interactive mode`);
70
+ console.log(` ${theme.accent('-h, --help')} Show this help message`);
71
+ console.log(` ${theme.accent('-v, --version')} Show version number`);
72
+ console.log();
73
+ console.log(frappe.text('Upload options:'));
74
+ console.log(` ${theme.accent('--prefix <path>')} Prepend path to uploaded keys`);
75
+ console.log(` ${theme.accent('--as <name>')} Override tarball filename`);
76
+ console.log(` ${theme.accent('--compression <1-12>')} Gzip level (default: 6)`);
77
+ console.log(
78
+ ` ${theme.accent('-f, --fast')} Fast network (5MB chunks, 16 connections)`
79
+ );
80
+ console.log(
81
+ ` ${theme.accent('-s, --slow')} Slow network (50MB chunks, 4 connections)`
82
+ );
83
+ console.log();
84
+ console.log(frappe.text('List options:'));
85
+ console.log(` ${theme.accent('--json')} Output as JSON (one object per line)`);
86
+ console.log();
87
+ console.log(frappe.text('Prune options:'));
88
+ console.log(` ${theme.accent('--older-than <days>')} Delete objects older than N days`);
89
+ console.log(` ${theme.accent('--keep-last <n>')} Keep only N most recent objects`);
90
+ console.log(` ${theme.accent('--min-age <duration>')} Minimum age to delete (default: 1d)`);
91
+ console.log(` ${theme.accent('--dry-run')} Show what would be deleted`);
92
+ console.log();
93
+ console.log(frappe.text('Examples:'));
94
+ console.log(` ${theme.dim('s3up image.png')} Upload single file`);
95
+ console.log(` ${theme.dim('s3up *.png')} Upload multiple files`);
96
+ console.log(
97
+ ` ${theme.dim('s3up ./workspace --prefix backups')} Upload directory as tarball`
98
+ );
99
+ console.log(` ${theme.dim('s3up list backups/')} List objects with prefix`);
100
+ console.log(` ${theme.dim('s3up prune backups/ --keep-last 7')} Keep last 7 backups`);
101
+ console.log();
102
+ console.log(frappe.subtext0('Files ≥100MB automatically use chunked parallel upload.'));
103
+ console.log(frappe.subtext0('Directories are automatically archived as .tar.gz files.'));
104
+ console.log();
105
+ }
106
+
107
+ // ─────────────────────────────────────────────────────────────────────────────
108
+ // Main
109
+ // ─────────────────────────────────────────────────────────────────────────────
110
+
111
+ async function main() {
112
+ const args = Bun.argv.slice(2);
113
+ const globalFlags = parseGlobalFlags(args);
114
+
115
+ // Handle --version anywhere
116
+ if (globalFlags.version) {
117
+ console.log(`s3up v${pkg.version}`);
118
+ process.exit(0);
119
+ }
120
+
121
+ // Handle --help anywhere
122
+ if (globalFlags.help) {
123
+ await showHelp();
124
+ process.exit(0);
125
+ }
126
+
127
+ const command = globalFlags.args[0];
128
+ const commandArgs = globalFlags.args.slice(1);
129
+
130
+ switch (command) {
131
+ case 'setup':
132
+ await setup();
133
+ break;
134
+
135
+ case 'teardown':
136
+ await teardown();
137
+ break;
138
+
139
+ case 'upload':
140
+ await upload(commandArgs, globalFlags);
141
+ break;
142
+
143
+ case 'list':
144
+ await list(commandArgs, globalFlags);
145
+ break;
146
+
147
+ case 'prune':
148
+ await prune(commandArgs, globalFlags);
149
+ break;
150
+
151
+ case undefined:
152
+ await showHelp();
153
+ break;
154
+
155
+ default:
156
+ // Treat as file paths for upload (backwards compatible)
157
+ await upload([command, ...commandArgs], globalFlags);
158
+ break;
159
+ }
160
+ }
161
+
162
+ main().catch((err) => {
163
+ console.error(theme.error(err instanceof Error ? err.message : String(err)));
164
+ process.exit(1);
165
+ });
@@ -0,0 +1,65 @@
1
+ // src/lib/archive.test.ts
2
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
3
+ import { mkdir, rm } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { createArchive, generateArchiveName } from './archive';
6
+
7
+ describe('generateArchiveName', () => {
8
+ test('generates name from single directory', () => {
9
+ const name = generateArchiveName(['/path/to/workspace']);
10
+ expect(name).toMatch(/^workspace-\d{4}-\d{2}-\d{2}\.tar\.gz$/);
11
+ });
12
+
13
+ test('generates name from multiple directories using first', () => {
14
+ const name = generateArchiveName(['/path/to/workspace', '/path/to/config']);
15
+ expect(name).toMatch(/^workspace-\d{4}-\d{2}-\d{2}\.tar\.gz$/);
16
+ });
17
+
18
+ test('uses override name when provided', () => {
19
+ const name = generateArchiveName(['/path/to/workspace'], 'backup.tar.gz');
20
+ expect(name).toBe('backup.tar.gz');
21
+ });
22
+
23
+ test('adds .tar.gz if missing from override', () => {
24
+ const name = generateArchiveName(['/path/to/workspace'], 'backup');
25
+ expect(name).toBe('backup.tar.gz');
26
+ });
27
+ });
28
+
29
+ describe('createArchive', () => {
30
+ const testDir = '/tmp/s3up-archive-test';
31
+ const subDir1 = path.join(testDir, 'workspace');
32
+ const subDir2 = path.join(testDir, 'config');
33
+
34
+ beforeAll(async () => {
35
+ await mkdir(subDir1, { recursive: true });
36
+ await mkdir(subDir2, { recursive: true });
37
+ await Bun.write(path.join(subDir1, 'file1.txt'), 'content1');
38
+ await Bun.write(path.join(subDir2, 'file2.txt'), 'content2');
39
+ });
40
+
41
+ afterAll(async () => {
42
+ await rm(testDir, { force: true, recursive: true });
43
+ });
44
+
45
+ test('creates archive from single directory', async () => {
46
+ const result = await createArchive([subDir1], { compression: 6 });
47
+ expect(result.blob).toBeDefined();
48
+ expect(result.blob.size).toBeGreaterThan(0);
49
+ expect(result.name).toMatch(/^workspace-\d{4}-\d{2}-\d{2}\.tar\.gz$/);
50
+ });
51
+
52
+ test('creates archive from multiple directories', async () => {
53
+ const result = await createArchive([subDir1, subDir2], { compression: 6 });
54
+ expect(result.blob).toBeDefined();
55
+ expect(result.blob.size).toBeGreaterThan(0);
56
+ });
57
+
58
+ test('uses custom name', async () => {
59
+ const result = await createArchive([subDir1], {
60
+ compression: 6,
61
+ name: 'custom-backup.tar.gz'
62
+ });
63
+ expect(result.name).toBe('custom-backup.tar.gz');
64
+ });
65
+ });