From 38e731f6dd01bdd8601c0281133526c011f75392 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 3 Mar 2023 16:40:56 -0800 Subject: [PATCH] bin: add interactive mode I'm slightly concerned that this will hang in CI, the node readline module is a bit touchy when stdin is not a tty. If it's a huge problem, can just make the timeout signal make the test pass rather than fail, even though that's really not ideal. It seems really reliable when it is actually interactive, though. --- README.md | 44 +++++---- src/bin.ts | 150 +++++++++++++++++++++++------ tap-snapshots/test/bin.js.test.cjs | 38 ++++++++ test/bin.js | 130 +++++++++++++++++++++---- 4 files changed, 295 insertions(+), 67 deletions(-) create mode 100644 tap-snapshots/test/bin.js.test.cjs diff --git a/README.md b/README.md index 4d4e9b27..5b5253d5 100644 --- a/README.md +++ b/README.md @@ -168,31 +168,39 @@ Synchronous form of `rimraf.moveRemove()` ### Command Line Interface ``` -rimraf version 4.2.0 +rimraf version 4.3.0 Usage: rimraf [ ...] Deletes all files and folders at "path", recursively. Options: - -- Treat all subsequent arguments as paths - -h --help Display this usage info - --preserve-root Do not remove '/' recursively (default) - --no-preserve-root Do not treat '/' specially - -G --no-glob Treat arguments as literal paths, not globs (default) - -g --glob Treat arguments as glob patterns - - --impl= Specify the implementation to use. - rimraf: choose the best option - native: the built-in implementation in Node.js - manual: the platform-specific JS implementation - posix: the Posix JS implementation - windows: the Windows JS implementation - move-remove: a slower Windows JS fallback implementation + -- Treat all subsequent arguments as paths + -h --help Display this usage info + --preserve-root Do not remove '/' recursively (default) + --no-preserve-root Do not treat '/' specially + -G --no-glob Treat arguments as literal paths, not globs (default) + -g --glob Treat arguments as glob patterns + -v --verbose Be verbose when deleting files, showing them as + they are removed. Not compatible with --impl=native + -V --no-verbose Be silent when deleting files, showing nothing as + they are removed (default) + -i --interactive Ask for confirmation before deleting anything + Not compatible with --impl=native + -I --no-interactive Do not ask for confirmation before deleting + + --impl= Specify the implementation to use: + rimraf: choose the best option (default) + native: the built-in implementation in Node.js + manual: the platform-specific JS implementation + posix: the Posix JS implementation + windows: the Windows JS implementation (falls back to + move-remove on ENOTEMPTY) + move-remove: a slow reliable Windows fallback Implementation-specific options: - --tmp= Folder to hold temp files for 'move-remove' implementation - --max-retries= maxRetries for the 'native' and 'windows' implementations - --retry-delay= retryDelay for the 'native' implementation, default 100 + --tmp= Temp file folder for 'move-remove' implementation + --max-retries= maxRetries for 'native' and 'windows' implementations + --retry-delay= retryDelay for 'native' implementation, default 100 --backoff= Exponential backoff factor for retries (default: 1.2) ``` diff --git a/src/bin.ts b/src/bin.ts index ad49e31f..73e64dc9 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { version } from '../package.json' import rimraf from './index-cjs.js' -import type { RimrafOptions } from './index.js' +import type { RimrafAsyncOptions } from './index.js' const runHelpForUsage = () => console.error('run `rimraf --help` for usage information') @@ -12,37 +12,110 @@ Usage: rimraf [ ...] Deletes all files and folders at "path", recursively. Options: - -- Treat all subsequent arguments as paths - -h --help Display this usage info - --preserve-root Do not remove '/' recursively (default) - --no-preserve-root Do not treat '/' specially - -G --no-glob Treat arguments as literal paths, not globs (default) - -g --glob Treat arguments as glob patterns - -v --verbose Be verbose when deleting files, showing them as - they are removed - -V --no-verbose Be silent when deleting files, showing nothing as - they are removed (default) - - --impl= Specify the implementation to use. - rimraf: choose the best option - native: the built-in implementation in Node.js - manual: the platform-specific JS implementation - posix: the Posix JS implementation - windows: the Windows JS implementation - move-remove: a slower Windows JS fallback implementation + -- Treat all subsequent arguments as paths + -h --help Display this usage info + --preserve-root Do not remove '/' recursively (default) + --no-preserve-root Do not treat '/' specially + -G --no-glob Treat arguments as literal paths, not globs (default) + -g --glob Treat arguments as glob patterns + -v --verbose Be verbose when deleting files, showing them as + they are removed. Not compatible with --impl=native + -V --no-verbose Be silent when deleting files, showing nothing as + they are removed (default) + -i --interactive Ask for confirmation before deleting anything + Not compatible with --impl=native + -I --no-interactive Do not ask for confirmation before deleting + + --impl= Specify the implementation to use: + rimraf: choose the best option (default) + native: the built-in implementation in Node.js + manual: the platform-specific JS implementation + posix: the Posix JS implementation + windows: the Windows JS implementation (falls back to + move-remove on ENOTEMPTY) + move-remove: a slow reliable Windows fallback Implementation-specific options: - --tmp= Folder to hold temp files for 'move-remove' implementation - --max-retries= maxRetries for the 'native' and 'windows' implementations - --retry-delay= retryDelay for the 'native' implementation, default 100 + --tmp= Temp file folder for 'move-remove' implementation + --max-retries= maxRetries for 'native' and 'windows' implementations + --retry-delay= retryDelay for 'native' implementation, default 100 --backoff= Exponential backoff factor for retries (default: 1.2) ` import { parse, relative, resolve } from 'path' const cwd = process.cwd() +import { createInterface, Interface } from 'readline' + +const prompt = async (rl: Interface, q: string) => + new Promise(res => rl.question(q, res)) + +const interactiveRimraf = async ( + impl: (path: string | string[], opt?: RimrafAsyncOptions) => Promise, + paths: string[], + opt: RimrafAsyncOptions +) => { + const existingFilter = opt.filter || (() => true) + let allRemaining = false + let noneRemaining = false + const queue: (() => Promise)[] = [] + let processing = false + const processQueue = async () => { + if (processing) return + processing = true + let next: (() => Promise) | undefined + while ((next = queue.shift())) { + await next() + } + processing = false + } + const oneAtATime = + (fn: (s: string) => Promise) => + async (s: string): Promise => { + const p = new Promise(res => { + queue.push(async () => { + const result = await fn(s) + res(result) + return result + }) + }) + processQueue() + return p + } + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }) + opt.filter = oneAtATime(async (path: string): Promise => { + if (noneRemaining) { + return false + } + while (!allRemaining) { + const a = (await prompt( + rl, + `rm? ${relative(cwd, path)}\n[(Yes)/No/All/Quit] > ` + )).trim() + if (/^n/i.test(a)) { + return false + } else if (/^a/i.test(a)) { + allRemaining = true + break + } else if (/^q/i.test(a)) { + noneRemaining = true + return false + } else if (a === '' || /^y/i.test(a)) { + break + } else { + continue + } + } + return existingFilter(path) + }) + await impl(paths, opt) + rl.close() +} + const main = async (...args: string[]) => { - const yesFilter = () => true const verboseFilter = (s: string) => { console.log(relative(cwd, s)) return true @@ -52,11 +125,15 @@ const main = async (...args: string[]) => { throw new Error('simulated rimraf failure') } - const opt: RimrafOptions = {} + const opt: RimrafAsyncOptions = {} const paths: string[] = [] let dashdash = false - let impl: (path: string | string[], opt?: RimrafOptions) => Promise = - rimraf + let impl: ( + path: string | string[], + opt?: RimrafAsyncOptions + ) => Promise = rimraf + + let interactive = false for (const arg of args) { if (dashdash) { @@ -72,11 +149,17 @@ const main = async (...args: string[]) => { } else if (arg === '-h' || arg === '--help') { console.log(help) return 0 + } else if (arg === '--interactive' || arg === '-i') { + interactive = true + continue + } else if (arg === '--no-interactive' || arg === '-I') { + interactive = false + continue } else if (arg === '--verbose' || arg === '-v') { opt.filter = verboseFilter continue } else if (arg === '--no-verbose' || arg === '-V') { - opt.filter = yesFilter + opt.filter = undefined continue } else if (arg === '-g' || arg === '--glob') { opt.glob = true @@ -151,7 +234,18 @@ const main = async (...args: string[]) => { return 1 } - await impl(paths, opt) + if (impl === rimraf.native && (interactive || opt.filter)) { + console.error('native implementation does not support -v or -i') + runHelpForUsage() + return 1 + } + + if (interactive) { + await interactiveRimraf(impl, paths, opt) + } else { + await impl(paths, opt) + } + return 0 } main.help = help diff --git a/tap-snapshots/test/bin.js.test.cjs b/tap-snapshots/test/bin.js.test.cjs new file mode 100644 index 00000000..91e4871d --- /dev/null +++ b/tap-snapshots/test/bin.js.test.cjs @@ -0,0 +1,38 @@ +/* IMPORTANT + * This snapshot file is auto-generated, but designed for humans. + * It should be checked into source control and tracked carefully. + * Re-generate by setting TAP_SNAPSHOT=1 and running tests. + * Make sure to inspect the output below. Do not ignore changes! + */ +'use strict' +exports[`test/bin.js TAP interactive deletes -V a > had any leftover 1`] = ` +false +` + +exports[`test/bin.js TAP interactive deletes -V hehaha, yes i think so, , A > had any leftover 1`] = ` +false +` + +exports[`test/bin.js TAP interactive deletes -V no, n, N, N, Q > had any leftover 1`] = ` +true +` + +exports[`test/bin.js TAP interactive deletes -V y, YOLO, no, quit > had any leftover 1`] = ` +true +` + +exports[`test/bin.js TAP interactive deletes -v a > had any leftover 1`] = ` +false +` + +exports[`test/bin.js TAP interactive deletes -v hehaha, yes i think so, , A > had any leftover 1`] = ` +false +` + +exports[`test/bin.js TAP interactive deletes -v no, n, N, N, Q > had any leftover 1`] = ` +true +` + +exports[`test/bin.js TAP interactive deletes -v y, YOLO, no, quit > had any leftover 1`] = ` +true +` diff --git a/test/bin.js b/test/bin.js index f8e75576..f3ef86e3 100644 --- a/test/bin.js +++ b/test/bin.js @@ -1,4 +1,6 @@ +const { basename } = require('path') const t = require('tap') +const { readdirSync } = require('fs') t.test('basic arg parsing stuff', t => { const LOGS = [] @@ -66,20 +68,6 @@ t.test('basic arg parsing stuff', t => { ]) }) - t.test('verbose', async t => { - t.equal(await bin('-v', 'foo'), 0) - t.equal(await bin('--verbose', 'foo'), 0) - t.equal(await bin('-v', '-V', '--verbose', 'foo'), 0) - t.same(LOGS, []) - t.same(ERRS, []) - for (const c of CALLS) { - t.equal(c[0], 'rimraf') - t.same(c[1], ['foo']) - t.type(c[2].filter, 'function') - t.equal(c[2].filter('x'), true) - } - }) - t.test('verbose', async t => { t.equal(await bin('-v', 'foo'), 0) t.equal(await bin('--verbose', 'foo'), 0) @@ -87,9 +75,11 @@ t.test('basic arg parsing stuff', t => { t.same(LOGS, []) t.same(ERRS, []) const { log } = console - t.teardown(() => { console.log = log }) + t.teardown(() => { + console.log = log + }) const logs = [] - console.log = (s) => logs.push(s) + console.log = s => logs.push(s) for (const c of CALLS) { t.equal(c[0], 'rimraf') t.same(c[1], ['foo']) @@ -107,14 +97,15 @@ t.test('basic arg parsing stuff', t => { t.same(LOGS, []) t.same(ERRS, []) const { log } = console - t.teardown(() => { console.log = log }) + t.teardown(() => { + console.log = log + }) const logs = [] - console.log = (s) => logs.push(s) + console.log = s => logs.push(s) for (const c of CALLS) { t.equal(c[0], 'rimraf') t.same(c[1], ['foo']) - t.type(c[2].filter, 'function') - t.equal(c[2].filter('x'), true) + t.type(c[2].filter, 'undefined') t.same(logs, []) } }) @@ -221,6 +212,26 @@ t.test('basic arg parsing stuff', t => { t.same(CALLS, []) }) + t.test('native cannot do filters', async t => { + t.equal(await bin('--impl=native', '-v', 'foo'), 1) + t.same(ERRS, [ + ['native implementation does not support -v or -i'], + ['run `rimraf --help` for usage information'], + ]) + ERRS.length = 0 + t.equal(await bin('--impl=native', '-i', 'foo'), 1) + t.same(ERRS, [ + ['native implementation does not support -v or -i'], + ['run `rimraf --help` for usage information'], + ]) + ERRS.length = 0 + t.same(CALLS, []) + t.same(LOGS, []) + // ok to turn it on and back off though + t.equal(await bin('--impl=native', '-i', '-I', 'foo'), 0) + t.same(CALLS, [['native', ['foo'], {}]]) + }) + const impls = [ 'rimraf', 'native', @@ -253,7 +264,7 @@ t.test('actually delete something with it', async t => { const bin = require.resolve('../dist/cjs/src/bin.js') const { spawnSync } = require('child_process') const res = spawnSync(process.execPath, [bin, path]) - const { statSync } = require('fs') + const { statSync, readdirSync } = require('fs') t.throws(() => statSync(path)) t.equal(res.status, 0) }) @@ -280,3 +291,80 @@ t.test('print failure when impl throws', async t => { t.equal(res.status, 1) t.match(res.stderr.toString(), /^Error: simulated rimraf failure/) }) + +t.test('interactive deletes', t => { + const scripts = [ + ['a'], + ['y', 'YOLO', 'no', 'quit'], + ['hehaha', 'yes i think so', '', 'A'], + ['no', 'n', 'N', 'N', 'Q'], + ] + const fixture = { + a: { b: '', c: '', d: '' }, + b: { c: '', d: '', e: '' }, + c: { d: '', e: '', f: '' }, + } + const verboseOpt = ['-v', '-V'] + + // t.jobs = scripts.length * verboseOpt.length + + const { spawn } = require('child_process') + const bin = require.resolve('../dist/cjs/src/bin.js') + const node = process.execPath + + const leftovers = d => { + try { + readdirSync(d) + return true + } catch (_) { + return false + } + } + + for (const verbose of verboseOpt) { + t.test(verbose, async t => { + for (const s of scripts) { + const script = s.slice() + t.test(script.join(', '), async t => { + const d = t.testdir(fixture) + const args = [bin, '-i', verbose, d] + const child = spawn(node, args, { + stdio: 'pipe', + }) + const out = [] + const err = [] + const timer = setTimeout(() => { + t.fail('timed out') + child.kill('SIGKILL') + }, 10000) + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', async c => { + await new Promise(r => setTimeout(r, 50)) + out.push(c.trim()) + const s = script.shift() + if (s !== undefined) { + out.push(s.trim()) + child.stdin.write(s + '\n') + } + }) + child.stderr.on('data', c => { + err.push(c) + }) + return new Promise(res => { + child.on('close', (code, signal) => { + clearTimeout(timer) + t.same(err, [], 'should not see any stderr') + t.equal(code, 0, 'code') + t.equal(signal, null, 'signal') + t.matchSnapshot(leftovers(d), 'had any leftover') + res() + }) + }) + }) + } + t.end() + }) + } + t.end() +})