Git Product home page Git Product logo

dart_cli_script's Introduction

Dart CLI Scripting

This package is designed to make it easy to write scripts that call out to subprocesses with the ease of shell scripting and the power of Dart. It captures the core virtues of shell scripting: terseness, pipelining, and composability. At the same time, it uses standard Dart idioms like exceptions, Streams, and Futures, with a few extensions to make them extra easy to work with in a scripting context.

While cli_script can be used as a library in any Dart application, its primary goal is to support stand-alone scripts that serve the same purpose as shell scripts. Because they're just normal Dart code, with static types and data structures and the entire Dart ecosystem at your fingertips, these scripts will be much more maintainable than their Shell counterparts without sacrificing ease of use.

Here's an example of a simple Hello World script:

import 'package:cli_script/cli_script.dart';

void main() {
  wrapMain(() async {
    await run('echo "Hello, world!"');
  });
}

(Note that wrapMain() isn't strictly necessary here, but it handles errors much more nicely than Dart's built-in handling!)

Many programming environments have tried to make themselves suitable for shell scripting, but in the end they all fall far short of the ease of calling out to subprocesseses in Bash. As such, a principal design goal of cli_script is to identify the core virtues that make shell scripting so appealing and reproduce them as closely as possible in Dart:

Terseness

Shell scripts make it very easy to write code that calls out to child processes tersely, without needing to write a bunch of boilerplate. Running a child process is as simple as calling run():

import 'package:cli_script/cli_script.dart';

void main() {
  wrapMain(() async {
    await run("mkdir -p path/to/dir");
    await run("touch path/to/dir/foo");
  });
}

Similarly, it's easy to get the output of a command just like you would using "$(command)" in a shell, using either output() to get a single string or lines() to get a stream of lines:

import 'package:cli_script/cli_script.dart';

void main() {
  wrapMain(() async {
    await for (var file in lines("find . -type f -maxdepth 1")) {
      var contents = await output("cat", args: [file]);
      if (contents.contains("needle")) print(file);
    }
  });
}

You can also use check() to test whether a script returns exit code 0 or not:

import 'package:cli_script/cli_script.dart';

void main() {
  wrapMain(() async {
    await for (var file in lines("find . -type f -maxdepth 1")) {
      if (await check("grep -q needle", args: [file])) print(file);
    }
  });
}

The Script Class

All of these top-level functions are just thin wrappers around the Script class at the heart of cli_script. This class represents a subprocess (or something process-like) and provides access to its stdin, stdout, stderr, and exitCode.

Although stdout and stderr are just simple Stream<List<int>>s, representing raw binary data, they're still easy to work with thanks to cli_script's extension methods. These make it easy to transform byte streams into line streams or just plain strings.

Do The Right Thing

Terseness also means that you don't need any extra boilerplate to ensure that the right thing happens when something goes wrong. In a shell script, if you don't redirect a subprocess's output it will automatically print it for the user to see, so you can automatically see any errors it prints. In cli_script, if you don't listen to a Script's stdout or stderr streams immediately after creating it, they'll be redirected to the parent script's stdout or stderr, respectively.

Similarly, in a shell script with set -e the script will abort as soon as a child process fails unless that process is in an if statement or similar. In cli_script, a Script will throw an exception if it exits with a failing exit code unless the exitCode or success fields are accessed.

Heads up: If you do want to handle a Script's stdout, stderr, or exitCode make sure to set up your handlers synchronously after you create the Script! If you try to listen too late, you'll get "Stream has already been listened to" errors because the streams have already been piped into the parent process's output.

Pipelining

In shell scripts, it's easy to hook multiple processes together in a pipeline where each one passes its output to the next. cli_script supports this to, using the | operator. This pipes all stdout from one script into another script's stdin and returns a new script that encapsulates both. This new script works just like a Bash pipeline with set -o pipefail: it forwards the last script's stdout and stderr, but it'll fail if any script in the pipeline fails.

import 'package:cli_script/cli_script.dart';

