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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +39 -464
- data/lib/space_architect/architect_project.rb +90 -39
- data/lib/space_architect/bug_report.rb +2 -1
- data/lib/space_architect/cli/architect.rb +189 -11
- data/lib/space_architect/cli/research.rb +1 -1
- data/lib/space_architect/cli.rb +9 -14
- data/lib/space_architect/loop_cost.rb +592 -0
- data/lib/space_architect/research/supervisor.rb +9 -2
- data/lib/space_architect/routing.rb +149 -0
- data/lib/space_architect/session_sync/bin_path.rb +1 -1
- data/lib/space_architect/session_sync/cursor.rb +1 -1
- data/lib/space_architect/session_sync/plist.rb +1 -1
- data/lib/{space_core → space_architect}/version.rb +2 -2
- data/lib/space_architect.rb +2 -1
- data/skill/architect/SKILL.md +3 -2
- data/skill/architect-vocabulary/SKILL.md +2 -2
- metadata +20 -177
- data/exe/space +0 -13
- data/exe/src +0 -13
- data/lib/space_architect/cli/space.rb +0 -29
- data/lib/space_architect/cli/src.rb +0 -38
- data/lib/space_core/atomic_write.rb +0 -21
- data/lib/space_core/cli/base_command.rb +0 -19
- data/lib/space_core/cli/build.rb +0 -27
- data/lib/space_core/cli/config.rb +0 -49
- data/lib/space_core/cli/current.rb +0 -16
- data/lib/space_core/cli/help.rb +0 -195
- data/lib/space_core/cli/helpers.rb +0 -115
- data/lib/space_core/cli/init.rb +0 -29
- data/lib/space_core/cli/list.rb +0 -24
- data/lib/space_core/cli/loop_status.rb +0 -47
- data/lib/space_core/cli/new.rb +0 -38
- data/lib/space_core/cli/pack.rb +0 -29
- data/lib/space_core/cli/path.rb +0 -16
- data/lib/space_core/cli/repeatable_options.rb +0 -84
- data/lib/space_core/cli/repo.rb +0 -76
- data/lib/space_core/cli/run.rb +0 -44
- data/lib/space_core/cli/shell.rb +0 -125
- data/lib/space_core/cli/show.rb +0 -21
- data/lib/space_core/cli/status.rb +0 -74
- data/lib/space_core/cli/use.rb +0 -17
- data/lib/space_core/cli.rb +0 -177
- data/lib/space_core/commands.rb +0 -51
- data/lib/space_core/config.rb +0 -152
- data/lib/space_core/errors.rb +0 -14
- data/lib/space_core/git_client.rb +0 -49
- data/lib/space_core/mise_client.rb +0 -37
- data/lib/space_core/oci_builder.rb +0 -56
- data/lib/space_core/oci_packer.rb +0 -103
- data/lib/space_core/oci_runner.rb +0 -89
- data/lib/space_core/paths.rb +0 -58
- data/lib/space_core/repo_reference.rb +0 -19
- data/lib/space_core/repo_resolver.rb +0 -167
- data/lib/space_core/shell_integration.rb +0 -438
- data/lib/space_core/slugger.rb +0 -16
- data/lib/space_core/space.rb +0 -174
- data/lib/space_core/space_store.rb +0 -319
- data/lib/space_core/state.rb +0 -86
- data/lib/space_core/templates/oci/dockerfile.erb +0 -73
- data/lib/space_core/templates/oci/dockerignore.erb +0 -17
- data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
- data/lib/space_core/terminal.rb +0 -161
- data/lib/space_core/warnings.rb +0 -13
- data/lib/space_core/xdg.rb +0 -33
- data/lib/space_core.rb +0 -26
- data/lib/space_src/cli/clone.rb +0 -54
- data/lib/space_src/cli/config.rb +0 -65
- data/lib/space_src/cli/daemon.rb +0 -362
- data/lib/space_src/cli/options.rb +0 -21
- data/lib/space_src/cli/org.rb +0 -199
- data/lib/space_src/cli/repo.rb +0 -169
- data/lib/space_src/cli/shell.rb +0 -121
- data/lib/space_src/cli/status.rb +0 -75
- data/lib/space_src/cli/sync.rb +0 -161
- data/lib/space_src/cli.rb +0 -159
- data/lib/space_src/cloner.rb +0 -77
- data/lib/space_src/config/contract.rb +0 -54
- data/lib/space_src/config/duration.rb +0 -79
- data/lib/space_src/config/model.rb +0 -49
- data/lib/space_src/config/store.rb +0 -156
- data/lib/space_src/forge/client.rb +0 -31
- data/lib/space_src/forge/github.rb +0 -98
- data/lib/space_src/launchd/agent.rb +0 -195
- data/lib/space_src/launchd/plist.rb +0 -129
- data/lib/space_src/log_rotator.rb +0 -46
- data/lib/space_src/migration.rb +0 -43
- data/lib/space_src/nav.rb +0 -99
- data/lib/space_src/paths.rb +0 -72
- data/lib/space_src/scm/client.rb +0 -87
- data/lib/space_src/scm/git.rb +0 -232
- data/lib/space_src/scm/status.rb +0 -24
- data/lib/space_src/shell.rb +0 -90
- data/lib/space_src/shell_integration.rb +0 -321
- data/lib/space_src/state/lock.rb +0 -59
- data/lib/space_src/state/store.rb +0 -140
- data/lib/space_src/sync/engine.rb +0 -503
- data/lib/space_src/sync/repo_plan.rb +0 -215
- data/lib/space_src/sync/report.rb +0 -18
- data/lib/space_src/ui/interactive_reporter.rb +0 -280
- data/lib/space_src/ui/json_reporter.rb +0 -39
- data/lib/space_src/ui/mode.rb +0 -68
- data/lib/space_src/ui/plain_reporter.rb +0 -53
- data/lib/space_src/ui/reporter.rb +0 -48
- data/lib/space_src/version.rb +0 -7
- data/lib/space_src.rb +0 -38
data/lib/space_src/cli/repo.rb
DELETED
|
@@ -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
|
data/lib/space_src/cli/shell.rb
DELETED
|
@@ -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
|
data/lib/space_src/cli/status.rb
DELETED
|
@@ -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
|
data/lib/space_src/cli/sync.rb
DELETED
|
@@ -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"
|