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/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
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
require "dry/monads"
|
|
6
|
-
require "space_src/config/model"
|
|
7
|
-
require "space_src/config/contract"
|
|
8
|
-
|
|
9
|
-
module Space::Src
|
|
10
|
-
module Config
|
|
11
|
-
# Load/validate/write-back the YAML config file.
|
|
12
|
-
#
|
|
13
|
-
# The store is the only place that touches the disk. It does not
|
|
14
|
-
# preserve unknown keys or YAML comments on write — that's a known
|
|
15
|
-
# limitation per the PRD §2 (YAML comment loss accepted) and is
|
|
16
|
-
# documented in the Slice 1 lane report. Managed fields round-trip
|
|
17
|
-
# byte-identically (gate G1).
|
|
18
|
-
class Store
|
|
19
|
-
extend Dry::Monads[:result]
|
|
20
|
-
|
|
21
|
-
DEFAULT_BASE_DIR = Space::Src::Paths::DEFAULT_BASE_DIR
|
|
22
|
-
DEFAULT_REFRESH_INTERVAL = 6 * 3600
|
|
23
|
-
DEFAULT_CONCURRENCY = 8
|
|
24
|
-
|
|
25
|
-
def self.load(path)
|
|
26
|
-
raw = read_yaml(path)
|
|
27
|
-
hash = symbolize(raw)
|
|
28
|
-
# CF1: normalize `refresh_interval` from a human-duration
|
|
29
|
-
# string ("6h", "90m", "45s", "30d") or bare integer string
|
|
30
|
-
# ("21600") into integer seconds BEFORE the contract runs.
|
|
31
|
-
# The contract stays integer-typed (:integer, gt?: 0); this
|
|
32
|
-
# is a load-layer normalization that lets a hand-edited
|
|
33
|
-
# config.yaml round-trip without rejecting "6h" as a
|
|
34
|
-
# non-integer. See lib/space_src/config/duration.rb.
|
|
35
|
-
if hash.key?(:refresh_interval)
|
|
36
|
-
result = Duration.parse(hash[:refresh_interval])
|
|
37
|
-
return result if result.failure?
|
|
38
|
-
hash[:refresh_interval] = result.success
|
|
39
|
-
end
|
|
40
|
-
with_defaults(hash) do |filled|
|
|
41
|
-
result = Contract.new.call(filled)
|
|
42
|
-
if result.success?
|
|
43
|
-
Success(Config.new(result.success))
|
|
44
|
-
else
|
|
45
|
-
Failure(result.failure)
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
rescue Errno::ENOENT
|
|
49
|
-
# Missing file is treated as an empty config (load defaults).
|
|
50
|
-
# The store does not create the file on read — that is the
|
|
51
|
-
# writer's job (write() is idempotent and always validates first).
|
|
52
|
-
Success(Config.new(defaults))
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def self.write(path, config)
|
|
56
|
-
# Always re-validate the struct's contents before writing. This
|
|
57
|
-
# guards against a caller constructing a Config with a
|
|
58
|
-
# constraint-violating value via Struct.new (which does not run
|
|
59
|
-
# the same checks as the contract).
|
|
60
|
-
hash = config.to_h
|
|
61
|
-
result = Contract.new.call(hash)
|
|
62
|
-
return result if result.failure?
|
|
63
|
-
|
|
64
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
65
|
-
File.write(path, emit(hash))
|
|
66
|
-
Success(config)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def self.update(path)
|
|
70
|
-
config = load(path).success
|
|
71
|
-
new_config = yield(config)
|
|
72
|
-
write(path, new_config)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# dry-struct update idiom: pass a hash of attributes to
|
|
76
|
-
# `Config#new` to get a new struct with the overrides applied
|
|
77
|
-
# (existing fields are kept).
|
|
78
|
-
def self.with(config, **changes)
|
|
79
|
-
config.new(**changes)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Hash → human-clean YAML string. String keys, defaults omitted.
|
|
83
|
-
# Stable key order: base_dir, refresh_interval, concurrency, repos, orgs.
|
|
84
|
-
# repos/orgs omitted when empty.
|
|
85
|
-
def self.emit(hash)
|
|
86
|
-
ordered = {}
|
|
87
|
-
ordered["base_dir"] = hash[:base_dir] if hash.key?(:base_dir)
|
|
88
|
-
ordered["refresh_interval"] = hash[:refresh_interval] if hash.key?(:refresh_interval)
|
|
89
|
-
ordered["concurrency"] = hash[:concurrency] if hash.key?(:concurrency)
|
|
90
|
-
|
|
91
|
-
repos = hash[:repos]
|
|
92
|
-
ordered["repos"] = repos.map { |r| compact_repo(r) } if repos && !repos.empty?
|
|
93
|
-
|
|
94
|
-
orgs = hash[:orgs]
|
|
95
|
-
ordered["orgs"] = orgs.map { |o| compact_org(o) } if orgs && !orgs.empty?
|
|
96
|
-
|
|
97
|
-
YAML.dump(ordered, line_width: -1)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def self.compact_repo(r)
|
|
101
|
-
h = {}
|
|
102
|
-
h["host"] = r[:host] if r[:host] && r[:host] != DEFAULT_HOST
|
|
103
|
-
h["owner"] = r[:owner]
|
|
104
|
-
h["name"] = r[:name]
|
|
105
|
-
h
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def self.compact_org(o)
|
|
109
|
-
h = {}
|
|
110
|
-
h["host"] = o[:host] if o[:host] && o[:host] != DEFAULT_HOST
|
|
111
|
-
h["name"] = o[:name]
|
|
112
|
-
h["include_archived"] = true if o[:include_archived]
|
|
113
|
-
h["include_forks"] = true if o[:include_forks]
|
|
114
|
-
ignored = o[:ignored_repos]
|
|
115
|
-
h["ignored_repos"] = ignored if ignored && !ignored.empty?
|
|
116
|
-
h
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def self.read_yaml(path)
|
|
120
|
-
return {} unless File.exist?(path)
|
|
121
|
-
# Symbol permitted because some YAML files use :symbol keys; the
|
|
122
|
-
# store's symbolize() then re-keys consistently anyway. We still
|
|
123
|
-
# disallow arbitrary classes.
|
|
124
|
-
YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false) || {}
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def self.symbolize(value)
|
|
128
|
-
case value
|
|
129
|
-
when Hash
|
|
130
|
-
value.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = symbolize(v) }
|
|
131
|
-
when Array
|
|
132
|
-
value.map { |v| symbolize(v) }
|
|
133
|
-
else
|
|
134
|
-
value
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
# Fill in missing top-level defaults before validation, so an empty
|
|
139
|
-
# YAML produces a fully-populated Config.
|
|
140
|
-
def self.with_defaults(hash)
|
|
141
|
-
filled = defaults.merge(hash)
|
|
142
|
-
yield(filled)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def self.defaults
|
|
146
|
-
{
|
|
147
|
-
base_dir: DEFAULT_BASE_DIR,
|
|
148
|
-
refresh_interval: DEFAULT_REFRESH_INTERVAL,
|
|
149
|
-
concurrency: DEFAULT_CONCURRENCY,
|
|
150
|
-
repos: [],
|
|
151
|
-
orgs: []
|
|
152
|
-
}
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "dry/monads"
|
|
4
|
-
require "space_src/config/model"
|
|
5
|
-
|
|
6
|
-
module Space::Src
|
|
7
|
-
module Forge
|
|
8
|
-
# Abstract forge interface. The GitHub implementation lists the
|
|
9
|
-
# repos belonging to an OrgRef. The interface is intentionally
|
|
10
|
-
# narrow: a forge is a source of (host, owner, name) triples. The
|
|
11
|
-
# sync engine expands an OrgRef into RepoRefs at sync time; it
|
|
12
|
-
# never asks the forge about a specific repo.
|
|
13
|
-
class Client
|
|
14
|
-
extend Dry::Monads[:result]
|
|
15
|
-
|
|
16
|
-
# Returns Success(:authenticated) or Failure({reason:}).
|
|
17
|
-
# Called ONCE by the engine before fanning out org listings.
|
|
18
|
-
def check_authenticated
|
|
19
|
-
raise NotImplementedError
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Returns Success([RepoRef, ...]) or Failure. Honors the
|
|
23
|
-
# include_archived / include_forks flags on the OrgRef.
|
|
24
|
-
# Does NOT perform authentication — the engine calls
|
|
25
|
-
# check_authenticated first.
|
|
26
|
-
def list_org(org_ref)
|
|
27
|
-
raise NotImplementedError
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "space_src/forge/client"
|
|
5
|
-
require "space_src/shell"
|
|
6
|
-
require "space_src/config/model"
|
|
7
|
-
|
|
8
|
-
module Space::Src
|
|
9
|
-
module Forge
|
|
10
|
-
# `gh repo list <org> --json …` implementation of Forge::Client.
|
|
11
|
-
#
|
|
12
|
-
# Per AGENTS.md gotcha, `gh` can silently fall back to
|
|
13
|
-
# unauthenticated (60 req/hr). We probe `gh auth status` once
|
|
14
|
-
# (via the engine calling check_authenticated before listing)
|
|
15
|
-
# and surface a clear Failure rather than risking the rate-limit wall.
|
|
16
|
-
class GitHub < Client
|
|
17
|
-
# Default page size for `gh repo list`. Matches gh's own --limit
|
|
18
|
-
# cap; orgs with >1000 repos are out of scope for Slice 1.
|
|
19
|
-
LIST_LIMIT = 1000
|
|
20
|
-
|
|
21
|
-
def initialize(shell: Shell)
|
|
22
|
-
@shell = shell
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Probe `gh auth status`. On stderr the unauthenticated case is
|
|
26
|
-
# obvious: "You are not logged into any GitHub hosts." We treat
|
|
27
|
-
# that exact phrase as Failure; otherwise Success.
|
|
28
|
-
#
|
|
29
|
-
# Public so the engine can call it once before fanning out org
|
|
30
|
-
# listings. `list_org` no longer authenticates per-call.
|
|
31
|
-
def check_authenticated
|
|
32
|
-
# `gh auth status` writes a human-friendly summary to stdout
|
|
33
|
-
# and exits 0 when authenticated, 1 when not. We also fail
|
|
34
|
-
# when the binary is missing (status 127 from the shell).
|
|
35
|
-
result = @shell.run("gh", "auth", "status")
|
|
36
|
-
return result if result.failure?
|
|
37
|
-
|
|
38
|
-
if result.success.include?("not logged into any GitHub hosts")
|
|
39
|
-
Dry::Monads::Failure({reason: "gh not authenticated; run `gh auth login` first"})
|
|
40
|
-
else
|
|
41
|
-
Dry::Monads::Success(:authenticated)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def list_org(org_ref)
|
|
46
|
-
return Dry::Monads::Failure({org: org_ref.name, reason: "missing org name"}) if org_ref.name.nil? || org_ref.name.empty?
|
|
47
|
-
|
|
48
|
-
argv = build_argv(org_ref)
|
|
49
|
-
result = @shell.run(*argv)
|
|
50
|
-
return result if result.failure?
|
|
51
|
-
|
|
52
|
-
parsed = parse_repos(result.success, org_ref)
|
|
53
|
-
Dry::Monads::Success(parsed)
|
|
54
|
-
rescue JSON::ParserError => e
|
|
55
|
-
Dry::Monads::Failure({org: org_ref.name, reason: "invalid JSON from gh", error: e.message})
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def build_argv(org_ref)
|
|
59
|
-
# G11 fix (Slice 2): `--no-source` is NOT a valid `gh repo list`
|
|
60
|
-
# flag (`gh repo list --help` lists `--archived`, `--no-archived`,
|
|
61
|
-
# `--fork`, `--source`, `--json`, `--limit`, `--topic`,
|
|
62
|
-
# `--language`, `--visibility`, `--jq`, `--template` — no
|
|
63
|
-
# `--no-source`). Fork exclusion is handled authoritatively in
|
|
64
|
-
# `parse_repos` below (the `include_forks` filter), so we no
|
|
65
|
-
# longer emit an advisory CLI flag for it. The existing G6
|
|
66
|
-
# behavioral tests for `include_forks=false` still pass.
|
|
67
|
-
argv = ["gh", "repo", "list", org_ref.name, "--json", "nameWithOwner,defaultBranchRef,isArchived,isFork", "--limit", LIST_LIMIT.to_s]
|
|
68
|
-
argv << "--no-archived" unless org_ref.include_archived
|
|
69
|
-
argv
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
private
|
|
73
|
-
|
|
74
|
-
# Parses gh's JSON output into RepoRef structs, honoring
|
|
75
|
-
# include_archived / include_forks (the CLI flags are advisory;
|
|
76
|
-
# the filter is authoritative so a future gh flag rename
|
|
77
|
-
# doesn't break us silently).
|
|
78
|
-
def parse_repos(json_text, org_ref)
|
|
79
|
-
rows = JSON.parse(json_text)
|
|
80
|
-
ignored = org_ref.ignored_repos
|
|
81
|
-
rows.map do |row|
|
|
82
|
-
next if !org_ref.include_archived && row["isArchived"]
|
|
83
|
-
next if !org_ref.include_forks && row["isFork"]
|
|
84
|
-
|
|
85
|
-
owner, name = row.fetch("nameWithOwner").split("/", 2)
|
|
86
|
-
next if ignored.include?(name) || ignored.include?(row.fetch("nameWithOwner"))
|
|
87
|
-
# default_branch is state, not config — the SCM layer resolves
|
|
88
|
-
# it on the local clone (see SCM::Git#default_branch).
|
|
89
|
-
Config::RepoRef.new(
|
|
90
|
-
host: org_ref.host,
|
|
91
|
-
owner: owner,
|
|
92
|
-
name: name
|
|
93
|
-
)
|
|
94
|
-
end.compact
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|