lyman 0.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.
@@ -0,0 +1,122 @@
1
+ require "open3"
2
+
3
+ module Lyman
4
+ module CLI
5
+ module Commands
6
+ # `lyman doctor` — smoke-tests a scaffolded project: manifest parses,
7
+ # every listed file exists (managed files note pristine/modified), and
8
+ # a subprocess runs the planted pipeline end to end against a stub
9
+ # transport (lib/lyman/cli/doctor_check.rb). See docs/design/
10
+ # deployment.md ("Ruby without a type checker").
11
+ class Doctor
12
+ # doctor_check.rb is CLI machinery, not a planted artifact — it always
13
+ # ships from this gem's own tree, never from a fixture source_root
14
+ # (source_root exists so other commands can point at "a newer lyman
15
+ # release"; doctor has no such concept, it only checks what's here).
16
+ CHECK_SCRIPT = File.join(Registry::GEM_ROOT, "lib", "lyman", "cli", "doctor_check.rb")
17
+
18
+ def initialize(thor, source_root:)
19
+ @thor = thor
20
+ @source_root = source_root
21
+ end
22
+
23
+ def call
24
+ project_root = Manifest.find!
25
+ manifest = load_manifest(project_root)
26
+
27
+ results = [!manifest.nil?]
28
+ if manifest
29
+ results << check_files(manifest, project_root)
30
+ results << check_pipeline(project_root)
31
+ end
32
+
33
+ exit 1 unless results.all?
34
+ end
35
+
36
+ private
37
+
38
+ # Check 1: the manifest itself parses. `Manifest.find!` already
39
+ # confirmed the file exists; the read can still blow up on malformed
40
+ # YAML, and that's worth its own ✓/✗ line rather than a raw backtrace.
41
+ def load_manifest(project_root)
42
+ manifest = Manifest.load(project_root)
43
+ report(true, "manifest parses (#{manifest.path})")
44
+ manifest
45
+ rescue => e
46
+ report(false, "manifest failed to parse: #{e.message}")
47
+ nil
48
+ end
49
+
50
+ # Check 2: every manifest-listed file exists. Managed files also get
51
+ # a pristine/modified note — modified is informational (that's
52
+ # `update`'s job to flag as a halt), not a doctor failure. Each entry
53
+ # records its own path, so this check needs no registry: even
54
+ # artifacts a future lyman release no longer knows about get checked.
55
+ def check_files(manifest, project_root)
56
+ ok = true
57
+
58
+ manifest.artifacts.each do |name, entry|
59
+ next unless entry["path"] # nothing to check without a location
60
+
61
+ dest = File.join(project_root, entry["path"])
62
+ unless File.exist?(dest)
63
+ report(false, "#{name}: planted file missing (#{dest})")
64
+ ok = false
65
+ next
66
+ end
67
+
68
+ if entry["status"] == "managed"
69
+ current_hash = Planter.hash(File.read(dest))
70
+ note = (current_hash == entry["hash"]) ? "pristine" : "modified"
71
+ report(true, "#{name}: file present (#{note})")
72
+ else
73
+ report(true, "#{name}: file present")
74
+ end
75
+ end
76
+
77
+ ok
78
+ end
79
+
80
+ # Check 3: run the planted pipeline against a stub transport, in a
81
+ # subprocess. It must be a subprocess — the client project has its
82
+ # own `Lyman::` constants (Conversation, Workers) that would collide
83
+ # with this CLI process if we required them in-process, and the
84
+ # client's bundle (not the CLI's) is what needs to resolve shifty.
85
+ def check_pipeline(project_root)
86
+ cmd = subprocess_command(project_root)
87
+ out, status = Open3.capture2e(*cmd, chdir: project_root)
88
+
89
+ # doctor_check.rb prints its own ✓/✗ verdict line; relay it
90
+ # verbatim rather than re-deriving one, so the subprocess's
91
+ # explanation of *why* it failed (if it did) reaches the user.
92
+ out.each_line { |line| @thor.say line.chomp }
93
+ status.success?
94
+ end
95
+
96
+ # A freshly scaffolded project has no Gemfile.lock until the user
97
+ # runs `bundle install` — `lyman doctor` should still work at that
98
+ # point, so it falls back to plain `ruby` with explicit `-I` load
99
+ # paths for shifty/ostruct, borrowed from the CLI process's own
100
+ # activated gems (Gem.loaded_specs — populated by Bundler when this
101
+ # CLI itself was launched via `bundle exec`/the installed gem).
102
+ # Once the client has a lock, `bundle exec` lets their own bundle
103
+ # (which might pin a different shifty version) resolve instead.
104
+ def subprocess_command(project_root)
105
+ if File.exist?(File.join(project_root, "Gemfile.lock"))
106
+ ["bundle", "exec", "ruby", CHECK_SCRIPT]
107
+ else
108
+ load_paths = %w[shifty ostruct].filter_map do |gem_name|
109
+ spec = Gem.loaded_specs[gem_name]
110
+ "#{spec.gem_dir}/lib" if spec
111
+ end
112
+ ["ruby", *load_paths.flat_map { |p| ["-I", p] }, CHECK_SCRIPT]
113
+ end
114
+ end
115
+
116
+ def report(ok, message)
117
+ @thor.say "#{ok ? "✓" : "✗"} #{message}"
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,53 @@
1
+ module Lyman
2
+ module CLI
3
+ module Commands
4
+ # `lyman eject ARTIFACT` — rewrites a managed artifact's manifest
5
+ # entry into a tombstone (status: ejected, ejected_at, pristine_hash),
6
+ # transferring ownership without deleting history. See docs/design/
7
+ # deployment.md ("Eject-to-own"). The pristine copy is left in place —
8
+ # it becomes the fork point that makes a later `lyman diff` meaningful.
9
+ class Eject
10
+ def initialize(thor, source_root:)
11
+ @thor = thor
12
+ @source_root = source_root
13
+ end
14
+
15
+ def call(artifact)
16
+ project_root = Manifest.find!
17
+ manifest = Manifest.load(project_root)
18
+ name = Registry.resolve(artifact, manifest: manifest)
19
+ entry = manifest.artifact(name)
20
+
21
+ case entry&.fetch("status", nil)
22
+ when "managed"
23
+ eject(manifest, name, entry)
24
+ when "owned"
25
+ @thor.say "#{name} is already yours; lyman never manages #{entry["path"]}."
26
+ when "ejected"
27
+ @thor.say "#{name} was already ejected at #{entry["ejected_at"]}; nothing to do."
28
+ else
29
+ raise Thor::Error, "#{name} isn't planted in this project; nothing to eject."
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ # The tombstone keeps the path: it's what lets `diff` and `doctor`
36
+ # find the fork without consulting a registry that may no longer
37
+ # know this artifact.
38
+ def eject(manifest, name, entry)
39
+ manifest.set_artifact(name, {
40
+ "status" => "ejected",
41
+ "ejected_at" => Lyman::CLI::VERSION,
42
+ "path" => entry["path"],
43
+ "pristine_hash" => entry["hash"]
44
+ })
45
+ manifest.save
46
+
47
+ @thor.say "#{name} is yours now. lyman will note upstream changes to it " \
48
+ "(run `lyman diff #{name}` to compare) but will never touch the file again."
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,72 @@
1
+ require "fileutils"
2
+
3
+ module Lyman
4
+ module CLI
5
+ module Commands
6
+ # `lyman new NAME` — the one command that doesn't need an existing
7
+ # project: it creates one, planting every registry artifact and
8
+ # writing the manifest that makes `update`/`eject`/`diff` possible
9
+ # afterward.
10
+ class New
11
+ def initialize(thor, source_root:)
12
+ @thor = thor
13
+ @source_root = source_root
14
+ end
15
+
16
+ def call(name)
17
+ project_root = File.expand_path(name)
18
+ refuse_nonempty!(project_root, name)
19
+ FileUtils.mkdir_p(project_root)
20
+
21
+ manifest = Manifest.load(project_root)
22
+ Registry::ARTIFACTS.each do |artifact_name, spec|
23
+ plant(manifest, artifact_name, spec, project_root)
24
+ end
25
+ manifest.save
26
+
27
+ epilogue(name)
28
+ end
29
+
30
+ private
31
+
32
+ def refuse_nonempty!(project_root, name)
33
+ return unless Dir.exist?(project_root)
34
+ return if Dir.empty?(project_root)
35
+
36
+ raise Thor::Error, "#{name} already exists and is not empty; " \
37
+ "choose a different name or clear the directory first."
38
+ end
39
+
40
+ # The manifest records where each artifact was planted, so every
41
+ # entry stays self-describing even if a later lyman release renames
42
+ # or drops the artifact — lifecycle commands read the path from
43
+ # here, not from the registry.
44
+ def plant(manifest, artifact_name, spec, project_root)
45
+ bytes = Planter.plant(artifact_name, spec, project_root: project_root, source_root: @source_root)
46
+ manifest.write_pristine(spec[:dest], bytes)
47
+
48
+ attrs = {
49
+ "status" => spec[:role].to_s,
50
+ "planted_at" => Lyman::CLI::VERSION,
51
+ "path" => spec[:dest]
52
+ }
53
+ attrs["hash"] = Planter.hash(bytes) if spec[:role] == :managed
54
+ manifest.set_artifact(artifact_name, attrs)
55
+ end
56
+
57
+ def epilogue(name)
58
+ @thor.say <<~EPILOGUE
59
+
60
+ Planted a new lyman project in #{name}.
61
+
62
+ Next steps:
63
+ cd #{name}
64
+ bundle install
65
+ ruby harness/chat.rb # defaults to Ollama at http://localhost:11434/v1
66
+ lyman doctor # smoke-test the pipeline
67
+ EPILOGUE
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,154 @@
1
+ module Lyman
2
+ module CLI
3
+ module Commands
4
+ # `lyman update` — walks the manifest (never the filesystem) and
5
+ # reconciles every managed artifact against the current lyman release.
6
+ # Three tiers per docs/design/deployment.md: managed artifacts either
7
+ # replace silently (pristine) or halt with a structured message
8
+ # (modified/missing); owned artifacts are the user's and are skipped;
9
+ # ejected artifacts get a one-time advisory. Artifacts not in the
10
+ # manifest are never even looked at.
11
+ class Update
12
+ def initialize(thor, source_root:)
13
+ @thor = thor
14
+ @source_root = source_root
15
+ end
16
+
17
+ def call
18
+ project_root = Manifest.find!
19
+ manifest = Manifest.load(project_root)
20
+
21
+ updated = []
22
+ up_to_date = []
23
+ halted = []
24
+ advisories = []
25
+
26
+ manifest.artifacts.each_key.to_a.each do |name|
27
+ spec = Registry::ARTIFACTS[name]
28
+ next unless spec # unknown-to-this-release entries are left alone
29
+
30
+ entry = manifest.artifact(name)
31
+ case entry["status"]
32
+ when "managed"
33
+ reconcile_managed(manifest, project_root, name, spec, entry, updated, up_to_date, halted)
34
+ when "owned"
35
+ next # the user's file; update never touches it
36
+ when "ejected"
37
+ reconcile_ejected(manifest, name, spec, entry, advisories)
38
+ end
39
+ end
40
+
41
+ manifest.save
42
+ summarize(updated, up_to_date, halted, advisories)
43
+ exit 1 unless halted.empty?
44
+ end
45
+
46
+ private
47
+
48
+ # The manifest's recorded path — not the registry's dest — says where
49
+ # this project's copy lives; the registry only says where the current
50
+ # release would plant a fresh one. When the two disagree (someone
51
+ # moved the file, or upstream relocated the artifact), that's a halt:
52
+ # update can't know which references would break by moving it.
53
+ def reconcile_managed(manifest, project_root, name, spec, entry, updated, up_to_date, halted)
54
+ path = entry["path"]
55
+ dest = File.join(project_root, path)
56
+
57
+ if spec[:dest] != path
58
+ halted << {name: name, reason: :relocated, path: path, upstream_dest: spec[:dest]}
59
+ return
60
+ end
61
+
62
+ unless File.exist?(dest)
63
+ halted << {name: name, reason: :missing, planted_at: entry["planted_at"], path: path}
64
+ return
65
+ end
66
+
67
+ current_bytes = File.read(dest)
68
+ current_hash = Planter.hash(current_bytes)
69
+
70
+ if current_hash != entry["hash"]
71
+ halted << {name: name, reason: :modified, planted_at: entry["planted_at"], path: path}
72
+ return
73
+ end
74
+
75
+ rendered = Planter.render(name, spec, source_root: @source_root)
76
+ new_hash = Planter.hash(rendered)
77
+
78
+ if new_hash == current_hash
79
+ up_to_date << name
80
+ return
81
+ end
82
+
83
+ from_version = entry["planted_at"]
84
+ Planter.plant(name, spec, project_root: project_root, source_root: @source_root)
85
+ manifest.write_pristine(path, rendered)
86
+ manifest.set_artifact(name, entry.merge(
87
+ "hash" => new_hash,
88
+ "planted_at" => Lyman::CLI::VERSION
89
+ ))
90
+ updated << {name: name, from: from_version, to: Lyman::CLI::VERSION}
91
+ end
92
+
93
+ # Ejected artifacts are never touched — the manifest just tracks
94
+ # whether upstream has moved since the fork point, and fires the
95
+ # advisory at most once per upstream version so a long-lived fork
96
+ # doesn't nag forever.
97
+ def reconcile_ejected(manifest, name, spec, entry, advisories)
98
+ rendered = Planter.render(name, spec, source_root: @source_root)
99
+ current_hash = Planter.hash(rendered)
100
+
101
+ return if current_hash == entry["pristine_hash"]
102
+ return if entry["notified"] == Lyman::CLI::VERSION
103
+
104
+ advisories << {name: name, ejected_at: entry["ejected_at"], upstream: Lyman::CLI::VERSION}
105
+ manifest.set_artifact(name, entry.merge("notified" => Lyman::CLI::VERSION))
106
+ end
107
+
108
+ def summarize(updated, up_to_date, halted, advisories)
109
+ updated.each do |u|
110
+ @thor.say "updated #{u[:name]} (#{u[:from]} → #{u[:to]})"
111
+ end
112
+
113
+ up_to_date.each do |name|
114
+ @thor.say "#{name} up to date"
115
+ end
116
+
117
+ halted.each do |h|
118
+ @thor.say(halt_message(h), :red)
119
+ end
120
+
121
+ advisories.each do |a|
122
+ @thor.say "#{a[:name]} (ejected at #{a[:ejected_at]}) has upstream changes in " \
123
+ "#{a[:upstream]}; run `lyman diff #{a[:name]}` to compare.", :yellow
124
+ end
125
+
126
+ @thor.say [
127
+ "#{updated.size} updated",
128
+ "#{up_to_date.size} up to date",
129
+ "#{halted.size} halted",
130
+ "#{advisories.size} advisories"
131
+ ].join(", ")
132
+ end
133
+
134
+ def halt_message(halted)
135
+ case halted[:reason]
136
+ when :missing
137
+ "#{halted[:name]} (planted at #{halted[:planted_at]}) is missing its planted file " \
138
+ "(#{halted[:path]}); run `lyman add #{halted[:name]} --force` to replant it."
139
+ when :modified
140
+ "#{halted[:name]} (planted at #{halted[:planted_at]}) has been modified since planting; " \
141
+ "pristine copy at .lyman/pristine/#{halted[:path]}. " \
142
+ "Run `lyman diff #{halted[:name]}` to see changes, or " \
143
+ "`lyman eject #{halted[:name]}` to take ownership."
144
+ when :relocated
145
+ "#{halted[:name]} lives at #{halted[:path]} in this project, but this lyman release " \
146
+ "plants it at #{halted[:upstream_dest]}. Move the file (and any references to it), " \
147
+ "set its `path:` in .lyman/manifest.yml to match, and rerun `lyman update` — or " \
148
+ "run `lyman eject #{halted[:name]}` to keep it where it is, as yours."
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # lyman doctor's pipeline smoke test, run as a subprocess inside the client
4
+ # project (see lib/lyman/cli/commands/doctor.rb for why it must be a
5
+ # subprocess: the client's Lyman:: constants must not collide with this CLI
6
+ # process's own, and it's the client's bundle — not the CLI's — that needs
7
+ # to resolve shifty).
8
+ #
9
+ # Wires the same circuit shape as harness/chat.rb (docs/design/
10
+ # circuit-pattern.md): a queue-backed source, the model⇄tool round, the
11
+ # finish/requeue plumbing. The only substitution is the transport: a stub
12
+ # stands in at the chat_completion seam. That's legitimate, not a mock
13
+ # reaching inside — the model transport is itself a swappable worker (see
14
+ # docs/design/deployment.md, "Ruby without a type checker"), so a stub here
15
+ # is an alternate implementation of the seam, and every other stage (tool
16
+ # execution, requeue, the finished-turn filter) still runs for real.
17
+
18
+ require "./lib/lyman"
19
+
20
+ include Shifty::DSL # standard:disable Style/MixinUsage
21
+
22
+ conversation = Lyman::Conversation.new(system_prompt: "doctor")
23
+ conversation.add_user_message("ping the tool once, then answer")
24
+ rounds = [] # the circuit's queue, shell scope — mirrors harness/chat.rb
25
+
26
+ # Stands in for Lyman::Workers.chat_completion: round 1 calls the "ping"
27
+ # tool, round 2 answers plainly. A real transport wouldn't know the round
28
+ # count in advance, but the seam only cares that something plays by the
29
+ # wire's rules — append an assistant message, bump the round counter.
30
+ call_count = 0
31
+ stub_transport = relay_worker do |c|
32
+ call_count += 1
33
+ message =
34
+ if call_count == 1
35
+ {
36
+ "role" => "assistant",
37
+ "content" => nil,
38
+ "tool_calls" => [
39
+ {
40
+ "id" => "call_1",
41
+ "type" => "function",
42
+ "function" => {"name" => "ping", "arguments" => "{}"}
43
+ }
44
+ ]
45
+ }
46
+ else
47
+ {"role" => "assistant", "content" => "done"}
48
+ end
49
+ c.add_assistant_message(message)
50
+ c.rounds += 1
51
+ c
52
+ end
53
+
54
+ pipeline =
55
+ source_worker { rounds.shift } |
56
+ stub_transport |
57
+ relay_worker { |c|
58
+ c.finish! if c.pending_tool_calls.empty? || c.runaway?
59
+ c
60
+ } |
61
+ Lyman::Workers.tool_execution({"ping" => ->(_) { "pong" }}) |
62
+ side_worker { |c| rounds << c unless c.finished? } |
63
+ filter_worker { |c| c.finished? }
64
+
65
+ # Never pull while the queue is empty (the nil footgun, CLAUDE.md) — enqueue
66
+ # the seed conversation before the first shift.
67
+ rounds << conversation
68
+ result = pipeline.shift
69
+
70
+ failures = []
71
+ failures << "conversation never finished" unless result.finished?
72
+
73
+ unless result.messages.all? { |m| m.keys.all? { |k| k.is_a?(String) } }
74
+ failures << "a message used non-string keys"
75
+ end
76
+
77
+ valid_roles = %w[system user assistant tool]
78
+ unless result.messages.all? { |m| valid_roles.include?(m["role"]) }
79
+ failures << "a message had a role outside system|user|assistant|tool"
80
+ end
81
+
82
+ tool_message = result.messages.find { |m| m["role"] == "tool" }
83
+ if tool_message.nil?
84
+ failures << "no tool result message found"
85
+ elsif tool_message["tool_call_id"] != "call_1"
86
+ failures << "tool result did not answer the expected tool call id"
87
+ elsif tool_message["content"] != "pong"
88
+ failures << "tool result content was #{tool_message["content"].inspect}, expected \"pong\""
89
+ end
90
+
91
+ if failures.empty?
92
+ puts "✓ pipeline smoke test passed (#{result.messages.size} messages, tool round-trip ok)"
93
+ exit 0
94
+ else
95
+ puts "✗ pipeline smoke test failed: #{failures.join("; ")}"
96
+ exit 1
97
+ end
@@ -0,0 +1,113 @@
1
+ require "yaml"
2
+ require "fileutils"
3
+
4
+ module Lyman
5
+ module CLI
6
+ # Reads and writes <project_root>/.lyman/manifest.yml — the record that
7
+ # makes `update` cheap (hash comparison instead of guessing) and `eject`
8
+ # meaningful (a tombstone, not a deletion). See docs/design/deployment.md.
9
+ class Manifest
10
+ RELATIVE_PATH = File.join(".lyman", "manifest.yml")
11
+ PRISTINE_DIR = File.join(".lyman", "pristine")
12
+
13
+ attr_reader :project_root
14
+
15
+ # Walks up from +start_dir+ looking for .lyman/manifest.yml, the same
16
+ # way git walks up looking for .git. Returns the project root or nil.
17
+ def self.find(start_dir = Dir.pwd)
18
+ dir = File.expand_path(start_dir)
19
+ loop do
20
+ return dir if File.exist?(File.join(dir, RELATIVE_PATH))
21
+ parent = File.dirname(dir)
22
+ break if parent == dir
23
+ dir = parent
24
+ end
25
+ nil
26
+ end
27
+
28
+ # Every command except `new` needs a project to act on; this is the one
29
+ # place that error is raised, worded for an agent to act on directly.
30
+ def self.find!(start_dir = Dir.pwd)
31
+ find(start_dir) || raise(Thor::Error, <<~MSG.strip)
32
+ Not inside a lyman project (no .lyman/manifest.yml found walking up
33
+ from #{start_dir}). Run `lyman new NAME` to create one.
34
+ MSG
35
+ end
36
+
37
+ def self.load(project_root)
38
+ new(project_root)
39
+ end
40
+
41
+ def initialize(project_root)
42
+ @project_root = project_root
43
+ @data = read
44
+ end
45
+
46
+ def path
47
+ File.join(project_root, RELATIVE_PATH)
48
+ end
49
+
50
+ def lyman_version
51
+ @data["lyman"]
52
+ end
53
+
54
+ def artifacts
55
+ @data["artifacts"] ||= {}
56
+ end
57
+
58
+ def artifact(name)
59
+ artifacts[name]
60
+ end
61
+
62
+ def set_artifact(name, attrs)
63
+ artifacts[name] = attrs
64
+ end
65
+
66
+ def delete_artifact(name)
67
+ artifacts.delete(name)
68
+ end
69
+
70
+ def save
71
+ @data["lyman"] = Lyman::CLI::VERSION
72
+ FileUtils.mkdir_p(File.dirname(path))
73
+ File.write(path, dump)
74
+ self
75
+ end
76
+
77
+ # The pristine cache mirrors the planted tree (.lyman/pristine/lib/
78
+ # lyman/conversation.rb, not a flat name) — collision-proof, and
79
+ # browsable as a shadow of exactly what was planted where.
80
+ def pristine_path(rel_path)
81
+ File.join(project_root, PRISTINE_DIR, rel_path)
82
+ end
83
+
84
+ def write_pristine(rel_path, bytes)
85
+ FileUtils.mkdir_p(File.dirname(pristine_path(rel_path)))
86
+ File.write(pristine_path(rel_path), bytes)
87
+ end
88
+
89
+ def read_pristine(rel_path)
90
+ File.read(pristine_path(rel_path))
91
+ end
92
+
93
+ def pristine?(rel_path)
94
+ File.exist?(pristine_path(rel_path))
95
+ end
96
+
97
+ private
98
+
99
+ # Stable key order (lyman, then artifacts sorted by name) so the
100
+ # manifest's own diffs stay calm across runs.
101
+ def dump
102
+ ordered = {"lyman" => @data["lyman"] || Lyman::CLI::VERSION}
103
+ ordered["artifacts"] = artifacts.sort.to_h
104
+ YAML.dump(ordered)
105
+ end
106
+
107
+ def read
108
+ return {"lyman" => Lyman::CLI::VERSION, "artifacts" => {}} unless File.exist?(path)
109
+ YAML.safe_load_file(path) || {}
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,40 @@
1
+ require "digest"
2
+ require "fileutils"
3
+
4
+ module Lyman
5
+ module CLI
6
+ # Turns a registry entry into planted bytes and writes them. Rendering
7
+ # and hashing are kept together deliberately: the hash must always be a
8
+ # hash of what actually landed on disk (banner included), never of the
9
+ # bare source file, or `update` would flag every managed artifact as
10
+ # locally modified on first run.
11
+ module Planter
12
+ BANNER = <<~RUBY
13
+ # Managed by lyman — extend, don't modify. `lyman eject %<name>s` takes
14
+ # ownership; see .lyman/manifest.yml.
15
+
16
+ RUBY
17
+
18
+ def self.render(name, spec, source_root: Registry::GEM_ROOT)
19
+ source = File.read(Registry.source_path(spec, source_root: source_root))
20
+ if spec[:role] == :managed && spec[:dest].end_with?(".rb")
21
+ format(BANNER, name: name) + source
22
+ else
23
+ source
24
+ end
25
+ end
26
+
27
+ def self.plant(name, spec, project_root:, source_root: Registry::GEM_ROOT)
28
+ bytes = render(name, spec, source_root: source_root)
29
+ dest = File.join(project_root, spec[:dest])
30
+ FileUtils.mkdir_p(File.dirname(dest))
31
+ File.write(dest, bytes)
32
+ bytes
33
+ end
34
+
35
+ def self.hash(bytes)
36
+ Digest::SHA256.hexdigest(bytes)
37
+ end
38
+ end
39
+ end
40
+ end