samagotchi 0.151.0 → 0.153.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/bundle-authoring.md +5 -5
  4. data/docs/bundles.md +84 -0
  5. data/docs/cli.md +35 -10
  6. data/docs/configuration.md +19 -14
  7. data/docs/guardrails.md +11 -5
  8. data/docs/hooks.md +4 -3
  9. data/docs/internals/anthropic.md +3 -3
  10. data/docs/internals/attached-context.md +3 -0
  11. data/docs/internals/block-renderers.md +3 -1
  12. data/docs/internals/bundles.md +48 -14
  13. data/docs/internals/engine.md +9 -5
  14. data/docs/internals/objectives.md +6 -4
  15. data/docs/internals/participants.md +1 -1
  16. data/docs/internals/prompt-caching.md +7 -3
  17. data/docs/internals/web-frontend.md +20 -17
  18. data/docs/memory.md +6 -1
  19. data/docs/plugins.md +80 -29
  20. data/docs/releasing.md +8 -3
  21. data/docs/sessions.md +2 -2
  22. data/docs/testing.md +2 -2
  23. data/lib/samagotchi/block_renderers.rb +9 -8
  24. data/lib/samagotchi/bundle_command/check.rb +43 -3
  25. data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
  27. data/lib/samagotchi/bundle_command.rb +88 -10
  28. data/lib/samagotchi/bundle_needs.rb +4 -6
  29. data/lib/samagotchi/bundle_settings_check.rb +14 -13
  30. data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
  38. data/lib/samagotchi/bundles/web-search/manifest.yml +13 -0
  39. data/lib/samagotchi/bundles/web-search/plugin.rb +174 -0
  40. data/lib/samagotchi/child_env.rb +15 -0
  41. data/lib/samagotchi/context_providers.rb +42 -10
  42. data/lib/samagotchi/engine.rb +37 -9
  43. data/lib/samagotchi/extension_load.rb +4 -3
  44. data/lib/samagotchi/guardrail_wiring.rb +28 -19
  45. data/lib/samagotchi/guardrails/approval.rb +2 -2
  46. data/lib/samagotchi/guardrails/review_boundary.rb +4 -3
  47. data/lib/samagotchi/guardrails/rules.rb +1 -0
  48. data/lib/samagotchi/guardrails/verdict.rb +20 -0
  49. data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
  50. data/lib/samagotchi/llm_context_stale.rb +2 -2
  51. data/lib/samagotchi/memory_bundle/asset_installer.rb +11 -8
  52. data/lib/samagotchi/memory_bundle/builder.rb +36 -30
  53. data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
  54. data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
  55. data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
  56. data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -46
  57. data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -15
  58. data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
  59. data/lib/samagotchi/memory_bundle/installer.rb +90 -16
  60. data/lib/samagotchi/memory_bundle/manifest.rb +146 -6
  61. data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
  62. data/lib/samagotchi/memory_bundle/merger.rb +7 -8
  63. data/lib/samagotchi/memory_bundle/profile.rb +3 -2
  64. data/lib/samagotchi/memory_bundle/provenance.rb +217 -18
  65. data/lib/samagotchi/memory_bundle/shas.rb +4 -3
  66. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
  67. data/lib/samagotchi/memory_bundle/status.rb +11 -10
  68. data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
  69. data/lib/samagotchi/message_parts.rb +3 -2
  70. data/lib/samagotchi/plugin/loader.rb +1 -1
  71. data/lib/samagotchi/session_config.rb +5 -4
  72. data/lib/samagotchi/system_one/record.rb +10 -2
  73. data/lib/samagotchi/toolkit.rb +8 -5
  74. data/lib/samagotchi/toolkit_binding.rb +30 -5
  75. data/lib/samagotchi/toolkit_report_command.rb +9 -5
  76. data/lib/samagotchi/toolkit_suggest.rb +12 -6
  77. data/lib/samagotchi/tools/builtin_calls.rb +14 -10
  78. data/lib/samagotchi/tools/deferred_tools.rb +2 -2
  79. data/lib/samagotchi/tools/delegate.rb +20 -2
  80. data/lib/samagotchi/tools/registry.rb +44 -20
  81. data/lib/samagotchi/tools/tool_index.rb +27 -21
  82. data/lib/samagotchi/update_command.rb +13 -2
  83. data/lib/samagotchi/version.rb +1 -1
  84. data/lib/samagotchi/web/app.rb +2 -1
  85. data/lib/samagotchi/web/public/app.js +61 -357
  86. data/lib/samagotchi/web/public/composer_mode.js +110 -0
  87. data/lib/samagotchi/web/public/owner_claim.js +81 -0
  88. data/lib/samagotchi/web/public/owner_verify.js +112 -0
  89. data/lib/samagotchi/web/public/session_fields.js +3 -3
  90. data/lib/samagotchi/web/public/strip_grip.js +3 -3
  91. data/lib/samagotchi/web/public/strip_hidden.js +65 -0
  92. data/lib/samagotchi/web/public/version_notices.js +132 -0
  93. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  94. data/lib/samagotchi/web/session_hub.rb +6 -0
  95. data/lib/samagotchi/worker.rb +6 -0
  96. metadata +11 -1
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "digest"
4
+ require_relative "file_digest"
5
5
  require_relative "../memory_paths"
