Skip to content

Commit

Permalink
Configure watch via .amber.yml (#996)
Browse files Browse the repository at this point in the history
* restart server process if it stops; display compilation time; improved wording of messages

* amber watch: can now (optionally) read config from .amber.yml (see template for details); rewrote ProcessRunner to handle any generic task that can be specified by a set of build and run commands and a set of globs to include and exclude; removed unused build_args and run_args args

* resolved ameba warnings
  • Loading branch information
anamba authored and drujensen committed Dec 22, 2018
1 parent dd6e7ab commit 05afdce
Show file tree
Hide file tree
Showing 6 changed files with 263 additions and 104 deletions.
2 changes: 0 additions & 2 deletions src/amber/cli/commands/watch.cr
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ module Amber::CLI

def run
CLI.toggle_colors(options.no_color?)
options.watch << "./config/**/*.cr"
options.watch << "./src/views/**/*.slang"
super
end
end
Expand Down
57 changes: 55 additions & 2 deletions src/amber/cli/config.cr
Original file line number Diff line number Diff line change
@@ -1,28 +1,81 @@
module Amber::CLI
def self.config
if File.exists? AMBER_YML
Config.from_yaml File.read(AMBER_YML)
begin
Config.from_yaml File.read(AMBER_YML)
rescue ex : YAML::ParseException
logger.error "Couldn't parse #{AMBER_YML} file", "Watcher", :red
exit 1
end
else
Config.new
end
end

class Config
SHARD_YML = "shard.yml"
DEFAULT_NAME = "[process_name]"

# see defaults below
alias WatchOptions = Hash(String, Hash(String, Array(String)))

property database : String = "pg"
property language : String = "slang"
property model : String = "granite"
property recipe : (String | Nil) = nil
property recipe_source : (String | Nil) = nil
property watch : WatchOptions

def initialize
@watch = default_watch_options
end

YAML.mapping(
database: {type: String, default: "pg"},
language: {type: String, default: "slang"},
model: {type: String, default: "granite"},
recipe: String | Nil,
recipe_source: String | Nil
recipe_source: String | Nil,
watch: {type: WatchOptions, default: default_watch_options}
)

def default_watch_options
appname = self.class.get_name

WatchOptions{
"run" => Hash{
"build_commands" => [
"mkdir -p bin",
"crystal build ./src/#{appname}.cr -o bin/#{appname}",
],
"run_commands" => [
"bin/#{appname}",
],
"include" => [
"./config/**/*.cr",
"./src/**/*.cr",
"./src/views/**/*.slang",
],
},
"npm" => Hash{
"build_commands" => [
"npm install --loglevel=error",
],
"run_commands" => [
"npm run watch",
],
},
}
end

def self.get_name
if File.exists?(SHARD_YML) &&
(yaml = YAML.parse(File.read SHARD_YML)) &&
(name = yaml["name"]?)
name.as_s
else
DEFAULT_NAME
end
end
end
end
3 changes: 3 additions & 0 deletions src/amber/cli/helpers/helpers.cr
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,8 @@ module Amber::CLI::Helpers
else
Process.new(command, shell: shell, output: Process::Redirect::Inherit, error: Process::Redirect::Inherit)
end
rescue ex : Errno
# typically means we could not find the executable
ex
end
end
203 changes: 141 additions & 62 deletions src/amber/cli/helpers/process_runner.cr
Original file line number Diff line number Diff line change
Expand Up @@ -2,113 +2,192 @@ require "./helpers"

module Sentry
class ProcessRunner
property processes = [] of Process
property processes = Hash(String, Array(Process)).new
property process_name : String
property files = [] of String
@logger : Amber::Environment::Logger
FILE_TIMESTAMPS = {} of String => String
FILE_TIMESTAMPS = Hash(String, Int64).new

def initialize(
@process_name : String,
@build_command : String,
@run_command : String,
@build_args : Array(String) = [] of String,
@run_args : Array(String) = [] of String,
files = [] of String,
@build_commands = Hash(String, String).new, # { "task1" => [ ... ], "task2" => [ ... ] }
@run_commands = Hash(String, String).new, # { "task1" => [ ... ], "task2" => [ ... ] }
@includes = Hash(String, Array(String)).new, # { "task1" => [ ... ], "task2" => [ ... ] }
@excludes = Hash(String, Array(String)).new, # { "task1" => [ ... ], "task2" => [ ... ] }
@logger = Amber::CLI.logger
)
@files = files
@npm_process = false
@app_running = false
end

def run
scan_files(no_actions: true)
start_processes

loop do
scan_files
check_processes
sleep 1
end
end

# Compiles and starts the application
def start_app
build_result = build_app_process
if build_result.is_a? Process::Status
if build_result.success?
stop_all_processes
create_all_processes
@app_running = true
elsif !@app_running
log "Compile time errors detected. Shutting down..."
exit 1
private def scan_files(no_actions = false)
# build a list of all files, with their associated tasks
all_files = Hash(String, Array(String)).new # { "file" => [ "task1", "task2" ]}
changed_files = Array(String).new

@includes.each do |task, includes|
excluded_files = Array(String).new
if (excludes = @excludes[task]?)
excludes.each { |glob| excluded_files += Dir.glob(glob) }
end
includes.each do |glob|
Dir.glob(glob).each do |f|
next if excluded_files.includes?(f)
all_files[f] ||= Array(String).new
all_files[f] << task
end
end
end
end

private def scan_files
file_counter = 0
Dir.glob(files) do |file|
all_files.each do |file, tasks|
timestamp = get_timestamp(file)
if FILE_TIMESTAMPS[file]? != timestamp
if @app_running
log "File changed: #{file.colorize(:light_gray)}"
end
FILE_TIMESTAMPS[file] = timestamp
file_counter += 1
unless no_actions
log :scan, "File changed: #{file.colorize(:light_gray)} (will notify: #{tasks.join(", ")})"
changed_files << file
end
end
end
if file_counter > 0
log "Watching #{file_counter} files (server reload)..."
start_app

return if no_actions || changed_files.empty?

tasks_to_run = Hash(String, Int32).new
changed_files.each do |file|
all_files[file].each do |task|
tasks_to_run[task] ||= 0
tasks_to_run[task] += 1
end
end
end

private def stop_all_processes
log "Terminating app #{project_name}..."
@processes.each do |process|
process.kill unless process.terminated?
tasks_to_run.each do |task, changed_file_count|
log task, "#{changed_file_count} file(s) changed."
start_processes(task)
end
processes.clear
end

private def create_all_processes
process = create_watch_process
@processes << process if process.is_a? Process
unless @npm_process
create_npm_process
@npm_process = true
# restart dead processes (currently limited to run task)
private def check_processes
@processes.each do |task, procs|
# clean up process list and restart if terminated
if procs.any?
procs.reject!(&.terminated?)

if procs.empty?
# restarting currently limited to run task (server process), otherwise just notify
if task == "run"
log task, "All processes died. Trying to restart..."
start_processes(task, skip_build: true)
else
log task, "Exited"
end
end
end
end
end

private def build_app_process
log "Building project #{project_name}..."
Amber::CLI::Helpers.run(@build_command)
end
private def stop_processes(task_to_stop = :all)
@processes.each do |task, procs|
next unless task_to_stop == :all || task_to_stop.to_s == task

private def create_watch_process
log "Starting #{project_name}..."
Amber::CLI::Helpers.run(@run_command, wait: false, shell: false)
if task == "run"
log task, "Terminating app #{project_name}..."
else
log task, "Terminating process..."
end
procs.each do |process|
process.kill unless process.terminated?
end
procs.clear
end
end

private def create_npm_process
node_log "Installing dependencies..."
Amber::CLI::Helpers.run("npm install --loglevel=error && npm run watch", wait: false)
node_log "Watching public directory"
private def start_processes(task_to_start = :all, skip_build = false)
if task_to_start == :all || task_to_start == "run"
# handle run task first, exit immediately if it fails
if (build_command_run = @build_commands["run"]) && (run_command_run = @run_commands["run"])
ok_to_run = false
if skip_build
ok_to_run = true
else
log :run, "Building..."
time = Time.monotonic
build_result = Amber::CLI::Helpers.run(build_command_run)
exit 1 unless build_result.is_a? Process::Status
if build_result.success?
log :run, "Compiled in #{(Time.monotonic - time)}"
stop_processes("run") if @app_running
ok_to_run = true
elsif !@app_running # first run
log :run, "Compile time errors detected, exiting...", :red
exit 1
end
end

if ok_to_run
process = Amber::CLI::Helpers.run(run_command_run, wait: false, shell: false)
if process.is_a? Process
@processes["run"] ||= Array(Process).new
@processes["run"] << process
elsif process.is_a? Exception
log :run, "Could not run (#{process.message}), exiting...", :red
log :run, "Please check your watch config and try again.", :red
exit 1
end

@app_running = true
end
else
log :run, "Build or run commands missing for run task, exiting...", :red
exit 1
end
end

@run_commands.each do |task, run_command|
next if task == "run" # already handled
next unless task_to_start == :all || task_to_start.to_s == task

if (build_command = @build_commands[task]?) && !skip_build
log task, "Building..."
build_result = Amber::CLI::Helpers.run(build_command)
next unless build_result.is_a? Process::Status

if build_result.success?
Amber::CLI::Helpers.run(build_command)
else
log task, "Build step failed."
next # don't continue to run command step
end
end

log task, "Starting..."
process = Amber::CLI::Helpers.run(run_command, wait: false, shell: true)
if process.is_a? Process
@processes[task] ||= Array(Process).new
@processes[task] << process
end
end
end

private def get_timestamp(file : String)
File.info(file).modification_time.to_s("%Y%m%d%H%M%S")
File.info(file).modification_time.to_unix
end

private def project_name
process_name.capitalize.colorize(:white)
end

private def log(msg)
@logger.info msg, "Watcher", :light_gray
end

private def node_log(msg)
@logger.info msg, "NodeJS", :dark_gray
private def log(task, msg, color = :light_gray)
@logger.info msg, "Watch #{task}", color
end
end
end
Loading

0 comments on commit 05afdce

Please sign in to comment.