space-architect 1.1.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 (80) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +284 -0
  4. data/exe/architect +13 -0
  5. data/exe/space +13 -0
  6. data/lib/space_architect/architect_mission.rb +436 -0
  7. data/lib/space_architect/atomic_write.rb +21 -0
  8. data/lib/space_architect/cli/architect.rb +388 -0
  9. data/lib/space_architect/cli/config.rb +61 -0
  10. data/lib/space_architect/cli/current.rb +22 -0
  11. data/lib/space_architect/cli/helpers.rb +117 -0
  12. data/lib/space_architect/cli/init.rb +35 -0
  13. data/lib/space_architect/cli/list.rb +30 -0
  14. data/lib/space_architect/cli/new.rb +43 -0
  15. data/lib/space_architect/cli/options.rb +12 -0
  16. data/lib/space_architect/cli/path.rb +22 -0
  17. data/lib/space_architect/cli/repo.rb +88 -0
  18. data/lib/space_architect/cli/shell.rb +137 -0
  19. data/lib/space_architect/cli/show.rb +27 -0
  20. data/lib/space_architect/cli/space.rb +35 -0
  21. data/lib/space_architect/cli/src.rb +32 -0
  22. data/lib/space_architect/cli/status.rb +39 -0
  23. data/lib/space_architect/cli/use.rb +23 -0
  24. data/lib/space_architect/cli.rb +102 -0
  25. data/lib/space_architect/config.rb +152 -0
  26. data/lib/space_architect/dispatcher.rb +21 -0
  27. data/lib/space_architect/errors.rb +14 -0
  28. data/lib/space_architect/git_client.rb +49 -0
  29. data/lib/space_architect/harness.rb +168 -0
  30. data/lib/space_architect/mise_client.rb +37 -0
  31. data/lib/space_architect/repo_reference.rb +19 -0
  32. data/lib/space_architect/repo_resolver.rb +167 -0
  33. data/lib/space_architect/shell_integration.rb +438 -0
  34. data/lib/space_architect/slugger.rb +16 -0
  35. data/lib/space_architect/space.rb +110 -0
  36. data/lib/space_architect/space_store.rb +319 -0
  37. data/lib/space_architect/state.rb +86 -0
  38. data/lib/space_architect/templates/architect.md.erb +48 -0
  39. data/lib/space_architect/templates/iteration.md.erb +66 -0
  40. data/lib/space_architect/terminal.rb +163 -0
  41. data/lib/space_architect/version.rb +5 -0
  42. data/lib/space_architect/warnings.rb +13 -0
  43. data/lib/space_architect/xdg.rb +33 -0
  44. data/lib/space_architect.rb +26 -0
  45. data/vendor/repo-tender/lib/space_architect/pristine/cli/clone.rb +55 -0
  46. data/vendor/repo-tender/lib/space_architect/pristine/cli/config.rb +66 -0
  47. data/vendor/repo-tender/lib/space_architect/pristine/cli/daemon.rb +347 -0
  48. data/vendor/repo-tender/lib/space_architect/pristine/cli/options.rb +21 -0
  49. data/vendor/repo-tender/lib/space_architect/pristine/cli/org.rb +200 -0
  50. data/vendor/repo-tender/lib/space_architect/pristine/cli/repo.rb +170 -0
  51. data/vendor/repo-tender/lib/space_architect/pristine/cli/status.rb +76 -0
  52. data/vendor/repo-tender/lib/space_architect/pristine/cli/sync.rb +149 -0
  53. data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +137 -0
  54. data/vendor/repo-tender/lib/space_architect/pristine/cloner.rb +75 -0
  55. data/vendor/repo-tender/lib/space_architect/pristine/config/contract.rb +54 -0
  56. data/vendor/repo-tender/lib/space_architect/pristine/config/duration.rb +79 -0
  57. data/vendor/repo-tender/lib/space_architect/pristine/config/model.rb +49 -0
  58. data/vendor/repo-tender/lib/space_architect/pristine/config/store.rb +156 -0
  59. data/vendor/repo-tender/lib/space_architect/pristine/forge/client.rb +31 -0
  60. data/vendor/repo-tender/lib/space_architect/pristine/forge/github.rb +98 -0
  61. data/vendor/repo-tender/lib/space_architect/pristine/launchd/agent.rb +195 -0
  62. data/vendor/repo-tender/lib/space_architect/pristine/launchd/plist.rb +129 -0
  63. data/vendor/repo-tender/lib/space_architect/pristine/log_rotator.rb +46 -0
  64. data/vendor/repo-tender/lib/space_architect/pristine/paths.rb +72 -0
  65. data/vendor/repo-tender/lib/space_architect/pristine/scm/client.rb +87 -0
  66. data/vendor/repo-tender/lib/space_architect/pristine/scm/git.rb +232 -0
  67. data/vendor/repo-tender/lib/space_architect/pristine/scm/status.rb +24 -0
  68. data/vendor/repo-tender/lib/space_architect/pristine/shell.rb +90 -0
  69. data/vendor/repo-tender/lib/space_architect/pristine/state/lock.rb +59 -0
  70. data/vendor/repo-tender/lib/space_architect/pristine/state/store.rb +140 -0
  71. data/vendor/repo-tender/lib/space_architect/pristine/sync/engine.rb +464 -0
  72. data/vendor/repo-tender/lib/space_architect/pristine/sync/repo_plan.rb +215 -0
  73. data/vendor/repo-tender/lib/space_architect/pristine/ui/interactive_reporter.rb +280 -0
  74. data/vendor/repo-tender/lib/space_architect/pristine/ui/json_reporter.rb +39 -0
  75. data/vendor/repo-tender/lib/space_architect/pristine/ui/mode.rb +68 -0
  76. data/vendor/repo-tender/lib/space_architect/pristine/ui/plain_reporter.rb +53 -0
  77. data/vendor/repo-tender/lib/space_architect/pristine/ui/reporter.rb +48 -0
  78. data/vendor/repo-tender/lib/space_architect/pristine/version.rb +7 -0
  79. data/vendor/repo-tender/lib/space_architect/pristine.rb +37 -0
  80. metadata +307 -0
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module SpaceArchitect
6
+ module XDG
7
+ module_function
8
+
9
+ def config_home(env: ENV)
10
+ Pathname.new(env.fetch("XDG_CONFIG_HOME", File.join(home(env: env), ".config")))
11
+ end
12
+
13
+ def state_home(env: ENV)
14
+ Pathname.new(env.fetch("XDG_STATE_HOME", File.join(home(env: env), ".local", "state")))
15
+ end
16
+
17
+ def home(env: ENV)
18
+ env.fetch("HOME", Dir.home)
19
+ end
20
+
21
+ def expand_user(path, env: ENV)
22
+ value = path.to_s
23
+
24
+ if value == "~"
25
+ home(env: env)
26
+ elsif value.start_with?("~/")
27
+ File.join(home(env: env), value[2..])
28
+ else
29
+ File.expand_path(value)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../vendor/repo-tender/lib", __dir__)
4
+ require "space_architect/pristine"
5
+
6
+ require_relative "space_architect/version"
7
+ require_relative "space_architect/errors"
8
+ require_relative "space_architect/warnings"
9
+ SpaceArchitect::Warnings.disable_experimental!
10
+ require_relative "space_architect/atomic_write"
11
+ require_relative "space_architect/xdg"
12
+ require_relative "space_architect/config"
13
+ require_relative "space_architect/state"
14
+ require_relative "space_architect/slugger"
15
+ require_relative "space_architect/space"
16
+ require_relative "space_architect/repo_reference"
17
+ require_relative "space_architect/repo_resolver"
18
+ require_relative "space_architect/git_client"
19
+ require_relative "space_architect/mise_client"
20
+ require_relative "space_architect/space_store"
21
+ require_relative "space_architect/shell_integration"
22
+ require_relative "space_architect/terminal"
23
+ require_relative "space_architect/harness"
24
+ require_relative "space_architect/dispatcher"
25
+ require_relative "space_architect/architect_mission"
26
+ require_relative "space_architect/cli"
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "dry/monads"
5
+ require "space_architect/pristine/cli"
6
+ require "space_architect/pristine/cli/options"
7
+ require "space_architect/pristine/cloner"
8
+
9
+ module SpaceArchitect::Pristine
10
+ module CLI
11
+ # `clone` command: APFS COW copy of an evergreen repo into a working dir.
12
+ # Resolves each NAME against config.base_dir and copies via cp -Rc.
13
+ # Multiple names are processed independently; a per-name failure is
14
+ # reported on err and does not abort the others. Exit code is 1 if any
15
+ # name failed, else 0.
16
+ class Clone < Dry::CLI::Command
17
+ include GlobalOptions
18
+
19
+ desc "Clone evergreen repo(s) into a working directory (APFS COW copy)"
20
+ argument :names, type: :array, required: true,
21
+ desc: "Repo name(s): bare name, owner/name, or host/owner/name"
22
+ option :into, default: ".",
23
+ desc: "Destination parent directory (default: current working directory)"
24
+
25
+ def call(names:, into: ".", plain: nil, json: nil, no_color: nil, quiet: nil, **)
26
+ mode = UI::Mode.resolve(
27
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
28
+ env: CLI.env,
29
+ out: out
30
+ )
31
+ pastel = Pastel.new(enabled: mode.color)
32
+
33
+ paths = CLI.make_paths
34
+ config = Config::Store.load(paths.config_file).success
35
+ cloner = Cloner.new(base_dir: config.base_dir)
36
+
37
+ any_failure = false
38
+ names.each do |name|
39
+ result = cloner.call(name: name, into: into)
40
+ if result.success?
41
+ dest = result.success
42
+ out.puts pastel.green("cloned: #{name} → #{dest}")
43
+ else
44
+ err.puts result.failure
45
+ any_failure = true
46
+ end
47
+ end
48
+
49
+ CLI.record_outcome(Outcome.new(exit_code: any_failure ? 1 : 0))
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ SpaceArchitect::Pristine::CLI::Registry.register "clone", SpaceArchitect::Pristine::CLI::Clone
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "yaml"
5
+ require "space_architect/pristine/cli"
6
+ require "space_architect/pristine/ui/mode"
7
+ require "space_architect/pristine/cli/options"
8
+
9
+ module SpaceArchitect::Pristine
10
+ module CLI
11
+ # `config` command group: path / show.
12
+ module ConfigCmd
13
+ class Path < Dry::CLI::Command
14
+ include GlobalOptions
15
+
16
+ desc "Print the resolved config file path (honors $XDG_CONFIG_HOME)"
17
+
18
+ def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
19
+ mode = UI::Mode.resolve(
20
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
21
+ env: CLI.env,
22
+ out: out
23
+ )
24
+ pastel = Pastel.new(enabled: mode.color)
25
+
26
+ paths = CLI.make_paths
27
+ out.puts pastel.cyan(paths.config_file)
28
+ CLI.record_outcome(Outcome.new(exit_code: 0))
29
+ end
30
+ end
31
+
32
+ class Show < Dry::CLI::Command
33
+ include GlobalOptions
34
+
35
+ desc "Print the effective (validated, defaults-applied) config as YAML"
36
+
37
+ def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
38
+ mode = UI::Mode.resolve(
39
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
40
+ env: CLI.env,
41
+ out: out
42
+ )
43
+ pastel = Pastel.new(enabled: mode.color)
44
+
45
+ paths = CLI.make_paths
46
+ config = Config::Store.load(paths.config_file).success
47
+ # Emit via the store's own emit() so the format matches
48
+ # what `config.yaml` looks like on disk (stable key order
49
+ # per Slice 1's emit implementation). This makes
50
+ # `config show` a faithful round-trip preview.
51
+ out.puts pastel.cyan(Config::Store.emit(config.to_h).chomp)
52
+ CLI.record_outcome(Outcome.new(exit_code: 0))
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ # `config` is the name of both our config-store module (Config) and
60
+ # the CLI command group. We register under a different module name
61
+ # (ConfigCmd) to avoid the constant clash, then alias the
62
+ # registration key as "config".
63
+ SpaceArchitect::Pristine::CLI::Registry.register "config" do |prefix|
64
+ prefix.register "path", SpaceArchitect::Pristine::CLI::ConfigCmd::Path
65
+ prefix.register "show", SpaceArchitect::Pristine::CLI::ConfigCmd::Show
66
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "fileutils"
5
+ require "dry/monads"
6
+ require "space_architect/pristine/cli"
7
+ require "space_architect/pristine/ui/mode"
8
+ require "space_architect/pristine/cli/options"
9
+ require "space_architect/pristine/launchd/agent"
10
+ require "space_architect/pristine/launchd/plist"
11
+
12
+ module SpaceArchitect::Pristine
13
+ module CLI
14
+ # `daemon` command group: install / uninstall / start / stop
15
+ # / restart / status. Installs a per-user launchd agent
16
+ # (`gui/<UID>`) that fires `repo-tender sync` on a
17
+ # `StartInterval`. The launchctl side is exercised ONLY
18
+ # through an injected command runner (slice-4 gates G2–G4);
19
+ # the live domain is proven by the manual real-Mac checklist
20
+ # in docs/gates/slice-4.md, not by these tests.
21
+ module Daemon
22
+ module Helpers
23
+ module_function
24
+
25
+ # Build a `Launchd::Agent` wired against the CLI's env
26
+ # seam (`CLI.env`). Tests inject a temp HOME (so
27
+ # `paths.launch_agents_dir` is under the temp HOME, not
28
+ # the real one) and a `runner:` via `Launchd::Agent.new`
29
+ # — the daemon commands don't accept a runner flag, so
30
+ # tests must use a real `Agent` (whose default `runner`
31
+ # only fires in an ambient Async::Task) OR stub the
32
+ # `Launchd::Agent` class.
33
+ def make_agent(uid: Process.uid, label: Launchd::Agent::DEFAULT_LABEL, runner: nil)
34
+ if runner
35
+ Launchd::Agent.new(runner: runner, uid: uid, label: label)
36
+ else
37
+ Launchd::Agent.new(uid: uid, label: label)
38
+ end
39
+ end
40
+
41
+ # Resolve the on-disk plist path for the agent label,
42
+ # rooted at the env's HOME-resolved `LaunchAgents/`
43
+ # (per slice-4 gate G3).
44
+ def plist_path(paths, label)
45
+ File.join(paths.launch_agents_dir, "#{label}.plist")
46
+ end
47
+
48
+ # Build the plist XML by looking up the absolute paths
49
+ # the launchd runtime needs (mise, ruby, the bin
50
+ # script, the repo root, the mise.toml). These come
51
+ # from a few places:
52
+ # * `mise_bin` — `which mise` (we shell out at
53
+ # install-time; the result is baked into the plist
54
+ # as an absolute path so launchd's empty PATH
55
+ # doesn't matter).
56
+ # * `ruby_bin` — `mise exec -- which ruby` (the
57
+ # toolchain-resolved ruby; pinned via mise.toml).
58
+ # * `bin_path` — `RbConfig.ruby` + the script path
59
+ # (we use `__dir__` of this file's caller; for the
60
+ # gem install, this is `<gem>/bin/repo-tender`).
61
+ #
62
+ # In tests, we inject these via the `Resolve` object
63
+ # (see below) — never call out to the shell.
64
+ def build_plist(resolve:, config:, paths:, label:)
65
+ Launchd::Plist.call(
66
+ label: label,
67
+ refresh_interval: config.refresh_interval,
68
+ log_dir: paths.log_dir,
69
+ repo_root: resolve.repo_root,
70
+ mise_toml: resolve.mise_toml,
71
+ mise_bin: resolve.mise_bin,
72
+ ruby_bin: resolve.ruby_bin,
73
+ bin_path: resolve.bin_path
74
+ )
75
+ end
76
+
77
+ def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
78
+
79
+ def fail_with(cmd, msg)
80
+ cmd.send(:err).puts msg
81
+ SpaceArchitect::Pristine::CLI.record_outcome(Outcome.new(exit_code: 1, message: msg))
82
+ end
83
+
84
+ # Bundle of resolved absolute paths the plist needs.
85
+ # Production code resolves these via the shell (see
86
+ # `Resolve.detect`); tests construct one directly with
87
+ # known absolute paths.
88
+ Resolve = Data.define(:repo_root, :mise_toml, :mise_bin, :ruby_bin, :bin_path) do
89
+ def initialize(repo_root:, mise_toml:, mise_bin:, ruby_bin:, bin_path:)
90
+ super
91
+ end
92
+ end
93
+ end
94
+
95
+ class Install < Dry::CLI::Command
96
+ include Helpers
97
+ include GlobalOptions
98
+
99
+ desc "Install the per-user launchd agent (writes the plist + bootstrap)"
100
+
101
+ def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
102
+ paths = CLI.make_paths
103
+ paths.ensure!
104
+ config = Config::Store.load(paths.config_file).success
105
+
106
+ mode = UI::Mode.resolve(
107
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
108
+ env: CLI.env,
109
+ out: out
110
+ )
111
+ pastel = Pastel.new(enabled: mode.color)
112
+
113
+ label = Launchd::Agent::DEFAULT_LABEL
114
+ pp = plist_path(paths, label)
115
+
116
+ resolve = Resolve.detect(repo_root: Dir.pwd)
117
+ xml = build_plist(resolve: resolve, config: config, paths: paths, label: label)
118
+ FileUtils.mkdir_p(File.dirname(pp))
119
+ File.write(pp, xml)
120
+
121
+ agent = make_agent
122
+ result = agent.install(pp)
123
+ if result.failure?
124
+ return fail_with(self, "bootstrap failed: #{format_failure(result.failure)}")
125
+ end
126
+
127
+ out.puts pastel.green("installed: #{pp}")
128
+ CLI.record_outcome(Outcome.new(exit_code: 0))
129
+ end
130
+ end
131
+
132
+ class Uninstall < Dry::CLI::Command
133
+ include Helpers
134
+ include GlobalOptions
135
+
136
+ desc "Uninstall the per-user launchd agent (bootout + remove the plist)"
137
+
138
+ def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
139
+ mode = UI::Mode.resolve(
140
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
141
+ env: CLI.env,
142
+ out: out
143
+ )
144
+ pastel = Pastel.new(enabled: mode.color)
145
+
146
+ paths = CLI.make_paths
147
+ label = Launchd::Agent::DEFAULT_LABEL
148
+ pp = plist_path(paths, label)
149
+
150
+ agent = make_agent
151
+ result = agent.uninstall
152
+ # CF5 (Slice 5): the Agent maps a not-loaded bootout
153
+ # (status 3 / "No such process") to Success — the
154
+ # common case at a 6h refresh interval. A non-benign
155
+ # bootout Failure (e.g. status 1 "Operation not
156
+ # permitted") still surfaces here as a real failure
157
+ # the operator needs to see. We still remove the
158
+ # plist regardless (idempotent uninstall, Slice-4
159
+ # gate G3): the bootout is the only best-effort step.
160
+ if result.failure?
161
+ err.puts "bootout reported: #{format_failure(result.failure)}"
162
+ end
163
+
164
+ if File.exist?(pp)
165
+ File.delete(pp)
166
+ out.puts pastel.green("removed plist: #{pp}")
167
+ else
168
+ out.puts pastel.yellow("plist not present: #{pp}")
169
+ end
170
+
171
+ CLI.record_outcome(Outcome.new(exit_code: 0))
172
+ end
173
+ end
174
+
175
+ class Start < Dry::CLI::Command
176
+ include Helpers
177
+ include GlobalOptions
178
+
179
+ desc "Start the agent (bootstrap + enable)"
180
+
181
+ def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
182
+ mode = UI::Mode.resolve(
183
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
184
+ env: CLI.env,
185
+ out: out
186
+ )
187
+ pastel = Pastel.new(enabled: mode.color)
188
+
189
+ paths = CLI.make_paths
190
+ label = Launchd::Agent::DEFAULT_LABEL
191
+ pp = plist_path(paths, label)
192
+
193
+ agent = make_agent
194
+ result = agent.start(pp)
195
+ if result.failure?
196
+ return fail_with(self, "start failed: #{format_failure(result.failure)}")
197
+ end
198
+ out.puts pastel.green("started: #{label}")
199
+ CLI.record_outcome(Outcome.new(exit_code: 0))
200
+ end
201
+ end
202
+
203
+ class Stop < Dry::CLI::Command
204
+ include Helpers
205
+ include GlobalOptions
206
+
207
+ desc "Stop the agent (bootout + disable)"
208
+
209
+ def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
210
+ mode = UI::Mode.resolve(
211
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
212
+ env: CLI.env,
213
+ out: out
214
+ )
215
+ pastel = Pastel.new(enabled: mode.color)
216
+
217
+ label = Launchd::Agent::DEFAULT_LABEL
218
+ agent = make_agent
219
+ result = agent.stop
220
+ if result.failure?
221
+ return fail_with(self, "stop failed: #{format_failure(result.failure)}")
222
+ end
223
+ out.puts pastel.green("stopped: #{label}")
224
+ CLI.record_outcome(Outcome.new(exit_code: 0))
225
+ end
226
+ end
227
+
228
+ class Restart < Dry::CLI::Command
229
+ include Helpers
230
+ include GlobalOptions
231
+
232
+ desc "Restart the agent (kickstart -k)"
233
+
234
+ def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
235
+ mode = UI::Mode.resolve(
236
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
237
+ env: CLI.env,
238
+ out: out
239
+ )
240
+ pastel = Pastel.new(enabled: mode.color)
241
+
242
+ label = Launchd::Agent::DEFAULT_LABEL
243
+ agent = make_agent
244
+ result = agent.restart
245
+ if result.failure?
246
+ return fail_with(self, "restart failed: #{format_failure(result.failure)}")
247
+ end
248
+ out.puts pastel.green("restarted: #{label}")
249
+ CLI.record_outcome(Outcome.new(exit_code: 0))
250
+ end
251
+ end
252
+
253
+ class Status < Dry::CLI::Command
254
+ include Helpers
255
+ include GlobalOptions
256
+
257
+ desc "Print the agent's loaded/running/last-exit state"
258
+
259
+ def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
260
+ mode = UI::Mode.resolve(
261
+ flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
262
+ env: CLI.env,
263
+ out: out
264
+ )
265
+ pastel = Pastel.new(enabled: mode.color)
266
+
267
+ label = Launchd::Agent::DEFAULT_LABEL
268
+ agent = make_agent
269
+ result = agent.status
270
+ if result.failure?
271
+ return fail_with(self, "status failed: #{format_failure(result.failure)}")
272
+ end
273
+ s = result.success
274
+ out.puts pastel.cyan("label: #{label}")
275
+ out.puts pastel.cyan("loaded: #{s[:loaded]}")
276
+ out.puts pastel.cyan("running: #{s[:running]}")
277
+ out.puts pastel.cyan("pid: #{s[:pid].inspect}")
278
+ out.puts pastel.cyan("last_exit: #{s[:last_exit].inspect}")
279
+ CLI.record_outcome(Outcome.new(exit_code: 0))
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end
285
+
286
+ # Detect the runtime paths the plist needs. The repo-tender
287
+ # install path matters because the plist stores an absolute
288
+ # `bin_path` — that is the script launchd invokes. We resolve
289
+ # `bin_path` from the on-disk gem layout if we can, else fall
290
+ # back to the directory the daemon command was run from.
291
+ class SpaceArchitect::Pristine::CLI::Daemon::Helpers::Resolve
292
+ # @param repo_root [String] absolute path of the working directory (where mise.toml is expected)
293
+ # @return [Resolve]
294
+ def self.detect(repo_root:)
295
+ mise_bin = detect_mise_bin
296
+ ruby_bin = detect_ruby_bin(repo_root, mise_bin)
297
+ bin_path = detect_bin_path(repo_root)
298
+ mise_toml = File.join(repo_root, "mise.toml")
299
+ new(repo_root: repo_root, mise_toml: mise_toml, mise_bin: mise_bin, ruby_bin: ruby_bin, bin_path: bin_path)
300
+ end
301
+
302
+ def self.detect_mise_bin
303
+ path = ENV["REPO_TENDER_MISE_BIN"]
304
+ return path if path && !path.empty?
305
+ require "open3"
306
+ out, _e, st = Open3.capture3("which", "mise")
307
+ st.success? ? out.strip : "/opt/homebrew/bin/mise"
308
+ end
309
+
310
+ def self.detect_ruby_bin(repo_root, mise_bin)
311
+ path = ENV["REPO_TENDER_RUBY_BIN"]
312
+ return path if path && !path.empty?
313
+ # `mise exec -- which ruby` — but we avoid spawning in tests;
314
+ # production path goes through here.
315
+ require "open3"
316
+ out, _e, st = Open3.capture3(mise_bin, "exec", "--", "which", "ruby", chdir: repo_root)
317
+ return out.strip if st.success? && !out.strip.empty?
318
+ # Fall back to the system ruby (last resort).
319
+ out, _e, st = Open3.capture3("which", "ruby")
320
+ st.success? ? out.strip : "/usr/bin/ruby"
321
+ end
322
+
323
+ def self.detect_bin_path(repo_root)
324
+ path = ENV["REPO_TENDER_BIN_PATH"]
325
+ return path if path && !path.empty?
326
+ # Prefer the on-disk dev bin at `<repo_root>/bin/repo-tender`
327
+ # — it's what the human runs during testing, and the gem is
328
+ # typically not `gem install`ed in a source checkout.
329
+ dev = File.join(repo_root, "bin", "repo-tender")
330
+ return dev if File.exist?(dev)
331
+ # Next, an installed binary on PATH.
332
+ require "open3"
333
+ out, _e, st = Open3.capture3("which", "repo-tender")
334
+ return out.strip if st.success? && !out.strip.empty?
335
+ # Last resort: the installed gem's bin (raises if not installed).
336
+ Gem.bin_path("repo-tender", "repo-tender")
337
+ end
338
+ end
339
+
340
+ SpaceArchitect::Pristine::CLI::Registry.register "daemon" do |prefix|
341
+ prefix.register "install", SpaceArchitect::Pristine::CLI::Daemon::Install
342
+ prefix.register "uninstall", SpaceArchitect::Pristine::CLI::Daemon::Uninstall
343
+ prefix.register "start", SpaceArchitect::Pristine::CLI::Daemon::Start
344
+ prefix.register "stop", SpaceArchitect::Pristine::CLI::Daemon::Stop
345
+ prefix.register "restart", SpaceArchitect::Pristine::CLI::Daemon::Restart
346
+ prefix.register "status", SpaceArchitect::Pristine::CLI::Daemon::Status
347
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpaceArchitect::Pristine
4
+ module CLI
5
+ # Shared output mode flags. Include in any command class to add:
6
+ # --plain force plain text output (one line per event, ANSI-free)
7
+ # --json force JSON output (one object per event line, 12-factor)
8
+ # --no-color disable ANSI color
9
+ # --quiet/-q suppress non-essential human output
10
+ #
11
+ # Resolved via UI::Mode.resolve in the command's #call.
12
+ module GlobalOptions
13
+ def self.included(base)
14
+ base.option :plain, type: :flag, desc: "Plain text output (one line per event, no color)"
15
+ base.option :json, type: :flag, desc: "JSON output (one object per event line, 12-factor)"
16
+ base.option :no_color, type: :flag, desc: "Disable color output"
17
+ base.option :quiet, type: :flag, aliases: ["-q"], desc: "Suppress non-essential output"
18
+ end
19
+ end
20
+ end
21
+ end