6
6
  require_relative "provenance"
7
7
  require_relative "manifest"
@@ -21,14 +21,16 @@ module Samagotchi
21
21
  #
22
22
  # Flow (#run):
23
23
  # 1. Normalize source → directory, read manifest.yml (load_source)
24
- # 2. Copy .md files to the target scope dir and update index.md
24
+ # 2. Refuse a bundle whose requires_chi this chi doesn't meet,
25
+ # unless force (refuse_too_new)
26
+ # 3. Copy .md files to the target scope dir and update index.md
25
27
  # (install_memories: plain, dry run, or an upgrade's 3-way merge)
26
- # 3. Hooks, guardrail rules, plugin, scripts into .bundles/<name>/
28
+ # 4. Hooks, guardrail rules, plugin, scripts into .bundles/<name>/
27
29
  # (AssetInstaller)
28
- # 4. An upgrade prunes the files the bundle dropped
29
- # 5. Verify checksums (strict mode), warn about missing needs
30
- # 6. Write provenance (.bundles/<name>/manifest.json, write_provenance)
31
- # 7. Detect and report {{placeholders}}
30
+ # 5. An upgrade prunes the files the bundle dropped
31
+ # 6. Verify checksums (strict mode), warn about missing needs
32
+ # 7. Write provenance (.bundles/<name>/manifest.json, write_provenance)
33
+ # 8. Detect and report {{placeholders}}
32
34
  class Installer
33
35
  class InstallError < StandardError; end
34
36
 
@@ -42,8 +44,10 @@ module Samagotchi
42
44
  # shipped_dir: the bundles this chi ships (a bundle of their names
43
45
  # installs from a dir in it only, unless force); ShippedUpdate,
44
46
  # Profile and SystemBundle pass the one they install from.
47
+ # chi_version: the chi this install is for (Profile and ShippedUpdate
48
+ # check their bundles against a version they are given); default this chi.
45
49
  def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false, prepared: nil,
46
- shipped_dir: SourceNormalizer::SHIPPED_DIR, dev: false)
50
+ shipped_dir: SourceNormalizer::SHIPPED_DIR, dev: false, chi_version: Samagotchi::VERSION)
47
51
  @source = source
48
52
  @shipped_dir = shipped_dir
49
53
  @prepared = prepared
@@ -54,6 +58,7 @@ module Samagotchi
54
58
  @upgrade = upgrade
55
59
  @dry_run = dry_run
56
60
  @dev = dev
61
+ @chi_version = chi_version
57
62
  @results = {}
58
63
  @warnings = []
59
64
  @placeholder_warnings = []
@@ -99,12 +104,14 @@ module Samagotchi
99
104
 
100
105
  def run
101
106
  load_source
107
+ refuse_too_new
108
+ warn_unknown_keys(@manifest) if @manifest
102
109
  @target_scope, @target_dir = resolve_target
103
110
  @file_descriptions = @manifest&.file_descriptions || {}
104
111
  @provenance = Provenance.new(name: @name)
105
112
  @existing = @provenance.record
106
113
  check_source
107
- # A plain install over an installed bundle: summary points at upgrade.
114
+ # A plain install over an installed bundle: its summary says so.
108
115
  @reinstall = @existing && !@upgrade && !@force && !@dry_run
109
116
  installed = replace_files
110
117
  verify_memory_checksums if @manifest && @strict
@@ -138,7 +145,15 @@ module Samagotchi
138
145
  merged = merged_files
139
146
  would_merge = @results.select { |_, r| r[:status].to_s == "would_merge" }.keys
140
147
  kept = @results.select { |_, r| %w[kept keep kept_pruned].include?(r[:status].to_s) }.keys
141
- skipped = @results.select { |_, r| %w[skipped noop would_skip].include?(r[:status].to_s) }.keys
148
+ # A file whose bytes are already the bundle's was not left out: it is
149
+ # up to date. Skipped keeps the files really left out -- the user's own
150
+ # memory, and a dry run's would_skip (a dry run's `noop` carries no
151
+ # reason, so it stays here).
152
+ up_to_date = @results.select { |_, r| r[:reason] == "already up to date" }.keys
153
+ skipped = @results.select do |_, r|
154
+ status = r[:status].to_s
155
+ status == "would_skip" || status == "noop" || (status == "skipped" && r[:reason] != "already up to date")
156
+ end.keys
142
157
  would_install = @results.select { |_, r| r[:status].to_s == "would_install" }.keys
143
158
  fast_forward = @results.select { |_, r| r[:status].to_s == "fast_forward" }.keys
144
159
  removed = @results.select { |_, r| r[:status].to_s == "removed" }.keys
