samagotchi 0.53.0 → 0.54.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 +32 -1
- data/README.md +1 -1
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +1 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +8 -5
- data/docs/internals/engine.md +16 -7
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundle_command.rb +31 -36
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +0 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +117 -134
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +170 -178
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +9 -253
- data/lib/samagotchi/session_metrics.rb +3 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +12 -2
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -0
- data/lib/samagotchi/web/public/app.js +6 -6
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/index.html +4 -2
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +5 -3
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_html.js +10 -6
- metadata +7 -1
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "digest"
|
|
5
5
|
require_relative "../memory_paths"
|
|
6
|
-
require_relative "bundle_hook"
|
|
7
6
|
require_relative "provenance"
|
|
8
7
|
require_relative "manifest"
|
|
9
8
|
require_relative "source"
|
|
@@ -11,7 +10,7 @@ require_relative "placeholder"
|
|
|
11
10
|
require_relative "index_updater"
|
|
12
11
|
require_relative "merger"
|
|
13
12
|
require_relative "trash"
|
|
14
|
-
require_relative "
|
|
13
|
+
require_relative "asset_installer"
|
|
15
14
|
require_relative "../bundle_needs"
|
|
16
15
|
require_relative "../model_overlay"
|
|
17
16
|
|
|
@@ -19,13 +18,15 @@ module Samagotchi
|
|
|
19
18
|
module MemoryBundle
|
|
20
19
|
# Installs a bundle into a scoped memories directory.
|
|
21
20
|
#
|
|
22
|
-
# Flow:
|
|
23
|
-
# 1. Normalize source → directory
|
|
24
|
-
# 2.
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
21
|
+
# Flow (#run):
|
|
22
|
+
# 1. Normalize source → directory, read manifest.yml (load_source)
|
|
23
|
+
# 2. Copy .md files to the target scope dir and update index.md
|
|
24
|
+
# (install_memories: plain, dry run, or an upgrade's 3-way merge)
|
|
25
|
+
# 3. Hooks, guardrail rules, plugin, scripts into .bundles/<name>/
|
|
26
|
+
# (AssetInstaller)
|
|
27
|
+
# 4. An upgrade prunes the files the bundle dropped
|
|
28
|
+
# 5. Verify checksums (strict mode), warn about missing needs
|
|
29
|
+
# 6. Write provenance (.bundles/<name>/manifest.json, write_provenance)
|
|
29
30
|
# 7. Detect and report {{placeholders}}
|
|
30
31
|
class Installer
|
|
31
32
|
class InstallError < StandardError; end
|
|
@@ -52,8 +53,10 @@ module Samagotchi
|
|
|
52
53
|
# none moved).
|
|
53
54
|
def trash_dir = @trash.dir
|
|
54
55
|
|
|
55
|
-
# The normalized source dir
|
|
56
|
-
#
|
|
56
|
+
# The normalized source dir. An owned one (a git/zip/tar extract) is
|
|
57
|
+
# removed after #run, or kept until #cleanup_source! when an upgrade
|
|
58
|
+
# has conflicts (the agent step reads the incoming files); nil once
|
|
59
|
+
# cleaned.
|
|
57
60
|
attr_reader :source_dir
|
|
58
61
|
|
|
59
62
|
# Removes the normalized source dir an owned source (git/zip/tar) was
|
|
@@ -61,394 +64,49 @@ module Samagotchi
|
|
|
61
64
|
# conflict prompt and resolve_conflicts can still read the incoming
|
|
62
65
|
# files; the caller runs it once the agent step is done. Idempotent.
|
|
63
66
|
def cleanup_source!
|
|
64
|
-
return unless @defer_cleanup && @
|
|
67
|
+
return unless @defer_cleanup && @source_dir
|
|
65
68
|
|
|
66
|
-
SourceNormalizer.cleanup(@
|
|
67
|
-
@
|
|
69
|
+
SourceNormalizer.cleanup(@source_dir)
|
|
70
|
+
@source_dir = nil
|
|
68
71
|
@defer_cleanup = false
|
|
69
72
|
end
|
|
70
73
|
|
|
71
74
|
def run
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
normalized_dir, source_owned, source_commit = SourceNormalizer.normalize(@source)
|
|
78
|
-
@bundle_dir = normalized_dir
|
|
79
|
-
@normalized_dir = normalized_dir
|
|
80
|
-
manifest = Manifest.read(dir: normalized_dir)
|
|
81
|
-
rescue SourceNormalizer::UnknownSourceError => e
|
|
82
|
-
raise InstallError, "Source normalization failed: #{e.message}"
|
|
83
|
-
rescue Manifest::ValidationError => e
|
|
84
|
-
raise InstallError, e.message if @strict
|
|
85
|
-
|
|
86
|
-
@warnings << "No manifest found — proceeding without strict manifest validation"
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Determine target scope (CLI wins over manifest).
|
|
90
|
-
cli_scope = @scope.to_s.strip.empty? ? nil : @scope
|
|
91
|
-
target_scope = cli_scope || manifest&.scope || "system"
|
|
92
|
-
target_dir = MemoryPaths.scope_dir(target_scope) or raise InstallError, "invalid scope: #{target_scope}"
|
|
93
|
-
FileUtils.mkdir_p(target_dir)
|
|
94
|
-
|
|
95
|
-
# Copy .md files and update index for each.
|
|
96
|
-
@file_descriptions = manifest&.file_descriptions || {}
|
|
97
|
-
all_files_in_bundle = []
|
|
98
|
-
provenance = Provenance.new(name: @name)
|
|
99
|
-
existing_provenance = provenance.read
|
|
75
|
+
load_source
|
|
76
|
+
@target_scope, @target_dir = resolve_target
|
|
77
|
+
@file_descriptions = @manifest&.file_descriptions || {}
|
|
78
|
+
@provenance = Provenance.new(name: @name)
|
|
79
|
+
@existing = @provenance.record
|
|
100
80
|
# A plain install over an installed bundle: summary points at upgrade.
|
|
101
|
-
@reinstall =
|
|
102
|
-
|
|
103
|
-
# wrote: these, from an earlier install, and the ones written now.
|
|
104
|
-
# A same-name file that was already there is the user's: skipped
|
|
105
|
-
# and never recorded.
|
|
106
|
-
owned_before = existing_provenance && existing_provenance[:files].is_a?(Hash) ? existing_provenance[:files].keys.map(&:to_s) : []
|
|
107
|
-
owned = []
|
|
108
|
-
|
|
109
|
-
Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
|
|
110
|
-
file_key = File.basename(file_path)
|
|
111
|
-
all_files_in_bundle << file_key
|
|
112
|
-
note_overlay(file_key, target_dir)
|
|
113
|
-
target_path = File.join(target_dir, file_key)
|
|
114
|
-
|
|
115
|
-
if @upgrade && existing_provenance && !@force && File.exist?(target_path) && !owned_before.include?(file_key)
|
|
116
|
-
# Not the bundle's (the user's, or another bundle's): no merge.
|
|
117
|
-
skip_existing(file_key, file_path, target_path, target_scope)
|
|
118
|
-
elsif @upgrade && existing_provenance && !@force && !@dry_run
|
|
119
|
-
# 3-way merge path for upgrades
|
|
120
|
-
owned << file_key
|
|
121
|
-
base_path = provenance.base_path(file_key)
|
|
122
|
-
classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
|
|
123
|
-
case classification
|
|
124
|
-
when :install
|
|
125
|
-
FileUtils.cp(file_path, target_path)
|
|
126
|
-
@results[file_key] = { status: "installed" }
|
|
127
|
-
update_target_index(target_scope, target_path, file_key)
|
|
128
|
-
when :fast_forward
|
|
129
|
-
FileUtils.cp(file_path, target_path)
|
|
130
|
-
@results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
|
|
131
|
-
update_target_index(target_scope, target_path, file_key)
|
|
132
|
-
when :keep
|
|
133
|
-
@results[file_key] = { status: "kept", reason: "bundle unchanged, local edits preserved" }
|
|
134
|
-
update_target_index(target_scope, target_path, file_key)
|
|
135
|
-
when :noop
|
|
136
|
-
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
137
|
-
update_target_index(target_scope, target_path, file_key)
|
|
138
|
-
when :conflict
|
|
139
|
-
@results[file_key] = { status: "conflict", reason: "local edits conflict with bundle update" }
|
|
140
|
-
@conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path }
|
|
141
|
-
@warnings << "Conflict in #{file_key}: local edits conflict with bundle update (use --force to overwrite or resolve interactively)"
|
|
142
|
-
update_target_index(target_scope, target_path, file_key)
|
|
143
|
-
end
|
|
144
|
-
elsif @dry_run
|
|
145
|
-
# Dry-run: classify but do not write
|
|
146
|
-
if File.exist?(target_path) && !@force
|
|
147
|
-
if @upgrade && existing_provenance
|
|
148
|
-
base_path = provenance.base_path(file_key)
|
|
149
|
-
classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
|
|
150
|
-
@results[file_key] = { status: classification.to_s }
|
|
151
|
-
@conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
|
|
152
|
-
elsif FileUtils.identical?(file_path, target_path)
|
|
153
|
-
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
154
|
-
else
|
|
155
|
-
@results[file_key] = { status: "would_skip" }
|
|
156
|
-
end
|
|
157
|
-
else
|
|
158
|
-
@results[file_key] = { status: "would_install" }
|
|
159
|
-
end
|
|
160
|
-
elsif File.exist?(target_path) && !@force
|
|
161
|
-
# A re-install keeps what an earlier install wrote.
|
|
162
|
-
owned << file_key if owned_before.include?(file_key)
|
|
163
|
-
skip_existing(file_key, file_path, target_path, target_scope, owned: owned_before.include?(file_key))
|
|
164
|
-
else
|
|
165
|
-
FileUtils.cp(file_path, target_path) unless @dry_run
|
|
166
|
-
owned << file_key
|
|
167
|
-
@results[file_key] = { status: "installed" }
|
|
168
|
-
# Update index for newly installed files.
|
|
169
|
-
update_target_index(target_scope, target_path, file_key) unless @dry_run
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# ── Hooks: copy hooks/*.rb into <bundle_dir>/hooks/ ───────────────
|
|
174
|
-
hooks_target = File.join(provenance.bundle_dir, "hooks")
|
|
175
|
-
all_hooks_in_bundle = []
|
|
176
|
-
hooks_files_for_provenance = {}
|
|
177
|
-
if manifest
|
|
178
|
-
hook_entries = manifest.hooks
|
|
179
|
-
# Fallback: if manifest has no hooks but source has hooks/*.rb, discover them
|
|
180
|
-
if hook_entries.empty?
|
|
181
|
-
discovered = Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).map { |p| File.basename(p) }
|
|
182
|
-
discovered.each do |bn|
|
|
183
|
-
# Build minimal metadata so copy still happens (event unknown -> skipped by loader but copied for integrity)
|
|
184
|
-
hook_entries[bn] = BundleHook.new
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
hook_entries.each do |basename, _meta|
|
|
189
|
-
next unless basename.is_a?(String) && !basename.empty?
|
|
190
|
-
|
|
191
|
-
all_hooks_in_bundle << basename
|
|
192
|
-
src = File.join(normalized_dir, "hooks", basename)
|
|
193
|
-
next unless File.exist?(src)
|
|
194
|
-
|
|
195
|
-
dest = File.join(hooks_target, basename)
|
|
196
|
-
|
|
197
|
-
if @dry_run
|
|
198
|
-
@results[basename] = if File.exist?(dest) && !@force
|
|
199
|
-
{ status: "would_skip" }
|
|
200
|
-
else
|
|
201
|
-
{ status: "would_install" }
|
|
202
|
-
end
|
|
203
|
-
next
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
# Local-edit detection for upgrade path (overwrite + warn)
|
|
207
|
-
if @upgrade && existing_provenance && File.exist?(dest) && !@force
|
|
208
|
-
prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
|
|
209
|
-
if prev_meta
|
|
210
|
-
prev_sha = BundleHook.parse(prev_meta).sha256
|
|
211
|
-
if File.exist?(dest) && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
|
|
212
|
-
@warnings << "Hook #{basename} was locally modified; overwriting"
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
FileUtils.mkdir_p(hooks_target)
|
|
218
|
-
unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
|
|
219
|
-
FileUtils.cp(src, dest)
|
|
220
|
-
@results[basename] = if unchanged
|
|
221
|
-
{ status: "skipped", reason: "already up to date" }
|
|
222
|
-
elsif @upgrade && existing_provenance
|
|
223
|
-
{ status: "updated" }
|
|
224
|
-
else
|
|
225
|
-
{ status: "installed" }
|
|
226
|
-
end
|
|
227
|
-
hooks_files_for_provenance[basename] = dest
|
|
228
|
-
end
|
|
229
|
-
else
|
|
230
|
-
# No manifest (non-strict): still copy any hooks/*.rb if present
|
|
231
|
-
Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).each do |src|
|
|
232
|
-
basename = File.basename(src)
|
|
233
|
-
all_hooks_in_bundle << basename
|
|
234
|
-
dest = File.join(hooks_target, basename)
|
|
235
|
-
if @dry_run
|
|
236
|
-
@results[basename] = { status: "would_install" }
|
|
237
|
-
else
|
|
238
|
-
FileUtils.mkdir_p(hooks_target)
|
|
239
|
-
FileUtils.cp(src, dest)
|
|
240
|
-
@results[basename] = { status: "installed" }
|
|
241
|
-
hooks_files_for_provenance[basename] = dest
|
|
242
|
-
end
|
|
243
|
-
end
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
# Prune stale hooks (removed from bundle) — reuse bases pruning via provenance but also remove files
|
|
247
|
-
if @upgrade && existing_provenance && existing_provenance[:hooks] && !@dry_run
|
|
248
|
-
existing_provenance[:hooks].keys.each do |old_key|
|
|
249
|
-
old_key_str = old_key.to_s
|
|
250
|
-
next if all_hooks_in_bundle.include?(old_key_str)
|
|
251
|
-
|
|
252
|
-
old_dest = File.join(hooks_target, old_key_str)
|
|
253
|
-
if File.exist?(old_dest) && !@force
|
|
254
|
-
@warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
|
|
255
|
-
end
|
|
256
|
-
FileUtils.rm_f(old_dest)
|
|
257
|
-
end
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
# Verify hook checksums in strict mode (mirror .md block)
|
|
261
|
-
if manifest && @strict
|
|
262
|
-
all_hooks_in_bundle.each do |basename|
|
|
263
|
-
next if @conflicts.key?(basename)
|
|
264
|
-
|
|
265
|
-
expected = manifest.checksum_for_hook(basename)
|
|
266
|
-
next unless expected
|
|
267
|
-
|
|
268
|
-
dest = File.join(hooks_target, basename)
|
|
269
|
-
next unless File.exist?(dest)
|
|
270
|
-
|
|
271
|
-
actual = Digest::SHA256.hexdigest(File.read(dest))
|
|
272
|
-
if actual != expected
|
|
273
|
-
@warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
274
|
-
end
|
|
275
|
-
end
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
# ── Guardrail rules: guardrails/*.yml into <bundle_dir>/guardrails/ ─
|
|
279
|
-
# The bundle's set replaces what an earlier version installed.
|
|
280
|
-
guardrails_target = provenance.guardrails_dir
|
|
281
|
-
guardrail_files_for_provenance = {}
|
|
282
|
-
incoming_rules = Dir.glob(File.join(normalized_dir, "guardrails", "*.yml")).sort
|
|
283
|
-
if @dry_run
|
|
284
|
-
incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
|
|
285
|
-
else
|
|
286
|
-
# Same bytes as installed: reported as up to date, not "Installed".
|
|
287
|
-
unchanged = incoming_rules.select do |src|
|
|
288
|
-
dest = File.join(guardrails_target, File.basename(src))
|
|
289
|
-
File.file?(dest) && FileUtils.identical?(src, dest)
|
|
290
|
-
end
|
|
291
|
-
FileUtils.rm_rf(guardrails_target)
|
|
292
|
-
unless incoming_rules.empty?
|
|
293
|
-
FileUtils.mkdir_p(guardrails_target)
|
|
294
|
-
incoming_rules.each do |src|
|
|
295
|
-
dest = File.join(guardrails_target, File.basename(src))
|
|
296
|
-
FileUtils.cp(src, dest)
|
|
297
|
-
@results["guardrails/#{File.basename(src)}"] =
|
|
298
|
-
unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
|
|
299
|
-
guardrail_files_for_provenance[File.basename(src)] = dest
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
end
|
|
81
|
+
@reinstall = @existing && !@upgrade && !@force && !@dry_run
|
|
82
|
+
install_memories
|
|
303
83
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
warn_hooks_requires_chi(manifest, all_hooks_in_bundle)
|
|
310
|
-
warn_rules_requires_chi(manifest, incoming_rules)
|
|
84
|
+
installed = AssetInstaller.new(
|
|
85
|
+
name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
|
|
86
|
+
results: @results, warnings: @warnings, conflicts: @conflicts,
|
|
87
|
+
force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run
|
|
88
|
+
).run
|
|
311
89
|
|
|
312
90
|
# Files the previous version shipped and this one doesn't.
|
|
313
|
-
if @upgrade &&
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if manifest && @strict
|
|
319
|
-
all_files_in_bundle.each do |file_key|
|
|
320
|
-
next if @conflicts.key?(file_key)
|
|
321
|
-
|
|
322
|
-
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
323
|
-
# skipped: not copied, so a local edit isn't a bad download.
|
|
324
|
-
next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
|
|
325
|
-
|
|
326
|
-
# For dry-run, fast_forward would appear as "fast_forward", updated not yet
|
|
327
|
-
target_path = File.join(target_dir, file_key)
|
|
328
|
-
next unless File.exist?(target_path)
|
|
329
|
-
|
|
330
|
-
expected = manifest.checksum_for(file_key)
|
|
331
|
-
next unless expected
|
|
332
|
-
|
|
333
|
-
actual = Digest::SHA256.hexdigest(File.read(target_path))
|
|
334
|
-
if actual != expected
|
|
335
|
-
@warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
336
|
-
end
|
|
337
|
-
end
|
|
91
|
+
prune_dropped_files(@existing.files, @bundle_md, @target_dir, @target_scope, @provenance) if @upgrade && @existing
|
|
92
|
+
verify_memory_checksums if @manifest && @strict
|
|
93
|
+
warn_missing_needs(@manifest) if @manifest
|
|
94
|
+
if @manifest && !@dry_run
|
|
95
|
+
write_provenance(installed)
|
|
338
96
|
end
|
|
97
|
+
detect_placeholders
|
|
339
98
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
# Write provenance (only if we had a manifest and not dry_run). An
|
|
343
|
-
# upgrade with conflicts writes it too: the new version, the plugin's
|
|
344
|
-
# and hooks' new shas, and the old base for each conflicted file,
|
|
345
|
-
# marked conflict (chi bundle status shows it). Skipping it left the
|
|
346
|
-
# replaced plugin failing its sha check.
|
|
347
|
-
if manifest && !@dry_run
|
|
348
|
-
provenance_files = {}
|
|
349
|
-
if @upgrade && existing_provenance
|
|
350
|
-
owned.each do |file_key|
|
|
351
|
-
target_path = File.join(target_dir, file_key)
|
|
352
|
-
next unless File.exist?(target_path)
|
|
353
|
-
|
|
354
|
-
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
355
|
-
if %w[kept noop conflict kept_pruned].include?(status)
|
|
356
|
-
# Keep old base snapshot — do not overwrite with edited current
|
|
357
|
-
# Use base snapshot as source if it exists to preserve old checksum
|
|
358
|
-
base_path = provenance.base_path(file_key)
|
|
359
|
-
provenance_files[file_key] = if File.exist?(base_path)
|
|
360
|
-
# Keep existing provenance entry by re-using base content
|
|
361
|
-
# We still need to include it to prevent pruning, but with base content
|
|
362
|
-
base_path
|
|
363
|
-
else
|
|
364
|
-
target_path
|
|
365
|
-
end
|
|
366
|
-
else
|
|
367
|
-
# installed, updated, skipped — use current target (which is incoming for updated)
|
|
368
|
-
provenance_files[file_key] = target_path
|
|
369
|
-
end
|
|
370
|
-
end
|
|
371
|
-
# Handle pruned but kept files (bundle removed file but local kept)
|
|
372
|
-
if existing_provenance[:files]
|
|
373
|
-
existing_provenance[:files].keys.each do |old_key|
|
|
374
|
-
old_key_str = old_key.to_s
|
|
375
|
-
next if all_files_in_bundle.include?(old_key_str)
|
|
376
|
-
|
|
377
|
-
# If it was kept_pruned, include it with base content to prevent pruning
|
|
378
|
-
next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
|
|
379
|
-
|
|
380
|
-
base_path = provenance.base_path(old_key_str)
|
|
381
|
-
target_path = File.join(target_dir, old_key_str)
|
|
382
|
-
# Use base if exists to keep old checksum, else target
|
|
383
|
-
src = File.exist?(base_path) ? base_path : target_path
|
|
384
|
-
provenance_files[old_key_str] = src if File.exist?(src)
|
|
385
|
-
end
|
|
386
|
-
end
|
|
387
|
-
else
|
|
388
|
-
owned.each do |file_key|
|
|
389
|
-
target_path = File.join(target_dir, file_key)
|
|
390
|
-
next unless File.exist?(target_path)
|
|
391
|
-
|
|
392
|
-
# Skipped for a local edit: keep the installed base, so the
|
|
393
|
-
# next upgrade still sees the edit (not a fast-forward over it).
|
|
394
|
-
base_path = provenance.base_path(file_key)
|
|
395
|
-
kept = existing_provenance && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
|
|
396
|
-
provenance_files[file_key] = kept ? base_path : target_path
|
|
397
|
-
end
|
|
398
|
-
end
|
|
399
|
-
# Build hooks metadata for provenance
|
|
400
|
-
hooks_for_provenance = {}
|
|
401
|
-
if manifest && manifest.hooks && !manifest.hooks.empty?
|
|
402
|
-
hooks_for_provenance = manifest.hooks
|
|
403
|
-
elsif hooks_files_for_provenance.any?
|
|
404
|
-
hooks_files_for_provenance.each do |basename, path|
|
|
405
|
-
next unless File.exist?(path)
|
|
406
|
-
|
|
407
|
-
hooks_for_provenance[basename] = BundleHook.of_file(path)
|
|
408
|
-
end
|
|
409
|
-
end
|
|
410
|
-
Provenance.new(name: @name).write(
|
|
411
|
-
files: provenance_files,
|
|
412
|
-
scope: target_scope,
|
|
413
|
-
version: manifest.version,
|
|
414
|
-
source_path: @source,
|
|
415
|
-
hooks: hooks_for_provenance,
|
|
416
|
-
trust_level: manifest.trust_level,
|
|
417
|
-
source_commit: source_commit,
|
|
418
|
-
hooks_files: hooks_files_for_provenance,
|
|
419
|
-
guardrails_files: guardrail_files_for_provenance,
|
|
420
|
-
plugin_file: plugin_file_for_provenance,
|
|
421
|
-
requires_chi: manifest.requires_chi,
|
|
422
|
-
needs: manifest.needs,
|
|
423
|
-
conflicts: @conflicts.keys,
|
|
424
|
-
scripts_files: scripts_for_provenance,
|
|
425
|
-
context_providers: manifest.context_providers,
|
|
426
|
-
file_descriptions: manifest.file_descriptions
|
|
427
|
-
)
|
|
428
|
-
end
|
|
429
|
-
|
|
430
|
-
# Detect placeholders.
|
|
431
|
-
all_files_in_bundle.each do |file_key|
|
|
432
|
-
file_path = File.join(target_dir, file_key)
|
|
433
|
-
next unless File.exist?(file_path)
|
|
434
|
-
|
|
435
|
-
names = Placeholder.detect_in_file(file_path)
|
|
436
|
-
if names.any?
|
|
437
|
-
@placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}"
|
|
438
|
-
end
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
[normalized_dir, manifest]
|
|
99
|
+
[@bundle_dir, @manifest]
|
|
442
100
|
ensure
|
|
443
101
|
# An owned source (git/zip/tar) is cleaned up here, unless an upgrade
|
|
444
102
|
# kept conflicts: the agent step still needs the incoming files, so
|
|
445
103
|
# the caller runs #cleanup_source! when it is done.
|
|
446
|
-
if source_owned
|
|
104
|
+
if @source_owned
|
|
447
105
|
if @conflicts.any? && !@dry_run
|
|
448
106
|
@defer_cleanup = true
|
|
449
107
|
else
|
|
450
|
-
SourceNormalizer.cleanup(
|
|
451
|
-
@
|
|
108
|
+
SourceNormalizer.cleanup(@source_dir)
|
|
109
|
+
@source_dir = nil
|
|
452
110
|
end
|
|
453
111
|
end
|
|
454
112
|
end
|
|
@@ -483,6 +141,217 @@ module Samagotchi
|
|
|
483
141
|
|
|
484
142
|
private
|
|
485
143
|
|
|
144
|
+
# The source normalized to a dir (@bundle_dir; an owned one, extracted
|
|
145
|
+
# from git/zip/tar, is cleaned up after #run) and its manifest.yml
|
|
146
|
+
# (@manifest, nil when it has none and the install isn't strict).
|
|
147
|
+
def load_source
|
|
148
|
+
@bundle_dir, @source_owned, @source_commit = SourceNormalizer.normalize(@source)
|
|
149
|
+
@source_dir = @bundle_dir
|
|
150
|
+
@manifest = Manifest.read(dir: @bundle_dir)
|
|
151
|
+
rescue SourceNormalizer::UnknownSourceError => e
|
|
152
|
+
raise InstallError, "Source normalization failed: #{e.message}"
|
|
153
|
+
rescue Manifest::ValidationError => e
|
|
154
|
+
raise InstallError, e.message if @strict
|
|
155
|
+
|
|
156
|
+
@warnings << "No manifest found — proceeding without strict manifest validation"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The scope the files go to (the CLI's wins over the manifest's) and
|
|
160
|
+
# its dir, created.
|
|
161
|
+
# @return [Array(String, String)] scope, dir
|
|
162
|
+
def resolve_target
|
|
163
|
+
cli_scope = @scope.to_s.strip.empty? ? nil : @scope
|
|
164
|
+
scope = cli_scope || @manifest&.scope || "system"
|
|
165
|
+
dir = MemoryPaths.scope_dir(scope) or raise InstallError, "invalid scope: #{scope}"
|
|
166
|
+
FileUtils.mkdir_p(dir)
|
|
167
|
+
[scope, dir]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Copies the bundle's *.md files into the target dir and refreshes
|
|
171
|
+
# their index lines: an upgrade merges (merge_memory), a dry run only
|
|
172
|
+
# classifies (preview_memory), a plain install writes the new ones.
|
|
173
|
+
# Sets @bundle_md (every file the bundle ships) and @owned: the bundle
|
|
174
|
+
# owns (records, upgrades, removes) only the files it wrote, these
|
|
175
|
+
# from an earlier install and the ones written now. A same-name file
|
|
176
|
+
# that was already there is the user's: skipped and never recorded.
|
|
177
|
+
def install_memories
|
|
178
|
+
owned_before = @existing ? @existing.files.keys : []
|
|
179
|
+
@bundle_md = []
|
|
180
|
+
@owned = []
|
|
181
|
+
Dir.glob(File.join(@bundle_dir, "*.md")).each do |file_path|
|
|
182
|
+
file_key = File.basename(file_path)
|
|
183
|
+
@bundle_md << file_key
|
|
184
|
+
note_overlay(file_key, @target_dir)
|
|
185
|
+
target_path = File.join(@target_dir, file_key)
|
|
186
|
+
merging = @upgrade && @existing && !@force
|
|
187
|
+
|
|
188
|
+
if merging && File.exist?(target_path) && !owned_before.include?(file_key)
|
|
189
|
+
# Not the bundle's (the user's, or another bundle's): no merge.
|
|
190
|
+
skip_existing(file_key, file_path, target_path, @target_scope)
|
|
191
|
+
elsif merging && !@dry_run
|
|
192
|
+
@owned << file_key
|
|
193
|
+
merge_memory(file_key, file_path, target_path)
|
|
194
|
+
elsif @dry_run
|
|
195
|
+
preview_memory(file_key, file_path, target_path)
|
|
196
|
+
elsif File.exist?(target_path) && !@force
|
|
197
|
+
# A re-install keeps what an earlier install wrote.
|
|
198
|
+
@owned << file_key if owned_before.include?(file_key)
|
|
199
|
+
skip_existing(file_key, file_path, target_path, @target_scope, owned: owned_before.include?(file_key))
|
|
200
|
+
else
|
|
201
|
+
FileUtils.cp(file_path, target_path)
|
|
202
|
+
@owned << file_key
|
|
203
|
+
@results[file_key] = { status: "installed" }
|
|
204
|
+
update_target_index(@target_scope, target_path, file_key)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# An upgrade's 3-way merge of one file (Merger.classify against the
|
|
210
|
+
# base the last install recorded); a conflict leaves the local file
|
|
211
|
+
# and is recorded in @conflicts for the agent step.
|
|
212
|
+
def merge_memory(file_key, file_path, target_path)
|
|
213
|
+
base_path = @provenance.base_path(file_key)
|
|
214
|
+
case Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
|
|
215
|
+
when :install
|
|
216
|
+
FileUtils.cp(file_path, target_path)
|
|
217
|
+
@results[file_key] = { status: "installed" }
|
|
218
|
+
when :fast_forward
|
|
219
|
+
FileUtils.cp(file_path, target_path)
|
|
220
|
+
@results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
|
|
221
|
+
when :keep
|
|
222
|
+
@results[file_key] = { status: "kept", reason: "bundle unchanged, local edits preserved" }
|
|
223
|
+
when :noop
|
|
224
|
+
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
225
|
+
when :conflict
|
|
226
|
+
@results[file_key] = { status: "conflict", reason: "local edits conflict with bundle update" }
|
|
227
|
+
@conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path }
|
|
228
|
+
@warnings << "Conflict in #{file_key}: local edits conflict with bundle update (use --force to overwrite or resolve interactively)"
|
|
229
|
+
end
|
|
230
|
+
update_target_index(@target_scope, target_path, file_key)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# A dry run's verdict for one file, nothing written: an upgrade
|
|
234
|
+
# classifies as merge_memory would (its status is the classification).
|
|
235
|
+
def preview_memory(file_key, file_path, target_path)
|
|
236
|
+
unless File.exist?(target_path) && !@force
|
|
237
|
+
@results[file_key] = { status: "would_install" }
|
|
238
|
+
return
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
if @upgrade && @existing
|
|
242
|
+
base_path = @provenance.base_path(file_key)
|
|
243
|
+
classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
|
|
244
|
+
@results[file_key] = { status: classification.to_s }
|
|
245
|
+
@conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
|
|
246
|
+
elsif FileUtils.identical?(file_path, target_path)
|
|
247
|
+
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
248
|
+
else
|
|
249
|
+
@results[file_key] = { status: "would_skip" }
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Strict mode: a memory written from the bundle whose sha256 differs
|
|
254
|
+
# from its manifest's warns. One that wasn't copied (kept, skipped:
|
|
255
|
+
# a local edit isn't a bad download) or was merged isn't checked.
|
|
256
|
+
def verify_memory_checksums
|
|
257
|
+
@bundle_md.each do |file_key|
|
|
258
|
+
next if @conflicts.key?(file_key)
|
|
259
|
+
|
|
260
|
+
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
261
|
+
next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
|
|
262
|
+
|
|
263
|
+
target_path = File.join(@target_dir, file_key)
|
|
264
|
+
next unless File.exist?(target_path)
|
|
265
|
+
|
|
266
|
+
expected = @manifest.checksum_for(file_key)
|
|
267
|
+
next unless expected
|
|
268
|
+
|
|
269
|
+
actual = Digest::SHA256.hexdigest(File.read(target_path))
|
|
270
|
+
if actual != expected
|
|
271
|
+
@warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# One line per memory with {{placeholders}} left to fill in.
|
|
277
|
+
def detect_placeholders
|
|
278
|
+
@bundle_md.each do |file_key|
|
|
279
|
+
file_path = File.join(@target_dir, file_key)
|
|
280
|
+
next unless File.exist?(file_path)
|
|
281
|
+
|
|
282
|
+
names = Placeholder.detect_in_file(file_path)
|
|
283
|
+
@placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}" if names.any?
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The install's record (Provenance#write), only with a manifest and
|
|
288
|
+
# not on a dry run. An upgrade with conflicts writes it too: the new
|
|
289
|
+
# version, the plugin's and hooks' new shas, and the old base for each
|
|
290
|
+
# conflicted file, marked conflict (chi bundle status shows it).
|
|
291
|
+
# Skipping it left the replaced plugin failing its sha check.
|
|
292
|
+
# @param installed [AssetInstaller::Installed]
|
|
293
|
+
def write_provenance(installed)
|
|
294
|
+
@provenance.write(
|
|
295
|
+
files: @upgrade && @existing ? upgrade_provenance_files : install_provenance_files,
|
|
296
|
+
scope: @target_scope,
|
|
297
|
+
version: @manifest.version,
|
|
298
|
+
source_path: @source,
|
|
299
|
+
# AssetInstaller added the discovered hooks/*.rb to manifest.hooks.
|
|
300
|
+
hooks: @manifest.hooks,
|
|
301
|
+
trust_level: @manifest.trust_level,
|
|
302
|
+
source_commit: @source_commit,
|
|
303
|
+
hooks_files: installed.hook_files,
|
|
304
|
+
guardrails_files: installed.guardrail_files,
|
|
305
|
+
plugin_file: installed.plugin_file,
|
|
306
|
+
requires_chi: @manifest.requires_chi,
|
|
307
|
+
needs: @manifest.needs,
|
|
308
|
+
conflicts: @conflicts.keys,
|
|
309
|
+
scripts_files: installed.script_files,
|
|
310
|
+
context_providers: @manifest.context_providers,
|
|
311
|
+
file_descriptions: @manifest.file_descriptions
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# file key => the file whose content becomes its base. A file the
|
|
316
|
+
# upgrade left as the user has it (kept, noop, conflict, kept_pruned)
|
|
317
|
+
# keeps its old base, so the next upgrade still sees the local edit;
|
|
318
|
+
# the rest (installed, updated, skipped) take what is on disk now. A
|
|
319
|
+
# file the bundle dropped but the user kept stays recorded too.
|
|
320
|
+
def upgrade_provenance_files
|
|
321
|
+
files = {}
|
|
322
|
+
@owned.each do |file_key|
|
|
323
|
+
target_path = File.join(@target_dir, file_key)
|
|
324
|
+
next unless File.exist?(target_path)
|
|
325
|
+
|
|
326
|
+
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
327
|
+
base_path = @provenance.base_path(file_key)
|
|
328
|
+
files[file_key] = %w[kept noop conflict kept_pruned].include?(status) && File.exist?(base_path) ? base_path : target_path
|
|
329
|
+
end
|
|
330
|
+
@existing.files.each_key do |old_key_str|
|
|
331
|
+
next if @bundle_md.include?(old_key_str)
|
|
332
|
+
next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
|
|
333
|
+
|
|
334
|
+
base_path = @provenance.base_path(old_key_str)
|
|
335
|
+
src = File.exist?(base_path) ? base_path : File.join(@target_dir, old_key_str)
|
|
336
|
+
files[old_key_str] = src if File.exist?(src)
|
|
337
|
+
end
|
|
338
|
+
files
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# file key => its base's content source for a plain install. Skipped
|
|
342
|
+
# for a local edit: keep the installed base, so the next upgrade still
|
|
343
|
+
# sees the edit (not a fast-forward over it).
|
|
344
|
+
def install_provenance_files
|
|
345
|
+
@owned.each_with_object({}) do |file_key, files|
|
|
346
|
+
target_path = File.join(@target_dir, file_key)
|
|
347
|
+
next unless File.exist?(target_path)
|
|
348
|
+
|
|
349
|
+
base_path = @provenance.base_path(file_key)
|
|
350
|
+
kept = @existing && @results.dig(file_key, :reason) == "already exists" && File.exist?(base_path)
|
|
351
|
+
files[file_key] = kept ? base_path : target_path
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
486
355
|
# A file already in the target dir that the install doesn't write: the
|
|
487
356
|
# same bytes are "already up to date", others are skipped with a
|
|
488
357
|
# --force hint (a dry run: would_skip). Its index line is refreshed
|
|
@@ -505,8 +374,7 @@ module Samagotchi
|
|
|
505
374
|
# matches what was installed, or with --force; one the user edited is
|
|
506
375
|
# kept with a note.
|
|
507
376
|
def prune_dropped_files(previous_files, bundle_files, target_dir, scope, provenance)
|
|
508
|
-
previous_files.each do |
|
|
509
|
-
key = old_key.to_s
|
|
377
|
+
previous_files.each do |key, meta|
|
|
510
378
|
next if bundle_files.include?(key)
|
|
511
379
|
|
|
512
380
|
target = File.join(target_dir, key)
|
|
@@ -532,7 +400,7 @@ module Samagotchi
|
|
|
532
400
|
# Whether the file on disk is what the previous install wrote: its
|
|
533
401
|
# recorded checksum, else the base snapshot. Unknown counts as edited.
|
|
534
402
|
def installed_unchanged?(target, meta, base_path)
|
|
535
|
-
recorded = meta
|
|
403
|
+
recorded = meta[:checksum]
|
|
536
404
|
return Provenance.sha_matches?(target, recorded) unless Provenance.recorded_sha(recorded).empty?
|
|
537
405
|
|
|
538
406
|
File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
|
|
@@ -555,110 +423,6 @@ module Samagotchi
|
|
|
555
423
|
end
|
|
556
424
|
end
|
|
557
425
|
|
|
558
|
-
# The hook loader skips every hook of a bundle whose requires_chi this
|
|
559
|
-
# chi doesn't meet (Hooks::BundleLoader); say so at install, as
|
|
560
|
-
# install_plugin does for the plugin.
|
|
561
|
-
def warn_hooks_requires_chi(manifest, hook_files)
|
|
562
|
-
return if manifest.nil? || hook_files.empty?
|
|
563
|
-
|
|
564
|
-
if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
|
|
565
|
-
@warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
|
|
566
|
-
end
|
|
567
|
-
end
|
|
568
|
-
|
|
569
|
-
# GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
|
|
570
|
-
# requires_chi this chi doesn't meet, and a session that never loaded
|
|
571
|
-
# them records a required load failure (the gate refuses guarded calls
|
|
572
|
-
# until chi is updated): say so at install, as the hooks warning does.
|
|
573
|
-
def warn_rules_requires_chi(manifest, rule_files)
|
|
574
|
-
return if manifest.nil? || rule_files.empty?
|
|
575
|
-
|
|
576
|
-
if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
|
|
577
|
-
@warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
|
|
578
|
-
"(chi update), guarded tool calls are refused"
|
|
579
|
-
end
|
|
580
|
-
end
|
|
581
|
-
|
|
582
|
-
# Copy the manifest's plugin file into the bundle's plugin/ dir,
|
|
583
|
-
# warning when its sha256 differs from the declared one or this chi
|
|
584
|
-
# doesn't meet requires_chi (the Engine then won't load it).
|
|
585
|
-
# @return [String, nil] the installed file (nil: none, or a dry run)
|
|
586
|
-
def install_plugin(manifest, source_dir, provenance)
|
|
587
|
-
plugin = manifest&.plugin
|
|
588
|
-
unless plugin
|
|
589
|
-
FileUtils.rm_rf(provenance.plugin_dir) unless @dry_run
|
|
590
|
-
return nil
|
|
591
|
-
end
|
|
592
|
-
|
|
593
|
-
src = File.join(source_dir, plugin[:file])
|
|
594
|
-
raise InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
|
|
595
|
-
|
|
596
|
-
if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
|
|
597
|
-
@warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
|
|
598
|
-
end
|
|
599
|
-
if @dry_run
|
|
600
|
-
@results[plugin[:file]] = { status: "would_install" }
|
|
601
|
-
return nil
|
|
602
|
-
end
|
|
603
|
-
|
|
604
|
-
dest = File.join(provenance.plugin_dir, plugin[:file])
|
|
605
|
-
unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
|
|
606
|
-
FileUtils.rm_rf(provenance.plugin_dir)
|
|
607
|
-
FileUtils.mkdir_p(provenance.plugin_dir)
|
|
608
|
-
FileUtils.cp(src, dest)
|
|
609
|
-
@results[plugin[:file]] = if unchanged
|
|
610
|
-
{ status: "skipped", reason: "already up to date" }
|
|
611
|
-
elsif @upgrade && provenance.read
|
|
612
|
-
{ status: "updated" }
|
|
613
|
-
else
|
|
614
|
-
{ status: "installed" }
|
|
615
|
-
end
|
|
616
|
-
expected = manifest.checksum_for_plugin
|
|
617
|
-
actual = Digest::SHA256.hexdigest(File.binread(dest))
|
|
618
|
-
if @strict && expected && actual != expected
|
|
619
|
-
@warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
620
|
-
end
|
|
621
|
-
dest
|
|
622
|
-
end
|
|
623
|
-
|
|
624
|
-
# The manifest's scripts, copied into <bundle_dir>/scripts/ (the
|
|
625
|
-
# folder replaced: a script the new version dropped goes). A declared
|
|
626
|
-
# sha256 that differs warns, as a plugin's does.
|
|
627
|
-
# @return [Hash{String => String}] file name => installed path
|
|
628
|
-
def install_scripts(manifest, source_dir, provenance)
|
|
629
|
-
scripts = manifest&.scripts || {}
|
|
630
|
-
scripts.each_key do |file|
|
|
631
|
-
next if File.file?(File.join(source_dir, "scripts", file))
|
|
632
|
-
|
|
633
|
-
raise InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have"
|
|
634
|
-
end
|
|
635
|
-
return {} if @dry_run
|
|
636
|
-
|
|
637
|
-
unchanged = scripts.keys.select do |file|
|
|
638
|
-
dest = File.join(provenance.scripts_dir, file)
|
|
639
|
-
File.file?(dest) && FileUtils.identical?(File.join(source_dir, "scripts", file), dest)
|
|
640
|
-
end
|
|
641
|
-
FileUtils.rm_rf(provenance.scripts_dir)
|
|
642
|
-
return {} if scripts.empty?
|
|
643
|
-
|
|
644
|
-
FileUtils.mkdir_p(provenance.scripts_dir)
|
|
645
|
-
scripts.to_h do |file, declared|
|
|
646
|
-
dest = File.join(provenance.scripts_dir, file)
|
|
647
|
-
FileUtils.cp(File.join(source_dir, "scripts", file), dest)
|
|
648
|
-
@results["scripts/#{file}"] = if unchanged.include?(file) then { status: "skipped", reason: "already up to date" }
|
|
649
|
-
elsif @upgrade && provenance.read then { status: "updated" }
|
|
650
|
-
else { status: "installed" }
|
|
651
|
-
end
|
|
652
|
-
FileUtils.chmod(0o755, dest)
|
|
653
|
-
actual = Digest::SHA256.hexdigest(File.binread(dest))
|
|
654
|
-
expected = declared.to_s.delete_prefix("sha256:")
|
|
655
|
-
if @strict && !expected.empty? && actual != expected
|
|
656
|
-
@warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
657
|
-
end
|
|
658
|
-
[file, dest]
|
|
659
|
-
end
|
|
660
|
-
end
|
|
661
|
-
|
|
662
426
|
# A model overlay (<name>.<key>.md, docs/memory.md) loads only with
|
|
663
427
|
# its base, under the matching model: it gets no index line (every
|
|
664
428
|
# model would see it as a memory of its own). Its base is looked for
|