space-architect 1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +284 -0
- data/exe/architect +13 -0
- data/exe/space +13 -0
- data/lib/space_architect/architect_mission.rb +436 -0
- data/lib/space_architect/atomic_write.rb +21 -0
- data/lib/space_architect/cli/architect.rb +388 -0
- data/lib/space_architect/cli/config.rb +61 -0
- data/lib/space_architect/cli/current.rb +22 -0
- data/lib/space_architect/cli/helpers.rb +117 -0
- data/lib/space_architect/cli/init.rb +35 -0
- data/lib/space_architect/cli/list.rb +30 -0
- data/lib/space_architect/cli/new.rb +43 -0
- data/lib/space_architect/cli/options.rb +12 -0
- data/lib/space_architect/cli/path.rb +22 -0
- data/lib/space_architect/cli/repo.rb +88 -0
- data/lib/space_architect/cli/shell.rb +137 -0
- data/lib/space_architect/cli/show.rb +27 -0
- data/lib/space_architect/cli/space.rb +35 -0
- data/lib/space_architect/cli/src.rb +32 -0
- data/lib/space_architect/cli/status.rb +39 -0
- data/lib/space_architect/cli/use.rb +23 -0
- data/lib/space_architect/cli.rb +102 -0
- data/lib/space_architect/config.rb +152 -0
- data/lib/space_architect/dispatcher.rb +21 -0
- data/lib/space_architect/errors.rb +14 -0
- data/lib/space_architect/git_client.rb +49 -0
- data/lib/space_architect/harness.rb +168 -0
- data/lib/space_architect/mise_client.rb +37 -0
- data/lib/space_architect/repo_reference.rb +19 -0
- data/lib/space_architect/repo_resolver.rb +167 -0
- data/lib/space_architect/shell_integration.rb +438 -0
- data/lib/space_architect/slugger.rb +16 -0
- data/lib/space_architect/space.rb +110 -0
- data/lib/space_architect/space_store.rb +319 -0
- data/lib/space_architect/state.rb +86 -0
- data/lib/space_architect/templates/architect.md.erb +48 -0
- data/lib/space_architect/templates/iteration.md.erb +66 -0
- data/lib/space_architect/terminal.rb +163 -0
- data/lib/space_architect/version.rb +5 -0
- data/lib/space_architect/warnings.rb +13 -0
- data/lib/space_architect/xdg.rb +33 -0
- data/lib/space_architect.rb +26 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/clone.rb +55 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/config.rb +66 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/daemon.rb +347 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/options.rb +21 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/org.rb +200 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/repo.rb +170 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/status.rb +76 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli/sync.rb +149 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cli.rb +137 -0
- data/vendor/repo-tender/lib/space_architect/pristine/cloner.rb +75 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/contract.rb +54 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/duration.rb +79 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/model.rb +49 -0
- data/vendor/repo-tender/lib/space_architect/pristine/config/store.rb +156 -0
- data/vendor/repo-tender/lib/space_architect/pristine/forge/client.rb +31 -0
- data/vendor/repo-tender/lib/space_architect/pristine/forge/github.rb +98 -0
- data/vendor/repo-tender/lib/space_architect/pristine/launchd/agent.rb +195 -0
- data/vendor/repo-tender/lib/space_architect/pristine/launchd/plist.rb +129 -0
- data/vendor/repo-tender/lib/space_architect/pristine/log_rotator.rb +46 -0
- data/vendor/repo-tender/lib/space_architect/pristine/paths.rb +72 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/client.rb +87 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/git.rb +232 -0
- data/vendor/repo-tender/lib/space_architect/pristine/scm/status.rb +24 -0
- data/vendor/repo-tender/lib/space_architect/pristine/shell.rb +90 -0
- data/vendor/repo-tender/lib/space_architect/pristine/state/lock.rb +59 -0
- data/vendor/repo-tender/lib/space_architect/pristine/state/store.rb +140 -0
- data/vendor/repo-tender/lib/space_architect/pristine/sync/engine.rb +464 -0
- data/vendor/repo-tender/lib/space_architect/pristine/sync/repo_plan.rb +215 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/interactive_reporter.rb +280 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/json_reporter.rb +39 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/mode.rb +68 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/plain_reporter.rb +53 -0
- data/vendor/repo-tender/lib/space_architect/pristine/ui/reporter.rb +48 -0
- data/vendor/repo-tender/lib/space_architect/pristine/version.rb +7 -0
- data/vendor/repo-tender/lib/space_architect/pristine.rb +37 -0
- metadata +307 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module SpaceArchitect
|
|
6
|
+
module XDG
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def config_home(env: ENV)
|
|
10
|
+
Pathname.new(env.fetch("XDG_CONFIG_HOME", File.join(home(env: env), ".config")))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def state_home(env: ENV)
|
|
14
|
+
Pathname.new(env.fetch("XDG_STATE_HOME", File.join(home(env: env), ".local", "state")))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def home(env: ENV)
|
|
18
|
+
env.fetch("HOME", Dir.home)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def expand_user(path, env: ENV)
|
|
22
|
+
value = path.to_s
|
|
23
|
+
|
|
24
|
+
if value == "~"
|
|
25
|
+
home(env: env)
|
|
26
|
+
elsif value.start_with?("~/")
|
|
27
|
+
File.join(home(env: env), value[2..])
|
|
28
|
+
else
|
|
29
|
+
File.expand_path(value)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.unshift File.expand_path("../vendor/repo-tender/lib", __dir__)
|
|
4
|
+
require "space_architect/pristine"
|
|
5
|
+
|
|
6
|
+
require_relative "space_architect/version"
|
|
7
|
+
require_relative "space_architect/errors"
|
|
8
|
+
require_relative "space_architect/warnings"
|
|
9
|
+
SpaceArchitect::Warnings.disable_experimental!
|
|
10
|
+
require_relative "space_architect/atomic_write"
|
|
11
|
+
require_relative "space_architect/xdg"
|
|
12
|
+
require_relative "space_architect/config"
|
|
13
|
+
require_relative "space_architect/state"
|
|
14
|
+
require_relative "space_architect/slugger"
|
|
15
|
+
require_relative "space_architect/space"
|
|
16
|
+
require_relative "space_architect/repo_reference"
|
|
17
|
+
require_relative "space_architect/repo_resolver"
|
|
18
|
+
require_relative "space_architect/git_client"
|
|
19
|
+
require_relative "space_architect/mise_client"
|
|
20
|
+
require_relative "space_architect/space_store"
|
|
21
|
+
require_relative "space_architect/shell_integration"
|
|
22
|
+
require_relative "space_architect/terminal"
|
|
23
|
+
require_relative "space_architect/harness"
|
|
24
|
+
require_relative "space_architect/dispatcher"
|
|
25
|
+
require_relative "space_architect/architect_mission"
|
|
26
|
+
require_relative "space_architect/cli"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "dry/monads"
|
|
5
|
+
require "space_architect/pristine/cli"
|
|
6
|
+
require "space_architect/pristine/cli/options"
|
|
7
|
+
require "space_architect/pristine/cloner"
|
|
8
|
+
|
|
9
|
+
module SpaceArchitect::Pristine
|
|
10
|
+
module CLI
|
|
11
|
+
# `clone` command: APFS COW copy of an evergreen repo into a working dir.
|
|
12
|
+
# Resolves each NAME against config.base_dir and copies via cp -Rc.
|
|
13
|
+
# Multiple names are processed independently; a per-name failure is
|
|
14
|
+
# reported on err and does not abort the others. Exit code is 1 if any
|
|
15
|
+
# name failed, else 0.
|
|
16
|
+
class Clone < Dry::CLI::Command
|
|
17
|
+
include GlobalOptions
|
|
18
|
+
|
|
19
|
+
desc "Clone evergreen repo(s) into a working directory (APFS COW copy)"
|
|
20
|
+
argument :names, type: :array, required: true,
|
|
21
|
+
desc: "Repo name(s): bare name, owner/name, or host/owner/name"
|
|
22
|
+
option :into, default: ".",
|
|
23
|
+
desc: "Destination parent directory (default: current working directory)"
|
|
24
|
+
|
|
25
|
+
def call(names:, into: ".", plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
26
|
+
mode = UI::Mode.resolve(
|
|
27
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
28
|
+
env: CLI.env,
|
|
29
|
+
out: out
|
|
30
|
+
)
|
|
31
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
32
|
+
|
|
33
|
+
paths = CLI.make_paths
|
|
34
|
+
config = Config::Store.load(paths.config_file).success
|
|
35
|
+
cloner = Cloner.new(base_dir: config.base_dir)
|
|
36
|
+
|
|
37
|
+
any_failure = false
|
|
38
|
+
names.each do |name|
|
|
39
|
+
result = cloner.call(name: name, into: into)
|
|
40
|
+
if result.success?
|
|
41
|
+
dest = result.success
|
|
42
|
+
out.puts pastel.green("cloned: #{name} → #{dest}")
|
|
43
|
+
else
|
|
44
|
+
err.puts result.failure
|
|
45
|
+
any_failure = true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
CLI.record_outcome(Outcome.new(exit_code: any_failure ? 1 : 0))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
SpaceArchitect::Pristine::CLI::Registry.register "clone", SpaceArchitect::Pristine::CLI::Clone
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "space_architect/pristine/cli"
|
|
6
|
+
require "space_architect/pristine/ui/mode"
|
|
7
|
+
require "space_architect/pristine/cli/options"
|
|
8
|
+
|
|
9
|
+
module SpaceArchitect::Pristine
|
|
10
|
+
module CLI
|
|
11
|
+
# `config` command group: path / show.
|
|
12
|
+
module ConfigCmd
|
|
13
|
+
class Path < Dry::CLI::Command
|
|
14
|
+
include GlobalOptions
|
|
15
|
+
|
|
16
|
+
desc "Print the resolved config file path (honors $XDG_CONFIG_HOME)"
|
|
17
|
+
|
|
18
|
+
def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
19
|
+
mode = UI::Mode.resolve(
|
|
20
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
21
|
+
env: CLI.env,
|
|
22
|
+
out: out
|
|
23
|
+
)
|
|
24
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
25
|
+
|
|
26
|
+
paths = CLI.make_paths
|
|
27
|
+
out.puts pastel.cyan(paths.config_file)
|
|
28
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Show < Dry::CLI::Command
|
|
33
|
+
include GlobalOptions
|
|
34
|
+
|
|
35
|
+
desc "Print the effective (validated, defaults-applied) config as YAML"
|
|
36
|
+
|
|
37
|
+
def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
38
|
+
mode = UI::Mode.resolve(
|
|
39
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
40
|
+
env: CLI.env,
|
|
41
|
+
out: out
|
|
42
|
+
)
|
|
43
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
44
|
+
|
|
45
|
+
paths = CLI.make_paths
|
|
46
|
+
config = Config::Store.load(paths.config_file).success
|
|
47
|
+
# Emit via the store's own emit() so the format matches
|
|
48
|
+
# what `config.yaml` looks like on disk (stable key order
|
|
49
|
+
# per Slice 1's emit implementation). This makes
|
|
50
|
+
# `config show` a faithful round-trip preview.
|
|
51
|
+
out.puts pastel.cyan(Config::Store.emit(config.to_h).chomp)
|
|
52
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# `config` is the name of both our config-store module (Config) and
|
|
60
|
+
# the CLI command group. We register under a different module name
|
|
61
|
+
# (ConfigCmd) to avoid the constant clash, then alias the
|
|
62
|
+
# registration key as "config".
|
|
63
|
+
SpaceArchitect::Pristine::CLI::Registry.register "config" do |prefix|
|
|
64
|
+
prefix.register "path", SpaceArchitect::Pristine::CLI::ConfigCmd::Path
|
|
65
|
+
prefix.register "show", SpaceArchitect::Pristine::CLI::ConfigCmd::Show
|
|
66
|
+
end
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "dry/monads"
|
|
6
|
+
require "space_architect/pristine/cli"
|
|
7
|
+
require "space_architect/pristine/ui/mode"
|
|
8
|
+
require "space_architect/pristine/cli/options"
|
|
9
|
+
require "space_architect/pristine/launchd/agent"
|
|
10
|
+
require "space_architect/pristine/launchd/plist"
|
|
11
|
+
|
|
12
|
+
module SpaceArchitect::Pristine
|
|
13
|
+
module CLI
|
|
14
|
+
# `daemon` command group: install / uninstall / start / stop
|
|
15
|
+
# / restart / status. Installs a per-user launchd agent
|
|
16
|
+
# (`gui/<UID>`) that fires `repo-tender sync` on a
|
|
17
|
+
# `StartInterval`. The launchctl side is exercised ONLY
|
|
18
|
+
# through an injected command runner (slice-4 gates G2–G4);
|
|
19
|
+
# the live domain is proven by the manual real-Mac checklist
|
|
20
|
+
# in docs/gates/slice-4.md, not by these tests.
|
|
21
|
+
module Daemon
|
|
22
|
+
module Helpers
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Build a `Launchd::Agent` wired against the CLI's env
|
|
26
|
+
# seam (`CLI.env`). Tests inject a temp HOME (so
|
|
27
|
+
# `paths.launch_agents_dir` is under the temp HOME, not
|
|
28
|
+
# the real one) and a `runner:` via `Launchd::Agent.new`
|
|
29
|
+
# — the daemon commands don't accept a runner flag, so
|
|
30
|
+
# tests must use a real `Agent` (whose default `runner`
|
|
31
|
+
# only fires in an ambient Async::Task) OR stub the
|
|
32
|
+
# `Launchd::Agent` class.
|
|
33
|
+
def make_agent(uid: Process.uid, label: Launchd::Agent::DEFAULT_LABEL, runner: nil)
|
|
34
|
+
if runner
|
|
35
|
+
Launchd::Agent.new(runner: runner, uid: uid, label: label)
|
|
36
|
+
else
|
|
37
|
+
Launchd::Agent.new(uid: uid, label: label)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolve the on-disk plist path for the agent label,
|
|
42
|
+
# rooted at the env's HOME-resolved `LaunchAgents/`
|
|
43
|
+
# (per slice-4 gate G3).
|
|
44
|
+
def plist_path(paths, label)
|
|
45
|
+
File.join(paths.launch_agents_dir, "#{label}.plist")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build the plist XML by looking up the absolute paths
|
|
49
|
+
# the launchd runtime needs (mise, ruby, the bin
|
|
50
|
+
# script, the repo root, the mise.toml). These come
|
|
51
|
+
# from a few places:
|
|
52
|
+
# * `mise_bin` — `which mise` (we shell out at
|
|
53
|
+
# install-time; the result is baked into the plist
|
|
54
|
+
# as an absolute path so launchd's empty PATH
|
|
55
|
+
# doesn't matter).
|
|
56
|
+
# * `ruby_bin` — `mise exec -- which ruby` (the
|
|
57
|
+
# toolchain-resolved ruby; pinned via mise.toml).
|
|
58
|
+
# * `bin_path` — `RbConfig.ruby` + the script path
|
|
59
|
+
# (we use `__dir__` of this file's caller; for the
|
|
60
|
+
# gem install, this is `<gem>/bin/repo-tender`).
|
|
61
|
+
#
|
|
62
|
+
# In tests, we inject these via the `Resolve` object
|
|
63
|
+
# (see below) — never call out to the shell.
|
|
64
|
+
def build_plist(resolve:, config:, paths:, label:)
|
|
65
|
+
Launchd::Plist.call(
|
|
66
|
+
label: label,
|
|
67
|
+
refresh_interval: config.refresh_interval,
|
|
68
|
+
log_dir: paths.log_dir,
|
|
69
|
+
repo_root: resolve.repo_root,
|
|
70
|
+
mise_toml: resolve.mise_toml,
|
|
71
|
+
mise_bin: resolve.mise_bin,
|
|
72
|
+
ruby_bin: resolve.ruby_bin,
|
|
73
|
+
bin_path: resolve.bin_path
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
|
|
78
|
+
|
|
79
|
+
def fail_with(cmd, msg)
|
|
80
|
+
cmd.send(:err).puts msg
|
|
81
|
+
SpaceArchitect::Pristine::CLI.record_outcome(Outcome.new(exit_code: 1, message: msg))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Bundle of resolved absolute paths the plist needs.
|
|
85
|
+
# Production code resolves these via the shell (see
|
|
86
|
+
# `Resolve.detect`); tests construct one directly with
|
|
87
|
+
# known absolute paths.
|
|
88
|
+
Resolve = Data.define(:repo_root, :mise_toml, :mise_bin, :ruby_bin, :bin_path) do
|
|
89
|
+
def initialize(repo_root:, mise_toml:, mise_bin:, ruby_bin:, bin_path:)
|
|
90
|
+
super
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class Install < Dry::CLI::Command
|
|
96
|
+
include Helpers
|
|
97
|
+
include GlobalOptions
|
|
98
|
+
|
|
99
|
+
desc "Install the per-user launchd agent (writes the plist + bootstrap)"
|
|
100
|
+
|
|
101
|
+
def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
102
|
+
paths = CLI.make_paths
|
|
103
|
+
paths.ensure!
|
|
104
|
+
config = Config::Store.load(paths.config_file).success
|
|
105
|
+
|
|
106
|
+
mode = UI::Mode.resolve(
|
|
107
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
108
|
+
env: CLI.env,
|
|
109
|
+
out: out
|
|
110
|
+
)
|
|
111
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
112
|
+
|
|
113
|
+
label = Launchd::Agent::DEFAULT_LABEL
|
|
114
|
+
pp = plist_path(paths, label)
|
|
115
|
+
|
|
116
|
+
resolve = Resolve.detect(repo_root: Dir.pwd)
|
|
117
|
+
xml = build_plist(resolve: resolve, config: config, paths: paths, label: label)
|
|
118
|
+
FileUtils.mkdir_p(File.dirname(pp))
|
|
119
|
+
File.write(pp, xml)
|
|
120
|
+
|
|
121
|
+
agent = make_agent
|
|
122
|
+
result = agent.install(pp)
|
|
123
|
+
if result.failure?
|
|
124
|
+
return fail_with(self, "bootstrap failed: #{format_failure(result.failure)}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
out.puts pastel.green("installed: #{pp}")
|
|
128
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class Uninstall < Dry::CLI::Command
|
|
133
|
+
include Helpers
|
|
134
|
+
include GlobalOptions
|
|
135
|
+
|
|
136
|
+
desc "Uninstall the per-user launchd agent (bootout + remove the plist)"
|
|
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
|
+
label = Launchd::Agent::DEFAULT_LABEL
|
|
148
|
+
pp = plist_path(paths, label)
|
|
149
|
+
|
|
150
|
+
agent = make_agent
|
|
151
|
+
result = agent.uninstall
|
|
152
|
+
# CF5 (Slice 5): the Agent maps a not-loaded bootout
|
|
153
|
+
# (status 3 / "No such process") to Success — the
|
|
154
|
+
# common case at a 6h refresh interval. A non-benign
|
|
155
|
+
# bootout Failure (e.g. status 1 "Operation not
|
|
156
|
+
# permitted") still surfaces here as a real failure
|
|
157
|
+
# the operator needs to see. We still remove the
|
|
158
|
+
# plist regardless (idempotent uninstall, Slice-4
|
|
159
|
+
# gate G3): the bootout is the only best-effort step.
|
|
160
|
+
if result.failure?
|
|
161
|
+
err.puts "bootout reported: #{format_failure(result.failure)}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
if File.exist?(pp)
|
|
165
|
+
File.delete(pp)
|
|
166
|
+
out.puts pastel.green("removed plist: #{pp}")
|
|
167
|
+
else
|
|
168
|
+
out.puts pastel.yellow("plist not present: #{pp}")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
class Start < Dry::CLI::Command
|
|
176
|
+
include Helpers
|
|
177
|
+
include GlobalOptions
|
|
178
|
+
|
|
179
|
+
desc "Start the agent (bootstrap + enable)"
|
|
180
|
+
|
|
181
|
+
def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
182
|
+
mode = UI::Mode.resolve(
|
|
183
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
184
|
+
env: CLI.env,
|
|
185
|
+
out: out
|
|
186
|
+
)
|
|
187
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
188
|
+
|
|
189
|
+
paths = CLI.make_paths
|
|
190
|
+
label = Launchd::Agent::DEFAULT_LABEL
|
|
191
|
+
pp = plist_path(paths, label)
|
|
192
|
+
|
|
193
|
+
agent = make_agent
|
|
194
|
+
result = agent.start(pp)
|
|
195
|
+
if result.failure?
|
|
196
|
+
return fail_with(self, "start failed: #{format_failure(result.failure)}")
|
|
197
|
+
end
|
|
198
|
+
out.puts pastel.green("started: #{label}")
|
|
199
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
class Stop < Dry::CLI::Command
|
|
204
|
+
include Helpers
|
|
205
|
+
include GlobalOptions
|
|
206
|
+
|
|
207
|
+
desc "Stop the agent (bootout + disable)"
|
|
208
|
+
|
|
209
|
+
def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
210
|
+
mode = UI::Mode.resolve(
|
|
211
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
212
|
+
env: CLI.env,
|
|
213
|
+
out: out
|
|
214
|
+
)
|
|
215
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
216
|
+
|
|
217
|
+
label = Launchd::Agent::DEFAULT_LABEL
|
|
218
|
+
agent = make_agent
|
|
219
|
+
result = agent.stop
|
|
220
|
+
if result.failure?
|
|
221
|
+
return fail_with(self, "stop failed: #{format_failure(result.failure)}")
|
|
222
|
+
end
|
|
223
|
+
out.puts pastel.green("stopped: #{label}")
|
|
224
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
class Restart < Dry::CLI::Command
|
|
229
|
+
include Helpers
|
|
230
|
+
include GlobalOptions
|
|
231
|
+
|
|
232
|
+
desc "Restart the agent (kickstart -k)"
|
|
233
|
+
|
|
234
|
+
def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
235
|
+
mode = UI::Mode.resolve(
|
|
236
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
237
|
+
env: CLI.env,
|
|
238
|
+
out: out
|
|
239
|
+
)
|
|
240
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
241
|
+
|
|
242
|
+
label = Launchd::Agent::DEFAULT_LABEL
|
|
243
|
+
agent = make_agent
|
|
244
|
+
result = agent.restart
|
|
245
|
+
if result.failure?
|
|
246
|
+
return fail_with(self, "restart failed: #{format_failure(result.failure)}")
|
|
247
|
+
end
|
|
248
|
+
out.puts pastel.green("restarted: #{label}")
|
|
249
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
class Status < Dry::CLI::Command
|
|
254
|
+
include Helpers
|
|
255
|
+
include GlobalOptions
|
|
256
|
+
|
|
257
|
+
desc "Print the agent's loaded/running/last-exit state"
|
|
258
|
+
|
|
259
|
+
def call(plain: nil, json: nil, no_color: nil, quiet: nil, **)
|
|
260
|
+
mode = UI::Mode.resolve(
|
|
261
|
+
flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
|
|
262
|
+
env: CLI.env,
|
|
263
|
+
out: out
|
|
264
|
+
)
|
|
265
|
+
pastel = Pastel.new(enabled: mode.color)
|
|
266
|
+
|
|
267
|
+
label = Launchd::Agent::DEFAULT_LABEL
|
|
268
|
+
agent = make_agent
|
|
269
|
+
result = agent.status
|
|
270
|
+
if result.failure?
|
|
271
|
+
return fail_with(self, "status failed: #{format_failure(result.failure)}")
|
|
272
|
+
end
|
|
273
|
+
s = result.success
|
|
274
|
+
out.puts pastel.cyan("label: #{label}")
|
|
275
|
+
out.puts pastel.cyan("loaded: #{s[:loaded]}")
|
|
276
|
+
out.puts pastel.cyan("running: #{s[:running]}")
|
|
277
|
+
out.puts pastel.cyan("pid: #{s[:pid].inspect}")
|
|
278
|
+
out.puts pastel.cyan("last_exit: #{s[:last_exit].inspect}")
|
|
279
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Detect the runtime paths the plist needs. The repo-tender
|
|
287
|
+
# install path matters because the plist stores an absolute
|
|
288
|
+
# `bin_path` — that is the script launchd invokes. We resolve
|
|
289
|
+
# `bin_path` from the on-disk gem layout if we can, else fall
|
|
290
|
+
# back to the directory the daemon command was run from.
|
|
291
|
+
class SpaceArchitect::Pristine::CLI::Daemon::Helpers::Resolve
|
|
292
|
+
# @param repo_root [String] absolute path of the working directory (where mise.toml is expected)
|
|
293
|
+
# @return [Resolve]
|
|
294
|
+
def self.detect(repo_root:)
|
|
295
|
+
mise_bin = detect_mise_bin
|
|
296
|
+
ruby_bin = detect_ruby_bin(repo_root, mise_bin)
|
|
297
|
+
bin_path = detect_bin_path(repo_root)
|
|
298
|
+
mise_toml = File.join(repo_root, "mise.toml")
|
|
299
|
+
new(repo_root: repo_root, mise_toml: mise_toml, mise_bin: mise_bin, ruby_bin: ruby_bin, bin_path: bin_path)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def self.detect_mise_bin
|
|
303
|
+
path = ENV["REPO_TENDER_MISE_BIN"]
|
|
304
|
+
return path if path && !path.empty?
|
|
305
|
+
require "open3"
|
|
306
|
+
out, _e, st = Open3.capture3("which", "mise")
|
|
307
|
+
st.success? ? out.strip : "/opt/homebrew/bin/mise"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def self.detect_ruby_bin(repo_root, mise_bin)
|
|
311
|
+
path = ENV["REPO_TENDER_RUBY_BIN"]
|
|
312
|
+
return path if path && !path.empty?
|
|
313
|
+
# `mise exec -- which ruby` — but we avoid spawning in tests;
|
|
314
|
+
# production path goes through here.
|
|
315
|
+
require "open3"
|
|
316
|
+
out, _e, st = Open3.capture3(mise_bin, "exec", "--", "which", "ruby", chdir: repo_root)
|
|
317
|
+
return out.strip if st.success? && !out.strip.empty?
|
|
318
|
+
# Fall back to the system ruby (last resort).
|
|
319
|
+
out, _e, st = Open3.capture3("which", "ruby")
|
|
320
|
+
st.success? ? out.strip : "/usr/bin/ruby"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def self.detect_bin_path(repo_root)
|
|
324
|
+
path = ENV["REPO_TENDER_BIN_PATH"]
|
|
325
|
+
return path if path && !path.empty?
|
|
326
|
+
# Prefer the on-disk dev bin at `<repo_root>/bin/repo-tender`
|
|
327
|
+
# — it's what the human runs during testing, and the gem is
|
|
328
|
+
# typically not `gem install`ed in a source checkout.
|
|
329
|
+
dev = File.join(repo_root, "bin", "repo-tender")
|
|
330
|
+
return dev if File.exist?(dev)
|
|
331
|
+
# Next, an installed binary on PATH.
|
|
332
|
+
require "open3"
|
|
333
|
+
out, _e, st = Open3.capture3("which", "repo-tender")
|
|
334
|
+
return out.strip if st.success? && !out.strip.empty?
|
|
335
|
+
# Last resort: the installed gem's bin (raises if not installed).
|
|
336
|
+
Gem.bin_path("repo-tender", "repo-tender")
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
SpaceArchitect::Pristine::CLI::Registry.register "daemon" do |prefix|
|
|
341
|
+
prefix.register "install", SpaceArchitect::Pristine::CLI::Daemon::Install
|
|
342
|
+
prefix.register "uninstall", SpaceArchitect::Pristine::CLI::Daemon::Uninstall
|
|
343
|
+
prefix.register "start", SpaceArchitect::Pristine::CLI::Daemon::Start
|
|
344
|
+
prefix.register "stop", SpaceArchitect::Pristine::CLI::Daemon::Stop
|
|
345
|
+
prefix.register "restart", SpaceArchitect::Pristine::CLI::Daemon::Restart
|
|
346
|
+
prefix.register "status", SpaceArchitect::Pristine::CLI::Daemon::Status
|
|
347
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpaceArchitect::Pristine
|
|
4
|
+
module CLI
|
|
5
|
+
# Shared output mode flags. Include in any command class to add:
|
|
6
|
+
# --plain force plain text output (one line per event, ANSI-free)
|
|
7
|
+
# --json force JSON output (one object per event line, 12-factor)
|
|
8
|
+
# --no-color disable ANSI color
|
|
9
|
+
# --quiet/-q suppress non-essential human output
|
|
10
|
+
#
|
|
11
|
+
# Resolved via UI::Mode.resolve in the command's #call.
|
|
12
|
+
module GlobalOptions
|
|
13
|
+
def self.included(base)
|
|
14
|
+
base.option :plain, type: :flag, desc: "Plain text output (one line per event, no color)"
|
|
15
|
+
base.option :json, type: :flag, desc: "JSON output (one object per event line, 12-factor)"
|
|
16
|
+
base.option :no_color, type: :flag, desc: "Disable color output"
|
|
17
|
+
base.option :quiet, type: :flag, aliases: ["-q"], desc: "Suppress non-essential output"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|