@@ -153,17 +168,52 @@ module Samagotchi
153
168
  lines << "Removed (no longer in the bundle): #{removed.join(", ")}#{" (moved to #{trash_dir})" if trash_dir}" unless removed.empty?
154
169
  lines << "Would remove (no longer in the bundle): #{would_remove.join(", ")}" unless would_remove.empty?
155
170
  lines << "Kept (local edits preserved): #{kept.join(", ")}" unless kept.empty?
171
+ lines << "Up to date: #{up_to_date.join(", ")}" unless up_to_date.empty?
156
172
  lines << "Skipped: #{skipped.join(", ")}" unless skipped.empty?
157
173
  lines << trash_copy_line if trash_copy_line
158
174
  lines.concat(@warnings) unless @warnings.empty?
159
175
  lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
160
- lines << "#{@name} is already installed; `chi bundle upgrade #{@name}` updates it and keeps local edits" if @reinstall
176
+ lines << reinstall_line if @reinstall
161
177
 
162
178
  lines.any? ? lines.join("\n") : "No files to install."
163
179
  end
164
180
 
181
+ # The one words for a record whose source can't be read again (relative,
182
+ # empty or gone): what `chi bundle upgrade NAME` refuses with and
183
+ # ManifestStanding names. An upgrade from a copy switches the source.
184
+ def self.source_gone_line(name, source)
185
+ recorded = source.to_s.strip
186
+ owner = recorded.empty? ? "#{name} has no recorded source" : "#{name}'s recorded source #{recorded} is gone"
187
+ "#{owner}: chi bundle upgrade <path or URL of a copy>"
188
+ end
189
+
190
+ # The one words for a dev install an upgrade or a plain install meets:
191
+ # it follows its source already. `Installer#refuse_dev_mismatch` raises
192
+ # with them, and `chi bundle upgrade NAME` (which never reaches the
193
+ # Installer for a dev record) prints the same line.
194
+ def self.dev_install_line(name, source)
195
+ "#{name} is a dev install of #{source}: it follows its source already " \
196
+ "(to leave dev mode: chi bundle uninstall #{name}, then chi bundle install without --dev)"
197
+ end
198
+
165
199
  private
166
200
 
201
+ # A plain install over a bundle that is already installed from the same
202
+ # source: it rewrote the bundle's hooks, rules, plugin, scripts and its
203
+ # record, and left a memory the user wrote as it was. So the line says
204
+ # what changed, and points at the upgrade for the memories it kept.
205
+ def reinstall_line
206
+ line = "#{@name} was already installed from #{@source}: its hooks, rules, plugin and scripts " \
207
+ "now match it and its record was rewritten"
208
+ # `already exists` is the only reason a memory is left out of a plain
209
+ # install (skip_existing): its bytes differ from the bundle's.
210
+ if @results.any? { |_, r| r[:reason] == "already exists" }
211
+ line << "; your own copies of its memories were kept (chi bundle upgrade #{@name} " \
212
+ "merges the bundle's changes into them)"
213
+ end
214
+ line
215
+ end
216
+
167
217
  # The steps that replace files: memories, the bundle's own folder, then
168
218
  # the files it dropped. The record isn't written yet, so a failed run is
169
219
  # retried as the same upgrade.
@@ -176,7 +226,7 @@ module Samagotchi
176
226
  installed = AssetInstaller.new(
177
227
  name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
178
228
  results: @results, warnings: @warnings, conflicts: @conflicts, trash: @trash,
179
- force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev
229
+ force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev, chi_version: @chi_version
180
230
  ).run
181
231
 
182
232
  # Files the previous version shipped and this one doesn't.
@@ -232,9 +282,9 @@ module Samagotchi
232
282
  return if @existing.nil? || @existing.dev == @dev
233
283
 
234
284
  if @existing.dev
235
- raise InstallError, "#{@name} is a dev install of #{@existing.source}: it follows its source already " \
236
- "(to leave dev mode: chi bundle uninstall #{@name}, then chi bundle install without --dev)"
285
+ raise InstallError, self.class.dev_install_line(@name, @existing.source)
237
286
  end
287
+
238
288
  raise InstallError, "#{@name} is installed normally; `chi bundle uninstall #{@name}` first, then chi bundle install --dev"
239
289
  end
240
290
 
@@ -298,6 +348,19 @@ module Samagotchi
298
348
  @warnings << "No manifest found — proceeding without strict manifest validation"
299
349
  end
300
350
 
351
+ # A bundle whose requires_chi this chi doesn't meet is not installed at
352
+ # all: --force is what installs it, and its plugin, hooks and rules then
353
+ # warn that they won't load (AssetInstaller). The words come from
354
+ # Manifest.requires_chi_failure, so the requirement is said one way.
355
+ # A dry run is refused the same way: nothing there would load either.
356
+ # @raise [InstallError]
357
+ def refuse_too_new
358
+ failure = Manifest.requires_chi_failure(@manifest&.requires_chi, @chi_version)
359
+ return if failure.nil? || @force
360
+
361
+ raise InstallError, "#{@name} can't be installed: #{failure} (chi update, or --force to install it anyway)"
362
+ end
363
+
301
364
  # The scope the files go to (the CLI's wins over the manifest's) and
302
365
  # its dir, created.
303
366
  # @return [Array(String, String)] scope, dir
@@ -473,7 +536,7 @@ module Samagotchi
473
536
  expected = @manifest.checksum_for(file_key)
474
537
  next unless expected
475
538
 
476
- actual = Digest::SHA256.hexdigest(File.read(target_path))
539
+ actual = FileDigest.hex(target_path)
477
540
  if actual != expected
478
541
  @warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
479
542
  end
@@ -522,7 +585,8 @@ module Samagotchi
522
585
  session_setting_types: @manifest.session_setting_types,
523
586
  settings: @manifest.settings,
524
587
  setting_types: @manifest.setting_types,
525
- dev: @dev
588
+ dev: @dev,
589
+ manifest_from: @bundle_dir
526
590
  )
