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
5 changes: 3 additions & 2 deletions bin/dev
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require "dev"
require "dev/global_dispatch"

# Global builtins (cd/plan/cred/learnings) dispatch before any dev.yml
# lookup — they are host- or workspace-global, not project commands.
# Global builtins (cd/clone/plan/cred/learnings) dispatch before any dev.yml
# lookup — they are host- or workspace-global, not project commands. Help
# spellings join them when no dev.yml encloses the cwd (global usage).
global_dispatch = Dev::GlobalDispatch.new
if global_dispatch.global_command?(ARGV)
global_dispatch.run(ARGV)
Expand Down
6 changes: 5 additions & 1 deletion src/dev/builtins/cd_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ module Builtins
class CdCommand < BuiltinCommand
extend T::Sig

# Shared with the global usage listing (GlobalDispatch), which reads
# descriptions without instantiating the builtin.
DESC = "Jump to a checkout under $DEV_CD_ROOT (default ~/src) by fuzzy name"

sig { params(accessor: Dev::Cd::Accessor).void }
def initialize(accessor: Dev::Cd::Accessor.new)
super()
@accessor = T.let(accessor, Dev::Cd::Accessor)
end

sig { override.returns(String) }
def desc = "Jump to a checkout under $DEV_CD_ROOT (default ~/src) by fuzzy name"
def desc = DESC

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow
Expand Down
6 changes: 5 additions & 1 deletion src/dev/builtins/clone_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ module Builtins
class CloneCommand < BuiltinCommand
extend T::Sig

# Shared with the global usage listing (GlobalDispatch), which reads
# descriptions without instantiating the builtin.
DESC = "Clone a GitHub repo (via gh auth) into $DEV_CD_ROOT (default ~/src), org defaults to d3mlabs"

sig { params(accessor: Dev::Clone::Accessor).void }
def initialize(accessor: Dev::Clone::Accessor.new)
super()
@accessor = T.let(accessor, Dev::Clone::Accessor)
end

sig { override.returns(String) }
def desc = "Clone a GitHub repo (via gh auth) into $DEV_CD_ROOT (default ~/src), org defaults to d3mlabs"
def desc = DESC

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow
Expand Down
6 changes: 5 additions & 1 deletion src/dev/builtins/cred_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ module Builtins
class CredCommand < BuiltinCommand
extend T::Sig

# Shared with the global usage listing (GlobalDispatch), which reads
# descriptions without instantiating the builtin.
DESC = "Resolve a stored credential (e.g. cred get <namespace> <key>)"

sig { params(accessor: Dev::CredentialAccessor).void }
def initialize(accessor: Dev::CredentialAccessor.new)
super()
@accessor = T.let(accessor, Dev::CredentialAccessor)
end

sig { override.returns(String) }
def desc = "Resolve a stored credential (e.g. cred get <namespace> <key>)"
def desc = DESC

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow
Expand Down
10 changes: 6 additions & 4 deletions src/dev/builtins/learnings_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ module Builtins
class LearningsCommand < BuiltinCommand
extend T::Sig

# Shared with the global usage listing (GlobalDispatch), which reads
# descriptions without instantiating the builtin.
DESC = "Learnings read path (sync: refresh now, status: what's linked, invariants: Tier-0 block, " \
"init: scaffold the index)"

