space-cadet 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +279 -0
- data/exe/space +13 -0
- data/lib/space_core/atomic_write.rb +21 -0
- data/lib/space_core/cli/base_command.rb +19 -0
- data/lib/space_core/cli/build.rb +27 -0
- data/lib/space_core/cli/config.rb +49 -0
- data/lib/space_core/cli/current.rb +16 -0
- data/lib/space_core/cli/help.rb +195 -0
- data/lib/space_core/cli/helpers.rb +115 -0
- data/lib/space_core/cli/init.rb +29 -0
- data/lib/space_core/cli/list.rb +24 -0
- data/lib/space_core/cli/loop_status.rb +47 -0
- data/lib/space_core/cli/new.rb +38 -0
- data/lib/space_core/cli/pack.rb +29 -0
- data/lib/space_core/cli/path.rb +16 -0
- data/lib/space_core/cli/repeatable_options.rb +84 -0
- data/lib/space_core/cli/repo.rb +76 -0
- data/lib/space_core/cli/run.rb +44 -0
- data/lib/space_core/cli/shell.rb +125 -0
- data/lib/space_core/cli/show.rb +21 -0
- data/lib/space_core/cli/status.rb +74 -0
- data/lib/space_core/cli/use.rb +17 -0
- data/lib/space_core/cli.rb +177 -0
- data/lib/space_core/cloner.rb +77 -0
- data/lib/space_core/commands.rb +51 -0
- data/lib/space_core/config.rb +152 -0
- data/lib/space_core/errors.rb +14 -0
- data/lib/space_core/git_client.rb +49 -0
- data/lib/space_core/mise_client.rb +37 -0
- data/lib/space_core/oci_builder.rb +56 -0
- data/lib/space_core/oci_packer.rb +103 -0
- data/lib/space_core/oci_runner.rb +89 -0
- data/lib/space_core/paths.rb +58 -0
- data/lib/space_core/repo_reference.rb +19 -0
- data/lib/space_core/repo_resolver.rb +167 -0
- data/lib/space_core/scm/client.rb +87 -0
- data/lib/space_core/scm/git.rb +232 -0
- data/lib/space_core/scm/status.rb +24 -0
- data/lib/space_core/shell.rb +90 -0
- data/lib/space_core/shell_integration.rb +438 -0
- data/lib/space_core/slugger.rb +16 -0
- data/lib/space_core/space.rb +174 -0
- data/lib/space_core/space_store.rb +319 -0
- data/lib/space_core/state.rb +86 -0
- data/lib/space_core/templates/oci/dockerfile.erb +73 -0
- data/lib/space_core/templates/oci/dockerignore.erb +17 -0
- data/lib/space_core/templates/oci/entrypoint.sh.erb +16 -0
- data/lib/space_core/terminal.rb +161 -0
- data/lib/space_core/version.rb +7 -0
- data/lib/space_core/warnings.rb +13 -0
- data/lib/space_core/xdg.rb +33 -0
- data/lib/space_core.rb +26 -0
- metadata +199 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../oci_runner"
|
|
5
|
+
|
|
6
|
+
module Space::Core::CLI
|
|
7
|
+
class Run < BaseCommand
|
|
8
|
+
desc "Run the packed OCI image for the current space (auth injected at runtime). Pass a command and its arguments after `--` so they forward as separate argv tokens"
|
|
9
|
+
|
|
10
|
+
argument :command, type: :array, required: false, desc: "Command to run in the container (default: login shell). Use `--` to pass a command with arguments, e.g. `space run -- hermes -z \"hello\"`"
|
|
11
|
+
option :tty, type: :boolean, default: nil, desc: "Force interactive TTY (default: auto-detect)"
|
|
12
|
+
option :env, type: :array, desc: "Host env var to forward into the container (repeatable; adds to run.env)"
|
|
13
|
+
example "-- hermes -z \"What is 17 plus 4?\" # `--` forwards the command and its args as separate tokens (a quoted multi-word command arrives as one token and fails in-guest)"
|
|
14
|
+
|
|
15
|
+
def call(command: [], tty: nil, env: [], **opts)
|
|
16
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
17
|
+
handle_errors do
|
|
18
|
+
result = store.current.bind do |space|
|
|
19
|
+
runner = Space::Core::OciRunner.new(
|
|
20
|
+
space: space, interactive: tty.nil? ? CLI.tty?(out) : tty, env_vars: env
|
|
21
|
+
)
|
|
22
|
+
runner.command(command).fmap { |argv| { argv: argv, runner: runner } }
|
|
23
|
+
end
|
|
24
|
+
render(result) do |r|
|
|
25
|
+
r[:runner].host_dirs.each { |d| FileUtils.mkdir_p(d) }
|
|
26
|
+
warn_missing_env(r[:runner].missing_env)
|
|
27
|
+
terminal.say "Running: #{r[:argv].join(' ')}"
|
|
28
|
+
out.flush # Kernel.exec replaces the process without flushing buffered IO
|
|
29
|
+
Kernel.exec(*r[:argv])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# A requested var absent from the host env is almost always a forgotten export;
|
|
37
|
+
# forwarding silently omits it and the payload fails opaquely in-guest. Warn, don't fail.
|
|
38
|
+
def warn_missing_env(missing)
|
|
39
|
+
return if missing.empty?
|
|
40
|
+
|
|
41
|
+
terminal.error("Warning: requested env not set on host, not forwarded: #{missing.join(', ')}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core::CLI
|
|
4
|
+
module Shell
|
|
5
|
+
class ShellInit < BaseCommand
|
|
6
|
+
desc "Print shell integration script"
|
|
7
|
+
argument :shell_name, required: true, desc: "Shell name (e.g. fish)"
|
|
8
|
+
|
|
9
|
+
def call(shell_name:, **opts)
|
|
10
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
11
|
+
handle_errors do
|
|
12
|
+
terminal.say Space::Core::ShellIntegration.for(shell_name)
|
|
13
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Fish < BaseCommand
|
|
19
|
+
desc "Manage fish shell integration: install, uninstall, path"
|
|
20
|
+
argument :subcommand, required: false, desc: "install, uninstall, or path (default: install)"
|
|
21
|
+
option :force, type: :boolean, default: false, desc: "Overwrite or remove existing shell files"
|
|
22
|
+
|
|
23
|
+
def call(subcommand: "install", force: false, **opts)
|
|
24
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
25
|
+
handle_errors do
|
|
26
|
+
case subcommand
|
|
27
|
+
when "install"
|
|
28
|
+
result = Space::Core::ShellIntegration.install("fish", env: project_config.env, force: force)
|
|
29
|
+
terminal.success fish_install_message(result.fetch(:action), result.fetch(:path))
|
|
30
|
+
terminal.success fish_completions_install_message(result.fetch(:completions_action), result.fetch(:completions_path))
|
|
31
|
+
terminal.say "Restart fish to load the integration in this terminal: exec fish"
|
|
32
|
+
when "uninstall"
|
|
33
|
+
result = Space::Core::ShellIntegration.uninstall("fish", env: project_config.env, force: force)
|
|
34
|
+
terminal.success fish_uninstall_message(result.fetch(:action), result.fetch(:path))
|
|
35
|
+
terminal.success fish_completions_uninstall_message(result.fetch(:completions_action), result.fetch(:completions_path))
|
|
36
|
+
when "path"
|
|
37
|
+
terminal.say "Function: #{terminal.path(Space::Core::ShellIntegration.path_for('fish', env: project_config.env))}"
|
|
38
|
+
terminal.say "Completions: #{terminal.path(Space::Core::ShellIntegration.completions_path_for('fish', env: project_config.env))}"
|
|
39
|
+
else
|
|
40
|
+
err.puts "Usage: space shell fish [install|uninstall|path]"
|
|
41
|
+
CLI.record_outcome(Outcome.new(exit_code: 1))
|
|
42
|
+
next
|
|
43
|
+
end
|
|
44
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def fish_install_message(action, path)
|
|
51
|
+
case action
|
|
52
|
+
when :unchanged then "Fish integration already installed: #{terminal.path(path)}"
|
|
53
|
+
when :updated then "Updated fish integration: #{terminal.path(path)}"
|
|
54
|
+
else "Installed fish integration: #{terminal.path(path)}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fish_uninstall_message(action, path)
|
|
59
|
+
case action
|
|
60
|
+
when :missing then "Fish integration was not installed: #{terminal.path(path)}"
|
|
61
|
+
else "Removed fish integration: #{terminal.path(path)}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def fish_completions_install_message(action, path)
|
|
66
|
+
case action
|
|
67
|
+
when :unchanged then "Fish completions already installed: #{terminal.path(path)}"
|
|
68
|
+
when :updated then "Updated fish completions: #{terminal.path(path)}"
|
|
69
|
+
else "Installed fish completions: #{terminal.path(path)}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def fish_completions_uninstall_message(action, path)
|
|
74
|
+
case action
|
|
75
|
+
when :missing then "Fish completions were not installed: #{terminal.path(path)}"
|
|
76
|
+
else "Removed fish completions: #{terminal.path(path)}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class Complete < BaseCommand
|
|
82
|
+
desc "Print completion candidates"
|
|
83
|
+
argument :kind, required: true, desc: "Completion kind"
|
|
84
|
+
argument :extra, type: :array, required: false, desc: "Extra args for completion"
|
|
85
|
+
|
|
86
|
+
def call(kind:, extra: [], **opts)
|
|
87
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
88
|
+
handle_errors do
|
|
89
|
+
completion_candidates(kind, Array(extra)).each { |c| terminal.say c }
|
|
90
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def completion_candidates(kind, args)
|
|
97
|
+
case kind
|
|
98
|
+
when "spaces" then store.list.map { |space| "#{space.id}\t#{space.title}" }
|
|
99
|
+
when "statuses" then Space::Core::Space::VALID_STATUSES
|
|
100
|
+
when "config-keys" then Space::Core::Config::EDITABLE_KEYS
|
|
101
|
+
when "config-values" then completion_values_for_config_key(args.first)
|
|
102
|
+
when "shells" then ["fish"]
|
|
103
|
+
when "color-modes" then %w[auto always never]
|
|
104
|
+
when "repo-subcommands" then %w[add list ls resolve]
|
|
105
|
+
when "config-subcommands" then %w[show path set]
|
|
106
|
+
when "fish-subcommands" then %w[install uninstall path]
|
|
107
|
+
else
|
|
108
|
+
raise Space::Core::Error, "Usage: space shell complete #{completion_kinds.join('|')}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def completion_values_for_config_key(key)
|
|
113
|
+
case key
|
|
114
|
+
when "git_clone_protocol" then Space::Core::Config::VALID_GIT_CLONE_PROTOCOLS
|
|
115
|
+
when "default_provider" then %w[github.com gitlab.com]
|
|
116
|
+
else []
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def completion_kinds
|
|
121
|
+
%w[spaces statuses config-keys config-values shells color-modes repo-subcommands config-subcommands fish-subcommands]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core::CLI
|
|
4
|
+
class Show < BaseCommand
|
|
5
|
+
desc "Show metadata for a space or the current space"
|
|
6
|
+
argument :identifier, required: false, desc: "Space ID or title slug"
|
|
7
|
+
|
|
8
|
+
def call(identifier: nil, **opts)
|
|
9
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
10
|
+
render(store.find(identifier)) do |space|
|
|
11
|
+
terminal.say "ID: #{space.id}"
|
|
12
|
+
terminal.say "Title: #{space.title}"
|
|
13
|
+
terminal.say "Status: #{space.status}"
|
|
14
|
+
terminal.say "Path: #{terminal.path(space.path)}"
|
|
15
|
+
terminal.say "Created: #{space.data['created_at']}"
|
|
16
|
+
terminal.say "Updated: #{space.data['updated_at']}"
|
|
17
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core::CLI
|
|
4
|
+
class Status < BaseCommand
|
|
5
|
+
desc "Set a space status: active, paused, done, archived"
|
|
6
|
+
argument :rest, type: :array, required: false, desc: "[SPACE] STATUS"
|
|
7
|
+
|
|
8
|
+
# One command, overloaded by its positional(s):
|
|
9
|
+
# help token (`help`, or dry-cli's -h/--help which never reach here) → help
|
|
10
|
+
# lone keyword / `<space> <keyword>` → set
|
|
11
|
+
# bare, or a lone non-keyword identifier → report
|
|
12
|
+
def call(rest: [], **opts)
|
|
13
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
14
|
+
args = Array(rest)
|
|
15
|
+
return show_help if help_token?(args)
|
|
16
|
+
|
|
17
|
+
handle_errors do
|
|
18
|
+
if set_args?(args)
|
|
19
|
+
set_status(args)
|
|
20
|
+
elsif args.length <= 1
|
|
21
|
+
report(args.first)
|
|
22
|
+
else
|
|
23
|
+
raise Space::Core::Error, "Usage: space status [SPACE] STATUS"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# The bare word `help` shows the command help, like dry-cli's -h/--help (which
|
|
31
|
+
# it intercepts before dispatch). It must never set status to "help" or report.
|
|
32
|
+
def help_token?(args)
|
|
33
|
+
args == ["help"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show_help
|
|
37
|
+
out.puts Dry::CLI::Banner.call(self.class, Dry::CLI::ProgramName.call(["status"]))
|
|
38
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A set request: `<space> <keyword>` (two args), or a lone status keyword. A
|
|
42
|
+
# lone non-keyword arg is a space identifier to REPORT, not a malformed status.
|
|
43
|
+
def set_args?(args)
|
|
44
|
+
args.length == 2 ||
|
|
45
|
+
(args.length == 1 && Space::Core::Space::VALID_STATUSES.include?(args.first))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def set_status(args)
|
|
49
|
+
identifier, status_value = args.length == 2 ? args : [nil, args.first]
|
|
50
|
+
render(store.find(identifier)) do |space|
|
|
51
|
+
space.update_status(status_value)
|
|
52
|
+
terminal.success "#{space.id} is #{space.status}"
|
|
53
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def report(identifier)
|
|
58
|
+
render(store.find(identifier)) do |space|
|
|
59
|
+
terminal.say "ID: #{space.id}"
|
|
60
|
+
terminal.say "Title: #{space.title}"
|
|
61
|
+
terminal.say "Status: #{space.status}"
|
|
62
|
+
terminal.say "Path: #{terminal.path(space.path)}"
|
|
63
|
+
terminal.say "Created: #{space.data['created_at']}"
|
|
64
|
+
terminal.say "Updated: #{space.data['updated_at']}"
|
|
65
|
+
lines = LoopStatus.lines(space.data["project"])
|
|
66
|
+
if lines
|
|
67
|
+
terminal.say ""
|
|
68
|
+
lines.each { |line| terminal.say line }
|
|
69
|
+
end
|
|
70
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core::CLI
|
|
4
|
+
class Use < BaseCommand
|
|
5
|
+
desc "Remember a space in recent state and print its path"
|
|
6
|
+
argument :identifier, required: true, desc: "Space ID or title slug"
|
|
7
|
+
|
|
8
|
+
def call(identifier:, **opts)
|
|
9
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
10
|
+
render(store.use(identifier)) do |space|
|
|
11
|
+
terminal.success "Recent space: #{space.id}"
|
|
12
|
+
terminal.say terminal.path(space.path)
|
|
13
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require "pastel"
|
|
5
|
+
require_relative "cli/repeatable_options"
|
|
6
|
+
require_relative "cli/help"
|
|
7
|
+
|
|
8
|
+
module Space::Core
|
|
9
|
+
module CLI
|
|
10
|
+
CLI = self
|
|
11
|
+
|
|
12
|
+
Outcome = Data.define(:exit_code, :message) do
|
|
13
|
+
def initialize(exit_code:, message: nil) = super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.record_outcome(o) = (Thread.current[:space_core_cli_outcome] = o)
|
|
17
|
+
def self.last_outcome = Thread.current[:space_core_cli_outcome]
|
|
18
|
+
|
|
19
|
+
# Pastel used by the colourful help listing (Help / the Usage reopen). Set
|
|
20
|
+
# per-invocation in .call from the output stream and --color; defaults to a
|
|
21
|
+
# disabled instance so non-CLI callers and tests render plain text.
|
|
22
|
+
def self.help_pastel = @help_pastel ||= Pastel.new(enabled: false)
|
|
23
|
+
|
|
24
|
+
def self.help_pastel=(pastel)
|
|
25
|
+
@help_pastel = pastel
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Whether the help listing should be colourised: honour an explicit
|
|
29
|
+
# --color/--colors (always/never), otherwise auto-detect from the streams the
|
|
30
|
+
# listing can land on (stdout for top-level help, stderr for bare namespaces).
|
|
31
|
+
def self.help_colors?(argv, out, err)
|
|
32
|
+
case color_mode(argv)
|
|
33
|
+
when "always" then true
|
|
34
|
+
when "never" then false
|
|
35
|
+
else tty?(out) || tty?(err)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.tty?(io) = io.respond_to?(:tty?) && io.tty?
|
|
40
|
+
|
|
41
|
+
def self.color_mode(argv)
|
|
42
|
+
argv.each_with_index do |arg, i|
|
|
43
|
+
return arg.split("=", 2)[1].to_s.downcase if arg.start_with?("--color=", "--colors=")
|
|
44
|
+
return argv[i + 1].to_s.downcase if %w[--color --colors].include?(arg)
|
|
45
|
+
end
|
|
46
|
+
"auto"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
module Registry
|
|
50
|
+
extend Dry::CLI::Registry
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
TOP_LEVEL_HELP = [[], ["--help"], ["-h"], ["help"]].freeze
|
|
54
|
+
VERSION_REQUEST = [["version"], ["--version"]].freeze
|
|
55
|
+
|
|
56
|
+
def self.call(argv, out = $stdout, err = $stderr)
|
|
57
|
+
Thread.current[:space_core_cli_outcome] = nil
|
|
58
|
+
self.help_pastel = Pastel.new(enabled: help_colors?(argv, out, err))
|
|
59
|
+
|
|
60
|
+
if TOP_LEVEL_HELP.include?(argv)
|
|
61
|
+
print_usage(out)
|
|
62
|
+
return 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if VERSION_REQUEST.include?(argv)
|
|
66
|
+
print_version(out)
|
|
67
|
+
return 0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
Dry::CLI.new(Registry).call(arguments: normalize_args(argv), out: out, err: err)
|
|
71
|
+
last_outcome&.exit_code || 0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Move --color/--colors options to the end of the argument list so dry-cli's
|
|
75
|
+
# command routing is not confused by options before the subcommand name.
|
|
76
|
+
#
|
|
77
|
+
# Two passes:
|
|
78
|
+
# 1. Leading: extract two-token form (--color VALUE) and =-form from the
|
|
79
|
+
# front while args still look like options.
|
|
80
|
+
# 2. Non-leading: extract =-form (--color=VALUE / --colors=VALUE) from any
|
|
81
|
+
# position before the -- separator. The bare two-token form is ambiguous
|
|
82
|
+
# with a subcommand name in non-leading position and is left in place.
|
|
83
|
+
def self.normalize_args(argv)
|
|
84
|
+
args = argv.dup
|
|
85
|
+
extracted = []
|
|
86
|
+
|
|
87
|
+
# Pass 1: leading two-token and =-form (existing behavior, unchanged)
|
|
88
|
+
while (arg = args.first) && arg != "--" && arg.start_with?("-")
|
|
89
|
+
if %w[--color --colors].include?(arg)
|
|
90
|
+
extracted << args.shift
|
|
91
|
+
extracted << args.shift if args.first && !args.first.start_with?("-")
|
|
92
|
+
elsif arg.start_with?("--color=", "--colors=")
|
|
93
|
+
extracted << args.shift
|
|
94
|
+
else
|
|
95
|
+
break
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Pass 2: =-form from any non-leading position, stop at --
|
|
100
|
+
sep = args.index("--")
|
|
101
|
+
head = sep ? args[0, sep] : args
|
|
102
|
+
tail = sep ? args[sep..] : []
|
|
103
|
+
mid_color, head = head.partition { |a| a.start_with?("--color=", "--colors=") }
|
|
104
|
+
extracted += mid_color
|
|
105
|
+
args = head + tail
|
|
106
|
+
|
|
107
|
+
extracted.empty? ? args : args + extracted
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.print_usage(out)
|
|
111
|
+
out.puts Dry::CLI::Usage.call(Registry.get([]))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.print_version(out)
|
|
115
|
+
out.puts ::Space::Core::VERSION
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.run(argv, out = $stdout, err = $stderr)
|
|
119
|
+
Kernel.exit(call(argv, out, err))
|
|
120
|
+
rescue Interrupt
|
|
121
|
+
err.puts "interrupted"
|
|
122
|
+
Kernel.exit(130)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
require_relative "cli/helpers"
|
|
128
|
+
require_relative "cli/base_command"
|
|
129
|
+
require_relative "cli/init"
|
|
130
|
+
require_relative "cli/new"
|
|
131
|
+
require_relative "cli/list"
|
|
132
|
+
require_relative "cli/show"
|
|
133
|
+
require_relative "cli/path"
|
|
134
|
+
require_relative "cli/use"
|
|
135
|
+
require_relative "cli/current"
|
|
136
|
+
require_relative "cli/status"
|
|
137
|
+
require_relative "cli/config"
|
|
138
|
+
require_relative "cli/repo"
|
|
139
|
+
require_relative "cli/shell"
|
|
140
|
+
require_relative "cli/pack"
|
|
141
|
+
require_relative "cli/build"
|
|
142
|
+
require_relative "cli/run"
|
|
143
|
+
|
|
144
|
+
Space::Core::CLI::Registry.register "init", Space::Core::CLI::Init
|
|
145
|
+
Space::Core::CLI::Registry.register "new", Space::Core::CLI::New
|
|
146
|
+
Space::Core::CLI::Registry.register "list", Space::Core::CLI::List
|
|
147
|
+
Space::Core::CLI::Registry.register "ls", Space::Core::CLI::List
|
|
148
|
+
Space::Core::CLI::Registry.register "show", Space::Core::CLI::Show
|
|
149
|
+
Space::Core::CLI::Registry.register "path", Space::Core::CLI::Path
|
|
150
|
+
Space::Core::CLI::Registry.register "use", Space::Core::CLI::Use
|
|
151
|
+
Space::Core::CLI::Registry.register "current", Space::Core::CLI::Current
|
|
152
|
+
Space::Core::CLI::Registry.register "status", Space::Core::CLI::Status
|
|
153
|
+
Space::Core::CLI::Registry.register "config" do |c|
|
|
154
|
+
c.register "show", Space::Core::CLI::Config::Show
|
|
155
|
+
c.register "path", Space::Core::CLI::Config::ConfigPath
|
|
156
|
+
c.register "set", Space::Core::CLI::Config::Set
|
|
157
|
+
end
|
|
158
|
+
Space::Core::CLI::Registry.register "repo" do |r|
|
|
159
|
+
r.register "add", Space::Core::CLI::Repo::Add
|
|
160
|
+
r.register "list", Space::Core::CLI::Repo::RepoList
|
|
161
|
+
r.register "ls", Space::Core::CLI::Repo::RepoList
|
|
162
|
+
r.register "resolve", Space::Core::CLI::Repo::Resolve
|
|
163
|
+
end
|
|
164
|
+
Space::Core::CLI::Registry.register "repos" do |r|
|
|
165
|
+
r.register "add", Space::Core::CLI::Repo::Add
|
|
166
|
+
r.register "list", Space::Core::CLI::Repo::RepoList
|
|
167
|
+
r.register "ls", Space::Core::CLI::Repo::RepoList
|
|
168
|
+
r.register "resolve", Space::Core::CLI::Repo::Resolve
|
|
169
|
+
end
|
|
170
|
+
Space::Core::CLI::Registry.register "shell" do |s|
|
|
171
|
+
s.register "init", Space::Core::CLI::Shell::ShellInit
|
|
172
|
+
s.register "fish", Space::Core::CLI::Shell::Fish
|
|
173
|
+
s.register "complete", Space::Core::CLI::Shell::Complete
|
|
174
|
+
end
|
|
175
|
+
Space::Core::CLI::Registry.register "pack", Space::Core::CLI::Pack
|
|
176
|
+
Space::Core::CLI::Registry.register "build", Space::Core::CLI::Build
|
|
177
|
+
Space::Core::CLI::Registry.register "run", Space::Core::CLI::Run
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "dry/monads"
|
|
5
|
+
require "space_core/shell"
|
|
6
|
+
|
|
7
|
+
module Space::Core
|
|
8
|
+
# Resolution + COW-copy boundary for `clone`. Returns Result; no
|
|
9
|
+
# side effects on Failure. Injected shell seam defaults to ShellRunner
|
|
10
|
+
# (wraps Shell.run in Sync{} so the Fiber-scheduler requirement is met
|
|
11
|
+
# from a plain synchronous CLI command — same pattern as Launchd::Agent).
|
|
12
|
+
class Cloner
|
|
13
|
+
include Dry::Monads[:result]
|
|
14
|
+
|
|
15
|
+
class ShellRunner
|
|
16
|
+
def run(*argv)
|
|
17
|
+
Sync { Shell.run(*argv) }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(base_dir:, shell: ShellRunner.new)
|
|
22
|
+
@base_dir = File.expand_path(base_dir)
|
|
23
|
+
@shell = shell
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Resolve `name` against `base_dir` and copy to `into/<leaf>`.
|
|
27
|
+
# Returns Success(dest_path) or Failure(message).
|
|
28
|
+
def call(name:, into: ".")
|
|
29
|
+
result = resolve(name)
|
|
30
|
+
return result if result.failure?
|
|
31
|
+
copy(result.success, into)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def resolve(name)
|
|
37
|
+
parts = name.split("/")
|
|
38
|
+
case parts.length
|
|
39
|
+
when 1
|
|
40
|
+
# paths:exempt - layout intent (checkout resolution by depth); checkout depth drives the glob shape
|
|
41
|
+
candidates = Dir.glob(File.join(@base_dir, "*", "*", name))
|
|
42
|
+
case candidates.length
|
|
43
|
+
when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
|
|
44
|
+
when 1 then Success(candidates.first)
|
|
45
|
+
else ambiguous(name, candidates, "owner/name or host/owner/name")
|
|
46
|
+
end
|
|
47
|
+
when 2
|
|
48
|
+
owner, repo_name = parts
|
|
49
|
+
# paths:exempt - layout intent (checkout resolution by depth); checkout depth drives the glob shape
|
|
50
|
+
candidates = Dir.glob(File.join(@base_dir, "*", owner, repo_name))
|
|
51
|
+
case candidates.length
|
|
52
|
+
when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
|
|
53
|
+
when 1 then Success(candidates.first)
|
|
54
|
+
else ambiguous(name, candidates, "host/owner/name")
|
|
55
|
+
end
|
|
56
|
+
when 3
|
|
57
|
+
path = File.join(@base_dir, *parts)
|
|
58
|
+
File.directory?(path) ? Success(path) : Failure("#{name.inspect} not found under base_dir #{@base_dir}")
|
|
59
|
+
else
|
|
60
|
+
Failure("invalid repo reference: #{name.inspect}")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def ambiguous(name, candidates, hint)
|
|
65
|
+
list = candidates.map { |c| " #{c.delete_prefix("#{@base_dir}/")}" }.join("\n")
|
|
66
|
+
Failure("ambiguous name #{name.inspect}; qualify with #{hint}:\n#{list}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def copy(src, into)
|
|
70
|
+
leaf = File.basename(src)
|
|
71
|
+
dest = File.join(File.expand_path(into), leaf)
|
|
72
|
+
return Failure("destination already exists: #{dest}") if File.exist?(dest)
|
|
73
|
+
result = @shell.run("cp", "-Rc", src, dest)
|
|
74
|
+
result.success? ? Success(dest) : result
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core
|
|
4
|
+
module Commands
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Render a multi-flag command with trailing " \" continuations broken at
|
|
8
|
+
# "--flag" boundaries, continuation lines indented two spaces. Commands
|
|
9
|
+
# without "--" flags are returned unchanged.
|
|
10
|
+
def wrap(command)
|
|
11
|
+
parts = split_at_flag_boundaries(command)
|
|
12
|
+
return command if parts.size <= 1
|
|
13
|
+
|
|
14
|
+
parts.each_with_index.map do |part, i|
|
|
15
|
+
segment = i.zero? ? part.rstrip : " #{part.lstrip}"
|
|
16
|
+
i < parts.size - 1 ? "#{segment} \\" : segment
|
|
17
|
+
end.join("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Same split points as command.split(/(?= --)/), but blind to " --" that
|
|
21
|
+
# falls inside a single- or double-quoted flag value, so a --title
|
|
22
|
+
# containing " -- " isn't torn apart mid-argument.
|
|
23
|
+
def split_at_flag_boundaries(command)
|
|
24
|
+
parts = []
|
|
25
|
+
start = 0
|
|
26
|
+
in_squote = false
|
|
27
|
+
in_dquote = false
|
|
28
|
+
escaped = false
|
|
29
|
+
command.each_char.with_index do |ch, i|
|
|
30
|
+
if escaped
|
|
31
|
+
escaped = false
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
case ch
|
|
35
|
+
# A backslash escapes the next character outside quotes and inside
|
|
36
|
+
# double quotes, but is a literal character inside single quotes.
|
|
37
|
+
when "\\" then escaped = true unless in_squote
|
|
38
|
+
when "'" then in_squote = !in_squote unless in_dquote
|
|
39
|
+
when '"' then in_dquote = !in_dquote unless in_squote
|
|
40
|
+
when " "
|
|
41
|
+
if !in_squote && !in_dquote && command[i + 1, 2] == "--"
|
|
42
|
+
parts << command[start...i]
|
|
43
|
+
start = i
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
parts << command[start..]
|
|
48
|
+
parts
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|