├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples └── Procfile-countdown ├── shard.yml ├── spec ├── helpers │ ├── colorize_helper.cr │ ├── intercepting_io_helper.cr │ └── procfile_helper.cr ├── nox │ ├── intercepting_io_spec.cr │ ├── process_spec.cr │ └── procfile_spec.cr └── spec_helper.cr └── src ├── nox.cr └── nox ├── cli.cr ├── intercepting_io.cr ├── process.cr ├── procfile.cr └── runner.cr /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Nox CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: "*" 8 | 9 | jobs: 10 | check_format: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: crystal-lang/install-crystal@v1 15 | - run: shards install 16 | - name: Check Formatting 17 | run: crystal tool format --check 18 | - name: Lint 19 | run: ./bin/ameba 20 | specs: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: crystal-lang/install-crystal@v1 25 | - run: shards install 26 | - run: crystal spec 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in applications that use them 9 | /shard.lock 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 matthewmcgarvey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nox 2 | 3 | Nox is a process manager for Procfiles written in Crystal. 4 | The reason for its existence is so that [Lucky](https://luckyframework.org/) can ship with a built-in process runner instead of requiring one to be installed. 5 | 6 | ## Installation 7 | 8 | 1. Add the dependency to your `shard.yml`: 9 | 10 | ```yaml 11 | dependencies: 12 | nox: 13 | github: matthewmcgarvey/nox 14 | version: ">= 0.2.0, < 0.3.0" 15 | ``` 16 | 17 | 2. Run `shards install` 18 | 19 | ## Command Line Installation 20 | 21 | - Clone the repo 22 | - Run `shards build nox` 23 | - Run `mv bin/nox /usr/local/bin` or to a different location that is on your `$PATH` 24 | 25 | ## Command Line Usage 26 | 27 | ``` 28 | nox --help # print help info 29 | 30 | nox start # run Procfile in current directory 31 | 32 | nox start -f Procfile.dev # run Procfile.dev in current directory 33 | ``` 34 | 35 | ## Usage 36 | 37 | ```crystal 38 | require "nox" 39 | 40 | Nox.run("Procfile") # runs the Procfile and exits when the processes are all done or the program is interrupted (ctrl-c) 41 | ``` 42 | 43 | ## Contributing 44 | 45 | 1. Fork it () 46 | 2. Create your feature branch (`git checkout -b my-new-feature`) 47 | 3. Commit your changes (`git commit -am 'Add some feature'`) 48 | 4. Push to the branch (`git push origin my-new-feature`) 49 | 5. Create a new Pull Request 50 | 51 | ## Contributors 52 | 53 | - [Matthew McGarvey](https://github.com/matthewmcgarvey) - creator and maintainer 54 | -------------------------------------------------------------------------------- /examples/Procfile-countdown: -------------------------------------------------------------------------------- 1 | countdown: while true; do printf '%s\r' "$(date)"; sleep 1; done 2 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: nox 2 | version: 0.2.3 3 | 4 | authors: 5 | - matthewmcgarvey 6 | 7 | crystal: ">= 1.0.0" 8 | 9 | license: MIT 10 | 11 | targets: 12 | nox: 13 | main: src/nox/cli.cr 14 | 15 | development_dependencies: 16 | ameba: 17 | github: crystal-ameba/ameba 18 | version: 1.5.0 19 | spectator: 20 | gitlab: arctic-fox/spectator 21 | version: ~> 0.11.3 22 | -------------------------------------------------------------------------------- /spec/helpers/colorize_helper.cr: -------------------------------------------------------------------------------- 1 | module ColorizeHelper 2 | # I'm always proud to link to stackoverflow when I finally get the chance to copy/paste code from there 3 | # https://stackoverflow.com/questions/16032726/removing-color-decorations-from-strings-before-writing-them-to-logfile 4 | def decolorize(str : String) : String 5 | str.gsub(/\e\[(\d+)(;\d+)*m/, "") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/helpers/intercepting_io_helper.cr: -------------------------------------------------------------------------------- 1 | class Nox::InterceptingIO < IO 2 | # clear the names so we can test padding and color reliably 3 | def self.reset! 4 | @@names.clear 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/helpers/procfile_helper.cr: -------------------------------------------------------------------------------- 1 | module ProcfileHelper 2 | def procfile_entry(process_type, command) 3 | Nox::Procfile::Entry.new(process_type, command) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/nox/intercepting_io_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | Spectator.describe Nox::InterceptingIO do 4 | include ColorizeHelper 5 | 6 | it "applies the given name to each line of output with a color" do 7 | wrapped = IO::Memory.new 8 | intercepting_io = Nox::InterceptingIO.new(wrapped, "foo") 9 | 10 | intercepting_io.print("line 1\nline 2") 11 | 12 | expect(decolorize(wrapped.to_s)).to eq("foo | line 1\nfoo | line 2\n") 13 | end 14 | 15 | it "adjusts padding of name based on largest one" do 16 | wrapped = IO::Memory.new 17 | intercepting_io = Nox::InterceptingIO.new(wrapped, "foo") 18 | Nox::InterceptingIO.new(wrapped, "longer_name") 19 | 20 | intercepting_io.print("hello") 21 | 22 | expect(decolorize(wrapped.to_s)).to eq("foo | hello\n") 23 | end 24 | 25 | it "applies a different color for each name" do 26 | wrapped = IO::Memory.new 27 | foo_io = Nox::InterceptingIO.new(wrapped, "foo") 28 | bar_io = Nox::InterceptingIO.new(wrapped, "bar") 29 | 30 | foo_io.print("hello") 31 | bar_io.print("world") 32 | output = wrapped.to_s 33 | 34 | expect(output).to contain("foo".colorize(:cyan).to_s) 35 | expect(output).to contain("bar".colorize(:yellow).to_s) 36 | end 37 | 38 | it "handles carriage returns" do 39 | wrapped = IO::Memory.new 40 | intercepting_io = Nox::InterceptingIO.new(wrapped, "foo") 41 | 42 | intercepting_io.print("line 1 - 1%\rline 1 - 2%") 43 | 44 | expect(decolorize(wrapped.to_s)).to eq("foo | line 1 - 1%\rfoo | line 1 - 2%\n") 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/nox/process_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | Spectator.describe Nox::Process do 4 | include ProcfileHelper 5 | include ColorizeHelper 6 | 7 | it "pipes output to passed in IO" do 8 | io = IO::Memory.new 9 | entry = procfile_entry("web", "echo hello") 10 | process = Nox::Process.new(entry, __DIR__, io) 11 | 12 | process.run 13 | output = decolorize(io.to_s).lines 14 | 15 | expect(output.shift).to match(/web | Starting with pid of \d+/) 16 | expect(output.shift).to eq("web | hello") 17 | expect(output.shift).to eq("web | Done") 18 | end 19 | 20 | it "can be run in a different directory" do 21 | io = IO::Memory.new 22 | entry = procfile_entry("web", "pwd") 23 | process = Nox::Process.new(entry, "./spec", io) 24 | 25 | process.run 26 | output = decolorize(io.to_s).lines 27 | 28 | expect(output).to contain("web | #{File.join(Dir.current, "spec")}") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/nox/procfile_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | Spectator.describe Nox::Procfile do 4 | include ProcfileHelper 5 | 6 | describe ".parse" do 7 | it "parses each line into an entry" do 8 | content = <<-PROCFILE 9 | web: crystal src/app.cr 10 | frontend: yarn start 11 | PROCFILE 12 | 13 | result = Nox::Procfile.parse(content) 14 | 15 | expect(result.entries).to contain_exactly( 16 | procfile_entry("web", "crystal src/app.cr"), 17 | procfile_entry("frontend", "yarn start") 18 | ) 19 | end 20 | 21 | it "handles spaces and comments in various places" do 22 | content = <<-PROCFILE 23 | 24 | web: crystal src/app.cr 25 | 26 | # comment! 27 | frontend: yarn start 28 | 29 | PROCFILE 30 | 31 | result = Nox::Procfile.parse(content) 32 | 33 | expect(result.entries).to contain_exactly( 34 | procfile_entry("web", "crystal src/app.cr"), 35 | procfile_entry("frontend", "yarn start") 36 | ) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "../src/nox" 2 | require "spectator" 3 | require "./helpers/**" 4 | 5 | Spectator.configure do |config| 6 | config.randomize 7 | config.before_each do 8 | Nox::InterceptingIO.reset! 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /src/nox.cr: -------------------------------------------------------------------------------- 1 | require "colorize" 2 | require "./nox/procfile" 3 | require "./nox/intercepting_io" 4 | require "./nox/process" 5 | require "./nox/runner" 6 | 7 | module Nox 8 | VERSION = "0.2.3" 9 | 10 | def self.run(file : String) 11 | procfile = Nox::Procfile.parse_file(file) 12 | runner = Nox::Runner.new(procfile, output: STDOUT) 13 | {% if compare_versions(Crystal::VERSION, "1.8.0") < 0 %} 14 | {% raise "Windows requires >= 1.8.0" if flag?(:win32) %} 15 | Signal::INT.trap { runner.interrupt_or_kill } 16 | {% else %} 17 | ::Process.on_interrupt { runner.interrupt_or_kill } 18 | {% end %} 19 | runner.run 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /src/nox/cli.cr: -------------------------------------------------------------------------------- 1 | require "../nox" 2 | require "option_parser" 3 | 4 | start = false 5 | file = "Procfile" 6 | 7 | OptionParser.parse do |parser| 8 | parser.on("start", "Run a Procfile") do 9 | start = true 10 | parser.on("-f PROCFILE", "--file=PROCFILE", "Specify the name of the Procfile to use") { |_file| file = _file } 11 | end 12 | 13 | parser.on("-h", "--help", "Show help") do 14 | puts parser 15 | exit 16 | end 17 | end 18 | 19 | if start 20 | Nox.run(file) 21 | end 22 | -------------------------------------------------------------------------------- /src/nox/intercepting_io.cr: -------------------------------------------------------------------------------- 1 | class Nox::InterceptingIO < IO 2 | COLORS = [ 3 | Colorize::ColorANSI::Cyan, 4 | Colorize::ColorANSI::Yellow, 5 | Colorize::ColorANSI::Green, 6 | Colorize::ColorANSI::Magenta, 7 | Colorize::ColorANSI::Red, 8 | Colorize::ColorANSI::Blue, 9 | Colorize::ColorANSI::LightCyan, 10 | Colorize::ColorANSI::LightYellow, 11 | Colorize::ColorANSI::LightGreen, 12 | Colorize::ColorANSI::LightMagenta, 13 | Colorize::ColorANSI::LightRed, 14 | Colorize::ColorANSI::LightBlue, 15 | ] 16 | 17 | @@names = [] of String 18 | 19 | @color : Colorize::ColorANSI 20 | 21 | def initialize(@wrapped : IO, @name : String) 22 | @@names << @name 23 | idx = @@names.size - 1 24 | @color = COLORS[idx % COLORS.size] 25 | end 26 | 27 | def read(slice : Bytes) 28 | raise "don't call this" 29 | end 30 | 31 | def write(slice : Bytes) : Nil 32 | lines = String.build(&.write(slice)).split(/(?<=[\n\r])/) 33 | lines.pop if lines.last.blank? 34 | lines.each do |line| 35 | result = String.build do |str| 36 | str << @name.ljust(max_name_size + 1).sub(@name, @name.colorize(@color).to_s) 37 | str << "| " 38 | str << line 39 | if !line.ends_with?(/[\n\r]/) 40 | str << "\n" 41 | end 42 | end 43 | @wrapped.print(result) 44 | end 45 | end 46 | 47 | private def max_name_size : Int32 48 | @@names.max_of(&.size) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /src/nox/process.cr: -------------------------------------------------------------------------------- 1 | class Nox::Process 2 | @process : ::Process? 3 | 4 | def initialize(@procfile_entry : Nox::Procfile::Entry, @dir : String, output : IO) 5 | @output = Nox::InterceptingIO.new(output, @procfile_entry.process_type) 6 | end 7 | 8 | def run 9 | process = @process = ::Process.new( 10 | @procfile_entry.command, 11 | output: @output, 12 | error: @output, 13 | shell: true, 14 | chdir: @dir 15 | ) 16 | print_bold "Starting with pid of #{process.pid}" 17 | process.wait 18 | print_bold "Done" 19 | end 20 | 21 | def interrupt 22 | with_process do |process| 23 | print_bold "Attempting to interrupt..." 24 | process.signal(Signal::INT) 25 | end 26 | end 27 | 28 | def kill 29 | with_process do |process| 30 | print_bold "Attempting to kill..." 31 | {% if compare_versions(Crystal::VERSION, "1.8.0") < 0 %} 32 | {% if flag?(:win32) %} 33 | process.terminate 34 | {% else %} 35 | process.signal(Signal::KILL) 36 | {% end %} 37 | {% else %} 38 | process.terminate(graceful: false) 39 | {% end %} 40 | end 41 | end 42 | 43 | private def with_process(&) 44 | if (process = @process) && process.exists? 45 | yield process 46 | end 47 | end 48 | 49 | private def print_bold(str : String) : Nil 50 | @output.print str.colorize.bold.to_s 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /src/nox/procfile.cr: -------------------------------------------------------------------------------- 1 | struct Nox::Procfile 2 | ENTRY_REGEX = /^([\w-]+):\s+(.+)$/ 3 | 4 | def self.parse_file(file : String) : self 5 | parse(File.read(file)) 6 | end 7 | 8 | def self.parse(content : String) : self 9 | proc_file = new 10 | content.each_line do |line| 11 | match_data = ENTRY_REGEX.match(line) 12 | next if match_data.nil? 13 | 14 | proc_file.entries << Nox::Procfile::Entry.new(match_data[1], match_data[2]) 15 | end 16 | proc_file 17 | end 18 | 19 | getter entries = [] of Nox::Procfile::Entry 20 | 21 | struct Entry 22 | getter process_type : String 23 | getter command : String 24 | 25 | def initialize(@process_type, @command) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/nox/runner.cr: -------------------------------------------------------------------------------- 1 | class Nox::Runner 2 | private getter done = Channel(Nil).new 3 | private getter processes : Array(Nox::Process) 4 | 5 | def initialize(procfile : Nox::Procfile, @output : IO) 6 | @processes = procfile.entries.map { |entry| Nox::Process.new(entry, dir: Dir.current, output: @output) } 7 | end 8 | 9 | def run 10 | processes.each do |process| 11 | spawn do 12 | process.run 13 | done.send(nil) 14 | end 15 | end 16 | 17 | processes.size.times do 18 | done.receive 19 | end 20 | end 21 | 22 | def interrupt_or_kill 23 | processes.each(&.interrupt) 24 | sleep 5.seconds 25 | processes.each(&.kill) 26 | end 27 | end 28 | --------------------------------------------------------------------------------