@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 ADDED
@@ -0,0 +1,204 @@
1
+ # s3up
2
+
3
+ Upload files to S3-compatible storage. Supports AWS S3, Cloudflare R2, DigitalOcean Spaces, Backblaze B2, and custom endpoints.
4
+
5
+ ## Features
6
+
7
+ **Simple uploads** — Upload files with a single command. URLs copied to clipboard automatically.
8
+
9
+ **Directory archiving** — Point at a directory, get a `.tar.gz` uploaded. Uses Bun's native Archive API.
10
+
11
+ **Multipart uploads** — Files over 100MB are automatically uploaded in parallel chunks with resume support.
12
+
13
+ **Lifecycle management** — List and prune old backups with `list` and `prune` commands.
14
+
15
+ **Automation-ready** — Quiet mode (`--quiet`) and CI mode (`--ci`) for scripts and cron jobs.
16
+
17
+ **Multiple providers** — AWS S3, Cloudflare R2, DigitalOcean Spaces, Backblaze B2, or any S3-compatible endpoint.
18
+
19
+ **Fast** — Bun. Standalone binary. Native S3 client.
20
+
21
+ ```bash
22
+ s3up image.png
23
+ s3up ./workspace --prefix backups/daily
24
+ s3up list backups/
25
+ s3up prune backups/ --keep-last 7
26
+ ```
27
+
28
+ ## Install
29
+
30
+ **Homebrew**
31
+
32
+ ```bash
33
+ brew install seanmozeik/tap/s3up
34
+ ```
35
+
36
+ **From source** (requires [Bun](https://bun.sh))
37
+
38
+ ```bash
39
+ git clone https://github.com/mozeik/s3up.git
40
+ cd s3up
41
+ bun install
42
+ bun run build
43
+ ```
44
+
45
+ Produces a standalone binary. Move it to your PATH:
46
+
47
+ ```bash
48
+ mv s3up ~/.local/bin/
49
+ ```
50
+
51
+ ## Setup
52
+
53
+ Run `s3up setup` to configure credentials interactively:
54
+
55
+ ```bash
56
+ s3up setup
57
+ ```
58
+
59
+ Select your provider and enter credentials. Stored securely in system keychain.
60
+
61
+ **Environment variable** — For containers/CI, set `S3UP_CONFIG` as JSON:
62
+
63
+ ```bash
64
+ export S3UP_CONFIG='{
65
+ "provider": "r2",
66
+ "accessKeyId": "...",
67
+ "secretAccessKey": "...",
68
+ "bucket": "my-bucket",
69
+ "publicUrlBase": "https://cdn.example.com",
70
+ "accountId": "..."
71
+ }'
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ ### Upload
77
+
78
+ ```bash
79
+ # Single file
80
+ s3up image.png
81
+
82
+ # Multiple files
83
+ s3up *.png
84
+
85
+ # Directory (auto-tarballed)
86
+ s3up ./workspace
87
+
88
+ # With prefix
89
+ s3up backup.sql --prefix db/2026-01-31
90
+
91
+ # Multiple directories into one tarball
92
+ s3up ./workspace ./config --prefix backups
93
+
94
+ # Custom tarball name
95
+ s3up ./workspace --as full-backup.tar.gz
96
+ ```
97
+
98
+ **Upload options:**
99
+
100
+ ```
101
+ --prefix <path> Prepend path to uploaded keys
102
+ --as <name> Override tarball filename for directories
103
+ --compression <1-12> Gzip compression level (default: 6)
104
+ -f, --fast Fast network preset (5MB chunks, 16 connections)
105
+ -s, --slow Slow network preset (50MB chunks, 4 connections)
106
+ ```
107
+
108
+ ### List
109
+
110
+ ```bash
111
+ # List all objects
112
+ s3up list
113
+
114
+ # List with prefix
115
+ s3up list backups/workspace/
116
+
117
+ # JSON output
118
+ s3up list backups/ --json
119
+ ```
120
+
121
+ ### Prune
122
+
123
+ ```bash
124
+ # Keep last 7 backups
125
+ s3up prune backups/workspace/ --keep-last 7
126
+
127
+ # Delete objects older than 30 days
128
+ s3up prune backups/ --older-than 30
129
+
130
+ # Preview what would be deleted
131
+ s3up prune backups/ --keep-last 7 --dry-run
132
+ ```
133
+
134
+ **Prune options:**
135
+
136
+ ```
137
+ --older-than <days> Delete objects older than N days
138
+ --keep-last <n> Keep only N most recent objects
139
+ --min-age <duration> Minimum age before deletion (default: 1d)
140
+ --dry-run Show what would be deleted
141
+ ```
142
+
143
+ **Safety:** Objects younger than `--min-age` (default 1 day) are never deleted, even if they match other criteria.
144
+
145
+ ### Global Options
146
+
147
+ ```
148
+ -q, --quiet Minimal output for scripting
149
+ --ci Non-interactive mode (fails if prompt required)
150
+ -h, --help Show help
151
+ -v, --version Show version
152
+ ```
153
+
154
+ ## Automation
155
+
156
+ Example backup cron script:
157
+
158
+ ```bash
159
+ #!/bin/bash
160
+ set -e
161
+
162
+ DATE=$(date +%Y-%m-%d)
163
+
164
+ # Upload workspace directory
165
+ s3up ./workspace --prefix "backups/workspace/${DATE}" --ci -q
166
+
167
+ # Prune old backups (keep last 14)
168
+ s3up prune "backups/workspace/" --keep-last 14 --ci -q
169
+
170
+ echo "Backup complete: ${DATE}"
171
+ ```
172
+
173
+ ## Exit Codes
174
+
175
+ | Code | Meaning |
176
+ |------|---------|
177
+ | 0 | Success |
178
+ | 1 | General error |
179
+ | 2 | Configuration error |
180
+ | 3 | Interactive prompt required in --ci mode |
181
+ | 4 | Partial failure (some files failed) |
182
+
183
+ ## Providers
184
+
185
+ | Provider | Configuration |
186
+ |----------|---------------|
187
+ | AWS S3 | `provider: "aws"`, `region` required |
188
+ | Cloudflare R2 | `provider: "r2"`, `accountId` required |
189
+ | DigitalOcean Spaces | `provider: "digitalocean"`, `region` required |
190
+ | Backblaze B2 | `provider: "backblaze"`, `region` required |
191
+ | Custom | `provider: "custom"`, `endpoint` required |
192
+
193
+ ## Development
194
+
195
+ ```bash
196
+ bun install
197
+ bun run dev # Run from source
198
+ bun run build # Build standalone binary
199
+ bun test # Run tests
200
+ ```
201
+
202
+ ## License
203
+
204
+ MIT
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@seanmozeik/s3up",
3
+ "version": "0.3.1",
4
+ "description": "Upload files to S3-compatible storage",
5
+ "type": "module",
6
+ "bin": {
7
+ "s3up": "src/index.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "package.json"
12
+ ],
13
+ "keywords": [
14
+ "s3",
15
+ "upload",
16
+ "cli",
17
+ "bun",
18
+ "r2",
19
+ "cloudflare",
20
+ "aws",
21
+ "backblaze"
22
+ ],
23
+ "license": "MIT",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/seanmozeik/s3up.git"
30
+ },
31
+ "dependencies": {
32
+ "@clack/prompts": "^1.0.0",
33
+ "boxen": "^8.0.1",
34
+ "figlet": "^1.10.0",
35
+ "gradient-string": "^3.0.0",
36
+ "picocolors": "^1.1.1"
37
+ },
38
+ "devDependencies": {
39
+ "@biomejs/biome": "^2.3.13",
40
+ "@types/bun": "1.3.8",
41
+ "@types/figlet": "^1.7.0",
42
+ "@types/gradient-string": "^1.1.6",
43
+ "typescript": "^5.9.3"
44
+ },
45
+ "scripts": {
46
+ "build": "bun build ./src/index.ts --compile --outfile s3up --minify",
47
+ "bundle": "bun build ./src/index.ts --outdir dist --target=bun --bytecode --minify",
48
+ "check": "biome check --write --unsafe .",
49
+ "dev": "bun run src/index.ts",
50
+ "format": "biome format --write .",
51
+ "install-local": "bun run build && mkdir -p ~/.local/bin && mv s3up ~/.local/bin/s3up && chmod +x ~/.local/bin/s3up",
52
+ "tc": "tsc --noEmit",
53
+ "test": "bun test"
54
+ }
55
+ }
@@ -0,0 +1,31 @@
1
+ // src/commands/list.test.ts
2
+ import { describe, expect, test } from 'bun:test';
3
+ import { formatListOutput } from './list';
4
+
5
+ describe('formatListOutput', () => {
6
+ const objects = [
7
+ {
8
+ key: 'backups/file1.tar.gz',
9
+ lastModified: new Date('2026-01-28T10:00:00Z'),
10
+ size: 1024000
11
+ },
12
+ {
13
+ key: 'backups/file2.tar.gz',
14
+ lastModified: new Date('2026-01-29T10:00:00Z'),
15
+ size: 2048000
16
+ }
17
+ ];
18
+
19
+ test('formats as tab-separated in quiet mode', () => {
20
+ const result = formatListOutput(objects, true, false);
21
+ expect(result).toContain('backups/file1.tar.gz');
22
+ expect(result).toContain('\t');
23
+ });
24
+
25
+ test('formats as JSON with --json flag', () => {
26
+ const result = formatListOutput(objects, true, true);
27
+ const lines = result.trim().split('\n');
28
+ expect(lines).toHaveLength(2);
29
+ expect(JSON.parse(lines[0]).key).toBe('backups/file1.tar.gz');
30
+ });
31
+ });
@@ -0,0 +1,58 @@
1
+ // src/commands/list.ts
2
+ import * as p from '@clack/prompts';
3
+
4
+ import { type GlobalFlags, parseListFlags } from '../lib/flags';
5
+ import { formatBytes, formatListItem } from '../lib/output';
6
+ import { loadConfig } from '../lib/providers';
7
+ import { createS3Client, listAllObjects, type S3Object } from '../lib/s3';
8
+ import { showBanner } from '../ui/banner';
9
+ import { frappe } from '../ui/theme';
10
+
11
+ export function formatListOutput(objects: S3Object[], quiet: boolean, json: boolean): string {
12
+ return objects.map((o) => formatListItem(o.key, o.size, o.lastModified, quiet, json)).join('\n');
13
+ }
14
+
15
+ export async function list(args: string[], globalFlags: GlobalFlags): Promise<void> {
16
+ const flags = parseListFlags(args);
17
+
18
+ const config = await loadConfig();
19
+ if (!config) {
20
+ if (globalFlags.ci) {
21
+ console.error('Error: S3UP_CONFIG not set and --ci prevents interactive setup');
22
+ process.exit(2);
23
+ }
24
+ if (!globalFlags.quiet) await showBanner();
25
+ p.log.error('S3 not configured. Run: s3up setup');
26
+ process.exit(2);
27
+ }
28
+
29
+ if (!globalFlags.quiet) {
30
+ await showBanner();
31
+ p.intro(frappe.text(`Listing objects${flags.prefix ? ` with prefix: ${flags.prefix}` : ''}`));
32
+ }
33
+
34
+ const client = createS3Client(config);
35
+ const objects = await listAllObjects(client, flags.prefix);
36
+
37
+ if (objects.length === 0) {
38
+ if (globalFlags.quiet) {
39
+ // No output for empty results in quiet mode
40
+ } else {
41
+ p.log.info('No objects found');
42
+ }
43
+ process.exit(0);
44
+ }
45
+
46
+ // Sort by lastModified descending (newest first)
47
+ objects.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
48
+
49
+ const output = formatListOutput(objects, globalFlags.quiet, flags.json);
50
+ console.log(output);
51
+
52
+ if (!globalFlags.quiet) {
53
+ const totalSize = objects.reduce((sum, o) => sum + o.size, 0);
54
+ p.outro(frappe.subtext1(`${objects.length} objects (${formatBytes(totalSize)} total)`));
55
+ }
56
+
57
+ process.exit(0);
58
+ }
@@ -0,0 +1,83 @@
1
+ // src/commands/prune.test.ts
2
+ import { describe, expect, test } from 'bun:test';
3
+ import type { S3Object } from '../lib/s3';
4
+ import { filterObjectsForPrune } from './prune';
5
+
6
+ describe('filterObjectsForPrune', () => {
7
+ const now = new Date('2026-01-31T12:00:00Z').getTime();
8
+
9
+ const objects: S3Object[] = [
10
+ {
11
+ key: 'backups/file1.tar.gz',
12
+ lastModified: new Date('2026-01-30T12:00:00Z'),
13
+ size: 1000
14
+ }, // 1 day old
15
+ {
16
+ key: 'backups/file2.tar.gz',
17
+ lastModified: new Date('2026-01-29T12:00:00Z'),
18
+ size: 2000
19
+ }, // 2 days old
20
+ {
21
+ key: 'backups/file3.tar.gz',
22
+ lastModified: new Date('2026-01-28T12:00:00Z'),
23
+ size: 3000
24
+ }, // 3 days old
25
+ {
26
+ key: 'backups/file4.tar.gz',
27
+ lastModified: new Date('2026-01-20T12:00:00Z'),
28
+ size: 4000
29
+ }, // 11 days old
30
+ {
31
+ key: 'backups/file5.tar.gz',
32
+ lastModified: new Date('2026-01-10T12:00:00Z'),
33
+ size: 5000
34
+ } // 21 days old
35
+ ];
36
+
37
+ test('filters by --older-than', () => {
38
+ const result = filterObjectsForPrune(objects, {
39
+ minAge: '0',
40
+ now,
41
+ olderThan: 10
42
+ });
43
+ expect(result.map((o) => o.key)).toEqual(['backups/file4.tar.gz', 'backups/file5.tar.gz']);
44
+ });
45
+
46
+ test('filters by --keep-last', () => {
47
+ const result = filterObjectsForPrune(objects, {
48
+ keepLast: 2,
49
+ minAge: '0',
50
+ now
51
+ });
52
+ // Keeps 2 newest, deletes rest
53
+ expect(result.map((o) => o.key)).toEqual([
54
+ 'backups/file3.tar.gz',
55
+ 'backups/file4.tar.gz',
56
+ 'backups/file5.tar.gz'
57
+ ]);
58
+ });
59
+
60
+ test('respects --min-age default (1d)', () => {
61
+ const result = filterObjectsForPrune(objects, {
62
+ keepLast: 1,
63
+ minAge: '1d',
64
+ now
65
+ });
66
+ // file1 is only 1 day old, protected by min-age
67
+ expect(result.map((o) => o.key)).not.toContain('backups/file1.tar.gz');
68
+ });
69
+
70
+ test('combines --older-than and --keep-last (both must match)', () => {
71
+ const result = filterObjectsForPrune(objects, {
72
+ keepLast: 3,
73
+ minAge: '0',
74
+ now,
75
+ olderThan: 2
76
+ });
77
+ // Must be older than 2 days AND not in top 3
78
+ // Top 3: file1, file2, file3
79
+ // Older than 2 days: file3, file4, file5
80
+ // Both: file4, file5
81
+ expect(result.map((o) => o.key)).toEqual(['backups/file4.tar.gz', 'backups/file5.tar.gz']);
82
+ });
83
+ });
@@ -0,0 +1,159 @@
1
+ // src/commands/prune.ts
2
+ import * as p from '@clack/prompts';
3
+
4
+ import { type GlobalFlags, parsePruneFlags } from '../lib/flags';
5
+ import { formatBytes, formatDeleteSummary, formatDryRunList } from '../lib/output';
6
+ import { loadConfig } from '../lib/providers';
7
+ import { createS3Client, deleteObjects, listAllObjects, parseAge, type S3Object } from '../lib/s3';
8
+ import { showBanner } from '../ui/banner';
9
+ import { frappe, theme } from '../ui/theme';
10
+
11
+ export interface PruneFilterOptions {
12
+ olderThan?: number;
13
+ keepLast?: number;
14
+ minAge: string;
15
+ now?: number;
16
+ }
17
+
18
+ export function filterObjectsForPrune(
19
+ objects: S3Object[],
20
+ options: PruneFilterOptions
21
+ ): S3Object[] {
22
+ const now = options.now ?? Date.now();
23
+ const minAgeMs = parseAge(options.minAge);
24
+
25
+ // Sort by lastModified descending (newest first)
26
+ const sorted = [...objects].sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
27
+
28
+ // Build set of protected keys (top N newest)
29
+ const protectedKeys = new Set<string>();
30
+ if (options.keepLast) {
31
+ for (let i = 0; i < Math.min(options.keepLast, sorted.length); i++) {
32
+ protectedKeys.add(sorted[i].key);
33
+ }
34
+ }
35
+
36
+ return sorted.filter((obj) => {
37
+ const age = now - obj.lastModified.getTime();
38
+
39
+ // Must be older than min-age
40
+ if (age <= minAgeMs) return false;
41
+
42
+ // Must not be in protected set (keep-last)
43
+ if (protectedKeys.has(obj.key)) return false;
44
+
45
+ // Must be older than --older-than days
46
+ if (options.olderThan !== undefined) {
47
+ const olderThanMs = options.olderThan * 24 * 60 * 60 * 1000;
48
+ if (age <= olderThanMs) return false;
49
+ }
50
+
51
+ return true;
52
+ });
53
+ }
54
+
55
+ export async function prune(args: string[], globalFlags: GlobalFlags): Promise<void> {
56
+ const flags = parsePruneFlags(args);
57
+
58
+ // Validate required prefix
59
+ if (!flags.prefix) {
60
+ console.error('Error: prefix is required for prune command');
61
+ console.error('Usage: s3up prune <prefix> --keep-last <n> | --older-than <days>');
62
+ process.exit(1);
63
+ }
64
+
65
+ // Validate at least one filter
66
+ if (flags.olderThan === undefined && flags.keepLast === undefined) {
67
+ console.error('Error: at least one of --older-than or --keep-last is required');
68
+ process.exit(1);
69
+ }
70
+
71
+ const config = await loadConfig();
72
+ if (!config) {
73
+ if (globalFlags.ci) {
74
+ console.error('Error: S3UP_CONFIG not set and --ci prevents interactive setup');
75
+ process.exit(2);
76
+ }
77
+ if (!globalFlags.quiet) await showBanner();
78
+ p.log.error('S3 not configured. Run: s3up setup');
79
+ process.exit(2);
80
+ }
81
+
82
+ if (!globalFlags.quiet) {
83
+ await showBanner();
84
+ p.intro(frappe.text(`Pruning objects with prefix: ${flags.prefix}`));
85
+ }
86
+
87
+ const client = createS3Client(config);
88
+ const objects = await listAllObjects(client, flags.prefix);
89
+
90
+ if (objects.length === 0) {
91
+ if (globalFlags.quiet) {
92
+ console.log('0 objects deleted');
93
+ } else {
94
+ p.log.info('No objects found matching prefix');
95
+ }
96
+ process.exit(0);
97
+ }
98
+
99
+ const toDelete = filterObjectsForPrune(objects, {
100
+ keepLast: flags.keepLast,
101
+ minAge: flags.minAge,
102
+ olderThan: flags.olderThan
103
+ });
104
+
105
+ if (toDelete.length === 0) {
106
+ if (globalFlags.quiet) {
107
+ console.log('0 objects deleted');
108
+ } else {
109
+ p.log.info('No objects match deletion criteria');
110
+ }
111
+ process.exit(0);
112
+ }
113
+
114
+ const totalSize = toDelete.reduce((sum, o) => sum + o.size, 0);
115
+
116
+ // Dry run mode
117
+ if (flags.dryRun) {
118
+ console.log(formatDeleteSummary(toDelete.length, totalSize, true, globalFlags.quiet));
119
+ if (!globalFlags.quiet) {
120
+ console.log(formatDryRunList(toDelete));
121
+ }
122
+ process.exit(0);
123
+ }
124
+
125
+ // Confirm deletion in interactive mode
126
+ if (!globalFlags.ci && !globalFlags.quiet) {
127
+ console.log(formatDryRunList(toDelete));
128
+ const confirm = await p.confirm({
129
+ message: `Delete ${toDelete.length} objects (${formatBytes(totalSize)})?`
130
+ });
131
+
132
+ if (p.isCancel(confirm) || !confirm) {
133
+ p.outro(frappe.subtext1('Cancelled'));
134
+ process.exit(0);
135
+ }
136
+ }
137
+
138
+ // Perform deletion
139
+ const spinner = globalFlags.quiet ? undefined : p.spinner();
140
+ spinner?.start(`Deleting ${toDelete.length} objects...`);
141
+
142
+ const { deleted, errors } = await deleteObjects(
143
+ client,
144
+ toDelete.map((o) => o.key)
145
+ );
146
+
147
+ if (errors.length > 0) {
148
+ spinner?.stop(theme.warning(`Deleted ${deleted}/${toDelete.length} objects`));
149
+ for (const err of errors) {
150
+ console.error(`Error: ${err}`);
151
+ }
152
+ process.exit(4);
153
+ }
154
+
155
+ spinner?.stop(theme.success(`Deleted ${deleted} objects`));
156
+ console.log(formatDeleteSummary(deleted, totalSize, false, globalFlags.quiet));
157
+
158
+ process.exit(0);
159
+ }