@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,145 @@
1
+ // src/lib/state.ts
2
+ // Manages .s3up state files for resumable uploads
3
+
4
+ import path from 'node:path';
5
+
6
+ export interface CompletedPart {
7
+ partNumber: number;
8
+ etag: string;
9
+ }
10
+
11
+ export interface UploadState {
12
+ version: 1;
13
+ uploadId: string;
14
+ bucket: string;
15
+ key: string;
16
+ fileSize: number;
17
+ fileModified: number;
18
+ chunkSize: number;
19
+ totalParts: number;
20
+ completedParts: CompletedPart[];
21
+ createdAt: number;
22
+ provider: string;
23
+ endpoint: string;
24
+ }
25
+
26
+ /**
27
+ * Get the state file path for a given file
28
+ */
29
+ export function getStateFilePath(filePath: string): string {
30
+ const dir = path.dirname(filePath);
31
+ const name = path.basename(filePath);
32
+ return path.join(dir, `.${name}.s3up`);
33
+ }
34
+
35
+ /**
36
+ * Load state from file, returns null if not found or invalid
37
+ */
38
+ export async function loadState(filePath: string): Promise<UploadState | null> {
39
+ const stateFile = getStateFilePath(filePath);
40
+ const file = Bun.file(stateFile);
41
+
42
+ if (!(await file.exists())) {
43
+ return null;
44
+ }
45
+
46
+ try {
47
+ const content = await file.text();
48
+ const state = JSON.parse(content) as UploadState;
49
+
50
+ // Validate version
51
+ if (state.version !== 1) {
52
+ return null;
53
+ }
54
+
55
+ return state;
56
+ } catch {
57
+ // Corrupted state file
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Save state to file
64
+ */
65
+ export async function saveState(filePath: string, state: UploadState): Promise<void> {
66
+ const stateFile = getStateFilePath(filePath);
67
+ await Bun.write(stateFile, JSON.stringify(state, null, 2));
68
+ }
69
+
70
+ /**
71
+ * Delete state file
72
+ */
73
+ export async function deleteState(filePath: string): Promise<void> {
74
+ const stateFile = getStateFilePath(filePath);
75
+ const file = Bun.file(stateFile);
76
+
77
+ if (await file.exists()) {
78
+ await Bun.write(stateFile, ''); // Clear content
79
+ const fs = await import('node:fs/promises');
80
+ await fs.unlink(stateFile);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Add a completed part to state and save
86
+ */
87
+ export async function addCompletedPart(
88
+ filePath: string,
89
+ state: UploadState,
90
+ part: CompletedPart
91
+ ): Promise<void> {
92
+ // Check if part already exists
93
+ const existing = state.completedParts.findIndex((p) => p.partNumber === part.partNumber);
94
+ if (existing >= 0) {
95
+ state.completedParts[existing] = part;
96
+ } else {
97
+ state.completedParts.push(part);
98
+ }
99
+
100
+ // Keep sorted by part number
101
+ state.completedParts.sort((a, b) => a.partNumber - b.partNumber);
102
+
103
+ await saveState(filePath, state);
104
+ }
105
+
106
+ /**
107
+ * Check if file has changed since state was created
108
+ */
109
+ export async function hasFileChanged(filePath: string, state: UploadState): Promise<boolean> {
110
+ const file = Bun.file(filePath);
111
+ const stat = await file.stat();
112
+
113
+ return file.size !== state.fileSize || stat.mtime.getTime() !== state.fileModified;
114
+ }
115
+
116
+ /**
117
+ * Create initial state for a new upload
118
+ */
119
+ export function createInitialState(
120
+ uploadId: string,
121
+ bucket: string,
122
+ key: string,
123
+ fileSize: number,
124
+ fileModified: number,
125
+ chunkSize: number,
126
+ provider: string,
127
+ endpoint: string
128
+ ): UploadState {
129
+ const totalParts = Math.ceil(fileSize / chunkSize);
130
+
131
+ return {
132
+ bucket,
133
+ chunkSize,
134
+ completedParts: [],
135
+ createdAt: Date.now(),
136
+ endpoint,
137
+ fileModified,
138
+ fileSize,
139
+ key,
140
+ provider,
141
+ totalParts,
142
+ uploadId,
143
+ version: 1
144
+ };
145
+ }
@@ -0,0 +1,120 @@
1
+ // src/lib/upload.ts
2
+ import type { S3Config } from './providers.js';
3
+ import { getEndpoint } from './providers.js';
4
+
5
+ export interface UploadResult {
6
+ filename: string;
7
+ size: number;
8
+ publicUrl: string;
9
+ success: true;
10
+ }
11
+
12
+ export interface UploadError {
13
+ filename: string;
14
+ error: string;
15
+ success: false;
16
+ }
17
+
18
+ export type UploadOutcome = UploadResult | UploadError;
19
+
20
+ export interface UploadProgress {
21
+ activeFiles: Set<string>;
22
+ completed: number;
23
+ total: number;
24
+ }
25
+
26
+ /**
27
+ * Format bytes to human readable string
28
+ */
29
+ export function formatBytes(bytes: number): string {
30
+ if (bytes === 0) return '0 B';
31
+ const k = 1024;
32
+ const sizes = ['B', 'KB', 'MB', 'GB'];
33
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
34
+ return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
35
+ }
36
+
37
+ /**
38
+ * Render progress string for spinner
39
+ */
40
+ export function renderProgress(progress: UploadProgress): string {
41
+ if (progress.activeFiles.size === 0) {
42
+ return `Uploaded ${progress.completed}/${progress.total} files`;
43
+ }
44
+ const active = [...progress.activeFiles].join(', ');
45
+ return `[${progress.completed}/${progress.total}] ${active}`;
46
+ }
47
+
48
+ /**
49
+ * Concurrency-limited parallel execution
50
+ */
51
+ export async function runWithConcurrency<T, R>(
52
+ items: T[],
53
+ limit: number,
54
+ fn: (item: T, index: number) => Promise<R>
55
+ ): Promise<R[]> {
56
+ const results: R[] = new Array(items.length);
57
+ const executing = new Set<Promise<void>>();
58
+ let currentIndex = 0;
59
+
60
+ for (const item of items) {
61
+ const index = currentIndex++;
62
+ const promise = fn(item, index).then((result) => {
63
+ results[index] = result;
64
+ executing.delete(promise);
65
+ });
66
+ executing.add(promise);
67
+
68
+ if (executing.size >= limit) {
69
+ await Promise.race(executing);
70
+ }
71
+ }
72
+
73
+ await Promise.all(executing);
74
+ return results;
75
+ }
76
+
77
+ /**
78
+ * Upload a single file to S3
79
+ */
80
+ export async function uploadFile(
81
+ file: { path: string; name: string; size: number },
82
+ config: S3Config
83
+ ): Promise<UploadOutcome> {
84
+ try {
85
+ const fileContent = Bun.file(file.path);
86
+ const endpoint = getEndpoint(config);
87
+
88
+ const response = await fetch(`s3://${config.bucket}/${file.name}`, {
89
+ body: fileContent.stream(),
90
+ headers: {
91
+ 'Content-Disposition': 'attachment'
92
+ },
93
+ method: 'PUT',
94
+ s3: {
95
+ accessKeyId: config.accessKeyId,
96
+ endpoint,
97
+ secretAccessKey: config.secretAccessKey
98
+ }
99
+ });
100
+
101
+ if (!response.ok) {
102
+ throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
103
+ }
104
+
105
+ const publicUrl = `${config.publicUrlBase}/${file.name}`;
106
+
107
+ return {
108
+ filename: file.name,
109
+ publicUrl,
110
+ size: file.size,
111
+ success: true
112
+ };
113
+ } catch (err) {
114
+ return {
115
+ error: err instanceof Error ? err.message : String(err),
116
+ filename: file.name,
117
+ success: false
118
+ };
119
+ }
120
+ }
@@ -0,0 +1,47 @@
1
+ // src/ui/banner.ts
2
+
3
+ import { dirname, join } from 'node:path';
4
+ import figlet from 'figlet';
5
+ import gradient from 'gradient-string';
6
+ // Embed font file for Bun standalone executable
7
+ // @ts-expect-error - Bun-specific import attribute
8
+ import fontPath from '../../node_modules/figlet/fonts/Slant.flf' with { type: 'file' };
9
+ import { gradientColors } from './theme.js';
10
+
11
+ // Create custom gradient using Catppuccin Frappe colors
12
+ const bannerGradient = gradient([...gradientColors.banner]);
13
+
14
+ // Lazy font loading for bytecode caching compatibility (no top-level await)
15
+ let fontLoaded = false;
16
+
17
+ async function ensureFontLoaded(): Promise<void> {
18
+ if (fontLoaded) return;
19
+ // In dev: fontPath is absolute. In bundled builds: fontPath is relative
20
+ // Use Bun.main for runtime path (import.meta.dir returns source dir with bytecode)
21
+ const resolvedFontPath = fontPath.startsWith('/') ? fontPath : join(dirname(Bun.main), fontPath);
22
+ const fontContent = await Bun.file(resolvedFontPath).text();
23
+ figlet.parseFont('Slant', fontContent);
24
+ fontLoaded = true;
25
+ }
26
+
27
+ /**
28
+ * Display the ASCII art banner with gradient colors
29
+ */
30
+ export async function showBanner(): Promise<void> {
31
+ await ensureFontLoaded();
32
+
33
+ const banner = figlet.textSync('s3up', {
34
+ font: 'Slant',
35
+ horizontalLayout: 'default'
36
+ });
37
+
38
+ const indent = ' ';
39
+ const indentedBanner = banner
40
+ .split('\n')
41
+ .map((line) => indent + line)
42
+ .join('\n');
43
+
44
+ console.log();
45
+ console.log(`\n${bannerGradient(indentedBanner)}\n`);
46
+ console.log();
47
+ }
@@ -0,0 +1,161 @@
1
+ // src/ui/setup.ts
2
+ import * as p from '@clack/prompts';
3
+ import { PROVIDERS, type Provider, type S3Config, saveConfig } from '../lib/providers.js';
4
+ import { showBanner } from './banner.js';
5
+ import { frappe, theme } from './theme.js';
6
+
7
+ /**
8
+ * Interactive setup flow for configuring S3 credentials
9
+ */
10
+ export async function setup(): Promise<void> {
11
+ await showBanner();
12
+ p.intro(frappe.text('Configure S3 credentials'));
13
+
14
+ // Provider selection
15
+ const providerOptions = Object.entries(PROVIDERS).map(([key, info]) => ({
16
+ hint: info.description,
17
+ label: info.name,
18
+ value: key as Provider
19
+ }));
20
+
21
+ const provider = await p.select({
22
+ message: 'Select your S3 provider:',
23
+ options: providerOptions
24
+ });
25
+
26
+ if (p.isCancel(provider)) {
27
+ p.outro(frappe.subtext1('Cancelled'));
28
+ process.exit(0);
29
+ }
30
+
31
+ const providerInfo = PROVIDERS[provider];
32
+ const config: Partial<S3Config> = { provider };
33
+
34
+ // Provider-specific prompts
35
+ if (providerInfo.requiresRegion && providerInfo.regions) {
36
+ const region = await p.select({
37
+ message: `${providerInfo.name} Region:`,
38
+ options: providerInfo.regions.map((r) => ({ label: r, value: r }))
39
+ });
40
+
41
+ if (p.isCancel(region)) {
42
+ p.outro(frappe.subtext1('Cancelled'));
43
+ process.exit(0);
44
+ }
45
+ config.region = region;
46
+ } else if (providerInfo.requiresRegion) {
47
+ const region = await p.text({
48
+ message: `${providerInfo.name} Region:`,
49
+ validate: (v) => (v?.trim() ? undefined : 'Region is required')
50
+ });
51
+
52
+ if (p.isCancel(region)) {
53
+ p.outro(frappe.subtext1('Cancelled'));
54
+ process.exit(0);
55
+ }
56
+ config.region = region.trim();
57
+ }
58
+
59
+ if (providerInfo.requiresAccountId) {
60
+ const accountId = await p.text({
61
+ message: 'Account ID:',
62
+ validate: (v) => (v?.trim() ? undefined : 'Account ID is required')
63
+ });
64
+
65
+ if (p.isCancel(accountId)) {
66
+ p.outro(frappe.subtext1('Cancelled'));
67
+ process.exit(0);
68
+ }
69
+ config.accountId = accountId.trim();
70
+ }
71
+
72
+ if (providerInfo.requiresEndpoint) {
73
+ const endpoint = await p.text({
74
+ message: 'S3 Endpoint URL:',
75
+ validate: (v) => {
76
+ if (!v?.trim()) return 'Endpoint is required';
77
+ try {
78
+ new URL(v.trim());
79
+ return undefined;
80
+ } catch {
81
+ return 'Must be a valid URL';
82
+ }
83
+ }
84
+ });
85
+
86
+ if (p.isCancel(endpoint)) {
87
+ p.outro(frappe.subtext1('Cancelled'));
88
+ process.exit(0);
89
+ }
90
+ config.endpoint = endpoint.trim();
91
+ }
92
+
93
+ // Common prompts
94
+ const accessKeyId = await p.text({
95
+ message: 'Access Key ID:',
96
+ validate: (v) => (v?.trim() ? undefined : 'Access Key ID is required')
97
+ });
98
+
99
+ if (p.isCancel(accessKeyId)) {
100
+ p.outro(frappe.subtext1('Cancelled'));
101
+ process.exit(0);
102
+ }
103
+ config.accessKeyId = accessKeyId.trim();
104
+
105
+ const secretAccessKey = await p.password({
106
+ message: 'Secret Access Key:',
107
+ validate: (v) => (v?.trim() ? undefined : 'Secret Access Key is required')
108
+ });
109
+
110
+ if (p.isCancel(secretAccessKey)) {
111
+ p.outro(frappe.subtext1('Cancelled'));
112
+ process.exit(0);
113
+ }
114
+ config.secretAccessKey = secretAccessKey.trim();
115
+
116
+ const bucket = await p.text({
117
+ message: 'Bucket name:',
118
+ validate: (v) => (v?.trim() ? undefined : 'Bucket name is required')
119
+ });
120
+
121
+ if (p.isCancel(bucket)) {
122
+ p.outro(frappe.subtext1('Cancelled'));
123
+ process.exit(0);
124
+ }
125
+ config.bucket = bucket.trim();
126
+
127
+ const publicUrlBase = await p.text({
128
+ message: 'Public URL base (for generating links):',
129
+ validate: (v) => {
130
+ if (!v?.trim()) return 'Public URL base is required';
131
+ try {
132
+ new URL(v.trim());
133
+ return undefined;
134
+ } catch {
135
+ return 'Must be a valid URL';
136
+ }
137
+ }
138
+ });
139
+
140
+ if (p.isCancel(publicUrlBase)) {
141
+ p.outro(frappe.subtext1('Cancelled'));
142
+ process.exit(0);
143
+ }
144
+ config.publicUrlBase = publicUrlBase.trim().replace(/\/$/, '');
145
+
146
+ // Store secrets
147
+ const s = p.spinner();
148
+ s.start('Storing credentials...');
149
+
150
+ try {
151
+ await saveConfig(config as S3Config);
152
+ s.stop(theme.success('Credentials stored securely'));
153
+ p.outro(
154
+ theme.success(`Setup complete! Run s3up <files...> to upload to ${providerInfo.name}.`)
155
+ );
156
+ } catch (err) {
157
+ s.stop(theme.error('Failed to store credentials'));
158
+ p.log.error(err instanceof Error ? err.message : String(err));
159
+ process.exit(1);
160
+ }
161
+ }
@@ -0,0 +1,140 @@
1
+ // src/ui/theme.ts
2
+ import pc from 'picocolors';
3
+
4
+ // Catppuccin Frappe palette
5
+ // https://github.com/catppuccin/catppuccin
6
+ const palette = {
7
+ base: '#303446',
8
+ blue: '#8caaee',
9
+ crust: '#232634',
10
+ flamingo: '#eebebe',
11
+ green: '#a6d189',
12
+ lavender: '#babbf1',
13
+ mantle: '#292c3c',
14
+ maroon: '#ea999c',
15
+ mauve: '#ca9ee6',
16
+ overlay0: '#737994',
17
+ overlay1: '#838ba7',
18
+ overlay2: '#949cbb',
19
+ peach: '#ef9f76',
20
+ pink: '#f4b8e4',
21
+ red: '#e78284',
22
+ rosewater: '#f2d5cf',
23
+ sapphire: '#85c1dc',
24
+ sky: '#99d1db',
25
+ subtext0: '#a5adce',
26
+ subtext1: '#b5bfe2',
27
+ surface0: '#414559',
28
+ surface1: '#51576d',
29
+ surface2: '#626880',
30
+ teal: '#81c8be',
31
+ text: '#c6d0f5',
32
+ yellow: '#e5c890'
33
+ } as const;
34
+
35
+ // ANSI 256-color approximations for Catppuccin Frappe
36
+ const ansi = {
37
+ base: 236,
38
+ blue: 111,
39
+ crust: 234,
40
+ flamingo: 217,
41
+ green: 150,
42
+ lavender: 147,
43
+ mantle: 235,
44
+ maroon: 217,
45
+ mauve: 183,
46
+ overlay0: 60,
47
+ overlay1: 103,
48
+ overlay2: 103,
49
+ peach: 216,
50
+ pink: 218,
51
+ red: 210,
52
+ rosewater: 224,
53
+ sapphire: 110,
54
+ sky: 117,
55
+ subtext0: 146,
56
+ subtext1: 146,
57
+ surface0: 59,
58
+ surface1: 59,
59
+ surface2: 60,
60
+ teal: 116,
61
+ text: 189,
62
+ yellow: 223
63
+ } as const;
64
+
65
+ // Color functions using ANSI 256 colors
66
+ function ansiColor(code: number): (text: string) => string {
67
+ return (text: string) => `\x1b[38;5;${code}m${text}\x1b[0m`;
68
+ }
69
+
70
+ function ansiBg(code: number): (text: string) => string {
71
+ return (text: string) => `\x1b[48;5;${code}m${text}\x1b[0m`;
72
+ }
73
+
74
+ // Theme colors as functions
75
+ export const frappe = {
76
+ base: ansiColor(ansi.base),
77
+ bg: {
78
+ base: ansiBg(ansi.base),
79
+ surface0: ansiBg(ansi.surface0),
80
+ surface1: ansiBg(ansi.surface1)
81
+ },
82
+ blue: ansiColor(ansi.blue),
83
+ crust: ansiColor(ansi.crust),
84
+ flamingo: ansiColor(ansi.flamingo),
85
+ green: ansiColor(ansi.green),
86
+ lavender: ansiColor(ansi.lavender),
87
+ mantle: ansiColor(ansi.mantle),
88
+ maroon: ansiColor(ansi.maroon),
89
+ mauve: ansiColor(ansi.mauve),
90
+ overlay0: ansiColor(ansi.overlay0),
91
+ overlay1: ansiColor(ansi.overlay1),
92
+ overlay2: ansiColor(ansi.overlay2),
93
+ peach: ansiColor(ansi.peach),
94
+ pink: ansiColor(ansi.pink),
95
+ red: ansiColor(ansi.red),
96
+ rosewater: ansiColor(ansi.rosewater),
97
+ sapphire: ansiColor(ansi.sapphire),
98
+ sky: ansiColor(ansi.sky),
99
+ subtext0: ansiColor(ansi.subtext0),
100
+ subtext1: ansiColor(ansi.subtext1),
101
+ surface0: ansiColor(ansi.surface0),
102
+ surface1: ansiColor(ansi.surface1),
103
+ surface2: ansiColor(ansi.surface2),
104
+ teal: ansiColor(ansi.teal),
105
+ text: ansiColor(ansi.text),
106
+ yellow: ansiColor(ansi.yellow)
107
+ } as const;
108
+
109
+ // Semantic aliases for common use cases
110
+ export const theme = {
111
+ accent: frappe.mauve,
112
+ dim: frappe.surface2,
113
+ error: frappe.red,
114
+ info: frappe.blue,
115
+ link: frappe.peach,
116
+ muted: frappe.overlay1,
117
+ primary: frappe.mauve,
118
+ secondary: frappe.pink,
119
+ subtle: frappe.subtext0,
120
+ success: frappe.green,
121
+ text: frappe.text,
122
+ warning: frappe.yellow
123
+ } as const;
124
+
125
+ // Gradient colors for banner (hex values for gradient-string)
126
+ export const gradientColors = {
127
+ banner: [palette.mauve, palette.pink, palette.flamingo]
128
+ } as const;
129
+
130
+ // Box border colors (hex for boxen)
131
+ export const boxColors = {
132
+ default: palette.surface2,
133
+ error: palette.red,
134
+ info: palette.blue,
135
+ primary: palette.mauve,
136
+ success: palette.green
137
+ } as const;
138
+
139
+ // Re-export picocolors for basic formatting
140
+ export { pc };