527
591
  # Replaced by --force: an earlier merge into it no longer says anything true.
528
592
  @provenance.clear_merge_notes([], files: @replaced)
@@ -672,6 +736,16 @@ module Samagotchi
672
736
  File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
673
737
  end
674
738
 
739
+ # One warning per manifest key this chi's parser never reads (docs/cli.md's
740
+ # unknown-key): it is ignored, so the advice is chi's own. A dry run warns
741
+ # too: the key is in the manifest either way.
742
+ def warn_unknown_keys(manifest)
743
+ manifest.unknown_keys.each do |key|
744
+ @warnings << "#{Manifest.unknown_key_line(key)}; a newer chi reads it from the installed manifest " \
745
+ "(chi update, then chi bundle status #{manifest.name} says whether it needs chi bundle upgrade #{manifest.name})"
746
+ end
747
+ end
748
+
675
749
  # Not fatal, like update_target_index: a warning says so.
676
750
  def remove_target_index(scope, file_key)
677
751
  IndexUpdater.remove_index(scope, file_key.delete_suffix(".md"))
@@ -4,6 +4,7 @@ require "yaml"
4
4
  require "digest"
5
5
  require "fileutils"
6
6
  require_relative "../context_providers"
7
+ require_relative "../version"
7
8
  require_relative "bundle_hook"
8
9
 
9
10
  module Samagotchi
@@ -72,15 +73,51 @@ module Samagotchi
72
73
  # A bundle name, as `chi bundle install <name>` takes a shipped one.
73
74
  BUNDLE_NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
74
75
 
76
+ # Every top-level manifest key this chi reads (requires_chi_next is known
77
+ # but read only by the release tooling: docs/releasing.md). A key not
78
+ # here is one this chi ignores: #unknown_keys, which `chi bundle check`
79
+ # and the Installer say.
80
+ KEYS = %w[name version scope description files hooks trust_level plugin requires_chi requires_chi_next needs
81
+ scripts context_providers block_renderers includes session_settings settings].freeze
82
+
83
+ # The first chi release that reads each manifest key or shape (from git:
84
+ # the first tag whose manifest.rb reads it), for `chi bundle check`'s
85
+ # requires-chi-floor on the manifest itself: a bundle whose requires_chi
86
+ # admits a chi that never saw the key. A key the first tag (v0.2.0)
87
+ # already reads has no entry. `files.description` is a files: entry given
88
+ # as a mapping, `*.types` a session_settings:/settings: given as a mapping
89
+ # of keys to types, `hooks.events` a hook entry with an events: list. A key
90
+ # whose release is not out yet waits here as a comment naming it, the way
91
+ # Plugin::Api::SINCE does, until `release:bump` gives it its version
92
+ # (docs/releasing.md).
93
+ SINCE = {
94
+ "includes" => "0.8.0",
95
+ "scripts" => "0.32.0",
96
+ "context_providers" => "0.32.0",
97
+ "files.description" => "0.34.0",
98
+ "session_settings" => "0.84.0",
99
+ "session_settings.types" => "0.92.0",
100
+ "settings.types" => "0.92.0",
101
+ "hooks.events" => "0.98.0",
102
+ "settings" => "0.143.0",
103
+ "block_renderers" => "0.151.0"
104
+ }.freeze
105
+
75
106
  # files: name → "sha256:<hex>" (both forms); file_descriptions: name →
76
107
  # the description a mapping gave, for the files that have one; hooks:
77
108
  # name → BundleHook.
78
109
  attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
79
- :scripts, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings, :setting_types
110
+ :scripts, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings, :setting_types, :unknown_keys
80
111
 
81
112
  def initialize(path:)
82
113
  @path = path
83
114
  raw = load_mapping(path)
115
+ # Every key of the manifest, for #floor_keys to look at the shapes a
116
+ # newer chi reads.
117
+ @raw = raw
118
+ # A key this chi's parser never reads is one it ignores, in the order
119
+ # the manifest lists it.
120
+ @unknown_keys = raw.keys - KEYS
84
121
  @name = require_str(raw, "name")
85
122
  # The name is a directory under .bundles/: never a path.
86
123
  if @name.match?(%r{[/\\\0]}) || %w[. ..].include?(@name)
@@ -124,6 +161,28 @@ module Samagotchi
124
161
  !@includes.empty?
125
162
  end
126
163
 
