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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +279 -0
  5. data/exe/space +13 -0
  6. data/lib/space_core/atomic_write.rb +21 -0
  7. data/lib/space_core/cli/base_command.rb +19 -0
  8. data/lib/space_core/cli/build.rb +27 -0
  9. data/lib/space_core/cli/config.rb +49 -0
  10. data/lib/space_core/cli/current.rb +16 -0
  11. data/lib/space_core/cli/help.rb +195 -0
  12. data/lib/space_core/cli/helpers.rb +115 -0
  13. data/lib/space_core/cli/init.rb +29 -0
  14. data/lib/space_core/cli/list.rb +24 -0
  15. data/lib/space_core/cli/loop_status.rb +47 -0
  16. data/lib/space_core/cli/new.rb +38 -0
  17. data/lib/space_core/cli/pack.rb +29 -0
  18. data/lib/space_core/cli/path.rb +16 -0
  19. data/lib/space_core/cli/repeatable_options.rb +84 -0
  20. data/lib/space_core/cli/repo.rb +76 -0
  21. data/lib/space_core/cli/run.rb +44 -0
  22. data/lib/space_core/cli/shell.rb +125 -0
  23. data/lib/space_core/cli/show.rb +21 -0
  24. data/lib/space_core/cli/status.rb +74 -0
  25. data/lib/space_core/cli/use.rb +17 -0
  26. data/lib/space_core/cli.rb +177 -0
  27. data/lib/space_core/cloner.rb +77 -0
  28. data/lib/space_core/commands.rb +51 -0
  29. data/lib/space_core/config.rb +152 -0
  30. data/lib/space_core/errors.rb +14 -0
  31. data/lib/space_core/git_client.rb +49 -0
  32. data/lib/space_core/mise_client.rb +37 -0
  33. data/lib/space_core/oci_builder.rb +56 -0
  34. data/lib/space_core/oci_packer.rb +103 -0
  35. data/lib/space_core/oci_runner.rb +89 -0
  36. data/lib/space_core/paths.rb +58 -0
  37. data/lib/space_core/repo_reference.rb +19 -0
  38. data/lib/space_core/repo_resolver.rb +167 -0
  39. data/lib/space_core/scm/client.rb +87 -0
  40. data/lib/space_core/scm/git.rb +232 -0
  41. data/lib/space_core/scm/status.rb +24 -0
  42. data/lib/space_core/shell.rb +90 -0
  43. data/lib/space_core/shell_integration.rb +438 -0
  44. data/lib/space_core/slugger.rb +16 -0
  45. data/lib/space_core/space.rb +174 -0
  46. data/lib/space_core/space_store.rb +319 -0
  47. data/lib/space_core/state.rb +86 -0
  48. data/lib/space_core/templates/oci/dockerfile.erb +73 -0
  49. data/lib/space_core/templates/oci/dockerignore.erb +17 -0
  50. data/lib/space_core/templates/oci/entrypoint.sh.erb +16 -0
  51. data/lib/space_core/terminal.rb +161 -0
  52. data/lib/space_core/version.rb +7 -0
  53. data/lib/space_core/warnings.rb +13 -0
  54. data/lib/space_core/xdg.rb +33 -0
  55. data/lib/space_core.rb +26 -0
  56. 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