@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.
- package/README.md +204 -0
- package/package.json +55 -0
- package/src/commands/list.test.ts +31 -0
- package/src/commands/list.ts +58 -0
- package/src/commands/prune.test.ts +83 -0
- package/src/commands/prune.ts +159 -0
- package/src/commands/upload.ts +350 -0
- package/src/index.test.ts +51 -0
- package/src/index.ts +165 -0
- package/src/lib/archive.test.ts +65 -0
- package/src/lib/archive.ts +89 -0
- package/src/lib/clipboard.ts +41 -0
- package/src/lib/flags.test.ts +72 -0
- package/src/lib/flags.ts +129 -0
- package/src/lib/multipart.ts +507 -0
- package/src/lib/output.test.ts +74 -0
- package/src/lib/output.ts +63 -0
- package/src/lib/progress-bar.ts +155 -0
- package/src/lib/providers.ts +150 -0
- package/src/lib/s3.test.ts +42 -0
- package/src/lib/s3.ts +124 -0
- package/src/lib/secrets.ts +81 -0
- package/src/lib/signing.ts +151 -0
- package/src/lib/state.ts +145 -0
- package/src/lib/upload.ts +120 -0
- package/src/ui/banner.ts +47 -0
- package/src/ui/setup.ts +161 -0
- package/src/ui/theme.ts +140 -0
|
@@ -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
|
+
});
|