@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,155 @@
1
+ // src/lib/progress-bar.ts
2
+ // Custom gradient progress bar for chunked uploads
3
+
4
+ import gradient from 'gradient-string';
5
+ import { frappe, gradientColors, theme } from '../ui/theme';
6
+
7
+ // Create gradient using existing Catppuccin palette
8
+ const progressGradient = gradient([...gradientColors.banner]);
9
+
10
+ export interface ProgressState {
11
+ filename: string;
12
+ completedParts: number;
13
+ totalParts: number;
14
+ bytesUploaded: number;
15
+ totalBytes: number;
16
+ startTime: number;
17
+ recentSamples: { time: number; bytes: number }[];
18
+ }
19
+
20
+ /**
21
+ * Create initial progress state
22
+ */
23
+ export function createProgressState(
24
+ filename: string,
25
+ totalParts: number,
26
+ totalBytes: number
27
+ ): ProgressState {
28
+ return {
29
+ bytesUploaded: 0,
30
+ completedParts: 0,
31
+ filename,
32
+ recentSamples: [{ bytes: 0, time: Date.now() }],
33
+ startTime: Date.now(),
34
+ totalBytes,
35
+ totalParts
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Update progress state with a completed part
41
+ */
42
+ export function updateProgress(state: ProgressState, bytesUploaded: number): void {
43
+ state.completedParts++;
44
+ state.bytesUploaded += bytesUploaded;
45
+
46
+ // Add sample for speed calculation (keep last 30 seconds for slow uploads)
47
+ const now = Date.now();
48
+ state.recentSamples.push({ bytes: state.bytesUploaded, time: now });
49
+
50
+ // Remove samples older than 30 seconds
51
+ const cutoff = now - 30000;
52
+ state.recentSamples = state.recentSamples.filter((s) => s.time >= cutoff);
53
+ }
54
+
55
+ /**
56
+ * Calculate transfer speed (bytes per second)
57
+ * Uses overall average if recent samples insufficient
58
+ */
59
+ function calculateSpeed(state: ProgressState): number {
60
+ // Try recent samples first
61
+ if (state.recentSamples.length >= 2) {
62
+ const oldest = state.recentSamples[0];
63
+ const newest = state.recentSamples[state.recentSamples.length - 1];
64
+
65
+ const timeDiff = (newest.time - oldest.time) / 1000;
66
+ if (timeDiff > 0) {
67
+ const bytesDiff = newest.bytes - oldest.bytes;
68
+ return bytesDiff / timeDiff;
69
+ }
70
+ }
71
+
72
+ // Fall back to overall average speed
73
+ const elapsed = (Date.now() - state.startTime) / 1000;
74
+ if (elapsed > 0 && state.bytesUploaded > 0) {
75
+ return state.bytesUploaded / elapsed;
76
+ }
77
+
78
+ return 0;
79
+ }
80
+
81
+ /**
82
+ * Format bytes to human readable
83
+ */
84
+ function formatBytes(bytes: number): string {
85
+ if (bytes === 0) return '0 B';
86
+ const k = 1024;
87
+ const sizes = ['B', 'KB', 'MB', 'GB'];
88
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
89
+ return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
90
+ }
91
+
92
+ /**
93
+ * Format speed to human readable
94
+ */
95
+ function formatSpeed(bytesPerSecond: number): string {
96
+ return `${formatBytes(bytesPerSecond)}/s`;
97
+ }
98
+
99
+ /**
100
+ * Render the progress bar string
101
+ */
102
+ export function renderProgressBar(state: ProgressState, width = 30): string {
103
+ const percent =
104
+ state.totalBytes > 0 ? Math.round((state.bytesUploaded / state.totalBytes) * 100) : 0;
105
+ const filledWidth = Math.round((percent / 100) * width);
106
+ const emptyWidth = width - filledWidth;
107
+
108
+ // Create bar characters
109
+ const filledBar = '█'.repeat(filledWidth);
110
+ const emptyBar = '░'.repeat(emptyWidth);
111
+
112
+ // Apply gradient to filled portion
113
+ const gradientBar = filledWidth > 0 ? progressGradient(filledBar) : '';
114
+ const fullBar = gradientBar + frappe.surface0(emptyBar);
115
+
116
+ // Calculate speed
117
+ const speed = calculateSpeed(state);
118
+ const speedStr = formatSpeed(speed);
119
+
120
+ // Format: ████░░░░░░ [5/20] 25% (12.3 MB/s)
121
+ const chunkInfo = `[${state.completedParts}/${state.totalParts}]`;
122
+ const percentStr = `${percent}%`;
123
+
124
+ return `${fullBar} ${frappe.subtext0(chunkInfo)} ${frappe.text(percentStr)} ${theme.dim(`(${speedStr})`)}`;
125
+ }
126
+
127
+ /**
128
+ * Render progress line with filename (for terminal output)
129
+ */
130
+ export function renderProgressLine(state: ProgressState): string {
131
+ const bar = renderProgressBar(state);
132
+ return ` ${bar}`;
133
+ }
134
+
135
+ /**
136
+ * Clear line and move cursor to start
137
+ */
138
+ export function clearLine(): void {
139
+ process.stdout.write('\r\x1b[K');
140
+ }
141
+
142
+ /**
143
+ * Write progress to terminal (same line)
144
+ */
145
+ export function writeProgress(state: ProgressState): void {
146
+ clearLine();
147
+ process.stdout.write(renderProgressLine(state));
148
+ }
149
+
150
+ /**
151
+ * Finish progress (move to next line)
152
+ */
153
+ export function finishProgress(): void {
154
+ console.log();
155
+ }
@@ -0,0 +1,150 @@
1
+ // src/lib/providers.ts
2
+ import { deleteConfigSecret, getConfigSecret, setConfigSecret } from './secrets.js';
3
+
4
+ export type Provider = 'aws' | 'r2' | 'digitalocean' | 'backblaze' | 'custom';
5
+
6
+ export interface ProviderInfo {
7
+ name: string;
8
+ description: string;
9
+ requiresRegion: boolean;
10
+ requiresAccountId: boolean;
11
+ requiresEndpoint: boolean;
12
+ regions?: string[];
13
+ }
14
+
15
+ export const PROVIDERS: Record<Provider, ProviderInfo> = {
16
+ aws: {
17
+ description: 'Amazon Web Services S3',
18
+ name: 'AWS S3',
19
+ regions: [
20
+ 'us-east-1',
21
+ 'us-east-2',
22
+ 'us-west-1',
23
+ 'us-west-2',
24
+ 'eu-west-1',
25
+ 'eu-west-2',
26
+ 'eu-west-3',
27
+ 'eu-central-1',
28
+ 'eu-north-1',
29
+ 'ap-northeast-1',
30
+ 'ap-northeast-2',
31
+ 'ap-northeast-3',
32
+ 'ap-southeast-1',
33
+ 'ap-southeast-2',
34
+ 'ap-south-1',
35
+ 'sa-east-1',
36
+ 'ca-central-1'
37
+ ],
38
+ requiresAccountId: false,
39
+ requiresEndpoint: false,
40
+ requiresRegion: true
41
+ },
42
+ backblaze: {
43
+ description: 'Backblaze B2 Cloud Storage',
44
+ name: 'Backblaze B2',
45
+ regions: ['us-west-000', 'us-west-001', 'us-west-002', 'us-west-004', 'eu-central-003'],
46
+ requiresAccountId: false,
47
+ requiresEndpoint: false,
48
+ requiresRegion: true
49
+ },
50
+ custom: {
51
+ description: 'Custom S3-compatible endpoint (MinIO, etc.)',
52
+ name: 'Custom S3',
53
+ requiresAccountId: false,
54
+ requiresEndpoint: true,
55
+ requiresRegion: false
56
+ },
57
+ digitalocean: {
58
+ description: 'DigitalOcean Spaces Object Storage',
59
+ name: 'DigitalOcean Spaces',
60
+ regions: ['nyc3', 'ams3', 'sgp1', 'fra1', 'sfo2', 'sfo3', 'blr1', 'syd1'],
61
+ requiresAccountId: false,
62
+ requiresEndpoint: false,
63
+ requiresRegion: true
64
+ },
65
+ r2: {
66
+ description: 'Cloudflare R2 Storage',
67
+ name: 'Cloudflare R2',
68
+ requiresAccountId: true,
69
+ requiresEndpoint: false,
70
+ requiresRegion: false
71
+ }
72
+ };
73
+
74
+ export interface S3Config {
75
+ provider: Provider;
76
+ accessKeyId: string;
77
+ secretAccessKey: string;
78
+ bucket: string;
79
+ publicUrlBase: string;
80
+ region?: string;
81
+ accountId?: string;
82
+ endpoint?: string;
83
+ }
84
+
85
+ /**
86
+ * Get the S3 endpoint URL for a provider
87
+ */
88
+ export function getEndpoint(config: S3Config): string {
89
+ switch (config.provider) {
90
+ case 'aws':
91
+ return `https://s3.${config.region}.amazonaws.com`;
92
+ case 'r2':
93
+ return `https://${config.accountId}.r2.cloudflarestorage.com`;
94
+ case 'digitalocean':
95
+ return `https://${config.region}.digitaloceanspaces.com`;
96
+ case 'backblaze':
97
+ return `https://s3.${config.region}.backblazeb2.com`;
98
+ case 'custom':
99
+ if (!config.endpoint) {
100
+ throw new Error('Custom provider requires endpoint');
101
+ }
102
+ return config.endpoint;
103
+ default:
104
+ throw new Error(`Unknown provider: ${config.provider}`);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Load config from secrets/environment (single keychain prompt)
110
+ */
111
+ export async function loadConfig(): Promise<S3Config | null> {
112
+ const json = await getConfigSecret();
113
+ if (!json) {
114
+ return null;
115
+ }
116
+
117
+ try {
118
+ const config = JSON.parse(json) as S3Config;
119
+
120
+ // Validate required fields
121
+ if (
122
+ !config.provider ||
123
+ !config.accessKeyId ||
124
+ !config.secretAccessKey ||
125
+ !config.bucket ||
126
+ !config.publicUrlBase
127
+ ) {
128
+ return null;
129
+ }
130
+
131
+ return config;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Save config to secrets (single keychain entry)
139
+ */
140
+ export async function saveConfig(config: S3Config): Promise<void> {
141
+ await setConfigSecret(JSON.stringify(config));
142
+ }
143
+
144
+ /**
145
+ * Delete all stored config
146
+ */
147
+ export async function deleteConfig(): Promise<number> {
148
+ const deleted = await deleteConfigSecret();
149
+ return deleted ? 1 : 0;
150
+ }
@@ -0,0 +1,42 @@
1
+ // src/lib/s3.test.ts
2
+ import { describe, expect, test } from 'bun:test';
3
+ import { createS3Client, parseAge } from './s3';
4
+
5
+ describe('parseAge', () => {
6
+ test('parses days', () => {
7
+ expect(parseAge('1d')).toBe(24 * 60 * 60 * 1000);
8
+ expect(parseAge('7d')).toBe(7 * 24 * 60 * 60 * 1000);
9
+ });
10
+
11
+ test('parses hours', () => {
12
+ expect(parseAge('12h')).toBe(12 * 60 * 60 * 1000);
13
+ });
14
+
15
+ test('parses minutes', () => {
16
+ expect(parseAge('30m')).toBe(30 * 60 * 1000);
17
+ });
18
+
19
+ test('parses zero', () => {
20
+ expect(parseAge('0')).toBe(0);
21
+ });
22
+
23
+ test('defaults to days if no unit', () => {
24
+ expect(parseAge('5')).toBe(5 * 24 * 60 * 60 * 1000);
25
+ });
26
+ });
27
+
28
+ describe('createS3Client', () => {
29
+ test('creates client from config', () => {
30
+ const config = {
31
+ accessKeyId: 'test-key',
32
+ bucket: 'test-bucket',
33
+ provider: 'aws' as const,
34
+ publicUrlBase: 'https://cdn.example.com',
35
+ region: 'us-east-1',
36
+ secretAccessKey: 'test-secret'
37
+ };
38
+
39
+ const client = createS3Client(config);
40
+ expect(client).toBeDefined();
41
+ });
42
+ });
package/src/lib/s3.ts ADDED
@@ -0,0 +1,124 @@
1
+ // src/lib/s3.ts
2
+ import { S3Client } from 'bun';
3
+ import { getEndpoint, type S3Config } from './providers';
4
+
5
+ export interface S3Object {
6
+ key: string;
7
+ size: number;
8
+ lastModified: Date;
9
+ etag?: string;
10
+ }
11
+
12
+ export interface ListResult {
13
+ objects: S3Object[];
14
+ isTruncated: boolean;
15
+ nextContinuationToken?: string;
16
+ }
17
+
18
+ export function createS3Client(config: S3Config): S3Client {
19
+ return new S3Client({
20
+ accessKeyId: config.accessKeyId,
21
+ bucket: config.bucket,
22
+ endpoint: getEndpoint(config),
23
+ secretAccessKey: config.secretAccessKey
24
+ });
25
+ }
26
+
27
+ export async function listObjects(
28
+ client: S3Client,
29
+ prefix?: string,
30
+ maxKeys = 1000
31
+ ): Promise<ListResult> {
32
+ const result = await client.list({
33
+ maxKeys,
34
+ prefix
35
+ });
36
+
37
+ const objects: S3Object[] = (result.contents ?? []).map((item) => ({
38
+ etag: item.eTag,
39
+ key: item.key,
40
+ lastModified: new Date(item.lastModified ?? Date.now()),
41
+ size: item.size ?? 0
42
+ }));
43
+
44
+ return {
45
+ isTruncated: result.isTruncated ?? false,
46
+ nextContinuationToken: result.nextContinuationToken,
47
+ objects
48
+ };
49
+ }
50
+
51
+ export async function listAllObjects(client: S3Client, prefix?: string): Promise<S3Object[]> {
52
+ const allObjects: S3Object[] = [];
53
+ let continuationToken: string | undefined;
54
+ let hasMore = true;
55
+
56
+ while (hasMore) {
57
+ const result = await client.list({
58
+ maxKeys: 1000,
59
+ prefix,
60
+ startAfter: continuationToken
61
+ });
62
+
63
+ const objects: S3Object[] = (result.contents ?? []).map((item) => ({
64
+ etag: item.eTag,
65
+ key: item.key,
66
+ lastModified: new Date(item.lastModified ?? Date.now()),
67
+ size: item.size ?? 0
68
+ }));
69
+
70
+ allObjects.push(...objects);
71
+
72
+ if (result.isTruncated && objects.length > 0) {
73
+ continuationToken = objects[objects.length - 1].key;
74
+ } else {
75
+ hasMore = false;
76
+ }
77
+ }
78
+
79
+ return allObjects;
80
+ }
81
+
82
+ export async function deleteObject(client: S3Client, key: string): Promise<void> {
83
+ await client.delete(key);
84
+ }
85
+
86
+ export async function deleteObjects(
87
+ client: S3Client,
88
+ keys: string[]
89
+ ): Promise<{ deleted: number; errors: string[] }> {
90
+ const errors: string[] = [];
91
+ let deleted = 0;
92
+
93
+ for (const key of keys) {
94
+ try {
95
+ await client.delete(key);
96
+ deleted++;
97
+ } catch (err) {
98
+ errors.push(`${key}: ${err instanceof Error ? err.message : String(err)}`);
99
+ }
100
+ }
101
+
102
+ return { deleted, errors };
103
+ }
104
+
105
+ export function parseAge(age: string): number {
106
+ if (age === '0') return 0;
107
+
108
+ const match = age.match(/^(\d+)([dhm])?$/);
109
+ if (!match) return 0;
110
+
111
+ const value = parseInt(match[1], 10);
112
+ const unit = match[2] || 'd';
113
+
114
+ switch (unit) {
115
+ case 'd':
116
+ return value * 24 * 60 * 60 * 1000;
117
+ case 'h':
118
+ return value * 60 * 60 * 1000;
119
+ case 'm':
120
+ return value * 60 * 1000;
121
+ default:
122
+ return value * 24 * 60 * 60 * 1000;
123
+ }
124
+ }
@@ -0,0 +1,81 @@
1
+ // src/lib/secrets.ts
2
+
3
+ const SECRETS_SERVICE = 'com.s3up.cli';
4
+ const CONFIG_KEY = 'S3UP_CONFIG';
5
+
6
+ // In-memory cache to avoid multiple keychain prompts per process
7
+ let configCache: string | null | undefined;
8
+
9
+ /**
10
+ * Get the full config JSON from keychain (single prompt)
11
+ */
12
+ export async function getConfigSecret(): Promise<string | null> {
13
+ // Check cache first
14
+ if (configCache !== undefined) {
15
+ return configCache;
16
+ }
17
+
18
+ // Check environment variable
19
+ const envValue = process.env[CONFIG_KEY];
20
+ if (envValue) {
21
+ configCache = envValue;
22
+ return envValue;
23
+ }
24
+
25
+ // Try system credential store via Bun.secrets
26
+ try {
27
+ const value = await Bun.secrets.get({
28
+ name: CONFIG_KEY,
29
+ service: SECRETS_SERVICE
30
+ });
31
+ configCache = value;
32
+ return value;
33
+ } catch {
34
+ configCache = null;
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Store config JSON in system credential store
41
+ */
42
+ export async function setConfigSecret(value: string): Promise<void> {
43
+ try {
44
+ await Bun.secrets.set({
45
+ name: CONFIG_KEY,
46
+ service: SECRETS_SERVICE,
47
+ value
48
+ });
49
+ configCache = value;
50
+ } catch (err) {
51
+ const msg = err instanceof Error ? err.message : String(err);
52
+ if (process.platform === 'linux' && msg.includes('libsecret')) {
53
+ throw new Error(
54
+ 'libsecret not found. Install it with:\n' +
55
+ ' Ubuntu/Debian: sudo apt install libsecret-1-0\n' +
56
+ ' Fedora/RHEL: sudo dnf install libsecret\n' +
57
+ ' Arch: sudo pacman -S libsecret\n' +
58
+ 'Or use environment variables instead.'
59
+ );
60
+ }
61
+ throw err;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Delete config from system credential store
67
+ */
68
+ export async function deleteConfigSecret(): Promise<boolean> {
69
+ configCache = undefined;
70
+ return await Bun.secrets.delete({
71
+ name: CONFIG_KEY,
72
+ service: SECRETS_SERVICE
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Get the secrets service name (for external tools)
78
+ */
79
+ export function getSecretsService(): string {
80
+ return SECRETS_SERVICE;
81
+ }
@@ -0,0 +1,151 @@
1
+ // src/lib/signing.ts
2
+ // AWS Signature Version 4 signing using Bun's native crypto
3
+
4
+ export interface AwsCredentials {
5
+ accessKeyId: string;
6
+ secretAccessKey: string;
7
+ region: string;
8
+ }
9
+
10
+ export interface SignedRequest {
11
+ url: string;
12
+ method: string;
13
+ headers: Record<string, string>;
14
+ }
15
+
16
+ /**
17
+ * SHA256 hash of data
18
+ */
19
+ function sha256(data: string | Uint8Array): string {
20
+ const hasher = new Bun.CryptoHasher('sha256');
21
+ hasher.update(data);
22
+ return hasher.digest('hex');
23
+ }
24
+
25
+ /**
26
+ * HMAC-SHA256
27
+ */
28
+ function hmacSha256(key: string | Uint8Array, data: string): Uint8Array {
29
+ const keyBuffer = typeof key === 'string' ? new TextEncoder().encode(key) : key;
30
+ const hasher = new Bun.CryptoHasher('sha256', keyBuffer);
31
+ hasher.update(data);
32
+ return new Uint8Array(hasher.digest());
33
+ }
34
+
35
+ /**
36
+ * Get AWS signing key
37
+ */
38
+ function getSigningKey(
39
+ secretKey: string,
40
+ dateStamp: string,
41
+ region: string,
42
+ service: string
43
+ ): Uint8Array {
44
+ const kDate = hmacSha256(`AWS4${secretKey}`, dateStamp);
45
+ const kRegion = hmacSha256(kDate, region);
46
+ const kService = hmacSha256(kRegion, service);
47
+ const kSigning = hmacSha256(kService, 'aws4_request');
48
+ return kSigning;
49
+ }
50
+
51
+ /**
52
+ * Format date for AWS (YYYYMMDD'T'HHMMSS'Z')
53
+ */
54
+ function toAmzDate(date: Date): string {
55
+ return date.toISOString().replace(/[:-]|\.\d{3}/g, '');
56
+ }
57
+
58
+ /**
59
+ * Format date stamp (YYYYMMDD)
60
+ */
61
+ function toDateStamp(date: Date): string {
62
+ return toAmzDate(date).slice(0, 8);
63
+ }
64
+
65
+ /**
66
+ * Sign an AWS request using SigV4
67
+ */
68
+ export function signRequest(
69
+ method: string,
70
+ url: string,
71
+ headers: Record<string, string>,
72
+ body: string | Uint8Array | null,
73
+ credentials: AwsCredentials,
74
+ service = 's3'
75
+ ): SignedRequest {
76
+ const parsedUrl = new URL(url);
77
+ const now = new Date();
78
+ const amzDate = toAmzDate(now);
79
+ const dateStamp = toDateStamp(now);
80
+
81
+ // Ensure host header
82
+ const signedHeaders: Record<string, string> = {
83
+ ...headers,
84
+ host: parsedUrl.host,
85
+ 'x-amz-content-sha256': body ? sha256(body) : sha256(''),
86
+ 'x-amz-date': amzDate
87
+ };
88
+
89
+ // Canonical headers (lowercase, sorted)
90
+ const headerKeys = Object.keys(signedHeaders)
91
+ .map((k) => k.toLowerCase())
92
+ .sort();
93
+ const getHeaderValue = (lowerKey: string): string => {
94
+ const originalKey = Object.keys(signedHeaders).find((h) => h.toLowerCase() === lowerKey);
95
+ return originalKey ? signedHeaders[originalKey] : '';
96
+ };
97
+ const canonicalHeaders = `${headerKeys.map((k) => `${k}:${getHeaderValue(k)}`).join('\n')}\n`;
98
+ const signedHeadersStr = headerKeys.join(';');
99
+
100
+ // Canonical request
101
+ const canonicalUri = parsedUrl.pathname;
102
+ const canonicalQuerystring = parsedUrl.searchParams.toString();
103
+ const payloadHash = body ? sha256(body) : sha256('');
104
+
105
+ const canonicalRequest = [
106
+ method,
107
+ canonicalUri,
108
+ canonicalQuerystring,
109
+ canonicalHeaders,
110
+ signedHeadersStr,
111
+ payloadHash
112
+ ].join('\n');
113
+
114
+ // String to sign
115
+ const algorithm = 'AWS4-HMAC-SHA256';
116
+ const credentialScope = `${dateStamp}/${credentials.region}/${service}/aws4_request`;
117
+ const stringToSign = [algorithm, amzDate, credentialScope, sha256(canonicalRequest)].join('\n');
118
+
119
+ // Calculate signature
120
+ const signingKey = getSigningKey(
121
+ credentials.secretAccessKey,
122
+ dateStamp,
123
+ credentials.region,
124
+ service
125
+ );
126
+ const signatureHasher = new Bun.CryptoHasher('sha256', signingKey);
127
+ signatureHasher.update(stringToSign);
128
+ const signature = signatureHasher.digest('hex');
129
+
130
+ // Authorization header
131
+ const authorization = `${algorithm} Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeadersStr}, Signature=${signature}`;
132
+
133
+ return {
134
+ headers: {
135
+ ...signedHeaders,
136
+ authorization
137
+ },
138
+ method,
139
+ url
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Helper to get region from endpoint for non-AWS providers
145
+ */
146
+ export function getRegionForSigning(provider: string, region?: string): string {
147
+ // R2 and other S3-compatible services use 'auto' or a specific region
148
+ if (provider === 'r2') return 'auto';
149
+ if (provider === 'custom') return region ?? 'us-east-1';
150
+ return region ?? 'us-east-1';
151
+ }