164
+ # An installed bundle's kept manifest.yml read key by key
165
+ # (InstalledManifest): each key parsed as #initialize parses it, in its
166
+ # own rescue and in this order, so a key that doesn't parse under this
167
+ # chi (a newer chi tightened it, or an older one wrote it) costs only
168
+ # that key, which reads as empty with its message in the problems:
169
+ # name, version (never checked: identity is the record's), scope;
170
+ # files and their descriptions, scripts, plugin; hooks (one bad entry
171
+ # fails the whole key); needs, context_providers; block_renderers
172
+ # (against +fallback_scripts+ when scripts: failed); includes (after
173
+ # what its check reads); session_settings with its types, settings
174
+ # with theirs (one raw value each, so one problem); requires_chi,
175
+ # trust_level, description. Then unknown_keys.
176
+ # @param fallback_scripts [Array<String>] the script names block_renderers
177
+ # is checked against when scripts: doesn't parse (the record's)
178
+ # @return [Array(Manifest, Hash{String => String})] the manifest and
179
+ # key => message for each key that didn't parse
180
+ # @raise [ValidationError] the file isn't YAML or isn't a mapping
181
+ def self.tolerant(path:, fallback_scripts: [])
182
+ manifest = allocate
183
+ [manifest, manifest.send(:read_tolerantly, path, fallback_scripts)]
184
+ end
185
+
127
186
  def self.read(dir:)
128
187
  manifest_path = File.join(dir, "manifest.yml")
129
188
  raise ValidationError, "manifest.yml not found in #{dir}" unless File.exist?(manifest_path)
@@ -132,12 +191,14 @@ module Samagotchi
132
191
  end
133
192
 
134
193
  # Returns the expected sha256 hex digest for a given file key
135
- # (e.g. "identity.md"), with the "sha256:" prefix stripped.
194
+ # (e.g. "identity.md"), with the "sha256:" prefix stripped, nil when
195
+ # the entry declares none (an empty value is nothing to verify).
136
196
  def checksum_for(file_key)
137
197
  raw = @files[file_key]
138
198
  return nil unless raw
139
199
 
140
- raw.start_with?("sha256:") ? raw[7..] : raw
200
+ hex = raw.start_with?("sha256:") ? raw[7..] : raw
201
+ hex unless hex.nil? || hex.empty?
141
202
  end
142
203
 
143
204
  # Returns sha256 hex for a hook basename (e.g. "guardrails.rb"), nil
@@ -162,10 +223,43 @@ module Samagotchi
162
223
  return nil if Gem::Requirement.new(*requirement.to_s.split(",").map(&:strip)).satisfied_by?(Gem::Version.new(version.to_s))
163
224
 
164
225
  "it requires chi #{requirement} (this is chi #{version})"
165
- rescue ArgumentError => e # Gem::Requirement::BadRequirementError is one
226
+ rescue ArgumentError, EncodingError => e
227
+ # Gem::Requirement::BadRequirementError is an ArgumentError; bytes that
228
+ # aren't UTF-8 (a record another chi wrote) raise an EncodingError out
229
+ # of Gem::Requirement. Either way this chi can't read the requirement,
230
+ # which is a failure: the bundle is refused, not loaded.
166
231
  "its requires_chi #{requirement.inspect} is not a version requirement (#{e.message})"
167
232
  end
168
233
 
234
+ # What an unknown manifest key means, said once (docs/cli.md): the
235
+ # Installer and `chi bundle check` add their own fix to it.
236
+ def self.unknown_key_line(key, version = Samagotchi::VERSION)
237
+ "this chi (#{version}) doesn't know the manifest key #{key}:, so it ignores it"
238
+ end
239
+
240
+ # The SINCE keys this manifest actually uses, each with the chi release
241
+ # that first read it: the plain keys, and the shapes only a newer chi
242
+ # reads — a files: entry given as a mapping, a hook entry with an events:
243
+ # list, a session_settings:/settings: given as a mapping of keys to types.
244
+ # @return [Hash{String => String}]
245
+ def floor_keys
246
+ raw = @raw
247
+ found = {}
248
+ found["includes"] = SINCE["includes"] unless @includes.empty?
249
+ found["scripts"] = SINCE["scripts"] unless @scripts.empty?
250
+ found["context_providers"] = SINCE["context_providers"] unless @context_providers.empty?
251
+ found["block_renderers"] = SINCE["block_renderers"] unless @block_renderers.empty?
252
+ found["session_settings"] = SINCE["session_settings"] unless @session_settings.empty?
253
+ found["settings"] = SINCE["settings"] unless @settings.nil?
254
+ found["files.description"] = SINCE["files.description"] if raw["files"].is_a?(Hash) && raw["files"].values.any?(Hash)
255
+ found["session_settings.types"] = SINCE["session_settings.types"] if raw["session_settings"].is_a?(Hash)
256
+ found["settings.types"] = SINCE["settings.types"] if raw["settings"].is_a?(Hash)
257
+ if raw["hooks"].is_a?(Hash) && raw["hooks"].values.any? { |entry| entry.is_a?(Hash) && entry.key?("events") }
258
+ found["hooks.events"] = SINCE["hooks.events"]
259
+ end
260
+ found
261
+ end
262
+
169
263
  # Writes a fresh manifest.yml from computed checksums at `dir`.
170
264
  # +file_descriptions+ (name → text) writes those files as mappings.
171
265
  def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
@@ -277,6 +371,51 @@ module Samagotchi
277
371
 
278
372
  private
279
373
 
