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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +53 -243
  4. data/lib/space_architect/bug_report.rb +2 -1
  5. data/lib/space_architect/cli/architect.rb +20 -7
  6. data/lib/space_architect/cli.rb +9 -14
  7. data/lib/space_architect/session_sync/bin_path.rb +1 -1
  8. data/lib/space_architect/session_sync/cursor.rb +1 -1
  9. data/lib/space_architect/session_sync/plist.rb +1 -1
  10. data/lib/{space_core → space_architect}/version.rb +2 -2
  11. data/lib/space_architect.rb +1 -1
  12. data/skill/architect/SKILL.md +3 -2
  13. data/skill/architect-vocabulary/SKILL.md +2 -2
  14. metadata +18 -177
  15. data/exe/space +0 -13
  16. data/exe/src +0 -13
  17. data/lib/space_architect/cli/space.rb +0 -29
  18. data/lib/space_architect/cli/src.rb +0 -38
  19. data/lib/space_core/atomic_write.rb +0 -21
  20. data/lib/space_core/cli/base_command.rb +0 -19
  21. data/lib/space_core/cli/build.rb +0 -27
  22. data/lib/space_core/cli/config.rb +0 -49
  23. data/lib/space_core/cli/current.rb +0 -16
  24. data/lib/space_core/cli/help.rb +0 -195
  25. data/lib/space_core/cli/helpers.rb +0 -115
  26. data/lib/space_core/cli/init.rb +0 -29
  27. data/lib/space_core/cli/list.rb +0 -24
  28. data/lib/space_core/cli/loop_status.rb +0 -47
  29. data/lib/space_core/cli/new.rb +0 -38
  30. data/lib/space_core/cli/pack.rb +0 -29
  31. data/lib/space_core/cli/path.rb +0 -16
  32. data/lib/space_core/cli/repeatable_options.rb +0 -84
  33. data/lib/space_core/cli/repo.rb +0 -76
  34. data/lib/space_core/cli/run.rb +0 -44
  35. data/lib/space_core/cli/shell.rb +0 -125
  36. data/lib/space_core/cli/show.rb +0 -21
  37. data/lib/space_core/cli/status.rb +0 -74
  38. data/lib/space_core/cli/use.rb +0 -17
  39. data/lib/space_core/cli.rb +0 -177
  40. data/lib/space_core/commands.rb +0 -51
  41. data/lib/space_core/config.rb +0 -152
  42. data/lib/space_core/errors.rb +0 -14
  43. data/lib/space_core/git_client.rb +0 -49
  44. data/lib/space_core/mise_client.rb +0 -37
  45. data/lib/space_core/oci_builder.rb +0 -56
  46. data/lib/space_core/oci_packer.rb +0 -103
  47. data/lib/space_core/oci_runner.rb +0 -89
  48. data/lib/space_core/paths.rb +0 -58
  49. data/lib/space_core/repo_reference.rb +0 -19
  50. data/lib/space_core/repo_resolver.rb +0 -167
  51. data/lib/space_core/shell_integration.rb +0 -438
  52. data/lib/space_core/slugger.rb +0 -16
  53. data/lib/space_core/space.rb +0 -174
  54. data/lib/space_core/space_store.rb +0 -319
  55. data/lib/space_core/state.rb +0 -86
  56. data/lib/space_core/templates/oci/dockerfile.erb +0 -73
  57. data/lib/space_core/templates/oci/dockerignore.erb +0 -17
  58. data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
  59. data/lib/space_core/terminal.rb +0 -161
  60. data/lib/space_core/warnings.rb +0 -13
  61. data/lib/space_core/xdg.rb +0 -33
  62. data/lib/space_core.rb +0 -26
  63. data/lib/space_src/cli/clone.rb +0 -54
  64. data/lib/space_src/cli/config.rb +0 -65
  65. data/lib/space_src/cli/daemon.rb +0 -362
  66. data/lib/space_src/cli/options.rb +0 -21
  67. data/lib/space_src/cli/org.rb +0 -199
  68. data/lib/space_src/cli/repo.rb +0 -169
  69. data/lib/space_src/cli/shell.rb +0 -121
  70. data/lib/space_src/cli/status.rb +0 -75
  71. data/lib/space_src/cli/sync.rb +0 -161
  72. data/lib/space_src/cli.rb +0 -159
  73. data/lib/space_src/cloner.rb +0 -77
  74. data/lib/space_src/config/contract.rb +0 -54
  75. data/lib/space_src/config/duration.rb +0 -79
  76. data/lib/space_src/config/model.rb +0 -49
  77. data/lib/space_src/config/store.rb +0 -156
  78. data/lib/space_src/forge/client.rb +0 -31
  79. data/lib/space_src/forge/github.rb +0 -98
  80. data/lib/space_src/launchd/agent.rb +0 -195
  81. data/lib/space_src/launchd/plist.rb +0 -129
  82. data/lib/space_src/log_rotator.rb +0 -46
  83. data/lib/space_src/migration.rb +0 -43
  84. data/lib/space_src/nav.rb +0 -99
  85. data/lib/space_src/paths.rb +0 -72
  86. data/lib/space_src/scm/client.rb +0 -87
  87. data/lib/space_src/scm/git.rb +0 -232
  88. data/lib/space_src/scm/status.rb +0 -24
  89. data/lib/space_src/shell.rb +0 -90
  90. data/lib/space_src/shell_integration.rb +0 -321
  91. data/lib/space_src/state/lock.rb +0 -59
  92. data/lib/space_src/state/store.rb +0 -140
  93. data/lib/space_src/sync/engine.rb +0 -503
  94. data/lib/space_src/sync/repo_plan.rb +0 -215
  95. data/lib/space_src/sync/report.rb +0 -18
  96. data/lib/space_src/ui/interactive_reporter.rb +0 -280
  97. data/lib/space_src/ui/json_reporter.rb +0 -39
  98. data/lib/space_src/ui/mode.rb +0 -68
  99. data/lib/space_src/ui/plain_reporter.rb +0 -53
  100. data/lib/space_src/ui/reporter.rb +0 -48
  101. data/lib/space_src/version.rb +0 -7
  102. 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("&", "&amp;")
94
- .gsub("<", "&lt;")
95
- .gsub(">", "&gt;")
96
- .gsub("\"", "&quot;")
97
- .gsub("'", "&apos;")
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