void main() {
  wrapMain(() async {
    var pipeline = Script("find -name *.dart") |
        Script("xargs grep waitFor") |
        Script("wc -l");
    print("${await pipeline.stdout.text} instances of waitFor");
  });
}

Depending on how you're using a pipeline, you may find it more convenient to use the Script.pipeline constructor. This works just like the | operator, it just uses a different syntax.

import 'package:cli_script/cli_script.dart';

void main() {
  wrapMain(() async {
    var count = await Script.pipeline([
      Script("find -name *.dart"),
      Script("xargs grep waitFor"),
      Script("wc -l")
    ]).stdout.text;
    print("$count instances of waitFor");
  });
}

You can even include certain StreamTransformers in pipelines: those that transform byte streams (StreamTransformer<List<int>, List<int>>) and those that transform streams of lines (StreamTransformer<String, String>). These act like scripts that transform their stdin into stdout according to the logic of the transformer.

import 'dart:io';

import 'package:cli_script/cli_script.dart';

void main() {
  wrapMain(() async {
    Script("cat data.gz") |
        zlib.decoder |
        Script("grep needle");
  });
}

In addition to piping scripts together, you can pipe the following types into scripts:

  • Stream<List<int>> (a stream of chunked binary data)
  • Stream<String> (a stream of lines of text)
  • List<List<int>> (chunked binary data)
  • List<int> (a single binary blob)
  • List<String> (lines of text)
  • String (a single text blob)

This makes it easy to pass standard Dart data into process, such as files:

import 'dart:io';

import 'package:cli_script/cli_script.dart';

void main() {
  wrapMain(() async {
    var pipeline = read("names.txt") |
        Script("grep Natalie") |
        Script("wc -l");
    print("There are ${await pipeline.stdout.text} Natalies");
  });
}

Script, Stream<List<int>>, and Stream<String> also support the > operator as a shorthand for Stream.pipe(). This makes it easy to write the output of a script or pipeline to a file on disk:

import 'dart:io';

import 'package:cli_script/cli_script.dart';

void main() {
  wrapMain(() {
    Script.capture((_) async {
      await for (var file in lines("find . -type f -maxdepth 1")) {
        var contents = await output("cat", args: [file]);
        if (contents.contains("needle")) print(file);
      }
    }) > write("needles.txt");
  });
}

Composability

In shell scripts, everything is a process. Obviously child processes are processes, but functions also have input/output streams and exit codes so that they work like processes to. You can even group a block of code into a virtual process using {}!

In cli_script, anything can be a Script. The most common way to make a script that's not a subprocess is using Script.capture(). This factory constructor runs a block of code and captures all stdout and stderr produced by child scripts (or calls to print()) into that Script's stdout and stderr:

import 'package:cli_script/cli_script.dart';

void main() {
  wrapMain(() async {
    var script = Script.capture((_) async {
      await run("find . -type f -maxdepth 1");
      print("subdir/extra-file");
    });

    await for (var file in script.stdout.lines) {
      if (await check("grep -q needle", args: [file])) print(file);
    }
  });
}

If an exception is thrown within Script.capture(), including by a child process returning an unhandled non-zero exit code, the entire capture block will fail—but it'll fail like a process: by printing error messages to its stderr and emitting a non-zero exit code that can be handled like any other Script's.

Script.capture() also provides access to the script's stdin, as a stream that's passed into the callback. The capture block can ignore this completely, it can use it as input to a child process or, it can do really whatever it wants!

Other Features

Argument Parsing

(This section describes how cli_script parses arguments that your script passes into child processes. For parsing arguments passed into your script, we recommend the args package)

All cli_script functions that spawn subprocesses accept arguments in the same format: a string named executableAndArgs along with a named List<String> parameter named args. This makes it easy to invoke simple commands with very little boilerplate (lines("find . -type f -name '*.dart'")) and easy to pass in dynamically-generated arguments without worrying whether they contain spaces (run("cp -r", args: [source, destination])).

