@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
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
|
+
}
|