Something went wrong. Try again.
A nicer S3 CLI
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156import { S3, S3Error } from './vendor/ordner.fyi/s3/s3.ts';import { readConfig, updateConfig, type BucketRecord, type Profile } from './config.ts';import { readSecret } from './secrets.ts';import { AuthError, UsageError } from './errors.ts';import { headBucket, type Access } from './service.ts';import { trace } from './observe.ts';
// From a bucket's name to a client that can reach it: which profile's keys,// which endpoint, which region. A remembered bucket says its profile (and// region, once learnt); any other is tried with --profile, or the default.
/** What is kept in the secret store for each profile, under `profile:<name>`. */export interface Keys { accessKeyId: string; secretAccessKey: string;}
export const secretKey = (profile: string) => `profile:${profile}`;
const keyCache = new Map<string, Keys>();
export function keysOf(profile: string): Keys { const cached = keyCache.get(profile); if (cached) return cached; const keys = readSecret<Keys>(secretKey(profile)); if (!keys?.accessKeyId || !keys.secretAccessKey) throw new AuthError(`the keys of profile '${profile}' are not in the secret store; run 's3cli login ${profile}' again`); keyCache.set(profile, keys); return keys;}
/** For login and logout: what was read before is no longer so. */export function forgetKeys(profile: string): void { keyCache.delete(profile);}
/** The profile named, or the default one. */export function profileNamed(name?: string): Profile { const config = readConfig(); const wanted = name ?? config.default; if (!wanted) { throw new AuthError(Object.keys(config.profiles).length ? "there is no default profile; pass --profile, or set one with 's3cli profile default <name>'" : "there are no profiles yet; add one with 's3cli login'"); } const profile = config.profiles[wanted]; if (!profile) throw new UsageError(`there is no profile '${wanted}' (have: ${Object.keys(config.profiles).join(', ') || 'none'})`); return profile;}
/** How to reach a service: AWS's endpoint follows from the region, any other is the profile's. */export function accessFor(profile: Profile, keys: Keys, region = profile.region, bucket?: string): Access { return { endpoint: profile.endpoint ?? `https://s3.${region}.amazonaws.com`, region, // AWS's certificates cover one label of bucket name: a dotted bucket must go by path. hostBased: !profile.pathStyle && !(profile.endpoint === undefined && bucket?.includes('.')), key: keys.accessKeyId, secret: keys.secretAccessKey, };}
/** A bucket, ready to be talked to. */export class Bucket { /** The name it is listed under. */ readonly alias: string; /** The name the service knows it by. */ readonly name: string; readonly profile: Profile; readonly remembered: boolean; #region: string; #client: S3 | undefined;
constructor(alias: string, profile: Profile, record?: BucketRecord) { this.alias = alias; this.name = record?.bucket ?? alias; this.profile = profile; this.remembered = Boolean(record); this.#region = record?.region ?? profile.region; }
get region(): string { return this.#region; }
access(): Access { return accessFor(this.profile, keysOf(this.profile.name), this.#region, this.name); }
get client(): S3 { if (!this.#client) { const access = this.access(); this.#client = new S3({ key: access.key, secret: access.secret, endpoint: access.endpoint, bucket: this.name, region: access.region, hostBased: access.hostBased, // A person is waiting: fail sooner than a server would, but ride out a blip. retry: { maxAttempts: 4, baseMs: 200, capMs: 5000 }, timeoutMs: 60_000, }); } return this.#client; }
/** * Run a call, and if AWS says the bucket is in another region than it was * asked in, ask it which, remember that, and run the call again there. */ async run<T>(call: (client: S3) => Promise<T>): Promise<T> { try { return await call(this.client); } catch (error) { if (this.profile.endpoint !== undefined || !wrongRegion(error)) throw error; const { region } = await headBucket(this.access(), this.name); if (!region || region === this.#region) throw error; trace(`${this.name} is in ${region}, not ${this.#region}`); this.#region = region; this.#client = undefined; if (this.remembered) { updateConfig((config) => { const record = config.buckets[this.alias]; if (record) record.region = region; }); } return await call(this.client); } }}
function wrongRegion(error: unknown): boolean { return error instanceof S3Error && (error.status === 301 || /^(PermanentRedirect|AuthorizationHeaderMalformed|IllegalLocationConstraintException|Http301)$/.test(error.code));}
const buckets = new Map<string, Bucket>();
/** The bucket listed as `alias`: remembered, or reached with `profile` (else the default). */export function bucketNamed(alias: string, profile?: string): Bucket { const key = `${alias}\0${profile ?? ''}`; const cached = buckets.get(key); if (cached) return cached; const record = readConfig().buckets[alias]; // --profile wins over what is remembered: it is how one bucket name at two services is told apart. const bucket = record && (!profile || profile === record.profile) ? new Bucket(alias, profileNamed(record.profile), record) : new Bucket(alias, profileNamed(profile)); buckets.set(key, bucket); return bucket;}
/** For login, logout and forgetting: buckets are looked up afresh. */export function forgetBuckets(): void { buckets.clear();}