The executableAndArgs string is parsed as a space-separated string. Components can also be surrounded by single quotes or double quotes, which will allow them to contain spaces, as in run("git commit -m 'A commit message'"). Characters can also be escaped with \. The best way to make sure the backslash isn't just consumed by Dart itself is to use a raw string, as in run(r"git commit -m A\ commit\ message").

The arguments from executableAndArgs always come before the arguments in args. You can also manually escape an argument for interpolation into executableAndArgs using the [arg()] function, as in run("cp -r ${arg(source)} build/").

Globs

On Linux and Mac OS, the executableAndArgs string also automatically performs glob expansions. This means it takes arguments like *.txt and expands them into a list of all matching files. It uses Dart's glob package to expand these globs, so it uses the same syntax as that package.

Just like in a shell, globs aren't used if they appear within quoted strings or if their active characters are backslash-escaped (so find -name '*.dart' or find -name \*.dart will pass the string "*.dart" to the find process). Also, if a glob doesn't match any files, it'll be passed to the child process as a normal argument rather than just omitting the argument.

Search and Replace

You can always continue to use grep and sed for your search-and-replace needs, but cli_script has some useful functions to make that possible without even bothering with a subprocess. The grep(), replace(), and replaceMapped() functions return StreamTransformers that can be used in pipelines just like Scripts:

import 'dart:io';

import 'package:cli_script/cli_script.dart';

void main() {
  wrapMain(() async {
    var pipeline = File("names.txt").openRead() |
        grep("Natalie") |
        Script("wc -l");
    print("There are ${await pipeline.stdout.text} Natalies");
  });
}

There are also corresponding Stream<String>.grep(), Stream<String>.replace(), and Stream<String>.replaceMapped() extension methods that make it easy to do these transformations on individual streams if you need.

dart_cli_script's People

Contributors

goodwine avatar jathak avatar mkustermann avatar nex3 avatar passsy avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

dart_cli_script's Issues

Different results with real terminal for double quotes searchs

Im trying to find files that contains referrer: ". (include " (double quotes))

This is my code that works in terminal
mdfind 'kMDItemTextContent="referrer: \"*"' -onlyin lib/
There is a one file with referrer: " text

But when i tried to use it inside Script like this, im getting all files (12 files) that contains referrer: (not include double quotes version)
Script(r'''mdfind 'kMDItemTextContent="referrer: \"*"' -onlyin lib/''')

I also tried all of these
Script(r'''mdfind 'kMDItemTextContent="referrer: "*"' -onlyin lib/''')
Script("mdfind 'kMDItemTextContent="referrer: "*"' -onlyin lib/")
Script("mdfind 'kMDItemTextContent=referrer: \"*' -onlyin lib/")

As i say, it works in normal terminal, is there any problem with script or package?

MacOS 13.0 M1
flutter doctor -v

[✓] Flutter (Channel stable, 3.7.6, on macOS 13.0 22A380 darwin-arm64, locale tr)
    • Flutter version 3.7.6 on channel stable at /Users/appnation/Desktop/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 12cb4eb7a0 (9 weeks ago), 2023-03-01 10:29:26 -0800
    • Engine revision ada363ee93
    • Dart version 2.19.3
    • DevTools version 2.20.1

[✓] Android toolchain - develop for Android devices (Android SDK version 32.1.0-rc1)
    • Android SDK at /Users/appnation/Library/Android/sdk
    • Platform android-33, build-tools 32.1.0-rc1
    • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 11.0.15+0-b2043.56-8887301)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 14.2)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 14C18
    • CocoaPods version 1.12.0

[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2022.1)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 11.0.15+0-b2043.56-8887301)

[✓] IntelliJ IDEA Ultimate Edition (version 2022.2.4)
    • IntelliJ at /Applications/IntelliJ IDEA.app
    • Flutter plugin version 71.0.5
    • Dart plugin version 222.4459.16

