space-architect 8.0.0 → 9.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +39 -464
  4. data/lib/space_architect/architect_project.rb +90 -39
  5. data/lib/space_architect/bug_report.rb +2 -1
  6. data/lib/space_architect/cli/architect.rb +189 -11
  7. data/lib/space_architect/cli/research.rb +1 -1
  8. data/lib/space_architect/cli.rb +9 -14
  9. data/lib/space_architect/loop_cost.rb +592 -0
  10. data/lib/space_architect/research/supervisor.rb +9 -2
  11. data/lib/space_architect/routing.rb +149 -0
  12. data/lib/space_architect/session_sync/bin_path.rb +1 -1
  13. data/lib/space_architect/session_sync/cursor.rb +1 -1
  14. data/lib/space_architect/session_sync/plist.rb +1 -1
  15. data/lib/{space_core → space_architect}/version.rb +2 -2
  16. data/lib/space_architect.rb +2 -1
  17. data/skill/architect/SKILL.md +3 -2
  18. data/skill/architect-vocabulary/SKILL.md +2 -2
  19. metadata +20 -177
  20. data/exe/space +0 -13
  21. data/exe/src +0 -13
  22. data/lib/space_architect/cli/space.rb +0 -29
  23. data/lib/space_architect/cli/src.rb +0 -38
  24. data/lib/space_core/atomic_write.rb +0 -21
  25. data/lib/space_core/cli/base_command.rb +0 -19
  26. data/lib/space_core/cli/build.rb +0 -27
  27. data/lib/space_core/cli/config.rb +0 -49
  28. data/lib/space_core/cli/current.rb +0 -16
  29. data/lib/space_core/cli/help.rb +0 -195
  30. data/lib/space_core/cli/helpers.rb +0 -115
  31. data/lib/space_core/cli/init.rb +0 -29
  32. data/lib/space_core/cli/list.rb +0 -24
  33. data/lib/space_core/cli/loop_status.rb +0 -47
  34. data/lib/space_core/cli/new.rb +0 -38
  35. data/lib/space_core/cli/pack.rb +0 -29
  36. data/lib/space_core/cli/path.rb +0 -16
  37. data/lib/space_core/cli/repeatable_options.rb +0 -84
  38. data/lib/space_core/cli/repo.rb +0 -76
  39. data/lib/space_core/cli/run.rb +0 -44
  40. data/lib/space_core/cli/shell.rb +0 -125
  41. data/lib/space_core/cli/show.rb +0 -21
  42. data/lib/space_core/cli/status.rb +0 -74
  43. data/lib/space_core/cli/use.rb +0 -17
  44. data/lib/space_core/cli.rb +0 -177
  45. data/lib/space_core/commands.rb +0 -51
  46. data/lib/space_core/config.rb +0 -152
  47. data/lib/space_core/errors.rb +0 -14
  48. data/lib/space_core/git_client.rb +0 -49
  49. data/lib/space_core/mise_client.rb +0 -37
  50. data/lib/space_core/oci_builder.rb +0 -56
  51. data/lib/space_core/oci_packer.rb +0 -103
  52. data/lib/space_core/oci_runner.rb +0 -89
  53. data/lib/space_core/paths.rb +0 -58
  54. data/lib/space_core/repo_reference.rb +0 -19
  55. data/lib/space_core/repo_resolver.rb +0 -167
  56. data/lib/space_core/shell_integration.rb +0 -438
  57. data/lib/space_core/slugger.rb +0 -16
  58. data/lib/space_core/space.rb +0 -174
  59. data/lib/space_core/space_store.rb +0 -319
  60. data/lib/space_core/state.rb +0 -86
  61. data/lib/space_core/templates/oci/dockerfile.erb +0 -73
  62. data/lib/space_core/templates/oci/dockerignore.erb +0 -17
  63. data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
  64. data/lib/space_core/terminal.rb +0 -161
  65. data/lib/space_core/warnings.rb +0 -13
  66. data/lib/space_core/xdg.rb +0 -33
  67. data/lib/space_core.rb +0 -26
  68. data/lib/space_src/cli/clone.rb +0 -54
  69. data/lib/space_src/cli/config.rb +0 -65
  70. data/lib/space_src/cli/daemon.rb +0 -362
  71. data/lib/space_src/cli/options.rb +0 -21
  72. data/lib/space_src/cli/org.rb +0 -199
  73. data/lib/space_src/cli/repo.rb +0 -169
  74. data/lib/space_src/cli/shell.rb +0 -121
  75. data/lib/space_src/cli/status.rb +0 -75
  76. data/lib/space_src/cli/sync.rb +0 -161
  77. data/lib/space_src/cli.rb +0 -159
  78. data/lib/space_src/cloner.rb +0 -77
  79. data/lib/space_src/config/contract.rb +0 -54
  80. data/lib/space_src/config/duration.rb +0 -79
  81. data/lib/space_src/config/model.rb +0 -49
  82. data/lib/space_src/config/store.rb +0 -156
  83. data/lib/space_src/forge/client.rb +0 -31
  84. data/lib/space_src/forge/github.rb +0 -98
  85. data/lib/space_src/launchd/agent.rb +0 -195
  86. data/lib/space_src/launchd/plist.rb +0 -129
  87. data/lib/space_src/log_rotator.rb +0 -46
  88. data/lib/space_src/migration.rb +0 -43
  89. data/lib/space_src/nav.rb +0 -99
  90. data/lib/space_src/paths.rb +0 -72
  91. data/lib/space_src/scm/client.rb +0 -87
  92. data/lib/space_src/scm/git.rb +0 -232
  93. data/lib/space_src/scm/status.rb +0 -24
  94. data/lib/space_src/shell.rb +0 -90
  95. data/lib/space_src/shell_integration.rb +0 -321
  96. data/lib/space_src/state/lock.rb +0 -59
  97. data/lib/space_src/state/store.rb +0 -140
  98. data/lib/space_src/sync/engine.rb +0 -503
  99. data/lib/space_src/sync/repo_plan.rb +0 -215
  100. data/lib/space_src/sync/report.rb +0 -18
  101. data/lib/space_src/ui/interactive_reporter.rb +0 -280
  102. data/lib/space_src/ui/json_reporter.rb +0 -39
  103. data/lib/space_src/ui/mode.rb +0 -68
  104. data/lib/space_src/ui/plain_reporter.rb +0 -53
  105. data/lib/space_src/ui/reporter.rb +0 -48
  106. data/lib/space_src/version.rb +0 -7
  107. data/lib/space_src.rb +0 -38