374
+ # Manifest.tolerant's read: the order and the keys are its comment's.
375
+ def read_tolerantly(path, fallback_scripts)
376
+ @path = path
377
+ raw = @raw = load_mapping(path)
378
+ problems = {}
379
+ @name = raw["name"].to_s
380
+ @version = raw["version"].to_s
381
+ @scope = normalize_scope(raw["scope"])
382
+ @files, @file_descriptions = tolerate(problems, "files", [{}, {}]) do
383
+ [parse_files(raw["files"] || {}), parse_file_descriptions(raw["files"])]
384
+ end
385
+ @scripts = tolerate(problems, "scripts", {}) { parse_scripts(raw["scripts"]) }
386
+ @plugin = tolerate(problems, "plugin", nil) { parse_plugin(raw["plugin"]) }
387
+ @hooks = tolerate(problems, "hooks", {}) { parse_hooks(raw["hooks"] || {}) }
388
+ @needs = tolerate(problems, "needs", []) { self.class.parse_needs(raw["needs"]) }
389
+ @context_providers = tolerate(problems, "context_providers", []) { parse_context_providers(raw["context_providers"]) }
390
+ script_names = problems.key?("scripts") ? fallback_scripts.map(&:to_s) : @scripts.keys
391
+ @block_renderers = tolerate(problems, "block_renderers", []) { parse_block_renderers(raw["block_renderers"], script_names) }
392
+ @includes = tolerate(problems, "includes", []) { parse_includes(raw["includes"]) }
393
+ @session_settings, @session_setting_types = tolerate(problems, "session_settings", [[], {}]) do
394
+ [self.class.parse_session_settings(raw["session_settings"]), self.class.parse_session_setting_types(raw["session_settings"])]
395
+ end
396
+ @settings, @setting_types = tolerate(problems, "settings", [nil, {}]) do
397
+ next [nil, {}] if raw["settings"].nil?
398
+
399
+ [self.class.parse_session_settings(raw["settings"], key: "settings"),
400
+ self.class.parse_session_setting_types(raw["settings"], key: "settings")]
401
+ end
402
+ @requires_chi = tolerate(problems, "requires_chi", nil) { parse_requires_chi(raw["requires_chi"]) }
403
+ @trust_level = tolerate(problems, "trust_level", "experimental") { (raw["trust_level"] || "experimental").to_s }
404
+ @description = tolerate(problems, "description", "") { (raw["description"] || "").to_s }
405
+ @unknown_keys = raw.keys - KEYS
406
+ problems
407
+ end
408
+
409
+ # The block's value, else +empty+ with the key's problem noted. Anything
410
+ # a parser raises counts: the file is a load-time input, and one key
411
+ # must never cost the bundle.
412
+ def tolerate(problems, key, empty)
413
+ yield
414
+ rescue StandardError => e
415
+ problems[key] = e.message
416
+ empty
417
+ end
418
+
280
419
  # The manifest.yml's top-level mapping ({} for an empty file); YAML that
281
420
  # doesn't parse, or isn't a mapping, raises ValidationError, as every
282
421
  # other manifest problem does.
@@ -369,13 +508,14 @@ module Samagotchi
369
508
  raise ValidationError, e.message
370
509
  end
371
510
 
511
+ # @param script_names [Array<String>] the scripts: keys each load must be
372
512
  # @return [Array<BlockRenderers::Renderer>] each load is a scripts: key
373
- def parse_block_renderers(raw)
513
+ def parse_block_renderers(raw, script_names = @scripts.keys)
374
514
  return [] if raw.nil?
375
515
 
376
516
  renderers = BlockRenderers.parse_list(raw)
377
517
  renderers.each do |renderer|
378
- missing = renderer.load - @scripts.keys
518
+ missing = renderer.load - script_names
379
519
  raise ValidationError, "block_renderers: #{renderer.lang} loads #{missing.join(", ")}, not under scripts:" unless missing.empty?
380
520
  end