[✓] VS Code (version 1.77.3)
    • VS Code at /Applications/Visual Studio Code.app/Contents
    • Flutter extension can be installed from:
      🔨 https://marketplace.visualstudio.com/items?itemName=Dart-Code.flutter

[✓] Connected device (3 available)
    • iPhone 11 (mobile) • 00008030-001675310E91802E • ios            • iOS 16.3.1 20D67
    • macOS (desktop)    • macos                     • darwin-arm64   • macOS 13.0 22A380 darwin-arm64
    • Chrome (web)       • chrome                    • web-javascript • Google Chrome 112.0.5615.137
    ! Error: (null) needs to connect to determine its availability. Check the connection between the device and its companion iPhone, and the connection between the iPhone and Xcode. Both devices may also
      need to be restarted and unlocked. (code 1)

[✓] HTTP Host Availability
    • All required HTTP hosts are available

• No issues found!

(stack overflow) what's the best way to pipe the output of Script into console?

I'm trying to pipe the output of a Script into the console that invoked the dart command line tool that is using Script.

The idea is that the dart CLI tool runs a script and it's totally transparent and visible to the user in their command line with formatting as intact as possible

What's the recommended way to do it with dart_cli_script?

How to kill a running script?

A Process can be killed with Process().kill() but how can I kill a Script? I have the isse that when I close the app, my interactive script still runs so I need a way to kill it before leaving the app.

stdout not drained when exitCode completes

The following test demonstrates that stdout is not completely drained when the exitCode completes.

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:cli_script/cli_script.dart';
import 'package:test/fake.dart';
import 'package:test/test.dart';

void main() {
  test('issue57', () async {
    final fakeStdout = FakeStdoutStream();
    await overrideIoStreams(
      body: () async {
        final script = Script(
          'echo',
          args: ['hello'],
        );
        await script.exitCode;

        // removing this line makes the test fail
        await Future(() {});
      },
      stdout: () {
        return fakeStdout;
      },
    );
    expect(fakeStdout.lines, ['hello\n']);
  });
}

class FakeStdoutStream with Fake implements Stdout {
  final List<List<int>> _writes = <List<int>>[];

  List<String> get lines => _writes.map(utf8.decode).toList();

  @override
  void add(List<int> bytes) {
    _writes.add(bytes);
  }

  @override
  void writeln([Object? object = ""]) {
    _writes.add(utf8.encode('$object'));
  }

  @override
  void write(Object? object) {
    _writes.add(utf8.encode('$object'));
  }

  @override
  void writeAll(Iterable objects, [String sep = ""]) {
    _writes.add(utf8.encode(objects.join(sep)));
  }

  @override
  void writeCharCode(int charCode) {
    _writes.add(utf8.encode(String.fromCharCode(charCode)));
  }

  @override
  bool get supportsAnsiEscapes => false;
}

T overrideIoStreams<T>({
  required T Function() body,
  Stdin Function()? stdin,
  Stdout Function()? stdout,
  Stdout Function()? stderr,
}) {
  return runZoned(
    () => IOOverrides.runZoned(
      body,
      stdout: stdout,
      stdin: stdin,
      stderr: stderr,
    ),
    zoneSpecification: ZoneSpecification(
      print: (self, parent, zone, line) {
        final override = IOOverrides.current;
        override?.stdout.writeln(line);
      },
    ),
  );
}

Adding await Future(() {}); makes stdout write the echo. It is still unclear whether waiting one event loop is always enough, or if it is just a lucky guess.

Expected

stdout and stderr are completely drained when exitCode completes.

Actual

stdout and stderr continue to write after exitCode completes.


Comparison:

dcli waits for stdout and stderr to complete before returning.

While testing, Script.capture hangs when piping a closed ByteStream

Repro

test('pipe a closed string stream into a Script.capture', () async {
  var completer = Completer<void>();
  var awaiterScript = Script.capture((_) async => await completer.future);
  var script = Stream.fromIterable(['1']) | awaiterScript;

  completer.complete();
  expect(script.done, completes);
}, timeout: Timeout(Duration(seconds: 3)));

