diff --git a/lib/prism/cli.rb b/lib/prism/cli.rb index 42f2f0e..c47a564 100644 --- a/lib/prism/cli.rb +++ b/lib/prism/cli.rb @@ -23,10 +23,191 @@ module Prism case command when 'new' then new_app(rest.first || raise(UsageError, 'usage: prism new APP_NAME')) when 'generate', 'g' then generate(rest) + when 'jobs' then jobs(rest) + when 'assets:precompile' then assets_precompile(rest) + when 'console', 'c' then console(rest) + when 'routes' then routes(rest) + when 'db:migrate' then db_migrate(rest) + when 'db:rollback' then db_rollback(rest) + when 'db:status' then db_status(rest) + when 'db:seed' then db_seed(rest) else raise UsageError, "unknown command #{command.inspect}" end end + # Applies every pending migration directly against a PStore file, the + # same work `Orm.migrator(...).migrate!` does when a generated app.rb + # boots - but as a standalone step that does not require loading the + # rest of the application (no controllers, no server), mirroring `rails + # db:migrate`. Useful on its own for apps that do not auto-migrate at + # boot, and it is the only way to inspect/rewind migrations (db:status, + # db:rollback below) without booting an app that would immediately + # re-apply everything pending anyway. + def db_migrate(args, out: $stdout) + migrator = build_migrator(parse_db_options(args)) + applied = migrator.migrate! + if applied.empty? + out.puts '[prism db:migrate] no pending migrations' + else + applied.each { |version| out.puts "[prism db:migrate] migrated #{version}" } + end + applied + end + + # Reverts the most recently applied migration(s), like `rails + # db:rollback STEP=n` - `--step=N` picks how many to undo (default 1). + def db_rollback(args, out: $stdout) + options = parse_db_options(args) + migrator = build_migrator(options) + reverted = migrator.rollback!(options[:step]) + if reverted.empty? + out.puts '[prism db:rollback] nothing to roll back' + else + reverted.each { |version| out.puts "[prism db:rollback] rolled back #{version}" } + end + reverted + end + + # Lists every migration file found under --migrations next to whether + # it has been applied to --database, like `rails db:migrate:status`. + def db_status(args, out: $stdout) + migrator = build_migrator(parse_db_options(args)) + status = migrator.status + rows = status.map { |entry, applied| [applied ? 'up' : 'down', entry.version, entry.name] } + header = %w[STATUS VERSION NAME] + widths = header.each_index.map { |i| ([header[i].length] + rows.map { |row| row[i].length }).max } + out.puts format_row(header, widths) + rows.each { |row| out.puts format_row(row, widths) } + status + end + + # Loads the application (via --require, same flag shape as `jobs work`/ + # `console`) and then `load`s --file (default db/seeds.rb) as plain Ruby, + # like `rails db:seed`. Deliberately `load` rather than `require`: a + # seeds file has no meaningful "already loaded" state of its own (unlike + # a model or controller file, it is not a class/module definition but a + # sequence of side effects), so a second `db:seed` run - or a console/ + # test process that seeds more than once - must re-execute it, not + # silently no-op the way `require` would after the first call already + # loaded that exact path. + # + # Idempotency of what the seeds file actually does (find_or_create_by + # vs. create!, etc.) is the app's concern, not this command's - same + # division of responsibility Rails draws. A missing seeds file is not an + # error: plenty of apps never have one, and `prism new` does not + # generate a stub, so failing here would make db:seed unusable as a + # step in a deploy script unless every app remembered to create an empty + # placeholder first. + def db_seed(args, out: $stdout) + options = parse_seed_options(args) + options[:require].each { |path| require File.expand_path(path, @root) } + + seeds_path = File.expand_path(options[:file], @root) + unless File.exist?(seeds_path) + out.puts "[prism db:seed] no seeds file at #{options[:file]}" + return false + end + + load seeds_path + out.puts "[prism db:seed] seeded from #{options[:file]}" + true + end + + # Lists every route registered on a loaded Application subclass, like + # `rails routes`: NAME, VERB, PATH and the controller#action (or + # "(block)" for routes defined inline rather than via `to:`) it + # dispatches to, in registration order (the same order the router tries + # them in, which matters when patterns overlap). Mounted Rack apps + # (`mount`) are listed separately underneath since they do not carry a + # single verb/action - only a prefix and whatever app answers under it. + def routes(args, out: $stdout) + options = parse_routes_options(args) + loaded_before = Application.subclasses + options[:require].each { |path| require File.expand_path(path, @root) } + newly_loaded = Application.subclasses - loaded_before + + app_class = resolve_app_class(options[:app], newly_loaded) + print_routes(app_class.router, out) + app_class + end + + # A minimal `rails console`-style REPL: loads the application (via + # --require, same flag shape as `jobs work`) and evaluates one line of + # Ruby at a time against a binding that survives across lines, so a + # local variable assigned on one line is still visible on the next - + # e.g. `post = Post.first` then `post.title`. Deliberately single-line + # (no heredoc/multi-line statement support) and has no history/tab + # completion, unlike a real irb - this is a REPL, not a terminal editor; + # it reads whatever `input` yields line by line, which makes it just as + # easy to drive from a real TTY as from a StringIO in tests. + def console(args, input: $stdin, out: $stdout) + options = parse_console_options(args) + options[:require].each { |path| require File.expand_path(path, @root) } + + session = Object.new + session_binding = session.instance_eval { binding } + + prompt = options[:prompt] + out.print(prompt) + input.each_line do |line| + line = line.chomp + stripped = line.strip + if %w[exit quit].include?(stripped) + break + elsif stripped.empty? + out.print(prompt) + next + end + + begin + result = session_binding.eval(line, '(console)') + out.puts "=> #{result.inspect}" + rescue StandardError, ScriptError => e + out.puts "Error: #{e.class}: #{e.message}" + end + out.print(prompt) + end + out.puts + nil + end + + # Runs Assets::Compiler directly - fingerprints every file under + # --source into --output plus a manifest.json - without booting the + # rest of the application (no Orm.connect, no migrations, no server). + # This is the missing deploy-time step Application#enable_assets never + # covered on its own: enable_assets compiles as a side effect of + # loading app.rb, which also means the ONLY way to precompile assets + # before this was to boot the entire app (database connection and all) + # just to get the compiled files onto disk. Mirrors `rake + # assets:precompile` in Rails: a standalone step meant to run once + # during deploy, before the server process starts. + def assets_precompile(args, out: $stdout) + options = parse_assets_precompile_options(args) + source = File.expand_path(options[:source], @root) + output = File.expand_path(options[:output], @root) + manifest = Assets::Compiler.new(source: source, output: output).compile! + out.puts "[prism assets:precompile] compiled #{manifest.to_h.size} file(s) into #{output}" + manifest + end + + # Runs a Jobs::Worker as a long-lived daemon: polls the queue, drains + # every ready job, sleeps `interval` seconds, repeats until SIGINT/SIGTERM + # (or forever with --once/--max-cycles for scripted/finite runs). Returns + # the number of poll cycles executed, mainly so callers/tests can assert + # on it without needing a real infinite loop. + def jobs_work(args, out: $stdout) + options = parse_jobs_work_options(args) + options[:require].each { |path| require File.expand_path(path, @root) } + + pidfile = options[:pidfile] && File.expand_path(options[:pidfile], @root) + write_pidfile(pidfile) if pidfile + + queue = Jobs::Queue.new(File.expand_path(options[:file], @root)) + worker = Jobs::Worker.new( + queue, + queue_name: options[:queue_name], + end + def new_app(name) app_dir = File.join(@root, name) raise UsageError, "#{app_dir} already exists" if Dir.exist?(app_dir)