381
521
  renderers
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "installed_bundle"
4
+ require_relative "installer"
5
+ require_relative "manifest"
6
+ require_relative "provenance"
7
+ require_relative "source"
8
+
9
+ module Samagotchi
10
+ module MemoryBundle
11
+ # The one owner of the words for an installed bundle's manifest.yml, as
12
+ # InstalledManifest read it (chi bundle status's Manifest: line, chi
13
+ # update's row), and of keeping it for a record an older chi wrote
14
+ # (.keep: Provenance#adopt_manifest from where it came from). The cases:
15
+ # :kept read from the kept file; a line only when chi +installed_by+
16
+ # predates a key it uses (Manifest::SINCE): newer than its installer
17
+ # :legacy no kept file, no manifest_sha: a chi before the kept manifest installed it
18
+ # :stale a kept file, no manifest_sha: such a chi rewrote the record since
19
+ # :missing a manifest_sha, no kept file
20
+ # :edited the kept file isn't the recorded one (or isn't a YAML mapping)
21
+ # The first three and :missing are "unkept": chi update keeps the manifest
22
+ # where it has the bundle's own at the recorded version, else the line
23
+ # names the command that does.
24
+ module ManifestStanding
25
+ UNKEPT = %i[legacy stale missing].freeze
26
+
27
+ # What .keep did: bundle is the record after it (re-read when kept),
28
+ # line what to say (nil: nothing), kept whether it kept (or, in a dry
29
+ # run, would keep) the manifest.
30
+ Outcome = Data.define(:bundle, :line, :kept)
31
+
32
+ module_function
33
+
34
+ # @param bundle [InstalledBundle]
35
+ # @return [Symbol, nil] the case, nil for a record with no dir or that doesn't read
36
+ def state(bundle)
37
+ return nil if bundle.error? || bundle.dir.nil?
38
+
39
+ manifest = bundle.manifest
40
+ return :kept if manifest.origin == :kept
41
+
42
+ manifest.standing || :legacy
43
+ end
44
+
45
+ # "[kept]" when chi reads the kept manifest.yml, else "[from the install record]".
46
+ def label(bundle) = state(bundle) == :kept ? "[kept]" : "[from the install record]"
47
+
48
+ # The case in words with what to run, nil for a clean kept one.
49
+ # @param refused [String, Symbol, nil] why .keep didn't keep it: :git (not
50
+ # cloned), :gone (no source left), or Provenance#adopt_manifest's why.
51
+ # nil: not tried, so a source chi update can read says that it will.
52
+ # @return [String, nil]
53
+ def line(bundle, refused: nil)
54
+ case (state = state(bundle))
55
+ when :kept then newer_line(bundle)
56
+ when :edited then "manifest.yml changed since it was installed, so chi reads the install record (#{reinstall(bundle)})"
57
+ when *UNKEPT then "#{unkept_words(state)}: #{unkept_fix(bundle, refused)}"
58
+ end
59
+ end
60
+
61
+ # #line as chi bundle status says it: for a record chi update would keep
62
+ # from its folder, a dry Provenance#adopt_manifest first, so the line says
63
+ # whether it will or why not (an archive isn't extracted here: chi update
64
+ # tries it).
65
+ # @return [String, nil]
66
+ # A bundle chi ships is checked against this chi's shipped copy, which chi
67
+ # update reads (at another version, chi update upgrades it instead).
68
+ def status_line(bundle)
69
+ return line(bundle) unless bundle.manifest_unkept? && !bundle.dev && !bundle.profile?
70
+
71
+ dir = try_dir(bundle)
72
+ dir ? line(bundle, refused: Provenance.new(name: bundle.name).adopt_manifest(dir, dry_run: true)) : line(bundle)
73
+ end
74
+
75
+ # The folder chi update keeps it from, nil when it wouldn't try one here.
76
+ def try_dir(bundle)
77
+ source = bundle.source.to_s.strip
78
+ unless SourceIdentity.of(source)&.shipped?
79
+ return source.start_with?("/") && File.directory?(source) ? source : nil
80
+ end
81
+
82
+ dir = File.join(SourceNormalizer::SHIPPED_DIR, File.basename(source.chomp("/")))
83
+ File.file?(File.join(dir, "manifest.yml")) && Manifest.read(dir: dir).version == bundle.version.to_s ? dir : nil
84
+ rescue Manifest::ValidationError, Psych::Exception
85
+ nil
86
+ end
87
+
88
+ # Keeps the manifest of a record without a usable kept one (UNKEPT, not
89
+ # dev) from +source_dir+, else from the record's source: a dir as it is,
90
+ # a zip or tar extracted and removed after; a git URL is never cloned.
91
+ # A bundle with nothing to keep gets its #line.
92
+ # @return [Outcome]
93
+ def keep(bundle, source_dir: nil, dry_run: false)
94
+ return Outcome.new(bundle: bundle, line: line(bundle), kept: false) unless bundle.manifest_unkept? && !bundle.dev
95
+
96
+ refused = adopt(bundle, source_dir, dry_run)
97
+ return Outcome.new(bundle: bundle, line: line(bundle, refused: refused), kept: false) if refused
98
+
99
+ kept = dry_run ? bundle : Provenance.new(name: bundle.name).record
100
+ Outcome.new(bundle: kept, line: dry_run ? "would keep its manifest" : "kept its manifest", kept: true)
101
+ end
102
+
103
+ # @return [String, Symbol, nil] why not (as #line's refused:), nil when kept
104
+ def adopt(bundle, source_dir, dry_run)
105
+ provenance = Provenance.new(name: bundle.name)
106
+ return provenance.adopt_manifest(source_dir, dry_run: dry_run) if source_dir
107
+
108
+ source = bundle.source.to_s.strip
109
+ return :git if SourceNormalizer.git_url?(source)
110
+ return :gone unless local?(source)
111
+ return provenance.adopt_manifest(source, dry_run: dry_run) if File.directory?(source)
112
+
113
+ prepared = PreparedSource.prepare(source)
114
+ begin
115
+ provenance.adopt_manifest(prepared.dir, dry_run: dry_run)
116
+ ensure
117
+ prepared.cleanup
118
+ end
119
+ rescue SourceNormalizer::UnknownSourceError => e
120
+ e.message
121
+ end
122
+
123
+ def unkept_words(state)
124
+ case state
125
+ when :legacy then "installed by an older chi, which didn't keep its manifest"
126
+ when :stale then "an older chi rewrote its record after the install, so its manifest.yml may be an earlier version's"
127
+ else "its kept manifest.yml is gone, so chi reads the install record"
128
+ end
129
+ end
130
+
131
+ def unkept_fix(bundle, refused)
132
+ source = bundle.source.to_s.strip
133
+ return reinstall(bundle) if bundle.dev
134
+ return "chi bundle upgrade #{bundle.name} reads it again from #{source}" if refused == :git || SourceNormalizer.git_url?(source)
135
+ return Installer.source_gone_line(bundle.name, source) if gone?(source, refused)
136
+
137
+ shipped = SourceIdentity.of(source)&.shipped?
138
+ # chi update keeps a profile's only when chi ships it (ShippedUpdate).
139
+ return "chi bundle upgrade #{bundle.name} reads it again from #{source}" if bundle.profile? && !shipped
140
+
141
+ if refused
142
+ upgrade = shipped ? "chi bundle upgrade #{bundle.name}" : "chi bundle upgrade #{bundle.name} reads it again from #{source}"
143
+ return "#{upgrade} (#{refused})"
144
+ end
145
+ return "chi update keeps it" if shipped
146
+
147
+ File.file?(source) ? "chi update tries to keep it from #{source}" : "chi update keeps it from #{source}"
148
+ end
149
+
150
+ def gone?(source, refused) = refused == :gone || (refused.nil? && !SourceIdentity.of(source)&.shipped? && !local?(source))
151
+
152
+ # A source chi can read again here: an absolute path that is there.
153
+ def local?(source) = source.start_with?("/") && File.exist?(source)
154
+
155
+ def reinstall(bundle) = bundle.dev ? "chi bundle install --dev #{bundle.source}" : "chi bundle upgrade #{bundle.name}"
156
+
157
+ # The kept keys whose floor is above the chi that installed it.
158
+ def newer_line(bundle)
159
+ by = bundle.installed_by.to_s
160
+ return nil if by.empty? || !Gem::Version.correct?(by)
161
+
162
+ newer = bundle.manifest.floor_keys.select { |_, floor| Gem::Version.new(floor) > Gem::Version.new(by) }
163
+ return nil if newer.empty?
164
+
165
+ keys = newer.map { |key, floor| "#{key}: (#{floor})" }.join(", ")
166
+ "installed by chi #{by}, which didn't act on #{keys}: #{reinstall(bundle)}"
167
+ end
168
+ private_class_method :try_dir, :adopt, :unkept_words, :unkept_fix, :gone?, :local?, :reinstall, :newer_line
169
+ end
170
+ end
171
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require_relative "../atomic_file"
5
+ require_relative "file_digest"
5
6
  require_relative "installed_bundle"