This works tho

Future<void> main() async {
  var completer = Completer<void>();
  var awaiterScript = Script.capture((_) async => await completer.future);
  var script = Stream.fromIterable(['1']) | awaiterScript;
  completer.complete();
  await script.done;
}

More info:

Changing Script.capture from using Script._ to using Script.fromComponents fixes the problem, my assumption is that either the additional step of passing the callback through Future.sync does the trick, or the additional step somehow helps? maybe the _checkCapture()?

- return Script._(scriptName, stdinController.sink, stdoutGroup.stream,
-      stderrGroup.stream, exitCodeCompleter.future);
+ return Script.fromComponents(
+     scriptName,
+     () => ScriptComponents(stdinController.sink, stdoutGroup.stream,
+         stderrGroup.stream, exitCodeCompleter.future));

Can't access Script.stderr for captured scripts

Depending on the script that gets executed, the stderr can be accessed or not.

  test("allows capture", () async {
    var script =
        Script.capture((_) async => await Script('someProgram').exitCode);
    final text = await script.stderr.text;
    // Error in someProgram:
    // ProcessException: No such file or directory
    // Command: someProgram 
    expect(text, contains('someProgram'));
  });

  test("fails", () async {
    var script =
        Script.capture((_) async => await Script('which someProgram').exitCode);
    final text = await script.stderr.text;
    expect(text, contains('someProgram'));
    // Expected: contains 'someProgram'
    // Actual: ''
  });

The only difference is that the first test runs someProgram the second which someProgram.

The first script throws ProcessException internally. The second parses the non-zero exit code and throws ScriptException and does not pipe the streams to Stream.capture via

stdinCompleter.setDestinationSink(components.stdin);
stdoutCompleter.setSourceStream(components.stdout);
stderrCompleter.setSourceStream(components.stderr);
return components.exitCode;

capture outputs to the console when an error occurs.

Looking at the capture method for use in dcli raises an issue.

The dcli contract is that we never output to the cli unless the user requests it. For the methods that do output (e.g. String.run) we provide an alternate method that doesn't output to the cli (String.start).

As I understand it the capture method always outputs to the cli if an error occurs.

This makes the capture method unusable from dcli.

I need a method that allows me to always control where the output goes.

check function needs to handle stdin

The check function isn't allowing stdin to be processed by the called script.

I've not tested but this may be a problem for output and lines as well.

Can't access Script stderr and exitCode together

final script = Script.capture((_) async {
  await run('which someProgram');
});
final code = await script.exitCode;
final err = await script.stdout.text;
expect(code, 1);
expect(err, contains("someProgram not found"));

Fails with

dart:async                                                _StreamImpl.listen
package:async/src/byte_collector.dart 48:29               _collectBytes
package:async/src/byte_collector.dart 17:10               collectBytes
package:cli_script/src/extensions/byte_stream.dart 30:24  ByteStreamExtensions.text
test/install_test.dart 28:41                              main.<fn>.<fn>.<fn>

Bad state: Stream has already been listened to.

Getting only exitCode or only stdout.text works.

Deadlock when using `operator>` on a stream that emits more than 16KiB

Context: https://gist.github.com/nex3/4aee4b681ffddf6480ea20dc75f8ad26

#50

    test("does not block with a large chunk of data", () async {
      var payloadSize = (1 << 16) + 1;
      // This must be an actual script. [Script.capture] won't fail.
      var script = mainScript('stderr.write("." * $payloadSize);');
      var controller = StreamController<List<int>>();
      script.stderr > controller.sink;

      await script.done;

      expect((await controller.stream.text).length, payloadSize);
    }, timeout: Timeout(Duration(seconds: 3)));

The issue comes from dart core libraries and is not cli_script-specific. However this may be considered WAI by Dart so it should be handled automatically.

I was able to repro with Process and Script.fromBytesBufferTransformer. However, the latter doesn't require a full payload so maybe each blocks for different reasons 🤔

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.