@@ -1,362 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pastel"
4
- require "fileutils"
5
- require "dry/monads"
6
- require "space_src/ui/mode"
7
- require "space_src/cli/options"
8
- require "space_src/launchd/agent"
9
- require "space_src/launchd/plist"
10
- require "space_src/migration"
11
-
12
- module Space::Src
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 `src 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>/exe/src`).
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
- Space::Src::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
- # Relabel: if old-identity plist exists, bootout (benign-failure-tolerant)
117
- # and remove it before bootstrapping the new-label plist.
118
- old_pp = plist_path(paths, Migration::OLD_LABEL)
119
- if File.exist?(old_pp)
120
- old_agent = make_agent(label: Migration::OLD_LABEL)
121
- old_agent.uninstall
122
- File.delete(old_pp) if File.exist?(old_pp)
123
- end
124
-
125
- resolve = Resolve.detect(repo_root: Dir.pwd)
126
- xml = build_plist(resolve: resolve, config: config, paths: paths, label: label)
127
- FileUtils.mkdir_p(File.dirname(pp))
128
- File.write(pp, xml)
129
-
130
- agent = make_agent
131
- result = agent.install(pp)
132
- if result.failure?
133
- return fail_with(self, "bootstrap failed: #{format_failure(result.failure)}")
134
- end
135
-
136
- out.puts pastel.green("installed: #{pp}")
137
- CLI.record_outcome(Outcome.new(exit_code: 0))
138
- end
139
- end
140
-
141
- class Uninstall < Dry::CLI::Command
142
- include Helpers
143
- include GlobalOptions
144
-
145
- desc "Uninstall the per-user launchd agent (bootout + remove the plist)"
146
-
147
- def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
148
- mode = UI::Mode.resolve(
149
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
150
- env: CLI.env,
151
- out: out
152
- )
153
- pastel = Pastel.new(enabled: mode.color)
154
-
155
- paths = CLI.make_paths
156
- label = Launchd::Agent::DEFAULT_LABEL
157
- pp = plist_path(paths, label)
158
-
159
- agent = make_agent
160
- result = agent.uninstall
161
- # CF5 (Slice 5): the Agent maps a not-loaded bootout
162
- # (status 3 / "No such process") to Success — the
163
- # common case at a 6h refresh interval. A non-benign
164
- # bootout Failure (e.g. status 1 "Operation not
165
- # permitted") still surfaces here as a real failure
166
- # the operator needs to see. We still remove the
167
- # plist regardless (idempotent uninstall, Slice-4
168
- # gate G3): the bootout is the only best-effort step.
169
- if result.failure?
170
- err.puts "bootout reported: #{format_failure(result.failure)}"
171
- end
172
-
173
- if File.exist?(pp)
174
- File.delete(pp)
175
- out.puts pastel.green("removed plist: #{pp}")
176
- else
177
- out.puts pastel.yellow("plist not present: #{pp}")
178
- end
179
-
180
- CLI.record_outcome(Outcome.new(exit_code: 0))
181
- end
182
- end
183
-
184
- class Start < Dry::CLI::Command
185
- include Helpers
186
- include GlobalOptions
187
-
188
- desc "Start the agent (bootstrap + enable)"
189
-
190
- def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
191
- mode = UI::Mode.resolve(
192
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
193
- env: CLI.env,
194
- out: out
195
- )
196
- pastel = Pastel.new(enabled: mode.color)
197
-
198
- paths = CLI.make_paths
199
- label = Launchd::Agent::DEFAULT_LABEL
200
- pp = plist_path(paths, label)
201
-
202
- agent = make_agent
203
- result = agent.start(pp)
204
- if result.failure?
205
- return fail_with(self, "start failed: #{format_failure(result.failure)}")
206
- end
207
- out.puts pastel.green("started: #{label}")
208
- CLI.record_outcome(Outcome.new(exit_code: 0))
209
- end
210
- end
211
-
212
- class Stop < Dry::CLI::Command
213
- include Helpers
214
- include GlobalOptions
215
-
216
- desc "Stop the agent (bootout + disable)"
217
-
218
- def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
219
- mode = UI::Mode.resolve(
220
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
221
- env: CLI.env,
222
- out: out
223
- )
224
- pastel = Pastel.new(enabled: mode.color)
225
-
226
- label = Launchd::Agent::DEFAULT_LABEL
227
- agent = make_agent
228
- result = agent.stop
229
- if result.failure?
230
- return fail_with(self, "stop failed: #{format_failure(result.failure)}")
231
- end
232
- out.puts pastel.green("stopped: #{label}")
233
- CLI.record_outcome(Outcome.new(exit_code: 0))
234
- end
235
- end
236
-
237
- class Restart < Dry::CLI::Command
238
- include Helpers
239
- include GlobalOptions
240
-
241
- desc "Restart the agent (kickstart -k)"
242
-
243
- def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
244
- mode = UI::Mode.resolve(
245
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
246
- env: CLI.env,
247
- out: out
248
- )
249
- pastel = Pastel.new(enabled: mode.color)
250
-
251
- label = Launchd::Agent::DEFAULT_LABEL
252
- agent = make_agent
253
- result = agent.restart
254
- if result.failure?
255
- return fail_with(self, "restart failed: #{format_failure(result.failure)}")
256
- end
257
- out.puts pastel.green("restarted: #{label}")
258
- CLI.record_outcome(Outcome.new(exit_code: 0))
259
- end
260
- end
261
-
262
- class Status < Dry::CLI::Command
263
- include Helpers
264
- include GlobalOptions
265
-
266
- desc "Print the agent's loaded/running/last-exit state"
267
-
268
- def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
269
- mode = UI::Mode.resolve(
270
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
271
- env: CLI.env,
272
- out: out
273
- )
274
- pastel = Pastel.new(enabled: mode.color)
275
-
276
- paths = CLI.make_paths
277
- old_pp = plist_path(paths, Migration::OLD_LABEL)
278
- if File.exist?(old_pp)
279
- err.puts pastel.yellow("warning: stale agent #{Migration::OLD_LABEL} detected; run `src daemon install` to upgrade")
280
- end
281
-
282
- label = Launchd::Agent::DEFAULT_LABEL
283
- agent = make_agent
284
- result = agent.status
285
- if result.failure?
286
- return fail_with(self, "status failed: #{format_failure(result.failure)}")
287
- end
288
- s = result.success
289
- out.puts pastel.cyan("label: #{label}")
290
- out.puts pastel.cyan("loaded: #{s[:loaded]}")
291
- out.puts pastel.cyan("running: #{s[:running]}")
292
- out.puts pastel.cyan("pid: #{s[:pid].inspect}")
293
- out.puts pastel.cyan("last_exit: #{s[:last_exit].inspect}")
294
- CLI.record_outcome(Outcome.new(exit_code: 0))
295
- end
296
- end
297
- end
298
- end
299
- end
300
-
301
- # Detect the runtime paths the plist needs. The src install path
302
- # matters because the plist stores an absolute `bin_path` — that
303
- # is the script launchd invokes. We resolve `bin_path` from the
304
- # on-disk gem layout if we can, else fall back to the directory
305
- # the daemon command was run from.
306
- class Space::Src::CLI::Daemon::Helpers::Resolve
307
- # @param repo_root [String] absolute path of the working directory (where mise.toml is expected)
308
- # @return [Resolve]
309
- def self.detect(repo_root:)
310
- mise_bin = detect_mise_bin
311
- ruby_bin = detect_ruby_bin(repo_root, mise_bin)
312
- bin_path = detect_bin_path(repo_root)
313
- mise_toml = File.join(repo_root, "mise.toml")
314
- new(repo_root: repo_root, mise_toml: mise_toml, mise_bin: mise_bin, ruby_bin: ruby_bin, bin_path: bin_path)
315
- end
316
-
317
- def self.detect_mise_bin
318
- path = ENV["SPACE_SRC_MISE_BIN"]
319
- return path if path && !path.empty?
320
- require "open3"
321
- out, _e, st = Open3.capture3("which", "mise")
322
- st.success? ? out.strip : "/opt/homebrew/bin/mise"
323
- end
324
-
325
- def self.detect_ruby_bin(repo_root, mise_bin)
326
- path = ENV["SPACE_SRC_RUBY_BIN"]
327
- return path if path && !path.empty?
328
- # `mise exec -- which ruby` — but we avoid spawning in tests;
329
- # production path goes through here.
330
- require "open3"
331
- out, _e, st = Open3.capture3(mise_bin, "exec", "--", "which", "ruby", chdir: repo_root)
332
- return out.strip if st.success? && !out.strip.empty?
333
- # Fall back to the system ruby (last resort).
334
- out, _e, st = Open3.capture3("which", "ruby")
335
- st.success? ? out.strip : "/usr/bin/ruby"
336
- end
337
-
338
- def self.detect_bin_path(repo_root)
339
- path = ENV["SPACE_SRC_BIN_PATH"]
340
- return path if path && !path.empty?
341
- # Prefer the on-disk dev bin at `<repo_root>/exe/src`
342
- # — it's what the human runs during testing, and the gem is
343
- # typically not `gem install`ed in a source checkout.
344
- dev = File.join(repo_root, "exe", "src")
345
- return dev if File.exist?(dev)
346
- # Next, an installed binary on PATH.
347
- require "open3"
348
- out, _e, st = Open3.capture3("which", "src")
349
- return out.strip if st.success? && !out.strip.empty?
350
- # Last resort: the installed gem's bin (raises if not installed).
351
- Gem.bin_path("space-architect", "src")
352
- end
353
- end
354
-
355
- Space::Src::CLI::Registry.register "daemon" do |prefix|
356
- prefix.register "install", Space::Src::CLI::Daemon::Install
357
- prefix.register "uninstall", Space::Src::CLI::Daemon::Uninstall
358
- prefix.register "start", Space::Src::CLI::Daemon::Start
359
- prefix.register "stop", Space::Src::CLI::Daemon::Stop
360
- prefix.register "restart", Space::Src::CLI::Daemon::Restart
361
- prefix.register "status", Space::Src::CLI::Daemon::Status
362
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Space::Src
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
@@ -1,199 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pastel"
4
- require "dry/monads"
5
- require "space_src/ui/mode"
6
- require "space_src/cli/options"
7
-
8
- module Space::Src
9
- module CLI
10
- # `org` command group: add / remove / list tracked orgs.
11
- # Same shape as `repo` but against `Config::OrgRef` (host + name
12
- # + two bool flags). The host defaults to "github.com" per
13
- # PRD §3.1 when omitted (so `org add socketry` works as well as
14
- # `org add github.com/socketry`).
15
- module Org
16
- module Helpers
17
- module_function
18
-
19
- # Parse an org ref string. Accepts "github.com/socketry" or
20
- # bare "socketry" (defaults host to github.com). Anything
21
- # with 3+ parts is rejected (org has only host + name).
22
- def parse_ref(name, include_archived: false, include_forks: false, ignored_repos: [])
23
- parts = name.to_s.split("/")
24
- case parts.length
25
- when 1
26
- org = parts[0]
27
- return Dry::Monads::Failure("invalid org reference: empty name in #{name.inspect}") if org.empty?
28
- Dry::Monads::Success(Config::OrgRef.new(
29
- host: Config::DEFAULT_HOST,
30
- name: org,
31
- include_archived: include_archived,
32
- include_forks: include_forks,
33
- ignored_repos: ignored_repos
34
- ))
35
- when 2
36
- host, n = parts
37
- return Dry::Monads::Failure("invalid org reference: empty host in #{name.inspect}") if host.empty?
38
- return Dry::Monads::Failure("invalid org reference: empty name in #{name.inspect}") if n.empty?
39
- Dry::Monads::Success(Config::OrgRef.new(
40
- host: host,
41
- name: n,
42
- include_archived: include_archived,
43
- include_forks: include_forks,
44
- ignored_repos: ignored_repos
45
- ))
46
- else
47
- Dry::Monads::Failure("invalid org reference: #{name.inspect} (expected \"<name>\" or \"<host>/<name>\")")
48
- end
49
- end
50
-
51
- def same_org?(a, b) = a.host == b.host && a.name == b.name
52
- def format_ref(o) = "#{o.host}/#{o.name}"
53
- def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
54
-
55
- def format_ignored(o)
56
- return "" if o.ignored_repos.empty?
57
- " ignored_repos=#{o.ignored_repos.inspect}"
58
- end
59
-
60
- def fail_with(cmd, msg)
61
- cmd.send(:err).puts msg
62
- Space::Src::CLI.record_outcome(Outcome.new(exit_code: 1, message: msg))
63
- end
64
- end
65
-
66
- class Add < Dry::CLI::Command
67
- include Helpers
68
- include GlobalOptions
69
-
70
- desc "Add a tracked org (idempotent on host/name)"
71
- argument :name, required: true,
72
- desc: "Org identity as <name> or <host>/<name> (host defaults to github.com)"
73
- option :include_archived, type: :boolean, default: false,
74
- desc: "Include archived repos when expanding the org"
75
- option :include_forks, type: :boolean, default: false,
76
- desc: "Include forks when expanding the org"
77
- option :ignored_repos, type: :array, default: [],
78
- desc: "Repos to exclude from expansion (bare name or owner/name)"
79
-
80
- def call(name:, include_archived: false, include_forks: false, ignored_repos: [], plain: nil, json: nil, no_color: nil, quiet: nil, **)
81
- mode = UI::Mode.resolve(
82
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
83
- env: CLI.env,
84
- out: out
85
- )
86
- pastel = Pastel.new(enabled: mode.color)
87
-
88
- parsed = parse_ref(name,
89
- include_archived: include_archived,
90
- include_forks: include_forks,
91
- ignored_repos: ignored_repos)
92
- return fail_with(self, parsed.failure) if parsed.failure?
93
-
94
- new_ref = parsed.success
95
- paths = CLI.make_paths
96
- config = Config::Store.load(paths.config_file).success
97
-
98
- if config.orgs.any? { |o| same_org?(o, new_ref) }
99
- out.puts pastel.yellow("already tracked: #{format_ref(new_ref)}" \
100
- " (include_archived=#{new_ref.include_archived}, include_forks=#{new_ref.include_forks})" \
101
- "#{format_ignored(new_ref)}")
102
- return CLI.record_outcome(Outcome.new(exit_code: 0))
103
- end
104
-
105
- result = Config::Store.update(paths.config_file) do |c|
106
- Config::Store.with(c, orgs: c.orgs + [new_ref])
107
- end
108
- if result.failure?
109
- return fail_with(self, "failed to update config: #{format_failure(result.failure)}")
110
- end
111
-
112
- out.puts pastel.green("added: #{format_ref(new_ref)}" \
113
- " (include_archived=#{new_ref.include_archived}, include_forks=#{new_ref.include_forks})" \
114
- "#{format_ignored(new_ref)}")
115
- CLI.record_outcome(Outcome.new(exit_code: 0))
116
- end
117
- end
118
-
119
- class Remove < Dry::CLI::Command
120
- include Helpers
121
- include GlobalOptions
122
-
123
- desc "Remove a tracked org (host/name)"
124
- argument :name, required: true,
125
- desc: "Org identity as <name> or <host>/<name> (host defaults to github.com)"
126
-
127
- def call(name:, plain: nil, json: nil, no_color: nil, quiet: nil, **)
128
- # The flags don't affect the identity match for remove; we
129
- # match on (host, name) only. This matches the user's
130
- # expectation that "remove" targets the org, not the flag
131
- # combination they added it with.
132
- mode = UI::Mode.resolve(
133
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
134
- env: CLI.env,
135
- out: out
136
- )
137
- pastel = Pastel.new(enabled: mode.color)
138
-
139
- parsed = parse_ref(name)
140
- return fail_with(self, parsed.failure) if parsed.failure?
141
-
142
- target = parsed.success
143
- paths = CLI.make_paths
144
- config = Config::Store.load(paths.config_file).success
145
-
146
- kept = config.orgs.reject { |o| same_org?(o, target) }
147
- if kept.size == config.orgs.size
148
- return fail_with(self, "not tracked: #{format_ref(target)}")
149
- end
150
-
151
- result = Config::Store.update(paths.config_file) do |c|
152
- Config::Store.with(c, orgs: kept)
153
- end
154
- if result.failure?
155
- return fail_with(self, "failed to update config: #{format_failure(result.failure)}")
156
- end
157
-
158
- out.puts pastel.green("removed: #{format_ref(target)}")
159
- CLI.record_outcome(Outcome.new(exit_code: 0))
160
- end
161
- end
162
-
163
- class List < Dry::CLI::Command
164
- include Helpers
165
- include GlobalOptions
166
-
167
- desc "List tracked orgs"
168
-
169
- def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
170
- mode = UI::Mode.resolve(
171
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
172
- env: CLI.env,
173
- out: out
174
- )
175
- pastel = Pastel.new(enabled: mode.color)
176
-
177
- paths = CLI.make_paths
178
- config = Config::Store.load(paths.config_file).success
179
- if config.orgs.empty?
180
- out.puts pastel.dim("(no tracked orgs)")
181
- else
182
- config.orgs.each do |o|
183
- out.puts pastel.cyan("#{o.host}/#{o.name}" \
184
- " (include_archived=#{o.include_archived}, include_forks=#{o.include_forks})" \
185
- "#{format_ignored(o)}")
186
- end
187
- end
188
- CLI.record_outcome(Outcome.new(exit_code: 0))
189
- end
190
- end
191
- end
192
- end
193
- end
194
-
195
- Space::Src::CLI::Registry.register "org" do |prefix|
196
- prefix.register "add", Space::Src::CLI::Org::Add
197
- prefix.register "remove", Space::Src::CLI::Org::Remove
198
- prefix.register "list", Space::Src::CLI::Org::List
199
- end