Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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