feat(cli): subcommand dispatch with 9 commands + output formatters
CLI grammar: cre run daemon (sqlite or postgres) cre watch daemon + live TUI in same process cre check one-shot policy eval (CI-friendly exit codes) cre rotate <id> manual rotation cre policy list / show compile-time-baked policy registry inspection cre export --framework=X compliance bundle (Phase 14 stub) cre audit verify hash chain + HMAC + Merkle integrity verification cre demo Tier 1 demo (Phase 15 stub) cre version version cre help usage Output module: human / json / ndjson formatter; CI-friendly exit codes (0 ok, 1 violations/error, 64 usage, 2 audit-chain-broken). run/watch wire engine + persistence + scheduler + evaluator + log notifier (and TUI for watch); SIGINT triggers graceful shutdown. 7 unit specs cover usage / version / policy-list-empty / policy-show-404 / unknown-subcommand / check-no-violations. All commands callable end-to-end against a working binary.
This commit is contained in:
parent
9651ff56f1
commit
545e189b43
|
|
@ -0,0 +1,58 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# cli_spec.cr
|
||||
# ===================
|
||||
|
||||
require "../../spec_helper"
|
||||
require "../../../src/cre/cli/cli"
|
||||
|
||||
describe CRE::Cli do
|
||||
it "prints usage when no args given and exits 64" do
|
||||
io = IO::Memory.new
|
||||
code = CRE::Cli.dispatch([] of String, io)
|
||||
code.should eq 64
|
||||
io.to_s.should contain "Subcommands"
|
||||
end
|
||||
|
||||
it "prints usage on help" do
|
||||
io = IO::Memory.new
|
||||
code = CRE::Cli.dispatch(["help"], io)
|
||||
code.should eq 0
|
||||
io.to_s.should contain "Subcommands"
|
||||
end
|
||||
|
||||
it "prints version on version subcommand" do
|
||||
io = IO::Memory.new
|
||||
code = CRE::Cli.dispatch(["version"], io)
|
||||
code.should eq 0
|
||||
io.to_s.strip.should eq CRE::VERSION
|
||||
end
|
||||
|
||||
it "policy list works against an empty registry" do
|
||||
CRE::Policy.clear_registry!
|
||||
io = IO::Memory.new
|
||||
code = CRE::Cli.dispatch(["policy", "list"], io)
|
||||
code.should eq 0
|
||||
io.to_s.should contain "no policies"
|
||||
end
|
||||
|
||||
it "policy show returns 1 on missing policy" do
|
||||
CRE::Policy.clear_registry!
|
||||
io = IO::Memory.new
|
||||
code = CRE::Cli.dispatch(["policy", "show", "nonexistent"], io)
|
||||
code.should eq 1
|
||||
end
|
||||
|
||||
it "rejects unknown subcommands" do
|
||||
io = IO::Memory.new
|
||||
code = CRE::Cli.dispatch(["thisisbad"], io)
|
||||
code.should eq 64
|
||||
io.to_s.should contain "unknown subcommand"
|
||||
end
|
||||
|
||||
it "check on empty db returns 0 (no violations)" do
|
||||
io = IO::Memory.new
|
||||
code = CRE::Cli.dispatch(["check", "--db=:memory:"], io)
|
||||
code.should eq 0
|
||||
end
|
||||
end
|
||||
|
|
@ -4,10 +4,11 @@
|
|||
# ===================
|
||||
|
||||
require "./cre/version"
|
||||
require "./cre/cli/cli"
|
||||
|
||||
module CRE
|
||||
def self.main(argv : Array(String)) : Int32
|
||||
0
|
||||
Cli.dispatch(argv)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# cli.cr
|
||||
# ===================
|
||||
|
||||
require "option_parser"
|
||||
require "../version"
|
||||
require "./output"
|
||||
require "./commands"
|
||||
|
||||
module CRE::Cli
|
||||
USAGE = <<-USAGE
|
||||
cre - Credential Rotation Enforcer
|
||||
|
||||
Usage: cre <subcommand> [options]
|
||||
|
||||
Subcommands:
|
||||
run headless daemon (production / systemd)
|
||||
watch engine + live TUI in same process
|
||||
check evaluate policies once, exit non-zero on violations
|
||||
rotate <credential-id> manually rotate a single credential
|
||||
policy list list compiled-in policies
|
||||
policy show <name> inspect one policy
|
||||
export --framework=<name> generate signed compliance evidence bundle
|
||||
audit verify verify hash chain + HMAC ratchet + Merkle batches
|
||||
demo tier-1 zero-deps demo (SQLite + .env rotator)
|
||||
version print version
|
||||
help this message
|
||||
|
||||
Common options:
|
||||
--output=human|json|ndjson output format (default: human)
|
||||
--config=PATH config file (default: $CRE_CONFIG or ./config.cr)
|
||||
USAGE
|
||||
|
||||
def self.dispatch(argv : Array(String), io : IO = STDOUT) : Int32
|
||||
if argv.empty? || %w[--help -h help].includes?(argv.first)
|
||||
io.puts USAGE
|
||||
return argv.empty? ? 64 : 0 # 64 = EX_USAGE
|
||||
end
|
||||
|
||||
subcommand = argv.shift
|
||||
case subcommand
|
||||
when "version"
|
||||
io.puts CRE::VERSION
|
||||
0
|
||||
when "run" then Commands::Run.new.execute(argv, io)
|
||||
when "watch" then Commands::Watch.new.execute(argv, io)
|
||||
when "check" then Commands::Check.new.execute(argv, io)
|
||||
when "rotate" then Commands::Rotate.new.execute(argv, io)
|
||||
when "policy" then Commands::Policy.new.execute(argv, io)
|
||||
when "export" then Commands::Export.new.execute(argv, io)
|
||||
when "audit" then Commands::Audit.new.execute(argv, io)
|
||||
when "demo" then Commands::Demo.new.execute(argv, io)
|
||||
else
|
||||
io.puts "unknown subcommand: #{subcommand}"
|
||||
io.puts USAGE
|
||||
64
|
||||
end
|
||||
rescue ex : OptionParser::InvalidOption | OptionParser::MissingOption
|
||||
io.puts "error: #{ex.message}"
|
||||
64
|
||||
rescue ex
|
||||
io.puts "error: #{ex.message}"
|
||||
1
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# commands.cr
|
||||
# ===================
|
||||
|
||||
require "./commands/run"
|
||||
require "./commands/watch"
|
||||
require "./commands/check"
|
||||
require "./commands/rotate"
|
||||
require "./commands/policy"
|
||||
require "./commands/export"
|
||||
require "./commands/audit"
|
||||
require "./commands/demo"
|
||||
require "./commands/version"
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# audit.cr
|
||||
# ===================
|
||||
|
||||
require "../../audit/audit_log"
|
||||
require "../../persistence/sqlite/sqlite_persistence"
|
||||
|
||||
module CRE::Cli::Commands
|
||||
class Audit
|
||||
def execute(argv : Array(String), io : IO) : Int32
|
||||
sub = argv.shift?
|
||||
case sub
|
||||
when "verify" then verify(argv, io)
|
||||
when nil, "--help", "-h"
|
||||
io.puts "Usage: cre audit verify [--db=PATH]"
|
||||
0
|
||||
else
|
||||
io.puts "unknown audit subcommand: #{sub}"
|
||||
64
|
||||
end
|
||||
end
|
||||
|
||||
private def verify(argv : Array(String), io : IO) : Int32
|
||||
db_path = ENV["CRE_DB_PATH"]? || "cre.db"
|
||||
hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64
|
||||
|
||||
OptionParser.parse(argv) do |parser|
|
||||
parser.on("--db=PATH", "") { |p| db_path = p }
|
||||
end
|
||||
|
||||
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(db_path)
|
||||
persist.migrate!
|
||||
|
||||
log = CRE::Audit::AuditLog.new(persist, hmac_hex.hexbytes, 1, 1024)
|
||||
ok = log.verify_chain
|
||||
latest_seq = persist.audit.latest_seq
|
||||
persist.close
|
||||
|
||||
if ok
|
||||
io.puts "✓ audit chain valid: #{latest_seq} entries"
|
||||
0
|
||||
else
|
||||
io.puts "✗ audit chain BROKEN — verification failed"
|
||||
2
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# check.cr
|
||||
# ===================
|
||||
|
||||
require "../../engine/event_bus"
|
||||
require "../../persistence/sqlite/sqlite_persistence"
|
||||
require "../../policy/evaluator"
|
||||
|
||||
module CRE::Cli::Commands
|
||||
class Check
|
||||
def execute(argv : Array(String), io : IO) : Int32
|
||||
_help_requested = false
|
||||
output_format = OutputFormat::Human
|
||||
db_path = ":memory:"
|
||||
|
||||
OptionParser.parse(argv) do |parser|
|
||||
parser.banner = "Usage: cre check [options]"
|
||||
parser.on("--output=FORMAT", "human|json|ndjson") { |f| output_format = Output.parse_format(f) }
|
||||
parser.on("--db=PATH", "SQLite path (default :memory:)") { |p| db_path = p }
|
||||
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
|
||||
end
|
||||
return 0 if _help_requested
|
||||
|
||||
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(db_path)
|
||||
persist.migrate!
|
||||
|
||||
bus = CRE::Engine::EventBus.new
|
||||
ch = bus.subscribe(buffer: 256)
|
||||
bus.run
|
||||
|
||||
CRE::Policy::Evaluator.new(bus, persist).evaluate_all
|
||||
sleep 0.1.seconds
|
||||
|
||||
violations = drain(ch).select(&.is_a?(CRE::Events::PolicyViolation)).map(&.as(CRE::Events::PolicyViolation))
|
||||
bus.stop
|
||||
persist.close
|
||||
|
||||
case output_format
|
||||
in OutputFormat::Human
|
||||
if violations.empty?
|
||||
io.puts "OK: no policy violations"
|
||||
else
|
||||
io.puts "VIOLATIONS (#{violations.size}):"
|
||||
violations.each do |v|
|
||||
io.puts " - credential=#{v.credential_id} policy=#{v.policy_name} reason=#{v.reason}"
|
||||
end
|
||||
end
|
||||
in OutputFormat::Json, OutputFormat::Ndjson
|
||||
rows = violations.map do |v|
|
||||
{
|
||||
"credential_id" => v.credential_id.to_s,
|
||||
"policy" => v.policy_name,
|
||||
"reason" => v.reason,
|
||||
"occurred_at" => v.occurred_at.to_rfc3339,
|
||||
}
|
||||
end
|
||||
Output.print(io, output_format, rows)
|
||||
end
|
||||
|
||||
violations.empty? ? 0 : 1
|
||||
end
|
||||
|
||||
private def drain(ch : ::Channel(CRE::Events::Event)) : Array(CRE::Events::Event)
|
||||
out = [] of CRE::Events::Event
|
||||
loop do
|
||||
select
|
||||
when ev = ch.receive
|
||||
out << ev
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
out
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# demo.cr
|
||||
# ===================
|
||||
|
||||
require "../../demo/tier_1"
|
||||
|
||||
module CRE::Cli::Commands
|
||||
class Demo
|
||||
def execute(argv : Array(String), io : IO) : Int32
|
||||
_help_requested = false
|
||||
OptionParser.parse(argv) do |parser|
|
||||
parser.banner = "Usage: cre demo (tier-1, no external deps)"
|
||||
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
|
||||
end
|
||||
return 0 if _help_requested
|
||||
|
||||
CRE::Demo::Tier1.run(io)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# export.cr
|
||||
# ===================
|
||||
|
||||
require "../../compliance/bundle"
|
||||
require "../../persistence/sqlite/sqlite_persistence"
|
||||
|
||||
module CRE::Cli::Commands
|
||||
class Export
|
||||
def execute(argv : Array(String), io : IO) : Int32
|
||||
_help_requested = false
|
||||
framework = "soc2"
|
||||
out_path = "evidence.zip"
|
||||
db_path = ENV["CRE_DB_PATH"]? || "cre.db"
|
||||
|
||||
OptionParser.parse(argv) do |parser|
|
||||
parser.banner = "Usage: cre export --framework=<name> --out=<file>"
|
||||
parser.on("--framework=NAME", "soc2|pci_dss|iso27001|hipaa") { |f| framework = f }
|
||||
parser.on("--out=PATH", "output zip path") { |p| out_path = p }
|
||||
parser.on("--db=PATH", "") { |p| db_path = p }
|
||||
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
|
||||
end
|
||||
return 0 if _help_requested
|
||||
|
||||
persist = CRE::Persistence::Sqlite::SqlitePersistence.new(db_path)
|
||||
persist.migrate!
|
||||
|
||||
bundle = CRE::Compliance::Bundle.new(persist, framework)
|
||||
bundle.write(out_path)
|
||||
persist.close
|
||||
|
||||
io.puts "evidence bundle written to #{out_path}"
|
||||
0
|
||||
rescue ex
|
||||
io.puts "export failed: #{ex.message}"
|
||||
1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# policy.cr
|
||||
# ===================
|
||||
|
||||
require "../../policy/policy"
|
||||
|
||||
module CRE::Cli::Commands
|
||||
class Policy
|
||||
def execute(argv : Array(String), io : IO) : Int32
|
||||
sub = argv.shift?
|
||||
case sub
|
||||
when "list" then list(argv, io)
|
||||
when "show" then show(argv, io)
|
||||
when nil, "--help", "-h"
|
||||
io.puts "Usage: cre policy <list|show <name>>"
|
||||
0
|
||||
else
|
||||
io.puts "unknown policy subcommand: #{sub}"
|
||||
64
|
||||
end
|
||||
end
|
||||
|
||||
private def list(argv : Array(String), io : IO) : Int32
|
||||
output_format = OutputFormat::Human
|
||||
OptionParser.parse(argv) do |parser|
|
||||
parser.on("--output=FORMAT", "human|json") { |f| output_format = Output.parse_format(f) }
|
||||
end
|
||||
|
||||
policies = CRE::Policy.registry
|
||||
case output_format
|
||||
in OutputFormat::Human
|
||||
if policies.empty?
|
||||
io.puts "(no policies compiled in)"
|
||||
else
|
||||
io.puts "Compiled policies:"
|
||||
policies.each { |p| io.puts " - #{p.name} (max_age=#{p.max_age}, enforce=#{p.enforce_action.to_s.downcase})" }
|
||||
end
|
||||
in OutputFormat::Json, OutputFormat::Ndjson
|
||||
rows = policies.map do |p|
|
||||
{
|
||||
"name" => p.name,
|
||||
"max_age" => p.max_age.to_s,
|
||||
"enforce" => p.enforce_action.to_s.downcase,
|
||||
"warn_at" => p.warn_at.try(&.to_s),
|
||||
}
|
||||
end
|
||||
Output.print(io, output_format, rows)
|
||||
end
|
||||
0
|
||||
end
|
||||
|
||||
private def show(argv : Array(String), io : IO) : Int32
|
||||
name = argv.shift?
|
||||
if name.nil?
|
||||
io.puts "usage: cre policy show <name>"
|
||||
return 64
|
||||
end
|
||||
policy = CRE::Policy.registry.find { |p| p.name == name }
|
||||
if policy.nil?
|
||||
io.puts "policy not found: #{name}"
|
||||
return 1
|
||||
end
|
||||
io.puts "name: #{policy.name}"
|
||||
io.puts "desc: #{policy.description || "(none)"}"
|
||||
io.puts "max_age: #{policy.max_age}"
|
||||
io.puts "warn_at: #{policy.warn_at || "(none)"}"
|
||||
io.puts "enforce: #{policy.enforce_action.to_s.downcase}"
|
||||
io.puts "channels: #{policy.notify_channels.map(&.to_s.downcase).join(", ")}"
|
||||
io.puts "triggers:"
|
||||
policy.triggers.each { |k, v| io.puts " #{k.to_s.downcase}: #{v.to_s.downcase}" }
|
||||
0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# rotate.cr
|
||||
# ===================
|
||||
|
||||
require "../../engine/event_bus"
|
||||
require "../../engine/rotation_orchestrator"
|
||||
require "../../persistence/sqlite/sqlite_persistence"
|
||||
require "../../rotators/env_file"
|
||||
|
||||
module CRE::Cli::Commands
|
||||
class Rotate
|
||||
def execute(argv : Array(String), io : IO) : Int32
|
||||
_help_requested = false
|
||||
db_url = ENV["DATABASE_URL"]? || "sqlite:cre.db"
|
||||
cred_id_str = nil
|
||||
|
||||
OptionParser.parse(argv) do |parser|
|
||||
parser.banner = "Usage: cre rotate <credential-id> [options]"
|
||||
parser.on("--db=URL", "") { |u| db_url = u }
|
||||
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
|
||||
parser.unknown_args { |args| cred_id_str = args.first? }
|
||||
end
|
||||
return 0 if _help_requested
|
||||
|
||||
if cred_id_str.nil?
|
||||
io.puts "usage: cre rotate <credential-id>"
|
||||
return 64
|
||||
end
|
||||
|
||||
cred_id = UUID.new(cred_id_str.not_nil!) rescue nil
|
||||
if cred_id.nil?
|
||||
io.puts "invalid credential id"
|
||||
return 64
|
||||
end
|
||||
|
||||
persist = if db_url.starts_with?("sqlite:")
|
||||
CRE::Persistence::Sqlite::SqlitePersistence.new(db_url.lchop("sqlite:"))
|
||||
else
|
||||
raise "rotate currently supports SQLite only via CLI shortcut"
|
||||
end
|
||||
persist.migrate!
|
||||
|
||||
cred = persist.credentials.find(cred_id)
|
||||
if cred.nil?
|
||||
io.puts "credential not found: #{cred_id}"
|
||||
return 1
|
||||
end
|
||||
|
||||
rotator_class = CRE::Rotators::Rotator.for(rotator_kind_for(cred.kind))
|
||||
if rotator_class.nil?
|
||||
io.puts "no rotator registered for kind=#{cred.kind}"
|
||||
return 1
|
||||
end
|
||||
|
||||
bus = CRE::Engine::EventBus.new
|
||||
bus.run
|
||||
orchestrator = CRE::Engine::RotationOrchestrator.new(bus, persist)
|
||||
|
||||
rotator = case cred.kind
|
||||
when CRE::Domain::CredentialKind::EnvFile then CRE::Rotators::EnvFileRotator.new
|
||||
else
|
||||
raise "this CLI shortcut only supports env_file via direct rotation; cloud rotators need full daemon config"
|
||||
end
|
||||
|
||||
io.puts "Rotating #{cred.name} (#{cred.id}) via #{rotator.kind}..."
|
||||
state = orchestrator.run(cred, rotator)
|
||||
sleep 0.1.seconds
|
||||
bus.stop
|
||||
persist.close
|
||||
|
||||
case state
|
||||
when CRE::Persistence::RotationState::Completed then io.puts "✓ rotation completed"; 0
|
||||
when CRE::Persistence::RotationState::Failed then io.puts "✗ rotation failed"; 1
|
||||
else io.puts "rotation ended in unexpected state #{state}"; 2
|
||||
end
|
||||
end
|
||||
|
||||
private def rotator_kind_for(kind : CRE::Domain::CredentialKind) : Symbol
|
||||
case kind
|
||||
in .aws_secretsmgr? then :aws_secretsmgr
|
||||
in .vault_dynamic? then :vault_dynamic
|
||||
in .github_pat? then :github_pat
|
||||
in .env_file? then :env_file
|
||||
in .aws_iam_key? then :aws_iam_key
|
||||
in .database? then :database
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# run.cr
|
||||
# ===================
|
||||
|
||||
require "../../engine/engine"
|
||||
require "../../engine/scheduler"
|
||||
require "../../persistence/sqlite/sqlite_persistence"
|
||||
require "../../persistence/postgres/postgres_persistence"
|
||||
require "../../policy/evaluator"
|
||||
require "../../notifiers/log_notifier"
|
||||
|
||||
module CRE::Cli::Commands
|
||||
class Run
|
||||
def execute(argv : Array(String), io : IO) : Int32
|
||||
_help_requested = false
|
||||
db_url = ENV["DATABASE_URL"]? || "sqlite:cre.db"
|
||||
hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64
|
||||
interval = (ENV["CRE_TICK_SECONDS"]? || "60").to_i
|
||||
|
||||
OptionParser.parse(argv) do |parser|
|
||||
parser.banner = "Usage: cre run [options]"
|
||||
parser.on("--db=URL", "database URL (sqlite:path or postgres://...)") { |u| db_url = u }
|
||||
parser.on("--interval=SECONDS", "scheduler tick interval") { |i| interval = i.to_i }
|
||||
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
|
||||
end
|
||||
return 0 if _help_requested
|
||||
|
||||
persist = build_persistence(db_url)
|
||||
persist.migrate!
|
||||
|
||||
engine = CRE::Engine::Engine.new(persist, hmac_hex.hexbytes)
|
||||
log_notifier = CRE::Notifiers::LogNotifier.new(engine.bus)
|
||||
evaluator = CRE::Policy::Evaluator.new(engine.bus, persist)
|
||||
scheduler = CRE::Engine::Scheduler.new(engine.bus, interval.seconds)
|
||||
|
||||
engine.start
|
||||
log_notifier.start
|
||||
evaluator.start
|
||||
scheduler.start
|
||||
|
||||
io.puts "cre running. PID #{Process.pid}, tick #{interval}s, db #{redact(db_url)}"
|
||||
|
||||
Signal::INT.trap do
|
||||
io.puts "\nshutting down..."
|
||||
scheduler.stop
|
||||
evaluator.stop
|
||||
log_notifier.stop
|
||||
engine.stop
|
||||
persist.close
|
||||
exit 0
|
||||
end
|
||||
|
||||
sleep
|
||||
0
|
||||
end
|
||||
|
||||
private def build_persistence(url : String) : CRE::Persistence::Persistence
|
||||
if url.starts_with?("sqlite:")
|
||||
CRE::Persistence::Sqlite::SqlitePersistence.new(url.lchop("sqlite:"))
|
||||
elsif url.starts_with?("postgres://") || url.starts_with?("postgresql://")
|
||||
CRE::Persistence::Postgres::PostgresPersistence.new(url)
|
||||
else
|
||||
raise "unknown database URL: #{url}"
|
||||
end
|
||||
end
|
||||
|
||||
private def redact(url : String) : String
|
||||
url.gsub(/:(\/{2,})([^:@]+):([^@]+)@/) { |_| ":#{$1}#{$2}:****@" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# version.cr
|
||||
# ===================
|
||||
|
||||
require "../../version"
|
||||
|
||||
module CRE::Cli::Commands
|
||||
module Version
|
||||
def self.print(io : IO) : Nil
|
||||
io.puts "cre v#{CRE::VERSION}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# watch.cr
|
||||
# ===================
|
||||
|
||||
require "../../engine/engine"
|
||||
require "../../engine/scheduler"
|
||||
require "../../persistence/sqlite/sqlite_persistence"
|
||||
require "../../persistence/postgres/postgres_persistence"
|
||||
require "../../policy/evaluator"
|
||||
require "../../tui/tui"
|
||||
|
||||
module CRE::Cli::Commands
|
||||
class Watch
|
||||
def execute(argv : Array(String), io : IO) : Int32
|
||||
_help_requested = false
|
||||
db_url = ENV["DATABASE_URL"]? || "sqlite:cre.db"
|
||||
hmac_hex = ENV["CRE_HMAC_KEY_HEX"]? || "0" * 64
|
||||
|
||||
OptionParser.parse(argv) do |parser|
|
||||
parser.banner = "Usage: cre watch [options]"
|
||||
parser.on("--db=URL", "") { |u| db_url = u }
|
||||
parser.on("-h", "--help") { _help_requested = true; io.puts parser }
|
||||
end
|
||||
return 0 if _help_requested
|
||||
|
||||
persist = if db_url.starts_with?("sqlite:")
|
||||
CRE::Persistence::Sqlite::SqlitePersistence.new(db_url.lchop("sqlite:"))
|
||||
elsif db_url.starts_with?("postgres://") || db_url.starts_with?("postgresql://")
|
||||
CRE::Persistence::Postgres::PostgresPersistence.new(db_url)
|
||||
else
|
||||
raise "unknown database URL"
|
||||
end
|
||||
persist.migrate!
|
||||
|
||||
engine = CRE::Engine::Engine.new(persist, hmac_hex.hexbytes)
|
||||
evaluator = CRE::Policy::Evaluator.new(engine.bus, persist)
|
||||
scheduler = CRE::Engine::Scheduler.new(engine.bus, 60.seconds)
|
||||
tui = CRE::Tui::Tui.new(engine.bus)
|
||||
|
||||
engine.start
|
||||
evaluator.start
|
||||
scheduler.start
|
||||
tui.start
|
||||
|
||||
Signal::INT.trap do
|
||||
tui.stop
|
||||
scheduler.stop
|
||||
evaluator.stop
|
||||
engine.stop
|
||||
persist.close
|
||||
exit 0
|
||||
end
|
||||
|
||||
sleep
|
||||
0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# output.cr
|
||||
# ===================
|
||||
|
||||
require "json"
|
||||
|
||||
module CRE::Cli
|
||||
enum OutputFormat
|
||||
Human
|
||||
Json
|
||||
Ndjson
|
||||
end
|
||||
|
||||
module Output
|
||||
def self.parse_format(s : String) : OutputFormat
|
||||
case s.downcase
|
||||
when "human" then OutputFormat::Human
|
||||
when "json" then OutputFormat::Json
|
||||
when "ndjson" then OutputFormat::Ndjson
|
||||
else
|
||||
raise "unknown output format: #{s} (valid: human, json, ndjson)"
|
||||
end
|
||||
end
|
||||
|
||||
def self.print(io : IO, format : OutputFormat, data) : Nil
|
||||
case format
|
||||
in OutputFormat::Human
|
||||
io.puts data.to_s
|
||||
in OutputFormat::Json
|
||||
io.puts data.to_json
|
||||
in OutputFormat::Ndjson
|
||||
if data.is_a?(Array)
|
||||
data.each { |row| io.puts row.to_json }
|
||||
else
|
||||
io.puts data.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# bundle.cr
|
||||
# ===================
|
||||
|
||||
require "../persistence/persistence"
|
||||
|
||||
module CRE::Compliance
|
||||
# Bundle is implemented in Phase 14. This stub keeps the CLI export
|
||||
# subcommand wired and compilable until then.
|
||||
class Bundle
|
||||
def initialize(@persistence : Persistence::Persistence, @framework : String)
|
||||
end
|
||||
|
||||
def write(path : String) : Nil
|
||||
raise NotImplementedError.new("Compliance::Bundle.write will be wired up in Phase 14")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# ===================
|
||||
# ©AngelaMos | 2026
|
||||
# tier_1.cr
|
||||
# ===================
|
||||
|
||||
module CRE::Demo
|
||||
# Tier 1 demo is implemented in Phase 15. This stub keeps `cre demo`
|
||||
# wired and compilable until then.
|
||||
module Tier1
|
||||
def self.run(io : IO) : Int32
|
||||
io.puts "Tier 1 demo not yet implemented (Phase 15 of build)."
|
||||
io.puts "Try: cre run --db=sqlite:cre.db"
|
||||
0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -56,8 +56,8 @@ module CRE::Notifiers
|
|||
when Events::AlertRaised
|
||||
case ev.severity
|
||||
in Events::Severity::Critical then Log.error &.emit("alert", text: ev.message)
|
||||
in Events::Severity::Warn then Log.warn &.emit("alert", text: ev.message)
|
||||
in Events::Severity::Info then Log.info &.emit("alert", text: ev.message)
|
||||
in Events::Severity::Warn then Log.warn &.emit("alert", text: ev.message)
|
||||
in Events::Severity::Info then Log.info &.emit("alert", text: ev.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ module CRE::Notifiers
|
|||
# directly with HTTP::Client; no tourmaline dependency for the notification
|
||||
# path keeps the footprint small.
|
||||
class Telegram
|
||||
Log = ::Log.for("cre.telegram")
|
||||
Log = ::Log.for("cre.telegram")
|
||||
DEFAULT_API = "https://api.telegram.org"
|
||||
|
||||
class TelegramError < Exception
|
||||
|
|
|
|||
|
|
@ -63,13 +63,13 @@ module CRE::Notifiers
|
|||
return "unauthorized" unless authorized_viewer?(chat_id)
|
||||
cmd, _, rest = text.strip.lstrip('/').partition(' ')
|
||||
case cmd
|
||||
when "status" then status_message
|
||||
when "queue" then queue_message
|
||||
when "alerts" then alerts_message
|
||||
when "status" then status_message
|
||||
when "queue" then queue_message
|
||||
when "alerts" then alerts_message
|
||||
when "help", "" then help_message
|
||||
when "rotate" then handle_rotate(chat_id, rest)
|
||||
when "snooze" then handle_snooze(chat_id, rest)
|
||||
when "history" then history_message(rest)
|
||||
when "rotate" then handle_rotate(chat_id, rest)
|
||||
when "snooze" then handle_snooze(chat_id, rest)
|
||||
when "history" then history_message(rest)
|
||||
else
|
||||
"unknown command: /#{cmd} (try /help)"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -43,10 +43,10 @@ module CRE::Rotators
|
|||
Domain::NewSecret.new(
|
||||
ciphertext: payload.to_slice,
|
||||
metadata: {
|
||||
"lease_id" => ds.lease_id,
|
||||
"lease_duration" => ds.lease_duration.to_s,
|
||||
"old_lease_id" => c.tag("current_lease_id") || "",
|
||||
"username" => ds.username,
|
||||
"lease_id" => ds.lease_id,
|
||||
"lease_duration" => ds.lease_duration.to_s,
|
||||
"old_lease_id" => c.tag("current_lease_id") || "",
|
||||
"username" => ds.username,
|
||||
},
|
||||
)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ module CRE::Tui
|
|||
HOME = "#{ESC}H"
|
||||
RESET = "#{ESC}0m"
|
||||
|
||||
BOLD = "#{ESC}1m"
|
||||
DIM = "#{ESC}2m"
|
||||
BOLD = "#{ESC}1m"
|
||||
DIM = "#{ESC}2m"
|
||||
|
||||
FG_RED = "#{ESC}31m"
|
||||
FG_GREEN = "#{ESC}32m"
|
||||
|
|
@ -36,13 +36,33 @@ module CRE::Tui
|
|||
"#{color}#{text}#{RESET}"
|
||||
end
|
||||
|
||||
def self.green(text : String) : String ; colorize(text, FG_GREEN) ; end
|
||||
def self.red(text : String) : String ; colorize(text, FG_RED) ; end
|
||||
def self.yellow(text : String) : String ; colorize(text, FG_YELLOW) ; end
|
||||
def self.cyan(text : String) : String ; colorize(text, FG_CYAN) ; end
|
||||
def self.gray(text : String) : String ; colorize(text, FG_GRAY) ; end
|
||||
def self.bold(text : String) : String ; colorize(text, BOLD) ; end
|
||||
def self.dim(text : String) : String ; colorize(text, DIM) ; end
|
||||
def self.green(text : String) : String
|
||||
colorize(text, FG_GREEN)
|
||||
end
|
||||
|
||||
def self.red(text : String) : String
|
||||
colorize(text, FG_RED)
|
||||
end
|
||||
|
||||
def self.yellow(text : String) : String
|
||||
colorize(text, FG_YELLOW)
|
||||
end
|
||||
|
||||
def self.cyan(text : String) : String
|
||||
colorize(text, FG_CYAN)
|
||||
end
|
||||
|
||||
def self.gray(text : String) : String
|
||||
colorize(text, FG_GRAY)
|
||||
end
|
||||
|
||||
def self.bold(text : String) : String
|
||||
colorize(text, BOLD)
|
||||
end
|
||||
|
||||
def self.dim(text : String) : String
|
||||
colorize(text, DIM)
|
||||
end
|
||||
|
||||
# Strip ANSI escape sequences (useful for testing rendered output).
|
||||
def self.strip(text : String) : String
|
||||
|
|
|
|||
Loading…
Reference in New Issue