6
7
 
7
8
  module Samagotchi
@@ -154,14 +155,12 @@ module Samagotchi
154
155
 
155
156
  # No base (legacy bundle without provenance or first file) -> treat as modified
156
157
  unless base_exists
157
- incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
158
- current_digest = Digest::SHA256.hexdigest(File.read(current_path))
159
- return current_digest == incoming_digest ? NOOP : CONFLICT
158
+ return FileDigest.hex(current_path) == FileDigest.hex(incoming_path) ? NOOP : CONFLICT
160
159
  end
161
160
 
162
- base_digest = Digest::SHA256.hexdigest(File.read(base_path))
163
- current_digest = Digest::SHA256.hexdigest(File.read(current_path))
164
- incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
161
+ base_digest = FileDigest.hex(base_path)
162
+ current_digest = FileDigest.hex(current_path)
163
+ incoming_digest = FileDigest.hex(incoming_path)
165
164
 
166
165
  if current_digest == incoming_digest
167
166
  NOOP
@@ -242,13 +241,13 @@ module Samagotchi
242
241
 
243
242
  # The file's digest, nil when there is none.
244
243
  def self.digest(path)
245
- File.exist?(path) ? Digest::SHA256.hexdigest(File.binread(path)) : nil
244
+ FileDigest.hex(path)
246
245
  end
247
246
 
248
247
  def self.current_modified?(base_path, current_path)
249
248
  return true unless base_path && File.exist?(base_path) && File.exist?(current_path)
250
249
 
251
- Digest::SHA256.hexdigest(File.read(base_path)) != Digest::SHA256.hexdigest(File.read(current_path))
250
+ FileDigest.hex(base_path) != FileDigest.hex(current_path)
252
251
  end
253
252
  end
254
253
  end
@@ -114,7 +114,8 @@ module Samagotchi
114
114
 
115
115
  Provenance.new(name: manifest.name).write(
116
116
  files: {}, scope: "system", version: manifest.version, source_path: dir,
117
- trust_level: manifest.trust_level, requires_chi: manifest.requires_chi, includes: includes
117
+ trust_level: manifest.trust_level, requires_chi: manifest.requires_chi, includes: includes,
118
+ manifest_from: dir
118
119
  )
119
120
  result
120
121
  end
@@ -127,7 +128,7 @@ module Samagotchi
127
128
  # A dry run runs the Installer's too, which writes nothing, so it
128
129
  # names the files the real run would keep as the user's own.
129
130
  installer = Installer.new(source: member_dir, name: member, scope: "system", force: force, strict: true,
130
- shipped_dir: shipped_dir, dry_run: dry_run)
131
+ shipped_dir: shipped_dir, dry_run: dry_run, chi_version: chi_version)
131
132
  installer.run
132
133
  result.installed << member
133
134
  kept = installer.results.select { |_, entry| kept_own?(entry) }.keys