Something went wrong. Try again.
A nicer S3 CLI
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173import * as FS from 'node:fs';import * as PATH from 'node:path';import * as Readline from 'node:readline';import { spawnSync } from 'node:child_process';import { execute } from './cli.ts';import { complete, tokenize } from './completion.ts';import type { Session } from './context.ts';import { configDir, readConfig, setting } from './config.ts';import { EXIT } from './errors.ts';import { bold, cyan, dim, setColour } from './output.ts';import { setAsker } from './prompt.ts';import { shellRoot } from './commands/index.ts';import { version } from './version.ts';
// The interactive shell, in the manner of ftp: a current folder in the// bucket tree, commands that read and change it, Tab to complete, and// history kept between sessions. Every line goes through the same parser and// commands as the command line (cli.ts `execute`), with the shell's tree:// the same commands, less login, plus cd, lcd and the like.//// With a terminal, it prompts; without one, it is a batch: each line of stdin// a command, stopping at the first failure (unless the line starts with `-`).
/** A word as it can be typed back: spaces and quotes escaped. */function escape(word: string): string { return word.replace(/([\s"'\\])/g, '\\$1');}
function historyFile(): string { return PATH.join(configDir(), 'history');}
function loadHistory(): string[] { try { // readline wants the newest first. return FS.readFileSync(historyFile(), 'utf-8').split('\n').filter(Boolean).reverse(); } catch { return []; }}
function saveHistory(history: readonly string[]): void { const size = Number(setting('history-size')); try { FS.mkdirSync(configDir(), { recursive: true, mode: 0o700 }); FS.writeFileSync(historyFile(), `${history.slice(0, size).reverse().join('\n')}\n`, { mode: 0o600 }); } catch { // History is a convenience: not worth failing the exit over. }}
function prompt(session: Session): string { return `${cyan('s3')}${dim(':')}${bold(session.cwd)}${dim('>')} `;}
/** A local command, for `!ls`; `!` alone starts the user's shell. */function local(command: string): number { const shell = process.env['SHELL'] || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh'); const args = command ? (process.platform === 'win32' ? ['/c', command] : ['-c', command]) : []; const result = spawnSync(shell, args, { stdio: 'inherit' }); return result.status ?? EXIT.error;}
export async function runShell(cwd: string, options: { profile?: string | undefined }): Promise<number> { const session: Session = { cwd, shell: true }; // `s3cli shell --profile x`: every line acts as if it had said --profile x. const extra = options.profile ? ['--profile', options.profile] : []; const run = async (words: string[]): Promise<number> => { if (!words.length) return EXIT.ok; return await execute(shellRoot, [...words, ...(extra.length && !words.includes('--profile') && !words.includes('-P') ? extra : [])], session); }; return process.stdin.isTTY ? await interactiveLoop(session, run, options) : await batch(session, run);}
async function batch(session: Session, run: (words: string[]) => Promise<number>): Promise<number> { setColour(false); const rl = Readline.createInterface({ input: process.stdin, terminal: false, crlfDelay: Infinity }); let number = 0; for await (const raw of rl) { number++; let line = raw.trim(); if (!line || line.startsWith('#')) continue; const tolerant = line.startsWith('-'); if (tolerant) line = line.slice(1).trimStart(); const code = line.startsWith('!') ? local(line.slice(1).trim()) : await run(tokenize(line).words); if (code !== EXIT.ok && !tolerant) { process.stderr.write(`stopped at line ${number}: ${line}\n`); rl.close(); return code; } if (session.quit) break; } return EXIT.ok;}
async function interactiveLoop(session: Session, run: (words: string[]) => Promise<number>, options: { profile?: string | undefined }): Promise<number> { const rl = Readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, history: loadHistory(), historySize: Number(setting('history-size')), removeHistoryDuplicates: true, completer: (line: string, callback: (error: null, result: [string[], string]) => void) => { const { words, current } = tokenize(line); const before = current ? words.slice(0, -1) : words; complete(shellRoot, before, current, { cwd: session.cwd, localFiles: true, timeoutMs: 3000, profile: options.profile }) .then(({ candidates }) => callback(null, [candidates.map(({ value }) => escape(value)), escape(current)]), () => callback(null, [[], line])); }, }) as Readline.Interface & { history: string[] };
// Questions commands ask (rm's "Delete 3 files?") go through this readline, not a second one. setAsker((question) => new Promise((resolve) => rl.question(question, resolve)));
let running: AbortController | undefined; let closed = false; rl.on('SIGINT', () => { if (running) { running.abort(new Error('cancelled')); return; } // At the prompt, Ctrl-C clears the line, as a shell's does. // Clearing the line redraws the prompt, on the line after the ^C. process.stdout.write('^C\n'); rl.write(null, { ctrl: true, name: 'u' }); }); rl.on('close', () => { closed = true; });
const count = Object.keys(readConfig().buckets).length; process.stdout.write(`${bold('s3cli')} ${version()} — ${count ? `${count} bucket${count === 1 ? '' : 's'} at /` : "no buckets remembered yet: 'bucket add <name>', or quit and 's3cli login'"}. ${dim("'help' lists the commands; Ctrl-D leaves.")}\n`);
while (!closed && !session.quit) { const line = await new Promise<string | undefined>((resolve) => { const onClose = () => resolve(undefined); rl.once('close', onClose); rl.question(prompt(session), (answer) => { rl.off('close', onClose); resolve(answer); }); }); if (line === undefined) break; const trimmed = line.trim(); if (!trimmed) continue; if (trimmed.startsWith('!')) { rl.pause(); local(trimmed.slice(1).trim()); rl.resume(); continue; } const { words, open } = tokenize(trimmed); if (open) { process.stderr.write('error: a quote is not closed\n'); continue; } running = new AbortController(); session.signal = running.signal; try { await run(words); } finally { running = undefined; session.signal = undefined; } } setAsker(undefined); saveHistory(rl.history); if (!closed) rl.close(); process.stdout.write('\n'); // An interactive session ends well however its last command went: each said so as it failed. return EXIT.ok;}