# Builds the accessor for the enclosing project (per-call root).
AccessorFactory = T.type_alias do
T.proc.params(project_root: Pathname).returns(Dev::Learnings::Accessor)
Expand All @@ -25,10 +30,7 @@ def initialize(accessor_factory: ->(project_root) { Dev::Learnings::Accessor.new
end

sig { override.returns(String) }
def desc
"Learnings read path (sync: refresh now, status: what's linked, invariants: Tier-0 block, " \
"init: scaffold the index)"
end
def desc = DESC

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow
Expand Down
6 changes: 5 additions & 1 deletion src/dev/builtins/plan_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ module Builtins
class PlanCommand < BuiltinCommand
extend T::Sig

# Shared with the global usage listing (GlobalDispatch), which reads
# descriptions without instantiating the builtin.
DESC = "Sync Cursor plans with GitHub issues (new/link/pull/push/status/init)"

# Builds the accessor for the enclosing project (per-call root).
AccessorFactory = T.type_alias do
T.proc.params(project_root: Pathname).returns(Dev::Plan::Accessor)
Expand All @@ -25,7 +29,7 @@ def initialize(accessor_factory: ->(project_root) { Dev::Plan::Accessor.new(proj
end

sig { override.returns(String) }
def desc = "Sync Cursor plans with GitHub issues (new/link/pull/push/status/init)"
def desc = DESC

sig { override.returns(Command::Category) }
def category = Command::Category::Workflow
Expand Down
33 changes: 33 additions & 0 deletions src/dev/cli/global_usage_printer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# typed: strict
# frozen_string_literal: true

require "stringio"

module Dev
module Cli
# The usage view for help outside a project (bare `dev`, `--help`, `-h`,
# `help` with no dev.yml in the cwd's ancestry): the global builtins that
# work from any directory, plus the hint that project commands need a
# dev.yml. A dedicated view rather than a UsagePrinter variant — that
# printer is shaped around a project catalog (project name, sections),
# and this listing is a flat, fixed set.
class GlobalUsagePrinter
extend T::Sig

# @param commands [Hash{String => String}] global command name => description
# @param out [IO, StringIO]
# @return [void]
sig { params(commands: T::Hash[String, String], out: T.any(IO, StringIO)).void }
def print(commands:, out:)
out.puts "Usage: dev <command> [args...]"
out.puts ""
out.puts "Global commands (available anywhere):"
commands.sort.each do |name, desc|
out.puts " #{name.ljust(12)} #{desc}"
end
out.puts ""
out.puts "Run dev inside a project that defines a dev.yml to see its commands."
end
end
end
end
77 changes: 70 additions & 7 deletions src/dev/global_dispatch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
# frozen_string_literal: true

require "pathname"
require "dev/builtins/cd_command"
require "dev/builtins/clone_command"
require "dev/builtins/cred_command"
require "dev/builtins/learnings_command"
require "dev/builtins/plan_command"
require "dev/cd"
require "dev/cli/global_usage_printer"
require "dev/clone"
require "dev/plan"
require "dev/learnings"
Expand All @@ -26,38 +32,64 @@ module Dev
# Runs before Dev::Runner is constructed, so these commands work from any
# directory. Project commands (`up`, yaml-declared names) keep the existing
# "must find dev.yml" failure in the Runner path.
#
# Help is a conditional citizen here: outside any dev.yml project, the help
# spellings (bare `dev`, `--help`, `-h`, `help`) render the global usage —
# inside a project they stay with the Runner, which lists the project's
# catalog.
class GlobalDispatch
extend T::Sig

GLOBAL_COMMANDS = T.let(%w[cd clone plan cred learnings].freeze, T::Array[String])
# Global command name => description. One hash serves both dispatch
# membership and the global usage listing; descriptions alias the
# builtins' canonical DESC constants so the two help views cannot drift.
GLOBAL_COMMANDS = T.let(
{
"cd" => Builtins::CdCommand::DESC,
"clone" => Builtins::CloneCommand::DESC,
"cred" => Builtins::CredCommand::DESC,
"learnings" => Builtins::LearningsCommand::DESC,
"plan" => Builtins::PlanCommand::DESC,
}.freeze,
T::Hash[String, String],
)

# Candidates shown in an ambiguous `dev cd` error before truncating.
AMBIGUOUS_CANDIDATE_CAP = 10

# @param cd_accessor [Dev::Cd::Accessor]
# @param clone_accessor [Dev::Clone::Accessor]
# @param cred_accessor [Dev::CredentialAccessor]
# @param usage_printer [Dev::Cli::GlobalUsagePrinter]
sig do
params(
cd_accessor: Dev::Cd::Accessor,
clone_accessor: Dev::Clone::Accessor,
cred_accessor: Dev::CredentialAccessor,
usage_printer: Dev::Cli::GlobalUsagePrinter,
).void
end
def initialize(cd_accessor: Dev::Cd::Accessor.new, clone_accessor: Dev::Clone::Accessor.new,
cred_accessor: Dev::CredentialAccessor.new)
cred_accessor: Dev::CredentialAccessor.new,
usage_printer: Dev::Cli::GlobalUsagePrinter.new)
@cd_accessor = T.let(cd_accessor, Dev::Cd::Accessor)
@clone_accessor = T.let(clone_accessor, Dev::Clone::Accessor)
@cred_accessor = T.let(cred_accessor, Dev::CredentialAccessor)
@usage_printer = T.let(usage_printer, Dev::Cli::GlobalUsagePrinter)
end

# Whether the argv names a global builtin this dispatcher owns.
# Whether the argv is dispatched here, before any dev.yml lookup: a
# global builtin from anywhere, or a help spelling outside any project
# (inside one, the Runner's help lists the project catalog instead).
#
# @param argv [Array<String>]
# @return [Boolean]
sig { params(argv: T::Array[String]).returns(T::Boolean) }
def global_command?(argv)
GLOBAL_COMMANDS.include?(argv.first)
cmd_name = argv.first
return true if cmd_name && GLOBAL_COMMANDS.key?(cmd_name)

help_argv?(argv) && nearest_dev_yaml_root.nil?
end

# Run a global builtin. Clean failures (usage errors, unresolved repos)
Expand All @@ -67,6 +99,11 @@ def global_command?(argv)
# @return [void]
sig { params(argv: T::Array[String]).void }
def run(argv)
if help_argv?(argv)
@usage_printer.print(commands: GLOBAL_COMMANDS, out: $stdout)
return
end

args = T.let(argv.dup, T::Array[String])
cmd_name = T.must(args.shift)
case cmd_name
Expand All @@ -93,6 +130,17 @@ def run(argv)

private

# Whether the argv is a help spelling. Mirrors the Runner's routing:
# bare `dev`, the exact conventional flags, and `help` as the command
# name (the help builtin ignores trailing args).
#
# @param argv [Array<String>]
# @return [Boolean]
sig { params(argv: T::Array[String]).returns(T::Boolean) }
def help_argv?(argv)
argv.empty? || argv == ["--help"] || argv == ["-h"] || argv.first == "help"
end

# Print an ambiguous `dev cd` result: the candidates (capped, each at its
# shortest-unique depth) and the escape hatch — refine or Tab-browse.
#
Expand Down Expand Up @@ -125,11 +173,26 @@ def workspace_root
# @return [Pathname, nil]
sig { returns(T.nilable(Pathname)) }
def enclosing_project_root
cwd = Pathname.new(Dir.pwd)
cwd.ascend do |path|
nearest_dev_yaml_root || nearest_git_root
end

# The nearest ancestor holding a dev.yml, or nil. This is the "inside a
# project?" test the help fallback uses: a plain git checkout with no
# dev.yml still gets the global usage.
#
# @return [Pathname, nil]
sig { returns(T.nilable(Pathname)) }
def nearest_dev_yaml_root
Pathname.new(Dir.pwd).ascend do |path|
return path if (path / Dev::DEV_YAML_FILENAME).exist?
end
cwd.ascend do |path|
nil
end

# @return [Pathname, nil] the nearest ancestor holding a .git, or nil
sig { returns(T.nilable(Pathname)) }
def nearest_git_root
Pathname.new(Dir.pwd).ascend do |path|
return path if (path / ".git").exist?
end
nil
Expand Down
31 changes: 28 additions & 3 deletions test/dev/bin_dev_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@
# frozen_string_literal: true

require "test_helper"
require "dev/global_dispatch"
require "open3"
require "tmpdir"

# bin/dev opens with an `unset` of the caller's bundler-activation env
# (dev#94): a harness running under `bundle exec` leaks RUBYOPT/BUNDLE_*
# into every child, and the Ruby interpreter acts on RUBYOPT before the
# script's first line — so the defense lives in the sh layer, and only
# spawning the real shim can exercise it. Three tests, one property each:
# spawning the real shim can exercise it. Scrub tests, one property each:
#
# 1. dev still boots when the caller's env is hostile (the bug's symptom).
# 2. The shim hands Ruby an env with every scrub key removed (the fix,
# key by key).
# 3. The scrub list keeps up with bundler: whatever the locked bundler
# exports must be on it (the drift over time).
#
# The shim is also the one place the full argv routing (global dispatch,
# then Runner) is wired together, so its end-to-end routing behavior —
# help outside a project — is exercised here too.
transform!(RSpock::AST::Transformation)
class Dev::BinDevTest < Minitest::Test
DEV_ROOT = File.expand_path("../..", __dir__)
Expand All @@ -36,8 +41,8 @@ class Dev::BinDevTest < Minitest::Test
"BUNDLE_GEMFILE" => "/nonexistent/harness/Gemfile",
}

When "running bin/dev there"
_out, err, status = Open3.capture3(hostile, "sh", BIN_DEV, chdir: dir)
When "running a project command (bare dev renders the global usage instead) there"
_out, err, status = Open3.capture3(hostile, "sh", BIN_DEV, "up", chdir: dir)

Then "dev reached its own no-dev.yml refusal — not a crash inside the caller's bundler"
!status.success?
Expand All @@ -48,6 +53,26 @@ class Dev::BinDevTest < Minitest::Test
FileUtils.rm_rf(dir)
end

test "bare dev and dev --help outside a project print the global usage and exit 0" do
Given "a directory with no dev.yml anywhere above it"
dir = Dir.mktmpdir("dev-bin-test-")

When "running bin/dev bare and with --help there"
bare_out, _bare_err, bare_status = Open3.capture3("sh", BIN_DEV, chdir: dir)
help_out, _help_err, help_status = Open3.capture3("sh", BIN_DEV, "--help", chdir: dir)

Then "both succeed with the global command listing and the project hint"
bare_status.success?
help_status.success?
bare_out.include?("Global commands (available anywhere):")
bare_out.include?("Run dev inside a project that defines a dev.yml to see its commands.")
Dev::GlobalDispatch::GLOBAL_COMMANDS.keys.all? { |name| bare_out.include?(name) }
help_out == bare_out

Cleanup
FileUtils.rm_rf(dir)
end

test "the shim removes every scrub key from the env it hands to ruby" do
Given "every scrub key planted hostile, and a stub ruby that prints the env it receives"
dir = Dir.mktmpdir("dev-bin-test-")
Expand Down
Loading
Loading