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,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/monads"
|
|
4
|
+
require "async"
|
|
5
|
+
require "space_architect/pristine/shell"
|
|
6
|
+
require "space_architect/pristine/launchd/plist"
|
|
7
|
+
|
|
8
|
+
module SpaceArchitect::Pristine
|
|
9
|
+
module Launchd
|
|
10
|
+
# launchctl wrapper. Holds an injected command runner (the
|
|
11
|
+
# real default goes through `SpaceArchitect::Pristine::Shell` inside a
|
|
12
|
+
# `Sync{}` block; tests inject a `RecordingRunner` that
|
|
13
|
+
# captures argv and returns canned output — gate G2).
|
|
14
|
+
#
|
|
15
|
+
# All public methods return `Dry::Monads::Result`. A non-zero
|
|
16
|
+
# `launchctl` exit surfaces as `Failure({argv:, stderr:,
|
|
17
|
+
# status:})` — the same shape `Shell.run` uses — NOT a
|
|
18
|
+
# raise. A non-zero `runner` exit propagates as `Failure` to
|
|
19
|
+
# the caller.
|
|
20
|
+
#
|
|
21
|
+
# Domain: every operation targets `gui/<UID>` (the user's
|
|
22
|
+
# per-GUI-session launchd domain — the conventional domain
|
|
23
|
+
# for user-installed LaunchAgents on macOS). The UID is
|
|
24
|
+
# resolved via `Process.uid` by default; tests may inject
|
|
25
|
+
# a different UID to assert the exact argv (G2).
|
|
26
|
+
class Agent
|
|
27
|
+
extend Dry::Monads[:result]
|
|
28
|
+
|
|
29
|
+
DEFAULT_LABEL = "io.github.jetpks.repo-tender.sync"
|
|
30
|
+
|
|
31
|
+
# The default real-runner. Wraps `Shell.run` in a `Sync{}`
|
|
32
|
+
# block so the Fiber-scheduler requirement is satisfied.
|
|
33
|
+
# Outside an ambient Async::Task, `Shell.run` would raise;
|
|
34
|
+
# the wrapper creates one. This is the only place the
|
|
35
|
+
# production code path touches `Shell` for launchctl.
|
|
36
|
+
class ShellRunner
|
|
37
|
+
def run(*argv, **opts)
|
|
38
|
+
Sync do |_task|
|
|
39
|
+
if opts.empty?
|
|
40
|
+
Shell.run(*argv)
|
|
41
|
+
else
|
|
42
|
+
Shell.run(*argv, **opts)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(runner: ShellRunner.new, uid: Process.uid, label: DEFAULT_LABEL)
|
|
49
|
+
@runner = runner
|
|
50
|
+
@uid = uid
|
|
51
|
+
@label = label
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
attr_reader :label
|
|
55
|
+
|
|
56
|
+
# `launchctl bootstrap gui/<UID> <abs-plist-path>`
|
|
57
|
+
def install(plist_path)
|
|
58
|
+
run("bootstrap", "gui/#{@uid}", plist_path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# `launchctl bootout gui/<UID>/<label>`
|
|
62
|
+
#
|
|
63
|
+
# Idempotency (Slice 5 / CF5): a benign bootout Failure
|
|
64
|
+
# (status 3 / "No such process" / "Could not find
|
|
65
|
+
# specified service") is mapped to **Success** —
|
|
66
|
+
# uninstalling a not-loaded agent is a no-op for the
|
|
67
|
+
# bootout step. The plist removal in the CLI command
|
|
68
|
+
# layer is independent of this result.
|
|
69
|
+
def uninstall
|
|
70
|
+
r = run("bootout", "gui/#{@uid}/#{@label}")
|
|
71
|
+
return Dry::Monads::Success("") if benign_bootout_failure?(r)
|
|
72
|
+
r
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# bootstrap the plist, then `enable` the service.
|
|
76
|
+
# Both must succeed (both 0 exit) for the operation to
|
|
77
|
+
# be a `Success`; the first failure short-circuits.
|
|
78
|
+
def start(plist_path)
|
|
79
|
+
r1 = run("bootstrap", "gui/#{@uid}", plist_path)
|
|
80
|
+
return r1 if r1.failure?
|
|
81
|
+
run("enable", "gui/#{@uid}/#{@label}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# bootout the service, then `disable` it.
|
|
85
|
+
#
|
|
86
|
+
# Idempotency (Slice 5 / CF5): a `bootout` Failure with
|
|
87
|
+
# `status == 3` ("No such process") or matching the
|
|
88
|
+
# not-loaded stderr is treated as **already not loaded**
|
|
89
|
+
# and is not propagated — the disable step still runs so
|
|
90
|
+
# the persistent `disable` override stays in place
|
|
91
|
+
# (matching the gate's recorded-argv assertion
|
|
92
|
+
# `[[bootout,…], [disable,…]]` and the
|
|
93
|
+
# "stopped" semantic). A non-benign bootout Failure
|
|
94
|
+
# (e.g. status 1 "Operation not permitted") short-
|
|
95
|
+
# circuits as before.
|
|
96
|
+
def stop
|
|
97
|
+
r1 = run("bootout", "gui/#{@uid}/#{@label}")
|
|
98
|
+
return r1 if r1.failure? && !benign_bootout_failure?(r1)
|
|
99
|
+
run("disable", "gui/#{@uid}/#{@label}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# `launchctl kickstart -k gui/<UID>/<label>` — `-k` kills
|
|
103
|
+
# the running instance first so the new one always starts.
|
|
104
|
+
def restart
|
|
105
|
+
run("kickstart", "-k", "gui/#{@uid}/#{@label}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Returns a defensive parse of `launchctl list` (the
|
|
109
|
+
# machine-readable form — `launchctl print` is documented
|
|
110
|
+
# as "not API"). We run `launchctl list` (no service
|
|
111
|
+
# target) and search the output for our label.
|
|
112
|
+
#
|
|
113
|
+
# The parser tolerates: empty output, a "Could not find"
|
|
114
|
+
# line, malformed rows, and PID values that are not
|
|
115
|
+
# integers. On any of those, we return Success(loaded:
|
|
116
|
+
# false) — the gate G4 "no raise on malformed" guarantee.
|
|
117
|
+
def status
|
|
118
|
+
result = run("list")
|
|
119
|
+
return result if result.failure?
|
|
120
|
+
parse_list(result.success)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# ----- internal: argv dispatch + list parser -----
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# CF5: a `bootout` Failure whose `status == 3` ("No such
|
|
128
|
+
# process") OR whose stderr matches the
|
|
129
|
+
# not-loaded markers is **not a real failure** — the
|
|
130
|
+
# service is simply not currently loaded, which is the
|
|
131
|
+
# common case at a 6h refresh interval. We key on
|
|
132
|
+
# `argv[1] == "bootout"` so the benign mapping is
|
|
133
|
+
# strictly scoped to bootout (bootstrap status-3
|
|
134
|
+
# remains a real Failure — gate G3 regression guard).
|
|
135
|
+
#
|
|
136
|
+
# Status 3 is the POSIX `ESRCH` errno (`launchctl error 3`
|
|
137
|
+
# → "No such process") and is the documented signal.
|
|
138
|
+
# The stderr regex is the defensive OR — `launchctl`
|
|
139
|
+
# stderr text is NOT API and may drift; we accept both
|
|
140
|
+
# observed phrasings ("No such process" from recent
|
|
141
|
+
# macOS, "Could not find specified service" from older
|
|
142
|
+
# releases / the legacy `unload` path).
|
|
143
|
+
def benign_bootout_failure?(result)
|
|
144
|
+
return false unless result.failure?
|
|
145
|
+
|
|
146
|
+
f = result.failure
|
|
147
|
+
return false unless f.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
argv = f[:argv]
|
|
150
|
+
return false unless argv.is_a?(Array) && argv[1] == "bootout"
|
|
151
|
+
|
|
152
|
+
return true if f[:status] == 3
|
|
153
|
+
stderr = f[:stderr].to_s
|
|
154
|
+
stderr.match?(/No such process|Could not find specified service/i)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Every operation is a `launchctl` subcommand — the program
|
|
158
|
+
# name must be argv[0] so the runner (real `Shell.run` →
|
|
159
|
+
# Open3) actually execs `launchctl`, not the bare subcommand.
|
|
160
|
+
def run(*argv)
|
|
161
|
+
@runner.run("launchctl", *argv)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Parses the tabular output of `launchctl list`. Each line
|
|
165
|
+
# is three tab-separated fields: PID, Status (last exit
|
|
166
|
+
# code), Label. PID is "-" if the job is not currently
|
|
167
|
+
# running. A line whose label matches ours is the row we
|
|
168
|
+
# want; everything else is ignored.
|
|
169
|
+
def parse_list(output)
|
|
170
|
+
return Dry::Monads::Success({loaded: false, running: false, last_exit: nil, pid: nil}) if output.nil? || output.empty?
|
|
171
|
+
|
|
172
|
+
output.each_line do |line|
|
|
173
|
+
fields = line.chomp.split("\t", 3)
|
|
174
|
+
next if fields.length < 3
|
|
175
|
+
pid_s, status_s, lbl = fields
|
|
176
|
+
next unless lbl == @label
|
|
177
|
+
pid = (pid_s == "-" || pid_s.nil? || pid_s.empty?) ? nil : Integer(pid_s, exception: false)
|
|
178
|
+
status = (status_s == "-" || status_s.nil? || status_s.empty?) ? nil : Integer(status_s, exception: false)
|
|
179
|
+
return Dry::Monads::Success({
|
|
180
|
+
loaded: true,
|
|
181
|
+
running: !pid.nil?,
|
|
182
|
+
pid: pid,
|
|
183
|
+
last_exit: status
|
|
184
|
+
})
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
Dry::Monads::Success({loaded: false, running: false, last_exit: nil, pid: nil})
|
|
188
|
+
rescue => e
|
|
189
|
+
# Defensive: any unexpected parse failure is reported as
|
|
190
|
+
# "unknown" — NOT a raise (gate G4).
|
|
191
|
+
Dry::Monads::Success({loaded: false, running: false, last_exit: nil, pid: nil, error: e.message})
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SpaceArchitect::Pristine
|
|
4
|
+
module Launchd
|
|
5
|
+
# Hand-rolled launchd plist emitter. The slice forbids a plist
|
|
6
|
+
# gem (PRD §2, AGENTS.md) — this class emits an XML property
|
|
7
|
+
# list as a string and is validated offline with `plutil -lint`.
|
|
8
|
+
#
|
|
9
|
+
# The plist produced here is a fixed-shape StartInterval-driven
|
|
10
|
+
# agent that:
|
|
11
|
+
# * runs `repo-tender sync` non-interactively under the
|
|
12
|
+
# repo's mise-managed Ruby (so the right toolchain is in
|
|
13
|
+
# effect without `mise activate`, which is broken
|
|
14
|
+
# non-interactively);
|
|
15
|
+
# * is classified as a Background process (lower scheduling
|
|
16
|
+
# + I/O priority — sync is a periodic background job);
|
|
17
|
+
# * writes its stdout/stderr to absolute log files under
|
|
18
|
+
# the log dir (launchd owns the redirect, the sync process
|
|
19
|
+
# rotates its own log on each run — see LogRotator);
|
|
20
|
+
# * has NO `KeepAlive` key — it is a periodic, not a daemon.
|
|
21
|
+
#
|
|
22
|
+
# The caller is responsible for passing absolute paths. We do
|
|
23
|
+
# NOT `File.expand_path` here — that would mask the caller's
|
|
24
|
+
# responsibility to pass absolute paths (the G1 / G3 gates
|
|
25
|
+
# assert that no `~` or `$HOME` appears in any value).
|
|
26
|
+
class Plist
|
|
27
|
+
# The plist's outer XML decl + DOCTYPE — required by
|
|
28
|
+
# plutil's lint and by launchd's parser.
|
|
29
|
+
HEADER = <<~XML
|
|
30
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
31
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
32
|
+
<plist version="1.0">
|
|
33
|
+
<dict>
|
|
34
|
+
XML
|
|
35
|
+
FOOTER = "</dict>\n</plist>\n"
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Emit a launchd plist for a sync job.
|
|
39
|
+
#
|
|
40
|
+
# @param label [String] The job label (must be a valid reverse-DNS string; appears as the basename of the on-disk plist).
|
|
41
|
+
# @param refresh_interval [Integer] StartInterval in seconds (must be > 0).
|
|
42
|
+
# @param log_dir [String] Absolute directory for the standard-out / standard-err logs.
|
|
43
|
+
# @param repo_root [String] Absolute path to set as WorkingDirectory (so mise finds the repo's mise.toml).
|
|
44
|
+
# @param mise_toml [String] Absolute path to mise.toml (pinned via EnvironmentVariables.MISE_CONFIG_FILE).
|
|
45
|
+
# @param mise_bin [String] Absolute path to the mise binary (ProgramArguments[0]).
|
|
46
|
+
# @param ruby_bin [String] Absolute path to the ruby to run the script under.
|
|
47
|
+
# @param bin_path [String] Absolute path to the repo-tender bin script.
|
|
48
|
+
# @return [String] The full plist XML, ready to be written to disk and `plutil -lint`-validated.
|
|
49
|
+
def call(label:, refresh_interval:, log_dir:, repo_root:, mise_toml:, mise_bin:, ruby_bin:, bin_path:)
|
|
50
|
+
raise ArgumentError, "label is required" if label.to_s.empty?
|
|
51
|
+
raise ArgumentError, "refresh_interval must be > 0" unless refresh_interval.is_a?(Integer) && refresh_interval > 0
|
|
52
|
+
%w[log_dir repo_root mise_toml mise_bin ruby_bin bin_path].each do |k|
|
|
53
|
+
v = binding.local_variable_get(k)
|
|
54
|
+
raise ArgumentError, "#{k} must be absolute (got #{v.inspect})" unless v.is_a?(String) && File.absolute_path?(v)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
out_log = File.join(log_dir, "#{label}.out.log")
|
|
58
|
+
err_log = File.join(log_dir, "#{label}.err.log")
|
|
59
|
+
|
|
60
|
+
body = +""
|
|
61
|
+
body << key("Label") << string(label) << "\n"
|
|
62
|
+
body << key("ProgramArguments") << "\n" << array([
|
|
63
|
+
mise_bin,
|
|
64
|
+
"exec",
|
|
65
|
+
"--",
|
|
66
|
+
ruby_bin,
|
|
67
|
+
bin_path,
|
|
68
|
+
"sync"
|
|
69
|
+
])
|
|
70
|
+
body << key("WorkingDirectory") << string(repo_root) << "\n"
|
|
71
|
+
body << key("EnvironmentVariables") << "\n" << dict({
|
|
72
|
+
"MISE_CONFIG_FILE" => mise_toml
|
|
73
|
+
})
|
|
74
|
+
body << key("StartInterval") << integer(refresh_interval) << "\n"
|
|
75
|
+
body << key("RunAtLoad") << boolean(true) << "\n"
|
|
76
|
+
body << key("ProcessType") << string("Background") << "\n"
|
|
77
|
+
body << key("StandardOutPath") << string(out_log) << "\n"
|
|
78
|
+
body << key("StandardErrorPath") << string(err_log) << "\n"
|
|
79
|
+
|
|
80
|
+
HEADER + body + FOOTER
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def key(name)
|
|
86
|
+
" <key>#{escape(name)}</key>\n"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# XML-escape: & must be first, then <, >, " and ' in attribute values
|
|
90
|
+
# (we only emit element text, but be safe for both).
|
|
91
|
+
def escape(s)
|
|
92
|
+
s.to_s
|
|
93
|
+
.gsub("&", "&")
|
|
94
|
+
.gsub("<", "<")
|
|
95
|
+
.gsub(">", ">")
|
|
96
|
+
.gsub("\"", """)
|
|
97
|
+
.gsub("'", "'")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def string(s)
|
|
101
|
+
" <string>#{escape(s)}</string>"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def integer(i)
|
|
105
|
+
" <integer>#{Integer(i)}</integer>"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def boolean(b)
|
|
109
|
+
" <#{b ? "true" : "false"}/>"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def array(items)
|
|
113
|
+
out = +" <array>\n"
|
|
114
|
+
items.each { |arg| out << " <string>#{escape(arg)}</string>\n" }
|
|
115
|
+
out << " </array>"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def dict(hash)
|
|
119
|
+
out = +" <dict>\n"
|
|
120
|
+
hash.each do |k, v|
|
|
121
|
+
out << " <key>#{escape(k)}</key>\n"
|
|
122
|
+
out << " <string>#{escape(v)}</string>\n"
|
|
123
|
+
end
|
|
124
|
+
out << " </dict>"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
require "dry/monads"
|
|
6
|
+
|
|
7
|
+
module SpaceArchitect::Pristine
|
|
8
|
+
# Rotates a log file when it exceeds a byte threshold. The
|
|
9
|
+
# archive's filename embeds an ISO-8601-compact timestamp
|
|
10
|
+
# (`YYYYMMDDTHHMMSSZ`) of the rotation event (the injected
|
|
11
|
+
# `now` — deterministic in tests, real `Time.now` in
|
|
12
|
+
# production). The rename is byte-for-byte — no copy, no
|
|
13
|
+
# data loss.
|
|
14
|
+
#
|
|
15
|
+
# The sync process calls this at the top of `Run#call` to
|
|
16
|
+
# rotate the two plist log paths (`<label>.out.log` and
|
|
17
|
+
# `<label>.err.log`). launchd opens those files fresh on the
|
|
18
|
+
# next spawn; the current process's inherited fd still points
|
|
19
|
+
# to the renamed file (writes succeed; the file is the
|
|
20
|
+
# archive). After the process exits, launchd re-opens a new
|
|
21
|
+
# file at the original path. This is the mechanism the spec
|
|
22
|
+
# asked for (gate G5).
|
|
23
|
+
class LogRotator
|
|
24
|
+
extend Dry::Monads[:result]
|
|
25
|
+
|
|
26
|
+
# @param log_path [String] absolute path to the log file to potentially rotate
|
|
27
|
+
# @param threshold_bytes [Integer] rotation threshold; > this many bytes ⇒ rotate
|
|
28
|
+
# @param now [Time] the timestamp to embed in the archive filename
|
|
29
|
+
# @return [Dry::Monads::Result<Hash>] Success({rotated: bool, archive_path: String|nil})
|
|
30
|
+
def self.call(log_path, threshold_bytes:, now: Time.now)
|
|
31
|
+
return Success({rotated: false, archive_path: nil, reason: "missing"}) unless File.exist?(log_path)
|
|
32
|
+
return Success({rotated: false, archive_path: nil, reason: "under_threshold"}) if File.size(log_path) <= threshold_bytes
|
|
33
|
+
|
|
34
|
+
archive = build_archive_path(log_path, now)
|
|
35
|
+
FileUtils.mv(log_path, archive)
|
|
36
|
+
Success({rotated: true, archive_path: archive, reason: "oversized"})
|
|
37
|
+
rescue => e
|
|
38
|
+
Failure({log_path: log_path, error: e.class.name, message: e.message})
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.build_archive_path(log_path, now)
|
|
42
|
+
ts = now.utc.strftime("%Y%m%dT%H%M%SZ")
|
|
43
|
+
"#{log_path}.#{ts}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "xdg"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module SpaceArchitect::Pristine
|
|
7
|
+
# XDG-aware path resolution. Honors $XDG_CONFIG_HOME / $XDG_STATE_HOME
|
|
8
|
+
# overrides (and a caller-supplied environment hash for testability);
|
|
9
|
+
# otherwise falls back to the XDG defaults (~/.config, ~/.local/state).
|
|
10
|
+
#
|
|
11
|
+
# `base_dir` is the on-disk home for the src clones
|
|
12
|
+
# ($BASE/:host/:owner/:repo). It defaults to ~/architect/src and is
|
|
13
|
+
# resolved from the config at call time (passed in as an argument here
|
|
14
|
+
# so this module owns nothing about config storage).
|
|
15
|
+
class Paths
|
|
16
|
+
APP_NAME = "repo-tender"
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_DIR = File.expand_path("~/architect/src")
|
|
19
|
+
|
|
20
|
+
def initialize(environment: ENV, base_dir: nil)
|
|
21
|
+
@environment = environment
|
|
22
|
+
@base_dir = base_dir
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def config_home = xdg.config_home.to_s
|
|
26
|
+
|
|
27
|
+
def state_home = xdg.state_home.to_s
|
|
28
|
+
|
|
29
|
+
def config_dir = File.join(config_home, APP_NAME)
|
|
30
|
+
|
|
31
|
+
def config_file = File.join(config_dir, "config.yaml")
|
|
32
|
+
|
|
33
|
+
def state_dir = File.join(state_home, APP_NAME)
|
|
34
|
+
|
|
35
|
+
def state_file = File.join(state_dir, "state.yaml")
|
|
36
|
+
|
|
37
|
+
def log_dir = File.join(state_dir, "logs")
|
|
38
|
+
|
|
39
|
+
# Per-user launchd agent directory. The user's
|
|
40
|
+
# `~/Library/LaunchAgents/` (PRD §3.2; slice-4 Lane 01). The
|
|
41
|
+
# path is resolved from the env's HOME so tests can inject a
|
|
42
|
+
# temp HOME via `Paths.new(environment:)` and assert that
|
|
43
|
+
# install/uninstall NEVER writes to the real
|
|
44
|
+
# `~/Library/LaunchAgents` (gate G3).
|
|
45
|
+
def launch_agents_dir
|
|
46
|
+
home = @environment["HOME"] || Dir.home
|
|
47
|
+
File.join(home, "Library", "LaunchAgents")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Default `base_dir` is ~/architect/src. Callers may override by passing
|
|
51
|
+
# one to the constructor (e.g. from loaded config).
|
|
52
|
+
def base_dir
|
|
53
|
+
@base_dir || DEFAULT_BASE_DIR
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Ensure the on-disk directories exist. The config file itself is
|
|
57
|
+
# optional and created lazily by Config::Store; we only ensure parent
|
|
58
|
+
# dirs. Idempotent.
|
|
59
|
+
def ensure!
|
|
60
|
+
FileUtils.mkdir_p(config_dir)
|
|
61
|
+
FileUtils.mkdir_p(state_dir)
|
|
62
|
+
FileUtils.mkdir_p(log_dir)
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def xdg
|
|
69
|
+
@xdg ||= XDG::Environment.new(environment: @environment)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/monads"
|
|
4
|
+
|
|
5
|
+
module SpaceArchitect::Pristine
|
|
6
|
+
module SCM
|
|
7
|
+
# Abstract SCM interface. The git CLI is the only implementation for
|
|
8
|
+
# now (per AGENTS.md / PRD §1), but the sync engine + tests must
|
|
9
|
+
# code to this interface so a future SCM is a drop-in.
|
|
10
|
+
#
|
|
11
|
+
# Every method returns a Dry::Monads::Result. Programmer error
|
|
12
|
+
# (e.g. nil path) is a raise; expected failures (no .git, non-fast-
|
|
13
|
+
# forward, network down) are Failure.
|
|
14
|
+
class Client
|
|
15
|
+
extend Dry::Monads[:result]
|
|
16
|
+
|
|
17
|
+
# Parse the working-tree status of a repo on disk. Implementation
|
|
18
|
+
# must read porcelain v2 (per AGENTS.md gotcha) and report a
|
|
19
|
+
# `SCM::Status` value object.
|
|
20
|
+
def status(path)
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the current branch name, or nil if HEAD is detached.
|
|
25
|
+
def current_branch(path)
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the bare remote's HEAD (e.g. "main" or "trunk"). Must
|
|
30
|
+
# work even when the default branch is not "main". May do a
|
|
31
|
+
# one-shot network call (`git remote set-head origin -a`) to
|
|
32
|
+
# refresh a stale `origin/HEAD`.
|
|
33
|
+
def default_branch(path)
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns the mtime of .git/FETCH_HEAD, or nil if absent. Treated
|
|
38
|
+
# as a freshness hint (PRD §3.3 step 4 + gate G5).
|
|
39
|
+
def last_fetch_at(path)
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# `git fetch --prune --no-tags origin`.
|
|
44
|
+
def fetch(path)
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# `git merge --ff-only origin/<default>`. Returns Failure if the
|
|
49
|
+
# local branch has diverged (left count > 0) — never resets.
|
|
50
|
+
# On Success: returns Integer commit count pulled.
|
|
51
|
+
# 0 → already up to date (no merge performed)
|
|
52
|
+
# N → fast-forwarded N commits
|
|
53
|
+
def fast_forward(path, default_branch)
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# `git clone <url> <path>`.
|
|
58
|
+
def clone(url, path)
|
|
59
|
+
raise NotImplementedError
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# `git switch <branch>`. Switches the local repo to the given
|
|
63
|
+
# branch. By default `git switch` refuses to clobber a dirty
|
|
64
|
+
# working tree (the operation is aborted on local-change loss
|
|
65
|
+
# per `man git-switch`); the engine treats that as a Failure.
|
|
66
|
+
# The engine is responsible for the upstream dirty-tree guard
|
|
67
|
+
# (per Slice 2 gate G5 / PHASE-0 ruling): the plan returns
|
|
68
|
+
# `:report_wrong_branch` / `:report_detached` for a dirty tree,
|
|
69
|
+
# so this method is only called on clean trees.
|
|
70
|
+
def switch(path, branch)
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Sync an unborn (empty) local clone. Called when the repo has no
|
|
75
|
+
# commits (`status.unborn? == true`) and the working tree is clean.
|
|
76
|
+
# Returns Success(:empty) when the remote has no branches (valid
|
|
77
|
+
# empty clone; no mutation). Returns Success(:fast_forwarded) when
|
|
78
|
+
# the remote has gained commits and the local clone was advanced to
|
|
79
|
+
# them. Returns Failure on a real network/probe error (the
|
|
80
|
+
# empty-vs-error distinction is made via `git ls-remote --heads
|
|
81
|
+
# origin`: exit 0 = definitive answer; non-zero = real error).
|
|
82
|
+
def sync_empty(path)
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|