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,169 +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
- # `repo` command group: add / remove / list tracked repos.
11
- # Backed by Config::Store (the on-disk config.yaml is the
12
- # source of truth — the CLI is just the CRUD interface).
13
- module Repo
14
- # Shared ref parser + formatters used by Add and Remove. Kept
15
- # in a module-level Helpers so the parsing rules live in one
16
- # place (the "invalid repo reference" message must be identical
17
- # across add/remove for G3 consistency).
18
- module Helpers
19
- module_function
20
-
21
- def parse_ref(ref)
22
- parts = ref.to_s.split("/")
23
- return Dry::Monads::Failure("invalid repo reference: #{ref.inspect} (expected host/owner/name)") if parts.length != 3
24
- host, owner, name = parts
25
- return Dry::Monads::Failure("invalid repo reference: empty host in #{ref.inspect}") if host.empty?
26
- return Dry::Monads::Failure("invalid repo reference: empty owner in #{ref.inspect}") if owner.empty?
27
- return Dry::Monads::Failure("invalid repo reference: empty name in #{ref.inspect}") if name.empty?
28
- Dry::Monads::Success(Config::RepoRef.new(host: host, owner: owner, name: name))
29
- end
30
-
31
- def same_repo?(a, b)
32
- a.host == b.host && a.owner == b.owner && a.name == b.name
33
- end
34
-
35
- def format_ref(r) = "#{r.host}/#{r.owner}/#{r.name}"
36
-
37
- def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
38
-
39
- def fail_with(cmd, msg)
40
- cmd.send(:err).puts msg
41
- Space::Src::CLI.record_outcome(Outcome.new(exit_code: 1, message: msg))
42
- end
43
- end
44
-
45
- # Add a tracked repo. Idempotent on the (host, owner, name)
46
- # triple: adding the same ref twice prints "already tracked"
47
- # and exits 0 (does NOT write a duplicate).
48
- class Add < Dry::CLI::Command
49
- include Helpers
50
- include GlobalOptions
51
-
52
- desc "Add a tracked repo (idempotent on host/owner/name)"
53
- argument :ref, required: true,
54
- desc: "Repo identity as host/owner/name (e.g. github.com/ruby/ruby)"
55
-
56
- def call(ref:, plain: nil, json: nil, no_color: nil, quiet: nil, **)
57
- mode = UI::Mode.resolve(
58
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
59
- env: CLI.env,
60
- out: out
61
- )
62
- pastel = Pastel.new(enabled: mode.color)
63
-
64
- parsed = parse_ref(ref)
65
- return fail_with(self, parsed.failure) if parsed.failure?
66
-
67
- new_ref = parsed.success
68
- paths = CLI.make_paths
69
- config = Config::Store.load(paths.config_file).success
70
-
71
- if config.repos.any? { |r| same_repo?(r, new_ref) }
72
- out.puts pastel.yellow("already tracked: #{format_ref(new_ref)}")
73
- return CLI.record_outcome(Outcome.new(exit_code: 0))
74
- end
75
-
76
- result = Config::Store.update(paths.config_file) do |c|
77
- Config::Store.with(c, repos: c.repos + [new_ref])
78
- end
79
-
80
- if result.failure?
81
- return fail_with(self, "failed to update config: #{format_failure(result.failure)}")
82
- end
83
-
84
- out.puts pastel.green("added: #{format_ref(new_ref)}")
85
- CLI.record_outcome(Outcome.new(exit_code: 0))
86
- end
87
- end
88
-
89
- # Remove a tracked repo. Exits 0 with a clear message if the
90
- # ref was present; exits 1 with "not tracked" if it wasn't
91
- # (and the config is untouched in that case).
92
- class Remove < Dry::CLI::Command
93
- include Helpers
94
- include GlobalOptions
95
-
96
- desc "Remove a tracked repo (host/owner/name)"
97
- argument :ref, required: true,
98
- desc: "Repo identity as host/owner/name (e.g. github.com/ruby/ruby)"
99
-
100
- def call(ref:, plain: nil, json: nil, no_color: nil, quiet: nil, **)
101
- mode = UI::Mode.resolve(
102
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
103
- env: CLI.env,
104
- out: out
105
- )
106
- pastel = Pastel.new(enabled: mode.color)
107
-
108
- parsed = parse_ref(ref)
109
- return fail_with(self, parsed.failure) if parsed.failure?
110
-
111
- target = parsed.success
112
- paths = CLI.make_paths
113
- config = Config::Store.load(paths.config_file).success
114
-
115
- kept = config.repos.reject { |r| same_repo?(r, target) }
116
- if kept.size == config.repos.size
117
- return fail_with(self, "not tracked: #{format_ref(target)}")
118
- end
119
-
120
- result = Config::Store.update(paths.config_file) do |c|
121
- Config::Store.with(c, repos: kept)
122
- end
123
- if result.failure?
124
- return fail_with(self, "failed to update config: #{format_failure(result.failure)}")
125
- end
126
-
127
- out.puts pastel.green("removed: #{format_ref(target)}")
128
- CLI.record_outcome(Outcome.new(exit_code: 0))
129
- end
130
- end
131
-
132
- # List tracked repos. One per line: "host/owner/name".
133
- class List < Dry::CLI::Command
134
- include GlobalOptions
135
-
136
- desc "List tracked repos"
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
- config = Config::Store.load(paths.config_file).success
148
- if config.repos.empty?
149
- out.puts pastel.dim("(no tracked repos)")
150
- else
151
- config.repos.each do |r|
152
- out.puts pastel.cyan("#{r.host}/#{r.owner}/#{r.name}")
153
- end
154
- end
155
- CLI.record_outcome(Outcome.new(exit_code: 0))
156
- end
157
- end
158
- end
159
- end
160
- end
161
-
162
- # Register the `repo` group + its subcommands. The block's `prefix`
163
- # is a Dry::CLI::Registry::Prefix proxy that namespaces the
164
- # subcommand names under "repo".
165
- Space::Src::CLI::Registry.register "repo" do |prefix|
166
- prefix.register "add", Space::Src::CLI::Repo::Add
167
- prefix.register "remove", Space::Src::CLI::Repo::Remove
168
- prefix.register "list", Space::Src::CLI::Repo::List
169
- end
@@ -1,121 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "space_src/shell_integration"
4
-
5
- module Space::Src
6
- module CLI
7
- module Shell
8
- class Init < Dry::CLI::Command
9
- desc "Print shell integration script"
10
- argument :shell_name, required: true, desc: "Shell name (e.g. fish)"
11
-
12
- def call(shell_name:, **)
13
- out.puts ShellIntegration.for(shell_name)
14
- CLI.record_outcome(Outcome.new(exit_code: 0))
15
- rescue => e
16
- err.puts "src shell init: #{e.message}"
17
- CLI.record_outcome(Outcome.new(exit_code: 1))
18
- end
19
- end
20
-
21
- class Fish < Dry::CLI::Command
22
- desc "Manage fish shell integration: install, uninstall, path"
23
- argument :subcommand, required: false, desc: "install, uninstall, or path (default: install)"
24
- option :force, type: :boolean, default: false, desc: "Overwrite or remove existing shell files"
25
-
26
- def call(subcommand: "install", force: false, **)
27
- case subcommand
28
- when "install"
29
- result = ShellIntegration.install("fish", env: CLI.env, force: force)
30
- out.puts fish_install_message(result.fetch(:action), result.fetch(:path))
31
- out.puts fish_completions_install_message(result.fetch(:completions_action), result.fetch(:completions_path))
32
- out.puts "Restart fish to load the integration in this terminal: exec fish"
33
- CLI.record_outcome(Outcome.new(exit_code: 0))
34
- when "uninstall"
35
- result = ShellIntegration.uninstall("fish", env: CLI.env, force: force)
36
- out.puts fish_uninstall_message(result.fetch(:action), result.fetch(:path))
37
- out.puts fish_completions_uninstall_message(result.fetch(:completions_action), result.fetch(:completions_path))
38
- CLI.record_outcome(Outcome.new(exit_code: 0))
39
- when "path"
40
- out.puts "Function: #{ShellIntegration.path_for("fish", env: CLI.env)}"
41
- out.puts "Completions: #{ShellIntegration.completions_path_for("fish", env: CLI.env)}"
42
- CLI.record_outcome(Outcome.new(exit_code: 0))
43
- else
44
- err.puts "Usage: src shell fish [install|uninstall|path]"
45
- CLI.record_outcome(Outcome.new(exit_code: 1))
46
- end
47
- rescue => e
48
- err.puts "src shell fish: #{e.message}"
49
- CLI.record_outcome(Outcome.new(exit_code: 1))
50
- end
51
-
52
- private
53
-
54
- def fish_install_message(action, path)
55
- case action
56
- when :unchanged then "Fish integration already installed: #{path}"
57
- when :updated then "Updated fish integration: #{path}"
58
- else "Installed fish integration: #{path}"
59
- end
60
- end
61
-
62
- def fish_uninstall_message(action, path)
63
- case action
64
- when :missing then "Fish integration was not installed: #{path}"
65
- else "Removed fish integration: #{path}"
66
- end
67
- end
68
-
69
- def fish_completions_install_message(action, path)
70
- case action
71
- when :unchanged then "Fish completions already installed: #{path}"
72
- when :updated then "Updated fish completions: #{path}"
73
- else "Installed fish completions: #{path}"
74
- end
75
- end
76
-
77
- def fish_completions_uninstall_message(action, path)
78
- case action
79
- when :missing then "Fish completions were not installed: #{path}"
80
- else "Removed fish completions: #{path}"
81
- end
82
- end
83
- end
84
-
85
- class Complete < Dry::CLI::Command
86
- desc "Print completion candidates"
87
- argument :kind, required: true, desc: "Completion kind"
88
- argument :extra, type: :array, required: false, desc: "Extra args for completion"
89
-
90
- def call(kind:, extra: [], **)
91
- case kind
92
- when "checkouts"
93
- begin
94
- paths = CLI.make_paths
95
- config = Config::Store.load(paths.config_file).success
96
- Nav.scan(config.base_dir).each { |e| out.puts e[:target] }
97
- rescue
98
- # Never raise on a completion call — missing/unparseable config is fine.
99
- end
100
- CLI.record_outcome(Outcome.new(exit_code: 0))
101
- when "shells"
102
- out.puts "fish"
103
- CLI.record_outcome(Outcome.new(exit_code: 0))
104
- else
105
- err.puts "Usage: src shell complete checkouts|shells"
106
- CLI.record_outcome(Outcome.new(exit_code: 1))
107
- end
108
- rescue => e
109
- err.puts "src shell complete: #{e.message}"
110
- CLI.record_outcome(Outcome.new(exit_code: 1))
111
- end
112
- end
113
- end
114
- end
115
- end
116
-
117
- Space::Src::CLI::Registry.register "shell" do |prefix|
118
- prefix.register "init", Space::Src::CLI::Shell::Init
119
- prefix.register "fish", Space::Src::CLI::Shell::Fish
120
- prefix.register "complete", Space::Src::CLI::Shell::Complete
121
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pastel"
4
- require "space_src/ui/mode"
5
- require "space_src/cli/options"
6
-
7
- module Space::Src
8
- module CLI
9
- # `status` command: render a per-repo evergreen table from
10
- # State::Store. Per G5, the rows must include each repo key
11
- # with status, last_synced_at, and default_branch.
12
- module Status
13
- class Show < Dry::CLI::Command
14
- include GlobalOptions
15
-
16
- STATUS_COLORS = {
17
- "clean" => :green,
18
- "dirty" => :yellow,
19
- "diverged" => :yellow,
20
- "wrong_branch" => :yellow,
21
- "detached" => :yellow,
22
- "error" => :red
23
- }.freeze
24
-
25
- desc "Show the per-repo evergreen status table (from $XDG_STATE_HOME/space-src/state.yaml)"
26
-
27
- def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
28
- mode = UI::Mode.resolve(
29
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
30
- env: CLI.env,
31
- out: out
32
- )
33
- pastel = Pastel.new(enabled: mode.color)
34
-
35
- paths = CLI.make_paths
36
- state = State::Store.load(paths.state_file).success
37
-
38
- if state.repos.empty?
39
- out.puts "(no repos in state — run `src sync` to populate)"
40
- return CLI.record_outcome(Outcome.new(exit_code: 0))
41
- end
42
-
43
- # Tab-separated columns. The G5 assertion is on captured
44
- # stdout containing each repo key and its status string —
45
- # tab-separated is the most assertion-friendly (no width
46
- # padding complexity, no ANSI codes).
47
- out.puts ["REPO", "STATUS", "DEFAULT_BRANCH", "LAST_SYNCED_AT", "LAST_FETCH_AT"].join("\t")
48
- state.repos.keys.sort.each do |key|
49
- r = state.repos[key]
50
- status_str = r.status.to_s
51
- color = STATUS_COLORS[status_str]
52
- styled_status = color ? pastel.decorate(status_str, color) : status_str
53
- out.puts [
54
- key,
55
- styled_status,
56
- r.default_branch.to_s,
57
- format_time(r.last_synced_at),
58
- format_time(r.last_fetch_at)
59
- ].join("\t")
60
- end
61
- CLI.record_outcome(Outcome.new(exit_code: 0))
62
- end
63
-
64
- private
65
-
66
- def format_time(t)
67
- return "" if t.nil?
68
- t.respond_to?(:iso8601) ? t.iso8601 : t.to_s
69
- end
70
- end
71
- end
72
- end
73
- end
74
-
75
- Space::Src::CLI::Registry.register "status", Space::Src::CLI::Status::Show
@@ -1,161 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/monads"
4
- require "space_src/cli/repo" # for Repo::Helpers.parse_ref
5
- require "space_src/cli/options"
6
- require "space_src/ui/mode"
7
- require "space_src/ui/plain_reporter"
8
- require "space_src/ui/json_reporter"
9
- require "space_src/ui/interactive_reporter"
10
-
11
- module Space::Src
12
- module CLI
13
- # `sync` command: invoke Sync::Engine over the full config, or
14
- # scope to a single repo with --repo.
15
- #
16
- # Scoping is implemented at the CLI layer (per gate G4): the
17
- # CLI builds a filtered Config (Config::Store.with(config,
18
- # repos: [match], orgs: [])) and passes it to the unchanged
19
- # engine. Sync::Engine#call is (config:, paths:) — there is no
20
- # scoping parameter on the engine, and the spec forbids editing
21
- # sync/engine.rb in this slice.
22
- module Sync
23
- class Run < Dry::CLI::Command
24
- include GlobalOptions
25
-
26
- desc "Run one sync pass (use --repo to scope to a single tracked repo)"
27
- option :repo, desc: "Scope to a single tracked repo (host/owner/name)"
28
-
29
- def call(repo: nil, plain: nil, json: nil, no_color: nil, quiet: nil, **)
30
- paths = CLI.make_paths
31
- paths.ensure!
32
- config = Config::Store.load(paths.config_file).success
33
-
34
- # Log rotation pre-step (slice-4 gate G5). launchd owns
35
- # the stdout/stderr redirect via StandardOutPath /
36
- # StandardErrorPath; the sync process rotates those
37
- # files at the start of each run so the previous run's
38
- # log doesn't grow unbounded. The rotator renames the
39
- # file to a timestamped archive (preserving bytes); the
40
- # current process's inherited fd still points to the
41
- # renamed file, so writes during this run succeed.
42
- # launchd opens a fresh file at the original path on
43
- # the next spawn. No-op when the log is missing or
44
- # under-threshold (sync tests in G4 stay green).
45
- rotate_plist_logs(paths)
46
-
47
- # Resolved BEFORE the scoping block: the "scoping sync to:"
48
- # notice below is prose, and must be suppressed in JSON mode
49
- # for the same reason the summary line is. Resolution depends
50
- # only on flags/env/out, so it is safe this early.
51
- mode = UI::Mode.resolve(
52
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
53
- env: CLI.env,
54
- out: out
55
- )
56
- # Every line of --json output must parse as one JSON object.
57
- human = mode.format != :json
58
-
59
- if repo
60
- target = scope_target(repo)
61
- return fail_with(self, "invalid repo reference: #{repo.inspect} (expected host/owner/name)") if target.failure?
62
-
63
- match = target.success
64
- found = config.repos.find { |r| Repo::Helpers.same_repo?(r, match) }
65
- if found.nil?
66
- return fail_with(self, "no such tracked repo: #{Repo::Helpers.format_ref(match)}")
67
- end
68
- # Filtered config: only the one matched repo, no orgs
69
- # (org expansion would discover other repos — that's
70
- # exactly the G4 "other repo gets no state row" test
71
- # path, so we explicitly empty orgs here).
72
- config = Config::Store.with(config, repos: [found], orgs: [])
73
- out.puts "scoping sync to: #{Repo::Helpers.format_ref(found)}" if human
74
- end
75
-
76
- reporter = if mode.format == :json
77
- UI::JsonReporter.new(out)
78
- elsif mode.animate
79
- UI::InteractiveReporter.new(out, mode: mode)
80
- else
81
- UI::PlainReporter.new(out, mode: mode)
82
- end
83
-
84
- # prune only on a full sweep. With --repo the config above was
85
- # narrowed to one repo, so pruning would delete the state rows
86
- # for every OTHER tracked repo.
87
- result = Space::Src::Sync::Engine.new(reporter: reporter)
88
- .call(config: config, paths: paths, prune: repo.nil?)
89
- if result.failure?
90
- return fail_with(self, "sync failed: #{format_failure(result.failure)}")
91
- end
92
-
93
- report = result.success
94
- # JsonReporter already emits a run_finished event carrying the
95
- # summary, so suppressing the prose line loses nothing.
96
- out.puts "synced #{report.processed} repo(s)" if human
97
- CLI.record_outcome(Outcome.new(exit_code: 0))
98
- end
99
-
100
- private
101
-
102
- def scope_target(repo)
103
- Repo::Helpers.parse_ref(repo)
104
- end
105
-
106
- def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
107
-
108
- def fail_with(cmd, msg)
109
- cmd.send(:err).puts msg
110
- Space::Src::CLI.record_outcome(Outcome.new(exit_code: 1, message: msg))
111
- end
112
-
113
- # Default log-rotation threshold: 10 MiB. Tunable via the
114
- # env var `SPACE_SRC_LOG_MAX_BYTES` (introspection /
115
- # ops escape hatch). The LogRotator itself is unit-tested
116
- # with an injected threshold (gate G5).
117
- DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024
118
-
119
- def rotate_plist_logs(paths)
120
- threshold = log_max_bytes
121
- label = Launchd::Agent::DEFAULT_LABEL
122
- [File.join(paths.log_dir, "#{label}.out.log"),
123
- File.join(paths.log_dir, "#{label}.err.log")].each do |p|
124
- Space::Src::LogRotator.call(p, threshold_bytes: threshold)
125
- end
126
- end
127
-
128
- # CF6 (Slice 5): defensively parse the
129
- # `SPACE_SRC_LOG_MAX_BYTES` env var so a malformed
130
- # operator value (e.g. `"10MB"`) falls back to the
131
- # 10 MiB default instead of raising `ArgumentError`
132
- # and crashing the entire `sync` run before any repo
133
- # work.
134
- #
135
- # Accepted: any positive integer in base 10
136
- # (e.g. `"1048576"`, `" 524288 "`). Falls back to
137
- # `DEFAULT_LOG_MAX_BYTES` (and emits a single
138
- # `Kernel#warn` to stderr) for: unset, empty,
139
- # whitespace, non-numeric (`"10MB"`, `"abc"`), zero,
140
- # and negative inputs. Never raises.
141
- #
142
- # The optional `env_value` arg exists so the unit
143
- # tests can pass arbitrary values without mutating
144
- # the real `ENV`; production callers invoke with
145
- # no args and the method reads `ENV` itself.
146
- def log_max_bytes(env_value = ENV["SPACE_SRC_LOG_MAX_BYTES"])
147
- return DEFAULT_LOG_MAX_BYTES if env_value.nil? || env_value.strip.empty?
148
-
149
- parsed = Integer(env_value, 10, exception: false)
150
- return parsed if parsed.is_a?(Integer) && parsed.positive?
151
-
152
- warn "src: SPACE_SRC_LOG_MAX_BYTES=#{env_value.inspect} is invalid; " \
153
- "falling back to #{DEFAULT_LOG_MAX_BYTES} bytes"
154
- DEFAULT_LOG_MAX_BYTES
155
- end
156
- end
157
- end
158
- end
159
- end
160
-
161
- Space::Src::CLI::Registry.register "sync", Space::Src::CLI::Sync::Run
data/lib/space_src/cli.rb DELETED
@@ -1,159 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/cli"
4
- require "space_src/migration"
5
-
6
- module Space::Src
7
- # CLI surface — thin translation layer between argv and the
8
- # existing Config::Store / State::Store / Sync::Engine boundaries.
9
- #
10
- # The CLI never mutates a git repo directly; repo mutation happens
11
- # inside the engine, which already upholds the no-data-loss
12
- # invariant (PRD §1). The CLI's job is:
13
- # 1. parse argv (via Dry::CLI's nested `register`)
14
- # 2. load/mutate validated config (Config::Store) OR delegate to
15
- # the engine / state store
16
- # 3. translate Result to: out/err message + exit code
17
- #
18
- # Exit-code seam: each command records an `Outcome(exit_code:,
19
- # message:)` (the thread-local stash) and writes the user-facing
20
- # message to `out`/`err` via the injected IOs. The `exe/src`
21
- # entrypoint reads the recorded Outcome and calls Kernel.exit with
22
- # the code — see CLI.run below. Tests can inspect last_outcome
23
- # in-process (no subprocess needed for unit tests); a subprocess
24
- # Open3.capture3 covers the G3 "real exit" proof.
25
- module CLI
26
- # The Outcome value object. `exit_code` is 0 for success and 1
27
- # for Failure-derived failures. `message` is the user-facing
28
- # explanation already written to err (kept here so the
29
- # entrypoint could log/record it in a future slice).
30
- Outcome = Data.define(:exit_code, :message) do
31
- def initialize(exit_code:, message: nil)
32
- super
33
- end
34
- end
35
-
36
- # Thread-local env hash. Defaults to ENV. Tests inject a temp
37
- # HOME / XDG_* hash via Thread.current[:space_src_cli_env] =
38
- # env_hash. The CLI's `make_paths` reads this to resolve the
39
- # config/state file locations under the test's temp home.
40
- def self.env
41
- Thread.current[:space_src_cli_env] || ENV
42
- end
43
-
44
- # Thread-local Outcome stash. The most recent command's Outcome
45
- # is read by CLI.run to set the process exit code.
46
- def self.record_outcome(outcome)
47
- Thread.current[:space_src_cli_outcome] = outcome
48
- end
49
-
50
- def self.last_outcome
51
- Thread.current[:space_src_cli_outcome]
52
- end
53
-
54
- # Program-name-level invocations that must succeed (exit 0) with
55
- # output on stdout. Dry::CLI has no root command registered, so it
56
- # would otherwise route these through its "command not found" path
57
- # (Usage → stderr → exit 1). We intercept ONLY the exact top-level
58
- # forms here; a *leaf* help like `sync --help` (argv ["sync",
59
- # "--help"]) and a *group* like `repo` / `repo --help` are NOT
60
- # matched, so Dry::CLI keeps handling them as before (leaf help →
61
- # stdout/exit 0; group → usage/stderr/exit 1, accepted per G7).
62
- TOP_LEVEL_HELP = [[], ["--help"], ["-h"], ["help"]].freeze
63
- VERSION_REQUEST = [["version"], ["--version"]].freeze
64
-
65
- # True when argv is a single token that is not a registered top-level
66
- # command or group, and not already handled by the help/version intercepts.
67
- # Both CLI.run and dispatch_src delegate to this shared predicate so the
68
- # routing logic is defined exactly once.
69
- def self.bare_query?(argv)
70
- return false if TOP_LEVEL_HELP.include?(argv)
71
- return false if VERSION_REQUEST.include?(argv)
72
- # paths:exempt - Registry.get([]).children is a dry-cli command-tree node, not a filesystem path
73
- argv.length == 1 && !Registry.get([]).children.key?(argv[0])
74
- end
75
-
76
- # Entrypoint. Called by exe/src. Intercepts the top-level
77
- # help/version forms (stdout, exit 0), otherwise hands argv to
78
- # Dry::CLI for command dispatch and translates the last Outcome to
79
- # a process exit code. A `Interrupt` raised from inside command
80
- # dispatch (most commonly: a SIGINT during a long-running
81
- # `Shell.run`, e.g. at a `git` username prompt or mid-clone) is
82
- # caught here and mapped to a clean exit code 130 (128 + SIGINT)
83
- # with a single human line on stderr — the G2 ^C-hygiene fix
84
- # (Slice 6). The reader-thread `IOError` noise that Open3 emits
85
- # in the same scenario is suppressed at the `Shell.run` seam
86
- # (see `lib/space_src/shell.rb`).
87
- def self.run(argv, stdout, stderr)
88
- return print_usage(stdout) if TOP_LEVEL_HELP.include?(argv)
89
- return print_version(stdout) if VERSION_REQUEST.include?(argv)
90
-
91
- Migration.run(paths: make_paths, err: stderr)
92
-
93
- if bare_query?(argv)
94
- paths = make_paths
95
- config = Config::Store.load(paths.config_file).success
96
- exit_code = Nav.dispatch(argv[0], stdout, stderr, config.base_dir)
97
- Kernel.exit(exit_code)
98
- end
99
-
100
- begin
101
- Dry::CLI.new(Registry).call(arguments: argv, out: stdout, err: stderr)
102
- outcome = last_outcome
103
- Kernel.exit(outcome&.exit_code || 0)
104
- rescue Interrupt
105
- # Map a user ^C to a clean exit-130 with a single human line.
106
- # `Kernel.exit` raises `SystemExit` (callers/tests can rescue
107
- # it to inspect the status). The `at_exit` handlers run,
108
- # stdio is flushed, the process exits with code 130. We do
109
- # NOT blanket-rescue `StandardError` and we do NOT make
110
- # non-interrupt failures exit 0 (the outcome-translation path
111
- # above is unchanged for the happy / non-Interrupt failure
112
- # paths).
113
- stderr.puts "interrupted"
114
- Kernel.exit(130)
115
- end
116
- end
117
-
118
- # Render the top-level command-group listing (reusing Dry::CLI's
119
- # own Usage formatter so it stays in sync with the registry) to
120
- # stdout and exit 0.
121
- def self.print_usage(stdout)
122
- stdout.puts Dry::CLI::Usage.call(Registry.get([]))
123
- Kernel.exit(0)
124
- end
125
-
126
- # Print the gem version to stdout and exit 0.
127
- def self.print_version(stdout)
128
- stdout.puts Space::Src::VERSION
129
- Kernel.exit(0)
130
- end
131
-
132
- # Internal: build a Paths instance scoped to the active env
133
- # (Thread.current[:space_src_cli_env] || ENV). Every command
134
- # uses this so tests can inject a temp home without mutating
135
- # the real ENV.
136
- def self.make_paths
137
- Paths.new(environment: env)
138
- end
139
-
140
- # The Dry::CLI::Registry-extended module. The subcommand files
141
- # (cli/repo.rb, cli/org.rb, …) call `register "x" do |p| ... end`
142
- # on this module at load time.
143
- module Registry
144
- extend Dry::CLI::Registry
145
- end
146
- end
147
- end
148
-
149
- # Subcommand files — each defines its command classes and
150
- # registers them under their group prefix.
151
- require "space_src/cli/repo"
152
- require "space_src/cli/org"
153
- require "space_src/cli/sync"
154
- require "space_src/cli/status"
155
- require "space_src/cli/config"
156
- require "space_src/cli/daemon"
157
- require "space_src/cli/clone"
158
- require "space_src/cli/shell"
159
- require "space_src/nav"