samagotchi 0.52.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 +58 -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/releasing.md +6 -3
- data/docs/sessions.md +1 -1
- data/docs/testing.md +5 -3
- data/lib/samagotchi/analytics_file.rb +35 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundle_command.rb +35 -44
- 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 +132 -135
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_registry.rb +13 -0
- 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 +184 -190
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- 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 +9 -29
- 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 +46 -57
- 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 +4 -28
- data/lib/samagotchi/session_commands.rb +32 -4
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +45 -257
- data/lib/samagotchi/session_metrics.rb +18 -21
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +25 -3
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -8
- data/lib/samagotchi/web/public/activity.js +2 -0
- data/lib/samagotchi/web/public/app.js +6 -8
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- 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/thinking_chip.js +31 -21
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_html.js +10 -6
- metadata +9 -1
|
@@ -43,14 +43,14 @@ module Samagotchi
|
|
|
43
43
|
# @return [Array<Installed>] by name; upgrade is the Shipped when it is newer
|
|
44
44
|
def installed(shipped: self.shipped)
|
|
45
45
|
by_name = shipped.to_h { |s| [s.name, s] }
|
|
46
|
-
Provenance.each_installed.map do |name,
|
|
47
|
-
next Installed.new(name: name, error: "manifest.json unreadable") if
|
|
46
|
+
Provenance.each_installed.map do |name, bundle|
|
|
47
|
+
next Installed.new(name: name, error: "manifest.json unreadable") if bundle.error?
|
|
48
48
|
|
|
49
49
|
ship = by_name[name]
|
|
50
|
-
Installed.new(name: name, version:
|
|
51
|
-
files:
|
|
52
|
-
upgrade: ship && newer?(ship.version,
|
|
53
|
-
includes:
|
|
50
|
+
Installed.new(name: name, version: bundle.version, scope: bundle.scope,
|
|
51
|
+
files: bundle.files.size, installed_at: bundle.installed_at,
|
|
52
|
+
upgrade: ship && newer?(ship.version, bundle.version) ? ship : nil,
|
|
53
|
+
includes: bundle.includes)
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
@@ -4,6 +4,7 @@ require "yaml"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require_relative "../context_providers"
|
|
7
|
+
require_relative "bundle_hook"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
9
10
|
module MemoryBundle
|
|
@@ -53,7 +54,8 @@ module Samagotchi
|
|
|
53
54
|
BUNDLE_NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
54
55
|
|
|
55
56
|
# files: name → "sha256:<hex>" (both forms); file_descriptions: name →
|
|
56
|
-
# the description a mapping gave, for the files that have one
|
|
57
|
+
# the description a mapping gave, for the files that have one; hooks:
|
|
58
|
+
# name → BundleHook.
|
|
57
59
|
attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
|
|
58
60
|
:scripts, :context_providers
|
|
59
61
|
|
|
@@ -97,15 +99,11 @@ module Samagotchi
|
|
|
97
99
|
raw.start_with?("sha256:") ? raw[7..] : raw
|
|
98
100
|
end
|
|
99
101
|
|
|
100
|
-
# Returns sha256 hex for a hook basename (e.g. "guardrails.rb")
|
|
102
|
+
# Returns sha256 hex for a hook basename (e.g. "guardrails.rb"), nil
|
|
103
|
+
# when it declares none (nothing to verify).
|
|
101
104
|
def checksum_for_hook(basename)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
sha = raw[:sha256] || raw["sha256"]
|
|
106
|
-
return nil unless sha
|
|
107
|
-
|
|
108
|
-
sha.to_s.start_with?("sha256:") ? sha.to_s[7..] : sha.to_s
|
|
105
|
+
hex = @hooks[basename]&.hex
|
|
106
|
+
hex unless hex.nil? || hex.empty?
|
|
109
107
|
end
|
|
110
108
|
|
|
111
109
|
# @return [String, nil] the plugin's sha256 hex, "sha256:" stripped
|
|
@@ -142,14 +140,7 @@ module Samagotchi
|
|
|
142
140
|
text = file_descriptions[key].to_s.strip
|
|
143
141
|
[key, text.empty? ? sha : { "sha256" => sha, "description" => text }]
|
|
144
142
|
end
|
|
145
|
-
if hooks && !hooks.empty?
|
|
146
|
-
# Normalize hooks to string-keyed with sha256 prefix preserved
|
|
147
|
-
manifest["hooks"] = hooks.transform_keys(&:to_s).transform_values do |v|
|
|
148
|
-
h = v.transform_keys(&:to_s)
|
|
149
|
-
h["sha256"] = h["sha256"].to_s.start_with?("sha256:") ? h["sha256"].to_s : "sha256:#{h["sha256"]}" if h["sha256"]
|
|
150
|
-
h
|
|
151
|
-
end
|
|
152
|
-
end
|
|
143
|
+
manifest["hooks"] = hooks.to_h { |k, v| [k.to_s, BundleHook.parse(v).to_record] } if hooks && !hooks.empty?
|
|
153
144
|
manifest["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
|
|
154
145
|
if plugin
|
|
155
146
|
sha = plugin[:sha256].to_s
|
|
@@ -306,18 +297,7 @@ module Samagotchi
|
|
|
306
297
|
next unless k.is_a?(String) && !k.empty?
|
|
307
298
|
next unless v.is_a?(Hash)
|
|
308
299
|
|
|
309
|
-
|
|
310
|
-
sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
|
|
311
|
-
event = (v["event"] || v[:event] || "").to_s.strip
|
|
312
|
-
on_error = (v["on_error"] || v[:on_error] || "skip").to_s.strip
|
|
313
|
-
on_error = "skip" if on_error.empty?
|
|
314
|
-
priority = (v["priority"] || v[:priority] || 100).to_i
|
|
315
|
-
acc[k] = {
|
|
316
|
-
sha256: sha,
|
|
317
|
-
event: event,
|
|
318
|
-
on_error: on_error,
|
|
319
|
-
priority: priority
|
|
320
|
-
}
|
|
300
|
+
acc[k] = BundleHook.parse(v)
|
|
321
301
|
end
|
|
322
302
|
end
|
|
323
303
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module MemoryBundle
|
|
5
|
+
# The plugin an installed bundle's record (manifest.json) names: its
|
|
6
|
+
# file under plugin/ and the sha256 recorded at install ("" when none
|
|
7
|
+
# is; either "sha256:<hex>" or a bare hex, Provenance.recorded_sha
|
|
8
|
+
# reads both).
|
|
9
|
+
PluginRef = Data.define(:file, :sha256) do
|
|
10
|
+
def initialize(file:, sha256: "")
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @param raw [Hash, PluginRef, nil] the record's plugin: mapping
|
|
15
|
+
# (symbol or string keys)
|
|
16
|
+
# @return [PluginRef, nil] nil when there is none or it names no file
|
|
17
|
+
def self.parse(raw)
|
|
18
|
+
return raw if raw.is_a?(PluginRef)
|
|
19
|
+
return nil unless raw.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
h = raw.transform_keys(&:to_s)
|
|
22
|
+
file = h["file"].to_s
|
|
23
|
+
file.empty? ? nil : new(file: file, sha256: h["sha256"].to_s)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -46,32 +46,32 @@ module Samagotchi
|
|
|
46
46
|
false
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
# Whether the installed bundle +name+ (its
|
|
50
|
-
# meta: it records includes, or it came
|
|
51
|
-
# a meta.
|
|
52
|
-
def installed_meta?(name,
|
|
53
|
-
return false unless
|
|
54
|
-
return true if
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
# Whether the installed bundle +name+ (its record +bundle+, an
|
|
50
|
+
# InstalledBundle or nil) is a meta: it records includes, or it came
|
|
51
|
+
# from chi and chi ships it as a meta.
|
|
52
|
+
def installed_meta?(name, bundle, shipped_dir: SourceNormalizer::SHIPPED_DIR)
|
|
53
|
+
return false unless bundle
|
|
54
|
+
return true if bundle.profile?
|
|
55
|
+
|
|
56
|
+
bundle.source.to_s.match?(SourceNormalizer::SHIPPED_SOURCE) && shipped_meta?(File.join(shipped_dir, name), shipped_dir: shipped_dir)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
# The members the meta has recorded: none when it isn't installed,
|
|
60
60
|
# all of +manifest+'s (the shipped meta, nil when chi ships none) when
|
|
61
61
|
# its provenance has no includes: an unknown record is taken as
|
|
62
62
|
# everything.
|
|
63
|
-
def recorded(
|
|
64
|
-
return [] unless
|
|
65
|
-
return manifest&.includes || [] unless
|
|
63
|
+
def recorded(bundle, manifest)
|
|
64
|
+
return [] unless bundle
|
|
65
|
+
return manifest&.includes || [] unless bundle.profile?
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
bundle.includes
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
def installed_names = Provenance.each_installed.map { |name, _| name }
|
|
71
71
|
|
|
72
72
|
# The one rule.
|
|
73
|
-
def to_install(manifest,
|
|
74
|
-
manifest.includes - recorded(
|
|
73
|
+
def to_install(manifest, bundle = Provenance.new(name: manifest.name).record, installed: installed_names)
|
|
74
|
+
manifest.includes - recorded(bundle, manifest) - installed
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
# Installs the shipped meta at +dir+: its to_install members from the
|
|
@@ -80,13 +80,13 @@ module Samagotchi
|
|
|
80
80
|
# @return [InstallResult]
|
|
81
81
|
def install(dir, shipped_dir: SourceNormalizer::SHIPPED_DIR, chi_version: Samagotchi::VERSION, dry_run: false, force: false)
|
|
82
82
|
manifest = Manifest.read(dir: dir)
|
|
83
|
-
|
|
84
|
-
was = recorded(
|
|
83
|
+
bundle = Provenance.new(name: manifest.name).record
|
|
84
|
+
was = recorded(bundle, manifest)
|
|
85
85
|
installed = installed_names
|
|
86
86
|
result = InstallResult.new(name: manifest.name, version: manifest.version, installed: [],
|
|
87
87
|
already: (manifest.includes - was) & installed, skipped: {}, failed: {})
|
|
88
88
|
|
|
89
|
-
to_install(manifest,
|
|
89
|
+
to_install(manifest, bundle, installed: installed).each do |member|
|
|
90
90
|
install_member(member, shipped_dir, chi_version, dry_run, force, result)
|
|
91
91
|
end
|
|
92
92
|
return result if dry_run
|
|
@@ -94,7 +94,7 @@ module Samagotchi
|
|
|
94
94
|
now = was | result.installed | result.already
|
|
95
95
|
includes = (manifest.includes & now) + (now - manifest.includes)
|
|
96
96
|
# Nothing new: the record stays as it is (installed_at too).
|
|
97
|
-
return result if
|
|
97
|
+
return result if bundle && bundle.version == manifest.version && bundle.source == dir && bundle.includes == includes
|
|
98
98
|
|
|
99
99
|
Provenance.new(name: manifest.name).write(
|
|
100
100
|
files: {}, scope: "system", version: manifest.version, source_path: dir,
|
|
@@ -123,12 +123,12 @@ module Samagotchi
|
|
|
123
123
|
# @return [UninstallResult]
|
|
124
124
|
# @raise [Uninstaller::UninstallError] when +name+ isn't installed
|
|
125
125
|
def uninstall(name, shipped_dir: SourceNormalizer::SHIPPED_DIR, force: false)
|
|
126
|
-
|
|
127
|
-
raise Uninstaller::UninstallError, "Bundle '#{name}' is not installed" unless
|
|
126
|
+
bundle = Provenance.new(name: name).record
|
|
127
|
+
raise Uninstaller::UninstallError, "Bundle '#{name}' is not installed" unless bundle
|
|
128
128
|
|
|
129
129
|
shipped = File.join(shipped_dir, name)
|
|
130
130
|
manifest = File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil
|
|
131
|
-
members = recorded(
|
|
131
|
+
members = recorded(bundle, manifest)
|
|
132
132
|
result = UninstallResult.new(name: name, removed: [], blocked: {}, gone: false, trash: {}, warnings: [])
|
|
133
133
|
(members & installed_names).each do |member|
|
|
134
134
|
uninstaller = Uninstaller.new(name: member, force: force)
|
|
@@ -6,6 +6,8 @@ require "date"
|
|
|
6
6
|
require "fileutils"
|
|
7
7
|
require_relative "../atomic_file"
|
|
8
8
|
require_relative "../memory_paths"
|
|
9
|
+
require_relative "bundle_hook"
|
|
10
|
+
require_relative "installed_bundle"
|
|
9
11
|
|
|
10
12
|
module Samagotchi
|
|
11
13
|
module MemoryBundle
|
|
@@ -46,9 +48,10 @@ module Samagotchi
|
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
# The plugin file as installed, or nil when the bundle has none.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
file
|
|
51
|
+
# @param bundle [InstalledBundle, nil] its record (read when not given)
|
|
52
|
+
def plugin_path(bundle = record)
|
|
53
|
+
file = bundle&.plugin&.file
|
|
54
|
+
file ? File.join(plugin_dir, file) : nil
|
|
52
55
|
end
|
|
53
56
|
|
|
54
57
|
# The plugin's base snapshot (bases/plugin/<file>), for bundle diff.
|
|
@@ -56,14 +59,14 @@ module Samagotchi
|
|
|
56
59
|
File.join(@bundle_dir, "bases", "plugin", file.to_s)
|
|
57
60
|
end
|
|
58
61
|
|
|
59
|
-
# Yields [name,
|
|
60
|
-
# bundles_dir holding a manifest.json, dot dirs aside), by name.
|
|
61
|
-
# is
|
|
62
|
-
# or isn't an object; the rest still come.
|
|
62
|
+
# Yields [name, bundle] for each installed bundle (a dir under
|
|
63
|
+
# bundles_dir holding a manifest.json, dot dirs aside), by name. bundle
|
|
64
|
+
# is its InstalledBundle, one with error set when manifest.json doesn't
|
|
65
|
+
# parse or isn't an object; the rest still come.
|
|
63
66
|
#
|
|
64
67
|
# With holding: (:hooks, :guardrails or :plugin) only the bundles
|
|
65
68
|
# whose manifest has a non-empty mapping under that key come, and a
|
|
66
|
-
# manifest that doesn't parse comes
|
|
69
|
+
# manifest that doesn't parse comes (with error) only when its bundle
|
|
67
70
|
# has that dir (hooks/, guardrails/, plugin/): what it would load
|
|
68
71
|
# can't be checked. One that isn't an object is skipped there.
|
|
69
72
|
# @param dir [String] the bundles dir (bundles_dir by default)
|
|
@@ -78,15 +81,14 @@ module Samagotchi
|
|
|
78
81
|
begin
|
|
79
82
|
data = JSON.parse(File.read(mjson), symbolize_names: true)
|
|
80
83
|
rescue JSON::ParserError, SystemCallError => e
|
|
81
|
-
|
|
84
|
+
if holding.nil? || Dir.exist?(File.join(dir, name, holding.to_s))
|
|
85
|
+
yield name, InstalledBundle.unreadable(name, "manifest.json is unreadable: #{e.message}")
|
|
86
|
+
end
|
|
82
87
|
next
|
|
83
88
|
end
|
|
84
|
-
if holding
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
data = { error: "manifest.json is not an object" }
|
|
88
|
-
end
|
|
89
|
-
yield name, data
|
|
89
|
+
next if holding && !(data.is_a?(Hash) && data[holding].is_a?(Hash) && !data[holding].empty?)
|
|
90
|
+
|
|
91
|
+
yield name, InstalledBundle.parse(name, data)
|
|
90
92
|
end
|
|
91
93
|
end
|
|
92
94
|
|
|
@@ -95,10 +97,9 @@ module Samagotchi
|
|
|
95
97
|
# A project-scoped record doesn't say which project: one from another
|
|
96
98
|
# repo counts too, which errs on keeping the file.
|
|
97
99
|
def self.claimants(file_key, scope:, except: nil, dir: bundles_dir)
|
|
98
|
-
each_installed(dir: dir).filter_map do |name,
|
|
99
|
-
next if name == except ||
|
|
100
|
-
next unless
|
|
101
|
-
next unless data[:files].is_a?(Hash) && data[:files].keys.map(&:to_s).include?(file_key.to_s)
|
|
100
|
+
each_installed(dir: dir).filter_map do |name, bundle|
|
|
101
|
+
next if name == except || bundle.error?
|
|
102
|
+
next unless bundle.effective_scope == scope.to_s && bundle.owns?(file_key)
|
|
102
103
|
|
|
103
104
|
name
|
|
104
105
|
end
|
|
@@ -145,11 +146,10 @@ module Samagotchi
|
|
|
145
146
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
146
147
|
FileUtils.mkdir_p(bases_dir)
|
|
147
148
|
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
merged_entries = existing && existing[:files] ? existing[:files].transform_keys(&:to_s) : {}
|
|
149
|
+
# The existing record: its file entries merge with these, and its
|
|
150
|
+
# files and hooks not in this install are stale.
|
|
151
|
+
existing = record
|
|
152
|
+
merged_entries = existing ? existing.files.dup : {}
|
|
153
153
|
|
|
154
154
|
files.each do |file_key, file_path|
|
|
155
155
|
content = File.read(file_path)
|
|
@@ -162,10 +162,9 @@ module Samagotchi
|
|
|
162
162
|
end
|
|
163
163
|
|
|
164
164
|
# Prune stale base snapshots for files no longer in the bundle.
|
|
165
|
-
if existing
|
|
166
|
-
existing
|
|
167
|
-
old_key_str
|
|
168
|
-
next if files.key?(old_key_str) || files.key?(old_key)
|
|
165
|
+
if existing
|
|
166
|
+
existing.files.each_key do |old_key_str|
|
|
167
|
+
next if files.key?(old_key_str) || files.key?(old_key_str.to_sym)
|
|
169
168
|
|
|
170
169
|
old_base = File.join(bases_dir, old_key_str)
|
|
171
170
|
FileUtils.rm_f(old_base)
|
|
@@ -175,47 +174,29 @@ module Samagotchi
|
|
|
175
174
|
|
|
176
175
|
# Hooks provenance: persist metadata and base snapshots for hooks
|
|
177
176
|
hooks_map = {}
|
|
178
|
-
#
|
|
179
|
-
existing_hooks = existing && existing[:hooks] ? existing[:hooks] : {}
|
|
180
|
-
# Normalize incoming hooks (basename => metadata hash)
|
|
177
|
+
# Incoming hooks: basename => BundleHook (or its metadata hash)
|
|
181
178
|
normalized_hooks = {}
|
|
182
179
|
if hooks.is_a?(Hash)
|
|
183
180
|
hooks.each do |k, v|
|
|
184
181
|
next unless k.is_a?(String) && !k.empty?
|
|
185
|
-
next unless v.is_a?(Hash)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
sha = (hv[:sha256] || "").to_s
|
|
189
|
-
sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
|
|
190
|
-
normalized_hooks[k] = {
|
|
191
|
-
sha256: sha,
|
|
192
|
-
event: (hv[:event] || "").to_s,
|
|
193
|
-
on_error: (hv[:on_error] || "skip").to_s,
|
|
194
|
-
priority: (hv[:priority] || 100).to_i
|
|
195
|
-
}
|
|
182
|
+
next unless v.is_a?(Hash) || v.is_a?(BundleHook)
|
|
183
|
+
|
|
184
|
+
normalized_hooks[k] = BundleHook.parse(v)
|
|
196
185
|
end
|
|
197
186
|
end
|
|
198
187
|
# Prune stale hook bases
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
old_key_str = old_key.to_s
|
|
202
|
-
unless normalized_hooks.key?(old_key_str) || normalized_hooks.key?(old_key_str.to_sym)
|
|
203
|
-
old_base = File.join(bases_dir, old_key_str)
|
|
204
|
-
FileUtils.rm_f(old_base)
|
|
205
|
-
end
|
|
206
|
-
end
|
|
188
|
+
existing&.hooks&.each_key do |old_key_str|
|
|
189
|
+
FileUtils.rm_f(File.join(bases_dir, old_key_str)) unless normalized_hooks.key?(old_key_str)
|
|
207
190
|
end
|
|
208
191
|
# Save base snapshots for hooks where a file path was provided, and
|
|
209
192
|
# record the sha256 of the installed file itself: the load-time check
|
|
210
193
|
# compares against it, and a declared sha can be wrong (installing
|
|
211
194
|
# only warns about that).
|
|
212
195
|
hooks_files = {} unless hooks_files.is_a?(Hash)
|
|
213
|
-
normalized_hooks.each do |k,
|
|
196
|
+
normalized_hooks.each do |k, hook|
|
|
214
197
|
src = hooks_files[k] || hooks_files[k.to_sym]
|
|
215
|
-
if src && File.exist?(src.to_s)
|
|
216
|
-
|
|
217
|
-
end
|
|
218
|
-
hooks_map[k] = meta.transform_keys(&:to_s)
|
|
198
|
+
hook = hook.with(sha256: BundleHook.of_file(src.to_s).sha256) if src && File.exist?(src.to_s)
|
|
199
|
+
hooks_map[k] = hook.to_record
|
|
219
200
|
next unless src && File.exist?(src.to_s)
|
|
220
201
|
|
|
221
202
|
begin
|
|
@@ -285,7 +266,15 @@ module Samagotchi
|
|
|
285
266
|
write_manifest(raw)
|
|
286
267
|
end
|
|
287
268
|
|
|
288
|
-
#
|
|
269
|
+
# The installed bundle's record, nil when it isn't installed.
|
|
270
|
+
# @return [InstalledBundle, nil]
|
|
271
|
+
# @raise [JSON::ParserError] manifest.json doesn't parse
|
|
272
|
+
def record
|
|
273
|
+
InstalledBundle.read(@name, File.join(@bundle_dir, "manifest.json"))
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# manifest.json as it is on disk, symbol keys (nil if not installed).
|
|
277
|
+
# Readers take #record.
|
|
289
278
|
def read
|
|
290
279
|
manifest_path = File.join(@bundle_dir, "manifest.json")
|
|
291
280
|
return nil unless File.exist?(manifest_path)
|
|
@@ -308,7 +297,7 @@ module Samagotchi
|
|
|
308
297
|
|
|
309
298
|
# Checks whether the bundle is installed.
|
|
310
299
|
def installed?
|
|
311
|
-
|
|
300
|
+
!record.nil?
|
|
312
301
|
end
|
|
313
302
|
end
|
|
314
303
|
end
|
|
@@ -45,8 +45,8 @@ module Samagotchi
|
|
|
45
45
|
# @return [Array<Row>] by name
|
|
46
46
|
def plan(shipped_dir: SourceNormalizer::SHIPPED_DIR, chi_version: Samagotchi::VERSION)
|
|
47
47
|
shipped = Listing.shipped(dir: shipped_dir).to_h { |s| [s.name, s] }
|
|
48
|
-
installed.map do |name,
|
|
49
|
-
plan_row(name,
|
|
48
|
+
installed.map do |name, bundle|
|
|
49
|
+
plan_row(name, bundle, shipped[name], shipped_dir, chi_version)
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
@@ -88,24 +88,24 @@ module Samagotchi
|
|
|
88
88
|
end
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
-
# [name,
|
|
91
|
+
# [name, InstalledBundle] for each installed bundle but the system one, by name.
|
|
92
92
|
def installed
|
|
93
93
|
Provenance.each_installed.reject { |name, _| name == SystemBundle::BUNDLE_NAME }
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
def plan_row(name,
|
|
96
|
+
def plan_row(name, bundle, ship, shipped_dir, chi_version)
|
|
97
97
|
row = Row.new(name: name, kept: [], replaced: [])
|
|
98
|
-
return skip(row, "manifest.json unreadable") if
|
|
98
|
+
return skip(row, "manifest.json unreadable") if bundle.error?
|
|
99
99
|
|
|
100
|
-
row.from =
|
|
101
|
-
row.scope =
|
|
102
|
-
return skip(row, "not from chi") unless ship &&
|
|
100
|
+
row.from = bundle.version.to_s
|
|
101
|
+
row.scope = bundle.effective_scope
|
|
102
|
+
return skip(row, "not from chi") unless ship && bundle.source.to_s.match?(SourceNormalizer::SHIPPED_SOURCE)
|
|
103
103
|
|
|
104
104
|
row.to = ship.version.to_s
|
|
105
105
|
return skip(row, "newer than shipped: left") if Listing.newer?(row.from, row.to)
|
|
106
106
|
|
|
107
107
|
row.source_dir = File.join(shipped_dir, ship.source)
|
|
108
|
-
return profile_row(row,
|
|
108
|
+
return profile_row(row, bundle, shipped_dir, chi_version) if Profile.shipped_meta?(row.source_dir, shipped_dir: shipped_dir)
|
|
109
109
|
return row.tap { |r| r.status = :up_to_date; r.to = nil } unless Listing.newer?(row.to, row.from)
|
|
110
110
|
|
|
111
111
|
manifest = Manifest.read(dir: row.source_dir)
|
|
@@ -115,20 +115,20 @@ module Samagotchi
|
|
|
115
115
|
return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
|
|
116
116
|
return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
|
|
117
117
|
|
|
118
|
-
row.kept = conflicts(row,
|
|
119
|
-
row.replaced = edited_executables(name,
|
|
118
|
+
row.kept = conflicts(row, bundle)
|
|
119
|
+
row.replaced = edited_executables(name, bundle)
|
|
120
120
|
row.status = :would_update
|
|
121
121
|
row
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
-
def profile_row(row,
|
|
124
|
+
def profile_row(row, bundle, shipped_dir, chi_version)
|
|
125
125
|
manifest = Manifest.read(dir: row.source_dir)
|
|
126
126
|
newer = Listing.newer?(row.to, row.from)
|
|
127
127
|
if newer && Manifest.requires_chi_failure(manifest.requires_chi, chi_version)
|
|
128
128
|
return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
-
row.adds = Profile.to_install(manifest,
|
|
131
|
+
row.adds = Profile.to_install(manifest, bundle).reject do |member|
|
|
132
132
|
Manifest.requires_chi_failure(Manifest.read(dir: File.join(shipped_dir, member)).requires_chi, chi_version)
|
|
133
133
|
rescue Manifest::ValidationError, Psych::Exception
|
|
134
134
|
false # apply reports it failing
|
|
@@ -147,12 +147,10 @@ module Samagotchi
|
|
|
147
147
|
|
|
148
148
|
# The bundle's .md files whose local edits conflict with the shipped
|
|
149
149
|
# version (a same-name file it didn't install is skipped, not merged).
|
|
150
|
-
def conflicts(row,
|
|
150
|
+
def conflicts(row, bundle)
|
|
151
151
|
provenance = Provenance.new(name: row.name)
|
|
152
152
|
target_dir = MemoryPaths.scope_dir!(row.scope)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
owned = data[:files].keys.map(&:to_s)
|
|
153
|
+
owned = bundle.files.keys
|
|
156
154
|
(Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort & owned).select do |key|
|
|
157
155
|
Merger.classify(base_path: provenance.base_path(key), current_path: File.join(target_dir, key),
|
|
158
156
|
incoming_path: File.join(row.source_dir, key)) == :conflict
|
|
@@ -161,22 +159,22 @@ module Samagotchi
|
|
|
161
159
|
|
|
162
160
|
# Installed hooks, rules and the plugin whose sha differs from the
|
|
163
161
|
# recorded one (they didn't load), as hooks/F, guardrails/F, plugin/F.
|
|
164
|
-
def edited_executables(name,
|
|
162
|
+
def edited_executables(name, bundle)
|
|
165
163
|
provenance = Provenance.new(name: name)
|
|
166
164
|
edited = []
|
|
167
|
-
|
|
168
|
-
edited << "hooks/#{file}" if edited?(File.join(provenance.hooks_dir, file
|
|
165
|
+
bundle.hooks.each do |file, hook|
|
|
166
|
+
edited << "hooks/#{file}" if edited?(File.join(provenance.hooks_dir, file), hook.sha256)
|
|
169
167
|
end
|
|
170
|
-
|
|
171
|
-
edited << "guardrails/#{file}" if edited?(File.join(provenance.guardrails_dir, file
|
|
168
|
+
bundle.guardrails.each do |file, sha|
|
|
169
|
+
edited << "guardrails/#{file}" if edited?(File.join(provenance.guardrails_dir, file), sha)
|
|
172
170
|
end
|
|
173
|
-
plugin = provenance.plugin_path(
|
|
174
|
-
edited << "plugin/#{File.basename(plugin)}" if plugin && edited?(plugin,
|
|
171
|
+
plugin = provenance.plugin_path(bundle)
|
|
172
|
+
edited << "plugin/#{File.basename(plugin)}" if plugin && edited?(plugin, bundle.plugin.sha256)
|
|
175
173
|
edited
|
|
176
174
|
end
|
|
177
175
|
|
|
178
|
-
|
|
179
|
-
|
|
176
|
+
# Whether the file differs from its recorded sha (none recorded: no).
|
|
177
|
+
def edited?(path, recorded)
|
|
180
178
|
return false if Provenance.recorded_sha(recorded).empty? || !File.file?(path)
|
|
181
179
|
|
|
182
180
|
!Provenance.sha_matches?(path, recorded)
|
|
@@ -17,30 +17,28 @@ module Samagotchi
|
|
|
17
17
|
# unchecked (no disk reads, nothing counted as missing).
|
|
18
18
|
def self.bundle_status(name, scope_override: nil)
|
|
19
19
|
provenance = Provenance.new(name: name)
|
|
20
|
-
|
|
21
|
-
return nil unless
|
|
20
|
+
bundle = provenance.record
|
|
21
|
+
return nil unless bundle
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
scope = raw_scope.nil? || raw_scope.strip.empty? ? "system" : raw_scope
|
|
23
|
+
scope = (scope_override ? bundle.with(scope: scope_override) : bundle).effective_scope
|
|
25
24
|
target_dir = MemoryPaths.scope_dir(scope)
|
|
26
|
-
files =
|
|
25
|
+
files = bundle.files
|
|
27
26
|
details = {}
|
|
28
|
-
files.each do |
|
|
29
|
-
file_key_str = file_key.to_s
|
|
27
|
+
files.each do |file_key_str, meta|
|
|
30
28
|
next details[file_key_str] = unchecked_file(meta) unless target_dir
|
|
31
29
|
|
|
32
30
|
target_path = File.join(target_dir, file_key_str)
|
|
33
31
|
base_path = provenance.base_path(file_key_str)
|
|
34
|
-
stored_checksum = meta[:checksum]
|
|
32
|
+
stored_checksum = meta[:checksum]
|
|
35
33
|
current_checksum = File.exist?(target_path) ? Digest::SHA256.hexdigest(File.read(target_path)) : nil
|
|
36
34
|
base_checksum = File.exist?(base_path) ? Digest::SHA256.hexdigest(File.read(base_path)) : nil
|
|
37
35
|
modified = base_checksum && current_checksum && base_checksum != current_checksum
|
|
38
36
|
missing = !File.exist?(target_path)
|
|
39
37
|
# A model overlay has no index line (Installer#note_overlay).
|
|
40
|
-
overlay = ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys
|
|
38
|
+
overlay = ModelOverlay.bundle_overlay?(file_key_str, bundle_files: files.keys, target_dir: target_dir)
|
|
41
39
|
index_present = overlay ? nil : index_has_entry?(scope, file_key_str)
|
|
42
40
|
details[file_key_str] = {
|
|
43
|
-
conflict: meta
|
|
41
|
+
conflict: meta[:conflict] == true,
|
|
44
42
|
stored_checksum: stored_checksum,
|
|
45
43
|
current_checksum: current_checksum,
|
|
46
44
|
base_checksum: base_checksum,
|
|
@@ -52,23 +50,23 @@ module Samagotchi
|
|
|
52
50
|
base_path: base_path
|
|
53
51
|
}
|
|
54
52
|
end
|
|
55
|
-
{ provenance:
|
|
56
|
-
target_dir: target_dir, files: details, plugin: plugin_status(provenance,
|
|
57
|
-
hooks_requires_failure: hooks_requires_failure(
|
|
53
|
+
{ provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
|
|
54
|
+
target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
|
|
55
|
+
hooks_requires_failure: hooks_requires_failure(bundle), needs: needs_status(bundle) }
|
|
58
56
|
end
|
|
59
57
|
|
|
60
58
|
# Why none of the bundle's hooks load (this chi doesn't meet its
|
|
61
59
|
# requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
|
|
62
|
-
def self.hooks_requires_failure(
|
|
63
|
-
return nil if
|
|
60
|
+
def self.hooks_requires_failure(bundle)
|
|
61
|
+
return nil if bundle.hooks.empty?
|
|
64
62
|
|
|
65
|
-
Manifest.requires_chi_failure(
|
|
63
|
+
Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION)
|
|
66
64
|
end
|
|
67
65
|
|
|
68
66
|
# The stored needs, each with found: from this process's PATH (the
|
|
69
67
|
# shell's; a worker's PATH can differ, see docs/memory.md).
|
|
70
|
-
def self.needs_status(
|
|
71
|
-
Manifest.parse_needs(
|
|
68
|
+
def self.needs_status(bundle, path: ENV["PATH"])
|
|
69
|
+
Manifest.parse_needs(bundle.needs).map do |need|
|
|
72
70
|
need.merge(found: BundleNeeds.found?(need[:command], path: path))
|
|
73
71
|
end
|
|
74
72
|
rescue Manifest::ValidationError
|
|
@@ -86,20 +84,20 @@ module Samagotchi
|
|
|
86
84
|
# The installed plugin: {file:, path:, state:, requires_chi:,
|
|
87
85
|
# requires_failure:}; state is "ok", "modified" (its sha256 differs
|
|
88
86
|
# from the installed one: it won't load) or "missing". nil without one.
|
|
89
|
-
def self.plugin_status(provenance,
|
|
90
|
-
path = provenance.plugin_path(
|
|
87
|
+
def self.plugin_status(provenance, bundle)
|
|
88
|
+
path = provenance.plugin_path(bundle)
|
|
91
89
|
return nil unless path
|
|
92
90
|
|
|
93
91
|
state = if !File.file?(path) then "missing"
|
|
94
|
-
elsif Provenance.sha_matches?(path,
|
|
92
|
+
elsif Provenance.sha_matches?(path, bundle.plugin.sha256) then "ok"
|
|
95
93
|
else "modified"
|
|
96
94
|
end
|
|
97
|
-
{ file: File.basename(path), path: path, state: state, requires_chi:
|
|
98
|
-
requires_failure: Manifest.requires_chi_failure(
|
|
95
|
+
{ file: File.basename(path), path: path, state: state, requires_chi: bundle.requires_chi,
|
|
96
|
+
requires_failure: Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION) }
|
|
99
97
|
end
|
|
100
98
|
|
|
101
99
|
def self.unchecked_file(meta)
|
|
102
|
-
{ conflict: meta
|
|
100
|
+
{ conflict: meta[:conflict] == true, stored_checksum: meta[:checksum],
|
|
103
101
|
unchecked: true, modified: false, missing: false, index_present: nil, overlay: false }
|
|
104
102
|
end
|
|
105
103
|
private_class_method :unchecked_file
|
|
@@ -72,10 +72,10 @@ module Samagotchi
|
|
|
72
72
|
to = gem_manifest.version.to_s
|
|
73
73
|
|
|
74
74
|
with_lock do
|
|
75
|
-
|
|
76
|
-
from =
|
|
75
|
+
bundle = Provenance.new(name: BUNDLE_NAME).record
|
|
76
|
+
from = bundle&.version.to_s
|
|
77
77
|
|
|
78
|
-
if
|
|
78
|
+
if bundle.nil?
|
|
79
79
|
dry_run ? result(:installed, to: to) : install_fresh(to)
|
|
80
80
|
elsif installed_newer?(from, to)
|
|
81
81
|
# Running an older checkout/gem: never downgrade the user's system memories
|
|
@@ -174,8 +174,7 @@ module Samagotchi
|
|
|
174
174
|
# here and changed in the shipped bundle (a dry run's kept).
|
|
175
175
|
def conflicting(gem_manifest)
|
|
176
176
|
provenance = Provenance.new(name: BUNDLE_NAME)
|
|
177
|
-
owned =
|
|
178
|
-
owned = owned.is_a?(Hash) ? owned.keys.map(&:to_s) : []
|
|
177
|
+
owned = provenance.record&.files&.keys || []
|
|
179
178
|
(gem_manifest.files.keys.map(&:to_s).sort & owned).select do |key|
|
|
180
179
|
Merger.classify(base_path: provenance.base_path(key), current_path: File.join(MemoryPaths.system_dir, key),
|
|
181
180
|
incoming_path: File.join(GEM_BUNDLE_DIR, key)) == :conflict
|