Something went wrong. Try again.
A nicer S3 CLI
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299import * as FS from 'node:fs';import * as OS from 'node:os';import * as PATH from 'node:path';import { findSubcommand, namesOf, optionsOf, type CommandSpec, type Completer } from './command.ts';import { readConfig, SETTINGS } from './config.ts';import { basename, dirname, resolveRemote } from './paths.ts';import { bucketNamed } from './remote.ts';import { listFolder } from './transfer.ts';
// Tab completion, one engine for both places it happens://// * the interactive shell's readline, where paths are relative to the// current folder and local files are listed here;// * bash, zsh, fish and PowerShell, through `s3cli __complete` — wired up// by the lines `s3cli completion <shell>` prints (after atcli's, which is// after tangled's and bundle's). There a local file is left to the// shell's own completion, which knows its quoting best.//// What is offered comes from the command tree itself — the same subcommands,// options, choices and descriptions the parser and help use — plus profiles,// remembered buckets, settings, and the folders and files in a bucket. That// last one asks the service: worth it, since keys are what one cannot type// from memory, but bounded, so a Tab never hangs on a slow network.
export interface Candidate { value: string; description?: string | undefined;}
export interface Completion { candidates: Candidate[]; /** Complete local file names instead, with the shell's own completion. */ files: boolean;}
export const SHELLS = ['bash', 'zsh', 'fish', 'powershell'] as const;
/** Split a line as a shell would: whitespace between words, '…' and "…" quote, \ escapes (unless `backslash` is off). */export function tokenize(line: string, { backslash = true }: { backslash?: boolean } = {}): { words: string[]; current: string; open: boolean; ended: boolean } { const words: string[] = []; let word = ''; let inWord = false; let quote: '"' | "'" | undefined; for (let i = 0; i < line.length; i++) { const char = line[i]!; if (quote) { if (char === quote) quote = undefined; else if (backslash && char === '\\' && quote === '"' && i + 1 < line.length) word += line[++i]; else word += char; } else if (char === '"' || char === "'") { quote = char; inWord = true; } else if (backslash && char === '\\' && i + 1 < line.length) { word += line[++i]; inWord = true; } else if (/\s/.test(char)) { if (inWord) words.push(word); word = ''; inWord = false; } else { word += char; inWord = true; } } if (inWord) words.push(word); return { words, current: inWord ? word : '', open: quote !== undefined, ended: !inWord };}
export interface CompleteOptions { /** The current remote folder (`/` on the command line). */ cwd: string; /** List local files here (the shell), rather than leave them to the shell's completion. */ localFiles: boolean; /** How long a remote listing may take before Tab gives up on it. */ timeoutMs?: number | undefined; /** --profile, for buckets that are not remembered. */ profile?: string | undefined;}
/** What to offer for `current`, after the words before it. */export async function complete(root: CommandSpec, words: readonly string[], current: string, options: CompleteOptions): Promise<Completion> { let command = root; let positional = 0; let pending: Completer | undefined | false = false; let profile = options.profile; for (const word of words) { if (pending !== false) { pending = false; continue; } const sub = positional === 0 ? findSubcommand(command, word) : undefined; if (sub) { command = sub; continue; } if (word.startsWith('-') && word !== '-') { const spec = optionsOf(command).find((option) => `--${option.name}` === word || (option.short && `-${option.short}` === word)); if (spec?.name === 'profile') profile = words[words.indexOf(word) + 1]; if (spec && spec.type !== 'boolean' && !word.includes('=')) pending = spec.complete ?? spec.choices; continue; } positional++; } const filter = (candidates: Candidate[], prefix = ''): Completion => ({ candidates: candidates.filter(({ value }) => value.startsWith(current.slice(prefix.length))).map((each) => ({ ...each, value: prefix + each.value })), files: false, }); if (pending !== false) return await completeValue(pending, current, { ...options, profile }, filter); if (current.startsWith('-')) { return filter(optionsOf(command).filter((option) => !option.hidden).map((option) => ({ value: `--${option.name}`, description: option.description }))); } if (command.subcommands?.length && positional === 0) { return filter(command.subcommands.filter((sub) => !sub.hidden).flatMap((sub) => namesOf(sub).map((name) => ({ value: name, description: sub.summary })))); } const args = command.args ?? []; const arg = args[Math.min(positional, args.length - 1)]; return await completeValue(arg?.complete, current, { ...options, profile }, filter);}
async function completeValue(completer: Completer | undefined, current: string, options: CompleteOptions, filter: (candidates: Candidate[], prefix?: string) => Completion): Promise<Completion> { if (!completer) return { candidates: [], files: false }; if (Array.isArray(completer)) return filter(completer.map((value) => ({ value }))); const config = readConfig(); switch (completer) { case 'remote': case 'remote-dir': { const folder = current.endsWith('/') ? current : dirname(current); const names = await withTimeout(remoteNames(options.cwd, folder, options.profile), options.timeoutMs); return filter((names ?? []).filter((name) => completer === 'remote' || name.endsWith('/')).map((value) => ({ value })), folder); } case 'local': case 'local-dir': { if (!options.localFiles) return { candidates: [], files: true }; const folder = current.endsWith('/') ? current : current.includes('/') ? current.slice(0, current.lastIndexOf('/') + 1) : ''; return filter(localNames(folder, completer === 'local-dir').map((value) => ({ value })), folder); } case 'profile': return filter(Object.values(config.profiles).map((each) => ({ value: each.name, description: each.endpoint ?? 'AWS' }))); case 'bucket': return filter(Object.entries(config.buckets).map(([alias, record]) => ({ value: alias, description: record.profile }))); case 'setting': return filter(Object.entries(SETTINGS).map(([key, spec]) => ({ value: key, description: spec.description }))); case 'command': return { candidates: [], files: false }; } return { candidates: [], files: false };}
async function withTimeout<T>(promise: Promise<T>, ms: number | undefined): Promise<T | undefined> { const guarded = promise.catch(() => undefined); if (!ms) return await guarded; let timer: NodeJS.Timeout | undefined; const timeout = new Promise<undefined>((resolve) => { timer = setTimeout(() => resolve(undefined), ms); timer.unref(); }); try { return await Promise.race([guarded, timeout]); } finally { clearTimeout(timer); }}
const remoteCache = new Map<string, { at: number; names: string[] }>();
/** The names in a remote folder: buckets at the root; folders end in '/'. Cached a few seconds, for the shell. */async function remoteNames(cwd: string, folder: string, profile: string | undefined): Promise<string[]> { const remote = resolveRemote(cwd, folder || '.'); const key = `${profile ?? ''}\0${remote.bucket}/${remote.key}`; const cached = remoteCache.get(key); if (cached && Date.now() - cached.at < 5000) return cached.names; let names: string[]; if (!remote.bucket) names = Object.keys(readConfig().buckets).map((name) => `${name}/`); else { const prefix = remote.key && !remote.key.endsWith('/') ? `${remote.key}/` : remote.key; const entries = await listFolder(bucketNamed(remote.bucket, profile), prefix); names = entries.flatMap((entry) => (entry.kind === 'prefix' ? [`${basename(entry.prefix)}/`] : entry.name === prefix ? [] : [basename(entry.name)])); } remoteCache.set(key, { at: Date.now(), names }); return names;}
function localNames(folder: string, onlyFolders: boolean): string[] { const dir = folder.startsWith('~') ? PATH.join(OS.homedir(), folder.slice(1)) : folder || '.'; try { return FS.readdirSync(dir, { withFileTypes: true }) .filter((entry) => !onlyFolders || entry.isDirectory()) .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name)); } catch { return []; }}
// ------------------------------------------------------- the OS's shells ---
/** `s3cli __complete …`: the candidates, as the shell asking wants them, one per line. */export async function respond(root: CommandSpec, argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<string[]> { const shellAt = argv.indexOf('--shell'); const shell = shellAt >= 0 ? argv[shellAt + 1] : 'bash'; const dashes = argv.indexOf('--'); let words: string[]; let current: string; if (dashes >= 0) { const given = argv.slice(dashes + 1); current = given.length ? given[given.length - 1]! : ''; words = given.slice(0, -1); } else { const line = (env['COMP_LINE'] ?? '').slice(0, Number(env['COMP_POINT'] ?? env['COMP_LINE']?.length ?? 0)); // PowerShell's escape character is the backtick; a backslash there is a path separator. const split = tokenize(line, { backslash: shell !== 'powershell' }); words = split.words.slice(1); current = split.current; if (!split.ended) words.pop(); } const { candidates, files } = await complete(root, words, current, { cwd: '/', localFiles: false, timeoutMs: 1500 }); if (shell === 'fish' || shell === 'powershell') { if (files) return [':files']; return candidates.map(({ value, description }) => (description ? `${value}\t${description}` : value)); } // Printing nothing makes bash and zsh fall back to file names (`complete -o default`). if (files) return []; // The scripts ask for no space after a completion (-o nospace), so that a // folder (`photos/`) stays open for its contents; a finished word gets its // space here instead. const spaced = (value: string) => (value.endsWith('/') ? value : `${value} `); if (shell === 'zsh') return candidates.map(({ value }) => spaced(value)); // bash replaces only what follows the last word-break character (`:` and `=` among them). const breaks = env['COMP_WORDBREAKS'] ?? ' \t\n"\'><=;|&(:'; let cut = -1; for (const char of breaks) cut = Math.max(cut, current.lastIndexOf(char)); return candidates.map(({ value }) => spaced(value.slice(cut + 1)));}
function psQuote(text: string): string { return `'${text.replace(/'/g, "''")}'`;}
/** What to evaluate at shell start for Tab completion. */export function script(shell: string, name = 's3cli'): string { const fn = `__${name.replace(/\W/g, '_')}_complete`; const lines = (...parts: string[]) => `${parts.join('\n')}\n`; switch (shell) { case 'bash': return lines( `# ${name} completion for bash. In ~/.bashrc: eval "$(${name} completion bash)"`, // -o nospace: a folder (ends in /) is not a finished word; `respond` spaces the rest. `complete -o default -o nospace -C '${name} __complete --shell bash' ${name}`, ); case 'zsh': return lines( `# ${name} completion for zsh. In ~/.zshrc: eval "$(${name} completion zsh)"`, `(( $+functions[compdef] )) || { autoload -Uz compinit && compinit; }`, `autoload -U +X bashcompinit && bashcompinit`, `complete -o default -o nospace -C '${name} __complete --shell zsh' ${name}`, ); case 'fish': return lines( `# ${name} completion for fish. In ~/.config/fish/config.fish: ${name} completion fish | source`, `complete -c ${name} -e`, `function ${fn}`, ` set -l tokens (commandline -opc)`, ` set -l current (commandline -ct)`, ` set -l out (${name} __complete --shell fish -- $tokens[2..-1] "$current" 2>/dev/null)`, ` if test "$out[1]" = ':files'`, ` __fish_complete_path "$current"`, ` else`, ` printf '%s\\n' $out`, ` end`, `end`, `complete -c ${name} -f -a '(${fn})'`, ); case 'powershell': case 'pwsh': { const names = [name, `${name}.cmd`, `${name}.nzip`].map(psQuote).join(', '); return lines( `# ${name} completion for PowerShell. In $PROFILE: ${name} completion powershell | Out-String | Invoke-Expression`, `Register-ArgumentCompleter -Native -CommandName ${names} -ScriptBlock {`, ` param($wordToComplete, $commandAst, $cursorPosition)`, ` $at = $cursorPosition - $commandAst.Extent.StartOffset`, ` $line = $commandAst.Extent.Text.PadRight($at).Substring(0, $at)`, ` $encoding = [Console]::OutputEncoding`, ` try {`, ` [Console]::OutputEncoding = [Text.Encoding]::UTF8`, ` $env:COMP_LINE = $line`, ` $env:COMP_POINT = $line.Length`, ` $out = @(& ${psQuote(name)} __complete --shell powershell 2>$null)`, ` } finally {`, ` [Console]::OutputEncoding = $encoding`, ` Remove-Item Env:COMP_LINE, Env:COMP_POINT -ErrorAction SilentlyContinue`, ` }`, ` if ($out.Count -gt 0 -and $out[0] -eq ':files') { return }`, ` foreach ($candidate in $out) {`, " $value, $description = $candidate -split \"`t\", 2", ` $type = if ($value.StartsWith('-')) { 'ParameterName' } else { 'ParameterValue' }`, ` [System.Management.Automation.CompletionResult]::new($value, $value, $type, $(if ($description) { $description } else { $value }))`, ` }`, `}`, ); } default: throw new Error(`no completion for the shell '${shell}': bash, zsh, fish or powershell`); }}