space-cadet 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 +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +279 -0
- data/exe/space +13 -0
- data/lib/space_core/atomic_write.rb +21 -0
- data/lib/space_core/cli/base_command.rb +19 -0
- data/lib/space_core/cli/build.rb +27 -0
- data/lib/space_core/cli/config.rb +49 -0
- data/lib/space_core/cli/current.rb +16 -0
- data/lib/space_core/cli/help.rb +195 -0
- data/lib/space_core/cli/helpers.rb +115 -0
- data/lib/space_core/cli/init.rb +29 -0
- data/lib/space_core/cli/list.rb +24 -0
- data/lib/space_core/cli/loop_status.rb +47 -0
- data/lib/space_core/cli/new.rb +38 -0
- data/lib/space_core/cli/pack.rb +29 -0
- data/lib/space_core/cli/path.rb +16 -0
- data/lib/space_core/cli/repeatable_options.rb +84 -0
- data/lib/space_core/cli/repo.rb +76 -0
- data/lib/space_core/cli/run.rb +44 -0
- data/lib/space_core/cli/shell.rb +125 -0
- data/lib/space_core/cli/show.rb +21 -0
- data/lib/space_core/cli/status.rb +74 -0
- data/lib/space_core/cli/use.rb +17 -0
- data/lib/space_core/cli.rb +177 -0
- data/lib/space_core/cloner.rb +77 -0
- data/lib/space_core/commands.rb +51 -0
- data/lib/space_core/config.rb +152 -0
- data/lib/space_core/errors.rb +14 -0
- data/lib/space_core/git_client.rb +49 -0
- data/lib/space_core/mise_client.rb +37 -0
- data/lib/space_core/oci_builder.rb +56 -0
- data/lib/space_core/oci_packer.rb +103 -0
- data/lib/space_core/oci_runner.rb +89 -0
- data/lib/space_core/paths.rb +58 -0
- data/lib/space_core/repo_reference.rb +19 -0
- data/lib/space_core/repo_resolver.rb +167 -0
- data/lib/space_core/scm/client.rb +87 -0
- data/lib/space_core/scm/git.rb +232 -0
- data/lib/space_core/scm/status.rb +24 -0
- data/lib/space_core/shell.rb +90 -0
- data/lib/space_core/shell_integration.rb +438 -0
- data/lib/space_core/slugger.rb +16 -0
- data/lib/space_core/space.rb +174 -0
- data/lib/space_core/space_store.rb +319 -0
- data/lib/space_core/state.rb +86 -0
- data/lib/space_core/templates/oci/dockerfile.erb +73 -0
- data/lib/space_core/templates/oci/dockerignore.erb +17 -0
- data/lib/space_core/templates/oci/entrypoint.sh.erb +16 -0
- data/lib/space_core/terminal.rb +161 -0
- data/lib/space_core/version.rb +7 -0
- data/lib/space_core/warnings.rb +13 -0
- data/lib/space_core/xdg.rb +33 -0
- data/lib/space_core.rb +26 -0
- metadata +199 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require "pastel"
|
|
5
|
+
require_relative "loop_status"
|
|
6
|
+
|
|
7
|
+
module Space::Core::CLI
|
|
8
|
+
# Colourful replacement for dry-cli's plain `Usage` listing — the "global
|
|
9
|
+
# help" shown by `space`, `architect`, and every bare namespace (`space repo`,
|
|
10
|
+
# `worktree`, ...). Per-command help still flows through dry-cli's Banner,
|
|
11
|
+
# whose content we like; only the listing is reskinned.
|
|
12
|
+
#
|
|
13
|
+
# We reopen Dry::CLI::Usage.call (below) to delegate here, so BOTH the
|
|
14
|
+
# intercepted top-level help and dry-cli's own bare-namespace path get the
|
|
15
|
+
# same treatment from one place. Colour follows CLI.help_pastel, set once per
|
|
16
|
+
# invocation from the output stream's tty-ness and --color, so piped and test
|
|
17
|
+
# output stay plain. The `src` binary never loads space_core, so its own plain
|
|
18
|
+
# Usage is untouched.
|
|
19
|
+
module Help
|
|
20
|
+
# One tagline per binary — the root header is served for both `space` and
|
|
21
|
+
# `architect` from here, so we pick by $PROGRAM_NAME. The default covers the
|
|
22
|
+
# test runner and any other invocation where the binary can't be identified.
|
|
23
|
+
TAGLINES = {
|
|
24
|
+
"space" => "date-prefixed workspaces; repos provisioned on fibers at copy-on-write speed",
|
|
25
|
+
"architect" => "the Architect Loop: structured judgment plus a fleet of headless AI builders"
|
|
26
|
+
}.freeze
|
|
27
|
+
DEFAULT_TAGLINE = TAGLINES.fetch("space")
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Header for the trailing group of children that declare no phase. Left nil
|
|
32
|
+
# for the `space` binary (whose commands are all phase-less → one ungrouped
|
|
33
|
+
# listing); the `architect` binary sets it so its namespaces list under a
|
|
34
|
+
# "Groups" header. Keeps phase vocabulary out of this generic renderer.
|
|
35
|
+
def self.trailing_group_label = @trailing_group_label
|
|
36
|
+
|
|
37
|
+
def self.trailing_group_label=(label)
|
|
38
|
+
@trailing_group_label = label
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(result, pastel: CLI.help_pastel)
|
|
42
|
+
groups = grouped_listing(result)
|
|
43
|
+
width = groups.flat_map { |_h, rows| rows.map { |label, _| label.length } }.max || 0
|
|
44
|
+
|
|
45
|
+
body = groups.flat_map do |group_header, rows|
|
|
46
|
+
lines = rows.map do |label, description|
|
|
47
|
+
painted = pastel.cyan(label.ljust(width))
|
|
48
|
+
description ? " #{painted} #{pastel.dim("# #{description}")}" : " #{painted}"
|
|
49
|
+
end
|
|
50
|
+
group_header ? ["", pastel.bold(group_header), *lines] : lines
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
[header(result, pastel), pastel.bold("Commands:"), *body,
|
|
54
|
+
footer(result, pastel), loop_status_block(result, pastel)].compact.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The richer header only makes sense at the true root (`space` / `architect`),
|
|
58
|
+
# not on every sub-namespace listing.
|
|
59
|
+
def header(result, pastel)
|
|
60
|
+
return unless result.names.empty?
|
|
61
|
+
|
|
62
|
+
"#{pastel.bold.cyan("space-cadet")} #{pastel.dim(Space::Core::VERSION)} " \
|
|
63
|
+
"#{pastel.dim("— #{tagline}")}\n"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The tagline for the binary in hand: `space` and `architect` each get their
|
|
67
|
+
# own, keyed off $PROGRAM_NAME (the same signal program_prefix reads).
|
|
68
|
+
def tagline
|
|
69
|
+
TAGLINES.fetch(File.basename($PROGRAM_NAME), DEFAULT_TAGLINE)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def footer(result, pastel)
|
|
73
|
+
"\n#{pastel.dim("Run `#{program_prefix(result)} <command> --help` for details on a command.")}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# "space" at the root, "space repo" inside a namespace. The `space`/`architect`
|
|
77
|
+
# binaries inject their name into ARGV, so $PROGRAM_NAME and the leading
|
|
78
|
+
# namespace segment can collide ("space space ..."); drop the duplicate.
|
|
79
|
+
def program_prefix(result)
|
|
80
|
+
prog = File.basename($PROGRAM_NAME)
|
|
81
|
+
names = result.names.dup
|
|
82
|
+
names.shift if names.first == prog
|
|
83
|
+
[prog, *names].join(" ")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Group non-hidden children into an ordered list of [header_or_nil, rows].
|
|
87
|
+
# When no child declares a phase (e.g. the `space` binary), returns a single
|
|
88
|
+
# unlabelled group sorted by name — byte-identical to the pre-phase listing.
|
|
89
|
+
# Otherwise groups declared commands under their phase label (groups, and
|
|
90
|
+
# members within a group, ordered by the declared order), with undeclared
|
|
91
|
+
# children (namespaces) trailing in the default group.
|
|
92
|
+
def grouped_listing(result)
|
|
93
|
+
# paths:exempt - result.children is a dry-cli command-tree node, not a filesystem path
|
|
94
|
+
decorated = result.children.filter_map do |name, node|
|
|
95
|
+
[name, node, phase_of(node)] unless node.hidden
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if decorated.all? { |_name, _node, phase| phase.nil? }
|
|
99
|
+
rows = decorated.sort_by { |name, _node, _phase| name }
|
|
100
|
+
.map { |name, node, _phase| row(result, name, node) }
|
|
101
|
+
return [[nil, rows]]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
phased, unphased = decorated.partition { |_name, _node, phase| phase }
|
|
105
|
+
groups = phased.group_by { |_name, _node, phase| phase.last }
|
|
106
|
+
listing = groups.sort_by { |_label, members| members.map { |_n, _node, phase| phase.first }.min }
|
|
107
|
+
.map do |label, members|
|
|
108
|
+
rows = members.sort_by { |_n, _node, phase| phase.first }
|
|
109
|
+
.map { |name, node, _phase| row(result, name, node) }
|
|
110
|
+
[label, rows]
|
|
111
|
+
end
|
|
112
|
+
listing << [trailing_group_label, unphased.map { |name, node, _phase| row(result, name, node) }] \
|
|
113
|
+
unless unphased.empty?
|
|
114
|
+
listing
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def row(result, name, node)
|
|
118
|
+
[label(result, name, node), description(node)]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def phase_of(node)
|
|
122
|
+
node.command.respond_to?(:phase) ? node.command.phase : nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Opportunistic, TOTAL loop-status embed: only the architect binary's ROOT
|
|
126
|
+
# listing gets it. Resolves the current space best-effort and renders the
|
|
127
|
+
# shared block from that space's own space.yaml `project` data (no
|
|
128
|
+
# space_cadet dependency). ANY failure — no space, store failure,
|
|
129
|
+
# malformed yaml, no `project` block — omits the block; help always renders.
|
|
130
|
+
def loop_status_block(result, pastel)
|
|
131
|
+
return unless result.names.empty? && File.basename($PROGRAM_NAME) == "architect"
|
|
132
|
+
|
|
133
|
+
project = current_space_project
|
|
134
|
+
lines = project && LoopStatus.lines(project)
|
|
135
|
+
return if lines.nil? || lines.empty?
|
|
136
|
+
|
|
137
|
+
["", pastel.bold("Loop:"), *lines.map { |l| " #{pastel.dim(l)}" }].join("\n")
|
|
138
|
+
rescue StandardError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def current_space_project
|
|
143
|
+
store = Space::Core::SpaceStore.new(config: Space::Core::Config.load, state: Space::Core::State.load)
|
|
144
|
+
store.current.value_or(nil)&.data&.[]("project")
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def label(result, name, node)
|
|
150
|
+
"#{program_prefix(result)} #{name}#{banner(node)}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def banner(node)
|
|
154
|
+
# paths:exempt - node.children? is a dry-cli command-tree predicate, not a filesystem path
|
|
155
|
+
if node.command && node.leaf? && node.children?
|
|
156
|
+
" [ARGUMENT|SUBCOMMAND]"
|
|
157
|
+
elsif node.leaf?
|
|
158
|
+
arguments(node.command)
|
|
159
|
+
else
|
|
160
|
+
" [SUBCOMMAND]"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def arguments(command)
|
|
165
|
+
return "" unless command.respond_to?(:required_arguments)
|
|
166
|
+
|
|
167
|
+
names = command.required_arguments.map { |arg| arg.name.to_s.upcase }
|
|
168
|
+
names += command.optional_arguments.map { |arg| "[#{arg.name.to_s.upcase}]" }
|
|
169
|
+
names.empty? ? "" : " #{names.join(" ")}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def description(node)
|
|
173
|
+
return unless node.leaf? && node.command.respond_to?(:description)
|
|
174
|
+
|
|
175
|
+
node.command.description
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Route dry-cli's plain namespace/root listing through our colourful renderer.
|
|
181
|
+
# We prepend over Usage.call wholesale and depend only on the LookupResult/Node
|
|
182
|
+
# API (children, command, leaf?/children?/hidden, names) rather than copying
|
|
183
|
+
# Usage's internals — see notes/ruby-cli-gems-report.md.
|
|
184
|
+
module Dry
|
|
185
|
+
class CLI
|
|
186
|
+
module Usage
|
|
187
|
+
module ColourPatch
|
|
188
|
+
def call(result)
|
|
189
|
+
Space::Core::CLI::Help.call(result)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
singleton_class.prepend(ColourPatch)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/monads"
|
|
4
|
+
|
|
5
|
+
module Space::Core::CLI
|
|
6
|
+
module Helpers
|
|
7
|
+
include Dry::Monads[:result]
|
|
8
|
+
def project_config
|
|
9
|
+
@project_config ||= Space::Core::Config.load
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def state
|
|
13
|
+
@state ||= Space::Core::State.load
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def store
|
|
17
|
+
@store ||= Space::Core::SpaceStore.new(config: project_config, state: state)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def terminal
|
|
21
|
+
@terminal
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def setup_terminal(color: "auto", colors: nil)
|
|
25
|
+
@terminal = Space::Core::Terminal.new(
|
|
26
|
+
config: project_config,
|
|
27
|
+
stdout: out,
|
|
28
|
+
stderr: err,
|
|
29
|
+
color_mode: colors || color || "auto"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def display_date(space)
|
|
34
|
+
id_date = space.id.match(/\A(\d{4})(\d{2})(\d{2})/)
|
|
35
|
+
return "#{id_date[1]}-#{id_date[2]}-#{id_date[3]}" if id_date
|
|
36
|
+
|
|
37
|
+
space.data["created_at"].to_s[0, 10]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def handle_errors
|
|
41
|
+
yield
|
|
42
|
+
rescue Space::Core::Error => e
|
|
43
|
+
if terminal
|
|
44
|
+
terminal.error(e.message)
|
|
45
|
+
else
|
|
46
|
+
err.puts e.message
|
|
47
|
+
end
|
|
48
|
+
CLI.record_outcome(Outcome.new(exit_code: 1, message: e.message))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def render(result)
|
|
52
|
+
case result
|
|
53
|
+
when Dry::Monads::Result::Success
|
|
54
|
+
yield result.value! if block_given?
|
|
55
|
+
when Dry::Monads::Result::Failure
|
|
56
|
+
error = result.failure
|
|
57
|
+
message = error.respond_to?(:message) ? error.message : error.to_s
|
|
58
|
+
terminal ? terminal.error(message) : err.puts(message)
|
|
59
|
+
CLI.record_outcome(Outcome.new(exit_code: 1, message: message))
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class RepoProgress
|
|
65
|
+
def initialize(total)
|
|
66
|
+
@total = total
|
|
67
|
+
@statuses = {}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def start(addition)
|
|
71
|
+
source = addition[:src_source]
|
|
72
|
+
@statuses[addition.fetch(:reference).full_name] = source&.directory? ? :copying : :cloning
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def trust(addition)
|
|
76
|
+
@statuses[addition.fetch(:reference).full_name] = :trusting
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def finish(addition)
|
|
80
|
+
@statuses[addition.fetch(:reference).full_name] = :done
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fail(addition)
|
|
84
|
+
@statuses[addition.fetch(:reference).full_name] = :failed
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def message
|
|
88
|
+
done = @statuses.count { |_repo, status| status == :done }
|
|
89
|
+
failed = @statuses.count { |_repo, status| status == :failed }
|
|
90
|
+
copying = @statuses.select { |_repo, status| status == :copying }.keys
|
|
91
|
+
cloning = @statuses.select { |_repo, status| status == :cloning }.keys
|
|
92
|
+
trusting = @statuses.select { |_repo, status| status == :trusting }.keys
|
|
93
|
+
|
|
94
|
+
if @total == 1
|
|
95
|
+
copying_repo = copying.first
|
|
96
|
+
cloning_repo = cloning.first
|
|
97
|
+
trusting_repo = trusting.first
|
|
98
|
+
return "Copying #{copying_repo}" if copying_repo
|
|
99
|
+
return "Cloning #{cloning_repo}" if cloning_repo
|
|
100
|
+
return "Trusting #{trusting_repo}" if trusting_repo
|
|
101
|
+
return "Fetch failed" if failed.positive?
|
|
102
|
+
|
|
103
|
+
"Preparing repos"
|
|
104
|
+
else
|
|
105
|
+
active = []
|
|
106
|
+
active << "copying #{copying.join(', ')}" unless copying.empty?
|
|
107
|
+
active << "cloning #{cloning.join(', ')}" unless cloning.empty?
|
|
108
|
+
active << "trusting #{trusting.join(', ')}" unless trusting.empty?
|
|
109
|
+
suffix = active.empty? ? nil : ": #{active.join('; ')}"
|
|
110
|
+
failed_text = failed.positive? ? ", #{failed} failed" : ""
|
|
111
|
+
"Fetching repos #{done}/#{@total}#{failed_text}#{suffix}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core::CLI
|
|
4
|
+
class Init < BaseCommand
|
|
5
|
+
desc "Create default XDG config and state files"
|
|
6
|
+
option :force, type: :boolean, default: false, desc: "Overwrite existing config and state files"
|
|
7
|
+
|
|
8
|
+
def call(force: false, **opts)
|
|
9
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
10
|
+
handle_errors do
|
|
11
|
+
if force
|
|
12
|
+
@project_config = Space::Core::Config.new
|
|
13
|
+
@state = Space::Core::State.new
|
|
14
|
+
project_config.save
|
|
15
|
+
state.save
|
|
16
|
+
else
|
|
17
|
+
project_config.ensure_exists!
|
|
18
|
+
state.ensure_exists!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
FileUtils.mkdir_p(project_config.spaces_dir)
|
|
22
|
+
terminal.success "Config: #{terminal.path(project_config.path)}"
|
|
23
|
+
terminal.success "State: #{terminal.path(state.path)}"
|
|
24
|
+
terminal.success "Spaces: #{terminal.path(project_config.spaces_dir)}"
|
|
25
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core::CLI
|
|
4
|
+
class List < BaseCommand
|
|
5
|
+
desc "List spaces"
|
|
6
|
+
|
|
7
|
+
def call(**opts)
|
|
8
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
9
|
+
handle_errors do
|
|
10
|
+
spaces = store.list
|
|
11
|
+
if spaces.empty?
|
|
12
|
+
terminal.say "No spaces found in #{terminal.path(project_config.spaces_dir)}"
|
|
13
|
+
next
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
rows = spaces.map do |space|
|
|
17
|
+
[space.status, display_date(space), space.title, terminal.path(space.path)]
|
|
18
|
+
end
|
|
19
|
+
terminal.say terminal.table(%w[Status Date Title Path], rows)
|
|
20
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core::CLI
|
|
4
|
+
# Compact Architect-Loop status block, derived from a space's own `project`
|
|
5
|
+
# block in space.yaml — so this carries NO dependency on space_cadet. It
|
|
6
|
+
# is shared by the `architect --help` embed and the `space status` report.
|
|
7
|
+
# Returns plain lines (callers colourise) and never raises on malformed data.
|
|
8
|
+
module LoopStatus
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Lines for the compact block, or nil when there is no `project` block.
|
|
12
|
+
def lines(project)
|
|
13
|
+
return nil unless project.is_a?(Hash) && !project.empty?
|
|
14
|
+
|
|
15
|
+
rows = ["Project status: #{project["status"] || "(none)"}"]
|
|
16
|
+
iter = current_iteration(project)
|
|
17
|
+
rows << "Iteration: #{ordinal(iter)} #{iter["name"]} — #{state_of(iter)}" if iter
|
|
18
|
+
rows
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def current_iteration(project)
|
|
22
|
+
name = project["current_iteration"]
|
|
23
|
+
return nil unless name
|
|
24
|
+
|
|
25
|
+
Array(project["iterations"]).find { |s| s["name"] == name }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ordinal(iter)
|
|
29
|
+
iter["ordinal"] ? format("I%02d", iter["ordinal"]) : "I--"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Derived loop state for the current iteration. Mirrors the precedence in
|
|
33
|
+
# `architect status`: a decided verdict wins, then an integrated lane
|
|
34
|
+
# (awaiting-verdict), then dispatched lanes, then a bare freeze, else spec.
|
|
35
|
+
def state_of(iter)
|
|
36
|
+
verdict = iter["verdict"]
|
|
37
|
+
return verdict if verdict && verdict != "pending"
|
|
38
|
+
|
|
39
|
+
lanes = Array(iter["lanes"])
|
|
40
|
+
return "awaiting-verdict" if lanes.any? { |l| l["integration_branch"] }
|
|
41
|
+
return "dispatched" if lanes.any?
|
|
42
|
+
return "frozen #{iter["freeze_sha"][0, 8]}" if iter["freeze_sha"]
|
|
43
|
+
|
|
44
|
+
"speccing"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core::CLI
|
|
4
|
+
class New < BaseCommand
|
|
5
|
+
desc "Create a new project space"
|
|
6
|
+
argument :title, required: true, desc: "Space title"
|
|
7
|
+
option :repo, type: :array, aliases: ["-r"], desc: "Repo ref to clone (repeatable: pass -r once per repo)"
|
|
8
|
+
option :git, type: :boolean, default: true, desc: "Initialize the space as a Git repository (use --no-git to skip)"
|
|
9
|
+
example "\"My Space\" -r org/repo -r example-tools/alpha # clone two repos into the space"
|
|
10
|
+
|
|
11
|
+
def call(title:, repo: [], git: true, **opts)
|
|
12
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
13
|
+
result = store.create(title, git: git).bind do |space|
|
|
14
|
+
terminal.success "Created #{space.id}"
|
|
15
|
+
|
|
16
|
+
repo_specs = Array(repo).compact
|
|
17
|
+
repo_specs.each { |spec| terminal.say "Queued #{spec}" }
|
|
18
|
+
|
|
19
|
+
next Success(space) if repo_specs.empty?
|
|
20
|
+
|
|
21
|
+
progress = RepoProgress.new(repo_specs.length)
|
|
22
|
+
terminal.with_spinner(-> { progress.message }) do
|
|
23
|
+
store.add_repos_to(space, repo_specs, reporter: progress)
|
|
24
|
+
end.fmap do |results|
|
|
25
|
+
results.each do |r|
|
|
26
|
+
terminal.success "Added #{r.fetch(:repo).fetch('full_name')}"
|
|
27
|
+
terminal.say terminal.path(r.fetch(:path))
|
|
28
|
+
end
|
|
29
|
+
space
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
render(result) do |space|
|
|
33
|
+
terminal.say terminal.path(space.path)
|
|
34
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "../oci_packer"
|
|
5
|
+
|
|
6
|
+
module Space::Core::CLI
|
|
7
|
+
class Pack < BaseCommand
|
|
8
|
+
desc "Generate a portable OCI build context for the current space"
|
|
9
|
+
option :output, aliases: ["-o"], type: :string, default: nil,
|
|
10
|
+
desc: "Output directory (default: build/oci/ under the space root)"
|
|
11
|
+
|
|
12
|
+
def call(output: nil, **opts)
|
|
13
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
14
|
+
handle_errors do
|
|
15
|
+
result = store.current.bind do |space|
|
|
16
|
+
out_dir = output ? Pathname.new(output).expand_path : space.path.join("build", "oci")
|
|
17
|
+
Space::Core::OciPacker.new(space: space, output_dir: out_dir).generate
|
|
18
|
+
.fmap { |dir| { space: space, dir: dir } }
|
|
19
|
+
end
|
|
20
|
+
render(result) do |r|
|
|
21
|
+
terminal.success "Generated OCI context: #{r[:dir]}"
|
|
22
|
+
terminal.say "Build: docker build -f #{r[:dir]}/Dockerfile -t #{r[:space].id}:latest ."
|
|
23
|
+
terminal.say " (run from: #{r[:space].path})"
|
|
24
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core::CLI
|
|
4
|
+
class Path < BaseCommand
|
|
5
|
+
desc "Print the path for a space or the current space"
|
|
6
|
+
argument :identifier, required: false, desc: "Space ID or title slug"
|
|
7
|
+
|
|
8
|
+
def call(identifier: nil, **opts)
|
|
9
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
10
|
+
render(store.path_for(identifier)) do |path|
|
|
11
|
+
terminal.say terminal.path(path)
|
|
12
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
|
|
5
|
+
# dry-cli (1.4.x) treats `type: :array` options as comma-separated and OVERWRITES
|
|
6
|
+
# on each occurrence, so `-r a -r b` yields ["b"]. We want repeated flags to
|
|
7
|
+
# accumulate (`-r a -r b -r c` => ["a", "b", "c"]) the way git/docker-style CLIs
|
|
8
|
+
# do, while still accepting the comma form. dry-cli exposes no hook for this, so
|
|
9
|
+
# we prepend two private seams, each mirroring dry-cli 1.4.1 with a single change:
|
|
10
|
+
#
|
|
11
|
+
# * Parser.call — concat instead of assign for array options.
|
|
12
|
+
# * Banner.extended_command_options — drop the "=VALUE1,VALUE2,.." hint that
|
|
13
|
+
# advertised the comma form as the only way; show the plain repeatable flag,
|
|
14
|
+
# matching how you actually type it (-r VALUE).
|
|
15
|
+
#
|
|
16
|
+
# These mirror the released 1.4.1 source EXACTLY (not the dry-rb main branch,
|
|
17
|
+
# which already differs), prepended onto the module's singleton class so `super`
|
|
18
|
+
# reaches dry-cli's own implementation instead of discarding it outright. Pinned
|
|
19
|
+
# via `~> 1.4`; if a future dry-cli reworks these methods, repeatable_options_test
|
|
20
|
+
# goes red and we re-sync. Rationale: notes/ruby-cli-gems-report.md.
|
|
21
|
+
module Dry
|
|
22
|
+
class CLI
|
|
23
|
+
module Parser
|
|
24
|
+
# Reopened on Parser's own nesting (not CLI's) so `Result` resolves
|
|
25
|
+
# lexically, exactly as it does in dry-cli's own `Parser.call`.
|
|
26
|
+
module RepeatableOptionsPatch
|
|
27
|
+
def call(command, arguments, prog_name)
|
|
28
|
+
original_arguments = arguments.dup
|
|
29
|
+
parsed_options = {}
|
|
30
|
+
|
|
31
|
+
OptionParser.new do |opts|
|
|
32
|
+
command.options.each do |option|
|
|
33
|
+
opts.on(*option.parser_options) do |value|
|
|
34
|
+
if option.array?
|
|
35
|
+
(parsed_options[option.name.to_sym] ||= []).concat(value)
|
|
36
|
+
else
|
|
37
|
+
parsed_options[option.name.to_sym] = value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
opts.on_tail("-h", "--help") do
|
|
43
|
+
return Result.help
|
|
44
|
+
end
|
|
45
|
+
end.parse!(arguments)
|
|
46
|
+
|
|
47
|
+
parsed_options = command.default_params.merge(parsed_options)
|
|
48
|
+
parse_required_params(command, arguments, prog_name, parsed_options)
|
|
49
|
+
rescue ::OptionParser::ParseError, ValueError
|
|
50
|
+
Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
singleton_class.prepend(RepeatableOptionsPatch)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
module Banner
|
|
57
|
+
module RepeatableOptionsPatch
|
|
58
|
+
def extended_command_options(command)
|
|
59
|
+
result = command.options.map do |option|
|
|
60
|
+
name = Inflector.dasherize(option.name)
|
|
61
|
+
name = if option.boolean?
|
|
62
|
+
"[no-]#{name}"
|
|
63
|
+
elsif option.flag?
|
|
64
|
+
name
|
|
65
|
+
else
|
|
66
|
+
# array options included: repeated flags accumulate, so show
|
|
67
|
+
# the single repeatable form rather than "=VALUE1,VALUE2,..".
|
|
68
|
+
"#{name}=VALUE"
|
|
69
|
+
end
|
|
70
|
+
name = "#{name}, #{option.alias_names.join(", ")}" if option.aliases.any?
|
|
71
|
+
name = " --#{name.ljust(30)}"
|
|
72
|
+
name = "#{name} # #{option.desc}"
|
|
73
|
+
name = "#{name}, default: #{option.default.inspect}" unless option.default.nil?
|
|
74
|
+
name
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
result << " --#{"help, -h".ljust(30)} # Print this help"
|
|
78
|
+
result.join("\n")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
singleton_class.prepend(RepeatableOptionsPatch)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Core::CLI
|
|
4
|
+
module Repo
|
|
5
|
+
class Add < BaseCommand
|
|
6
|
+
desc "Add repos to the current space (copy-on-write from an evergreen checkout when available, else clone)"
|
|
7
|
+
argument :repos, type: :array, required: false, desc: "REPO [REPO...]"
|
|
8
|
+
|
|
9
|
+
def call(repos: [], **opts)
|
|
10
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
11
|
+
handle_errors do
|
|
12
|
+
specs = Array(repos).compact
|
|
13
|
+
if specs.empty?
|
|
14
|
+
terminal.error("Usage: space repo add REPO [REPO...]")
|
|
15
|
+
CLI.record_outcome(Outcome.new(exit_code: 1))
|
|
16
|
+
next
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
progress = RepoProgress.new(specs.length)
|
|
20
|
+
add_result = terminal.with_spinner(-> { progress.message }) do
|
|
21
|
+
store.add_repos(specs, reporter: progress)
|
|
22
|
+
end
|
|
23
|
+
render(add_result) do |results|
|
|
24
|
+
results.each do |result|
|
|
25
|
+
terminal.success "Added #{result.fetch(:repo).fetch('full_name')}"
|
|
26
|
+
terminal.say terminal.path(result.fetch(:path))
|
|
27
|
+
end
|
|
28
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class RepoList < BaseCommand
|
|
35
|
+
desc "List repos in the current space"
|
|
36
|
+
|
|
37
|
+
def call(**opts)
|
|
38
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
39
|
+
handle_errors do
|
|
40
|
+
render(store.repos) do |repos|
|
|
41
|
+
if repos.empty?
|
|
42
|
+
id = store.find.fmap(&:id).value_or("(unknown space)")
|
|
43
|
+
terminal.say "No repos found in #{id}"
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
rows = repos.map { |repo| [repo.fetch("full_name", repo["name"]), repo.fetch("path", "")] }
|
|
48
|
+
terminal.say terminal.table(["Repo", "Path"], rows)
|
|
49
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class Resolve < BaseCommand
|
|
56
|
+
desc "Resolve repo refs without cloning"
|
|
57
|
+
argument :repos, type: :array, required: false, desc: "REPO [REPO...]"
|
|
58
|
+
|
|
59
|
+
def call(repos: [], **opts)
|
|
60
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
61
|
+
handle_errors do
|
|
62
|
+
specs = Array(repos).compact
|
|
63
|
+
if specs.empty?
|
|
64
|
+
terminal.error("Usage: space repo resolve REPO [REPO...]")
|
|
65
|
+
CLI.record_outcome(Outcome.new(exit_code: 1))
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
references = specs.map { |spec| Space::Core::RepoResolver.new(project_config).resolve(spec) }
|
|
70
|
+
terminal.say terminal.table(["Repo", "Clone URL"], references.map { |ref| [ref.full_name, ref.clone_url] })
|
|
71
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|