space-architect 8.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +53 -243
- data/lib/space_architect/bug_report.rb +2 -1
- data/lib/space_architect/cli/architect.rb +20 -7
- data/lib/space_architect/cli.rb +9 -14
- 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 +1 -1
- data/skill/architect/SKILL.md +3 -2
- data/skill/architect-vocabulary/SKILL.md +2 -2
- metadata +18 -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/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"
|
data/lib/space_src/cloner.rb
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "async"
|
|
4
|
-
require "dry/monads"
|
|
5
|
-
require "space_src/shell"
|
|
6
|
-
|
|
7
|
-
module Space::Src
|
|
8
|
-
# Resolution + COW-copy boundary for `clone`. Returns Result; no
|
|
9
|
-
# side effects on Failure. Injected shell seam defaults to ShellRunner
|
|
10
|
-
# (wraps Shell.run in Sync{} so the Fiber-scheduler requirement is met
|
|
11
|
-
# from a plain synchronous CLI command — same pattern as Launchd::Agent).
|
|
12
|
-
class Cloner
|
|
13
|
-
include Dry::Monads[:result]
|
|
14
|
-
|
|
15
|
-
class ShellRunner
|
|
16
|
-
def run(*argv)
|
|
17
|
-
Sync { Space::Src::Shell.run(*argv) }
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def initialize(base_dir:, shell: ShellRunner.new)
|
|
22
|
-
@base_dir = File.expand_path(base_dir)
|
|
23
|
-
@shell = shell
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Resolve `name` against `base_dir` and copy to `into/<leaf>`.
|
|
27
|
-
# Returns Success(dest_path) or Failure(message).
|
|
28
|
-
def call(name:, into: ".")
|
|
29
|
-
result = resolve(name)
|
|
30
|
-
return result if result.failure?
|
|
31
|
-
copy(result.success, into)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def resolve(name)
|
|
37
|
-
parts = name.split("/")
|
|
38
|
-
case parts.length
|
|
39
|
-
when 1
|
|
40
|
-
# paths:exempt - layout intent (checkout resolution by depth); space_src is deliberately independent of space_core and must not require it
|
|
41
|
-
candidates = Dir.glob(File.join(@base_dir, "*", "*", name))
|
|
42
|
-
case candidates.length
|
|
43
|
-
when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
|
|
44
|
-
when 1 then Success(candidates.first)
|
|
45
|
-
else ambiguous(name, candidates, "owner/name or host/owner/name")
|
|
46
|
-
end
|
|
47
|
-
when 2
|
|
48
|
-
owner, repo_name = parts
|
|
49
|
-
# paths:exempt - layout intent (checkout resolution by depth); space_src is deliberately independent of space_core and must not require it
|
|
50
|
-
candidates = Dir.glob(File.join(@base_dir, "*", owner, repo_name))
|
|
51
|
-
case candidates.length
|
|
52
|
-
when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
|
|
53
|
-
when 1 then Success(candidates.first)
|
|
54
|
-
else ambiguous(name, candidates, "host/owner/name")
|
|
55
|
-
end
|
|
56
|
-
when 3
|
|
57
|
-
path = File.join(@base_dir, *parts)
|
|
58
|
-
File.directory?(path) ? Success(path) : Failure("#{name.inspect} not found under base_dir #{@base_dir}")
|
|
59
|
-
else
|
|
60
|
-
Failure("invalid repo reference: #{name.inspect}")
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def ambiguous(name, candidates, hint)
|
|
65
|
-
list = candidates.map { |c| " #{c.delete_prefix("#{@base_dir}/")}" }.join("\n")
|
|
66
|
-
Failure("ambiguous name #{name.inspect}; qualify with #{hint}:\n#{list}")
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def copy(src, into)
|
|
70
|
-
leaf = File.basename(src)
|
|
71
|
-
dest = File.join(File.expand_path(into), leaf)
|
|
72
|
-
return Failure("destination already exists: #{dest}") if File.exist?(dest)
|
|
73
|
-
result = @shell.run("cp", "-Rc", src, dest)
|
|
74
|
-
result.success? ? Success(dest) : result
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "dry/validation"
|
|
4
|
-
require "dry/validation/extensions/monads"
|
|
5
|
-
require "dry/monads"
|
|
6
|
-
|
|
7
|
-
module Space::Src
|
|
8
|
-
module Config
|
|
9
|
-
# Validates the raw YAML hash before it is built into a Config struct.
|
|
10
|
-
# Returns a Dry::Monads::Result (via the :monads extension):
|
|
11
|
-
# Success(validated_hash) — keys are coerced + bad keys dropped
|
|
12
|
-
# Failure(errors_to_h) — field-level messages keyed by path
|
|
13
|
-
#
|
|
14
|
-
# Per gate G2, each rejection case (missing required field, bad
|
|
15
|
-
# refresh_interval, non-integer concurrency, malformed repo entry,
|
|
16
|
-
# malformed org entry) must produce a Failure with a field-level
|
|
17
|
-
# message — verified by Config::ContractTest.
|
|
18
|
-
class Contract < Dry::Validation::Contract
|
|
19
|
-
include Dry::Monads[:result]
|
|
20
|
-
|
|
21
|
-
schema do
|
|
22
|
-
optional(:base_dir).filled(:string)
|
|
23
|
-
optional(:refresh_interval).filled(:integer, gt?: 0)
|
|
24
|
-
optional(:concurrency).filled(:integer, gt?: 0)
|
|
25
|
-
|
|
26
|
-
optional(:repos).array(:hash) do
|
|
27
|
-
optional(:host).filled(:string)
|
|
28
|
-
required(:owner).filled(:string)
|
|
29
|
-
required(:name).filled(:string)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
optional(:orgs).array(:hash) do
|
|
33
|
-
optional(:host).filled(:string)
|
|
34
|
-
required(:name).filled(:string)
|
|
35
|
-
optional(:include_archived).filled(:bool)
|
|
36
|
-
optional(:include_forks).filled(:bool)
|
|
37
|
-
optional(:ignored_repos).array(:string)
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# Override call to return a Dry::Monads::Result. Dry-validation's
|
|
42
|
-
# monads extension gives us Result#to_monad; we map to our own
|
|
43
|
-
# namespace so callers see a single Result type at every boundary.
|
|
44
|
-
def call(input)
|
|
45
|
-
result = super
|
|
46
|
-
if result.success?
|
|
47
|
-
Success(result.to_h)
|
|
48
|
-
else
|
|
49
|
-
Failure(result.errors.to_h)
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "dry/monads"
|
|
4
|
-
|
|
5
|
-
module Space::Src
|
|
6
|
-
module Config
|
|
7
|
-
# CF1: parse a human-duration string into integer seconds.
|
|
8
|
-
#
|
|
9
|
-
# The contract and model keep `refresh_interval` as an integer
|
|
10
|
-
# (types::Integer.constrained(gt: 0)); this module is the load-
|
|
11
|
-
# layer normalization that lets a hand-edited config.yaml contain
|
|
12
|
-
# "6h" / "90m" / "45s" / "30d" and have it round-trip through
|
|
13
|
-
# Config::Store.load as 21600 / 5400 / 45 / 2592000 — so a user
|
|
14
|
-
# who writes `refresh_interval: 6h` in their config gets the
|
|
15
|
-
# same effect as `refresh_interval: 21600` without ever touching
|
|
16
|
-
# the contract.
|
|
17
|
-
#
|
|
18
|
-
# The write-back path emits integer seconds (the human form is
|
|
19
|
-
# not preserved on rewrite — consistent with Slice 1's documented
|
|
20
|
-
# YAML comment-loss limitation, see test_write_emits_only_managed_fields).
|
|
21
|
-
#
|
|
22
|
-
# Usage:
|
|
23
|
-
# Duration.parse("6h") # => Success(21600)
|
|
24
|
-
# Duration.parse(21600) # => Success(21600)
|
|
25
|
-
# Duration.parse("-3h") # => Failure("invalid duration: \"-3h\"")
|
|
26
|
-
# Duration.parse("6x") # => Failure("invalid duration: \"6x\"")
|
|
27
|
-
module Duration
|
|
28
|
-
extend Dry::Monads[:result]
|
|
29
|
-
|
|
30
|
-
# Unit suffixes recognized (PRD §3.1 documents "6h" / "90m" /
|
|
31
|
-
# integer seconds; we also accept "s" and "d" for completeness
|
|
32
|
-
# — they're natural extensions and cost zero code).
|
|
33
|
-
UNIT_SECONDS = {
|
|
34
|
-
nil => 1, # bare integer string ("21600") or Integer input
|
|
35
|
-
"s" => 1,
|
|
36
|
-
"m" => 60,
|
|
37
|
-
"h" => 3600,
|
|
38
|
-
"d" => 86_400
|
|
39
|
-
}.freeze
|
|
40
|
-
|
|
41
|
-
# Strictly positive integer (no sign, no decimal, no leading
|
|
42
|
-
# zeros that change the value's magnitude). Bare integer
|
|
43
|
-
# strings ("21600") are accepted by making the unit suffix
|
|
44
|
-
# optional in the pattern.
|
|
45
|
-
PATTERN = /\A(\d+)([smhd])?\z/
|
|
46
|
-
|
|
47
|
-
def self.parse(value)
|
|
48
|
-
case value
|
|
49
|
-
when Integer
|
|
50
|
-
return failure_for(value) if value <= 0
|
|
51
|
-
Success(value)
|
|
52
|
-
when String
|
|
53
|
-
parse_string(value)
|
|
54
|
-
else
|
|
55
|
-
failure_for(value)
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def self.parse_string(str)
|
|
60
|
-
s = str.strip
|
|
61
|
-
return failure_for(str) if s.empty?
|
|
62
|
-
m = PATTERN.match(s)
|
|
63
|
-
return failure_for(str) unless m
|
|
64
|
-
n = m[1].to_i
|
|
65
|
-
unit = m[2]
|
|
66
|
-
# The pattern guarantees n is a positive integer ("\d+" matches
|
|
67
|
-
# 1+ digits, never "0" with no other digits… well, "0" would
|
|
68
|
-
# match with n=0). Reject zero explicitly to keep the
|
|
69
|
-
# contract's "gt?: 0" guarantee.
|
|
70
|
-
return failure_for(str) if n <= 0
|
|
71
|
-
Success(n * UNIT_SECONDS.fetch(unit))
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def self.failure_for(value)
|
|
75
|
-
Failure("invalid duration: #{value.inspect} (expected positive integer or \"<n>[s|m|h|d]\" e.g. \"6h\", \"90m\", \"21600\")")
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "dry/struct"
|
|
4
|
-
require "dry/types"
|
|
5
|
-
|
|
6
|
-
module Space::Src
|
|
7
|
-
module Config
|
|
8
|
-
Types = Dry.Types()
|
|
9
|
-
|
|
10
|
-
# Default host for repo/org entries. Per PRD §3.1, host is omitted in
|
|
11
|
-
# the YAML when the user means github.com.
|
|
12
|
-
DEFAULT_HOST = "github.com"
|
|
13
|
-
# Per PRD §3.1, default refresh_interval is 6h. Stored as integer seconds.
|
|
14
|
-
DEFAULT_REFRESH_INTERVAL = 6 * 3600
|
|
15
|
-
DEFAULT_CONCURRENCY = 8
|
|
16
|
-
|
|
17
|
-
class RepoRef < Dry::Struct
|
|
18
|
-
transform_keys(&:to_sym)
|
|
19
|
-
|
|
20
|
-
attribute :host, Types::String.default(DEFAULT_HOST)
|
|
21
|
-
attribute :owner, Types::String
|
|
22
|
-
attribute :name, Types::String
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
class OrgRef < Dry::Struct
|
|
26
|
-
transform_keys(&:to_sym)
|
|
27
|
-
|
|
28
|
-
attribute :host, Types::String.default(DEFAULT_HOST)
|
|
29
|
-
attribute :name, Types::String
|
|
30
|
-
attribute :include_archived, Types::Bool.default(false)
|
|
31
|
-
attribute :include_forks, Types::Bool.default(false)
|
|
32
|
-
attribute :ignored_repos, Types::Array.of(Types::String).default([].freeze)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Top-level config. The on-disk schema per PRD §3.1:
|
|
36
|
-
# base_dir, refresh_interval, concurrency, repos, orgs.
|
|
37
|
-
# `base_dir` has no struct-level default — the value is set from
|
|
38
|
-
# Paths::DEFAULT_BASE_DIR at store-load time (see Config::Store).
|
|
39
|
-
class Config < Dry::Struct
|
|
40
|
-
transform_keys(&:to_sym)
|
|
41
|
-
|
|
42
|
-
attribute :base_dir, Types::String
|
|
43
|
-
attribute :refresh_interval, Types::Integer.constrained(gt: 0)
|
|
44
|
-
attribute :concurrency, Types::Integer.constrained(gt: 0)
|
|
45
|
-
attribute :repos, Types::Array.of(RepoRef).default([].freeze)
|
|
46
|
-
attribute :orgs, Types::Array.of(OrgRef).default([].freeze)
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|