space-architect 8.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +53 -243
- data/lib/space_architect/bug_report.rb +2 -1
- data/lib/space_architect/cli/architect.rb +20 -7
- data/lib/space_architect/cli.rb +9 -14
- data/lib/space_architect/session_sync/bin_path.rb +1 -1
- data/lib/space_architect/session_sync/cursor.rb +1 -1
- data/lib/space_architect/session_sync/plist.rb +1 -1
- data/lib/{space_core → space_architect}/version.rb +2 -2
- data/lib/space_architect.rb +1 -1
- data/skill/architect/SKILL.md +3 -2
- data/skill/architect-vocabulary/SKILL.md +2 -2
- metadata +18 -177
- data/exe/space +0 -13
- data/exe/src +0 -13
- data/lib/space_architect/cli/space.rb +0 -29
- data/lib/space_architect/cli/src.rb +0 -38
- data/lib/space_core/atomic_write.rb +0 -21
- data/lib/space_core/cli/base_command.rb +0 -19
- data/lib/space_core/cli/build.rb +0 -27
- data/lib/space_core/cli/config.rb +0 -49
- data/lib/space_core/cli/current.rb +0 -16
- data/lib/space_core/cli/help.rb +0 -195
- data/lib/space_core/cli/helpers.rb +0 -115
- data/lib/space_core/cli/init.rb +0 -29
- data/lib/space_core/cli/list.rb +0 -24
- data/lib/space_core/cli/loop_status.rb +0 -47
- data/lib/space_core/cli/new.rb +0 -38
- data/lib/space_core/cli/pack.rb +0 -29
- data/lib/space_core/cli/path.rb +0 -16
- data/lib/space_core/cli/repeatable_options.rb +0 -84
- data/lib/space_core/cli/repo.rb +0 -76
- data/lib/space_core/cli/run.rb +0 -44
- data/lib/space_core/cli/shell.rb +0 -125
- data/lib/space_core/cli/show.rb +0 -21
- data/lib/space_core/cli/status.rb +0 -74
- data/lib/space_core/cli/use.rb +0 -17
- data/lib/space_core/cli.rb +0 -177
- data/lib/space_core/commands.rb +0 -51
- data/lib/space_core/config.rb +0 -152
- data/lib/space_core/errors.rb +0 -14
- data/lib/space_core/git_client.rb +0 -49
- data/lib/space_core/mise_client.rb +0 -37
- data/lib/space_core/oci_builder.rb +0 -56
- data/lib/space_core/oci_packer.rb +0 -103
- data/lib/space_core/oci_runner.rb +0 -89
- data/lib/space_core/paths.rb +0 -58
- data/lib/space_core/repo_reference.rb +0 -19
- data/lib/space_core/repo_resolver.rb +0 -167
- data/lib/space_core/shell_integration.rb +0 -438
- data/lib/space_core/slugger.rb +0 -16
- data/lib/space_core/space.rb +0 -174
- data/lib/space_core/space_store.rb +0 -319
- data/lib/space_core/state.rb +0 -86
- data/lib/space_core/templates/oci/dockerfile.erb +0 -73
- data/lib/space_core/templates/oci/dockerignore.erb +0 -17
- data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
- data/lib/space_core/terminal.rb +0 -161
- data/lib/space_core/warnings.rb +0 -13
- data/lib/space_core/xdg.rb +0 -33
- data/lib/space_core.rb +0 -26
- data/lib/space_src/cli/clone.rb +0 -54
- data/lib/space_src/cli/config.rb +0 -65
- data/lib/space_src/cli/daemon.rb +0 -362
- data/lib/space_src/cli/options.rb +0 -21
- data/lib/space_src/cli/org.rb +0 -199
- data/lib/space_src/cli/repo.rb +0 -169
- data/lib/space_src/cli/shell.rb +0 -121
- data/lib/space_src/cli/status.rb +0 -75
- data/lib/space_src/cli/sync.rb +0 -161
- data/lib/space_src/cli.rb +0 -159
- data/lib/space_src/cloner.rb +0 -77
- data/lib/space_src/config/contract.rb +0 -54
- data/lib/space_src/config/duration.rb +0 -79
- data/lib/space_src/config/model.rb +0 -49
- data/lib/space_src/config/store.rb +0 -156
- data/lib/space_src/forge/client.rb +0 -31
- data/lib/space_src/forge/github.rb +0 -98
- data/lib/space_src/launchd/agent.rb +0 -195
- data/lib/space_src/launchd/plist.rb +0 -129
- data/lib/space_src/log_rotator.rb +0 -46
- data/lib/space_src/migration.rb +0 -43
- data/lib/space_src/nav.rb +0 -99
- data/lib/space_src/paths.rb +0 -72
- data/lib/space_src/scm/client.rb +0 -87
- data/lib/space_src/scm/git.rb +0 -232
- data/lib/space_src/scm/status.rb +0 -24
- data/lib/space_src/shell.rb +0 -90
- data/lib/space_src/shell_integration.rb +0 -321
- data/lib/space_src/state/lock.rb +0 -59
- data/lib/space_src/state/store.rb +0 -140
- data/lib/space_src/sync/engine.rb +0 -503
- data/lib/space_src/sync/repo_plan.rb +0 -215
- data/lib/space_src/sync/report.rb +0 -18
- data/lib/space_src/ui/interactive_reporter.rb +0 -280
- data/lib/space_src/ui/json_reporter.rb +0 -39
- data/lib/space_src/ui/mode.rb +0 -68
- data/lib/space_src/ui/plain_reporter.rb +0 -53
- data/lib/space_src/ui/reporter.rb +0 -48
- data/lib/space_src/version.rb +0 -7
- data/lib/space_src.rb +0 -38
|
@@ -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
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "dry/monads"
|
|
4
|
-
require "async"
|
|
5
|
-
require "space_src/shell"
|
|
6
|
-
require "space_src/launchd/plist"
|
|
7
|
-
|
|
8
|
-
module Space::Src
|
|
9
|
-
module Launchd
|
|
10
|
-
# launchctl wrapper. Holds an injected command runner (the
|
|
11
|
-
# real default goes through `Space::Src::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.space-src.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
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Space::Src
|
|
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 `src 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 src 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
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "time"
|
|
5
|
-
require "dry/monads"
|
|
6
|
-
|
|
7
|
-
module Space::Src
|
|
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
|