samagotchi 0.150.0 → 0.152.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/bundle-authoring.md +5 -5
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +1 -1
  7. data/docs/guardrails.md +4 -0
  8. data/docs/internals/block-renderers.md +98 -0
  9. data/docs/internals/bridge.md +53 -17
  10. data/docs/internals/bundles.md +49 -14
  11. data/docs/internals/objectives.md +6 -4
  12. data/docs/internals/web-frontend.md +2 -2
  13. data/docs/memory.md +7 -2
  14. data/docs/plugins.md +91 -14
  15. data/docs/releasing.md +7 -2
  16. data/docs/sessions.md +1 -1
  17. data/lib/samagotchi/block_renderers.rb +116 -0
  18. data/lib/samagotchi/bridge/card_places.rb +93 -0
  19. data/lib/samagotchi/bridge/card_store.rb +219 -122
  20. data/lib/samagotchi/bridge.rb +12 -4
  21. data/lib/samagotchi/bundle_command/check.rb +43 -3
  22. data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
  23. data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
  24. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  25. data/lib/samagotchi/bundle_command.rb +88 -10
  26. data/lib/samagotchi/bundle_needs.rb +4 -6
  27. data/lib/samagotchi/bundle_settings_check.rb +14 -13
  28. data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
  29. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  33. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  35. data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
  36. data/lib/samagotchi/child_env.rb +15 -0
  37. data/lib/samagotchi/context_providers.rb +42 -10
  38. data/lib/samagotchi/engine.rb +47 -6
  39. data/lib/samagotchi/extension_load.rb +4 -3
  40. data/lib/samagotchi/guardrail_wiring.rb +28 -19
  41. data/lib/samagotchi/guardrails/approval.rb +2 -2
  42. data/lib/samagotchi/guardrails/rules.rb +1 -0
  43. data/lib/samagotchi/guardrails/verdict.rb +20 -0
  44. data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
  45. data/lib/samagotchi/memory_bundle/asset_installer.rb +18 -9
  46. data/lib/samagotchi/memory_bundle/builder.rb +39 -31
  47. data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
  48. data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
  49. data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
  50. data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -43
  51. data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -14
  52. data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
  53. data/lib/samagotchi/memory_bundle/installer.rb +91 -16
  54. data/lib/samagotchi/memory_bundle/manifest.rb +166 -5
  55. data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
  56. data/lib/samagotchi/memory_bundle/merger.rb +7 -8
  57. data/lib/samagotchi/memory_bundle/profile.rb +3 -2
  58. data/lib/samagotchi/memory_bundle/provenance.rb +220 -19
  59. data/lib/samagotchi/memory_bundle/shas.rb +4 -3
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
  61. data/lib/samagotchi/memory_bundle/status.rb +11 -10
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
  63. data/lib/samagotchi/plugin/loader.rb +1 -1
  64. data/lib/samagotchi/session_chain.rb +2 -7
  65. data/lib/samagotchi/session_commands.rb +15 -9
  66. data/lib/samagotchi/session_config.rb +5 -4
  67. data/lib/samagotchi/session_manager.rb +10 -7
  68. data/lib/samagotchi/steer.rb +17 -0
  69. data/lib/samagotchi/system_one/record.rb +10 -2
  70. data/lib/samagotchi/terminal_ui.rb +4 -4
  71. data/lib/samagotchi/tools/delegate.rb +20 -2
  72. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  73. data/lib/samagotchi/turn_flow.rb +2 -3
  74. data/lib/samagotchi/update_command.rb +13 -2
  75. data/lib/samagotchi/version.rb +1 -1
  76. data/lib/samagotchi/web/app.rb +24 -13
  77. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  78. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  79. data/lib/samagotchi/web/message_display.rb +3 -3
  80. data/lib/samagotchi/web/participant_view.rb +1 -1
  81. data/lib/samagotchi/web/public/app.js +2 -0
  82. data/lib/samagotchi/web/public/block_frame.js +202 -0
  83. data/lib/samagotchi/web/public/block_host.js +502 -0
  84. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  85. data/lib/samagotchi/web/public/block_source.js +37 -0
  86. data/lib/samagotchi/web/public/bubbles.js +9 -5
  87. data/lib/samagotchi/web/public/card.js +17 -10
  88. data/lib/samagotchi/web/public/cards_place.js +2 -2
  89. data/lib/samagotchi/web/public/claim_link.js +4 -2
  90. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  91. data/lib/samagotchi/web/public/index.html +2 -0
  92. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  93. data/lib/samagotchi/web/public/text_roots.js +6 -0
  94. data/lib/samagotchi/web/public/turn_html.js +4 -1
  95. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  96. data/lib/samagotchi/web/session_hub.rb +6 -0
  97. data/lib/samagotchi/web/session_show.rb +1 -1
  98. data/lib/samagotchi/worker.rb +4 -2
  99. metadata +13 -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
@@ -516,12 +579,14 @@ module Samagotchi
516
579
  incoming_dir: @bundle_dir,
517
580
  scripts_files: installed.script_files,
518
581
  context_providers: @manifest.context_providers,
582
+ block_renderers: @manifest.block_renderers,
519
583
  file_descriptions: @manifest.file_descriptions,
520
584
  session_settings: @manifest.session_settings,
521
585
  session_setting_types: @manifest.session_setting_types,
522
586
  settings: @manifest.settings,
523
587
  setting_types: @manifest.setting_types,
524
- dev: @dev
588
+ dev: @dev,
589
+ manifest_from: @bundle_dir
525
590
  )
526
591
  # Replaced by --force: an earlier merge into it no longer says anything true.
527
592
  @provenance.clear_merge_notes([], files: @replaced)
@@ -671,6 +736,16 @@ module Samagotchi
671
736
  File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
672
737
  end
673
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
+
674
749
  # Not fatal, like update_target_index: a warning says so.
675
750
  def remove_target_index(scope, file_key)
676
751
  IndexUpdater.remove_index(scope, file_key.delete_suffix(".md"))
@@ -4,9 +4,12 @@ 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
11
+ autoload :BlockRenderers, File.expand_path("../block_renderers", __dir__)
12
+
10
13
  module MemoryBundle
11
14
  # Parses and validates a bundle manifest.yml.
12
15
  #
@@ -41,6 +44,9 @@ module Samagotchi
41
44
  # cmd: '{ruby} {bundle_dir}/scripts/pr_context.rb {url}'
42
45
  # why: GitHub PR # optional
43
46
  # every_seconds: 300 # optional
47
+ # block_renderers: # optional — a fence's language → the scripts/
48
+ # - lang: mermaid # files chi web's page loads to draw it
49
+ # load: [lib.js, draw.js] # (BlockRenderers); each one a scripts: key
44
50
  # session_settings: [cap] # optional — the settings a session may set
45
51
  # # (chi --set cost-cap.cap=3), read by the
46
52
  # # plugin from ctx.settings at call time;
@@ -67,15 +73,51 @@ module Samagotchi
67
73
  # A bundle name, as `chi bundle install <name>` takes a shipped one.
68
74
  BUNDLE_NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
69
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
+
70
106
  # files: name → "sha256:<hex>" (both forms); file_descriptions: name →
71
107
  # the description a mapping gave, for the files that have one; hooks:
72
108
  # name → BundleHook.
73
109
  attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
74
- :scripts, :context_providers, :session_settings, :session_setting_types, :settings, :setting_types
110
+ :scripts, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings, :setting_types, :unknown_keys
75
111
 
76
112
  def initialize(path:)
77
113
  @path = path
78
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
79
121
  @name = require_str(raw, "name")
80
122
  # The name is a directory under .bundles/: never a path.
81
123
  if @name.match?(%r{[/\\\0]}) || %w[. ..].include?(@name)
@@ -94,6 +136,7 @@ module Samagotchi
94
136
  @needs = self.class.parse_needs(raw["needs"])
95
137
  @scripts = parse_scripts(raw["scripts"])
96
138
  @context_providers = parse_context_providers(raw["context_providers"])
139
+ @block_renderers = parse_block_renderers(raw["block_renderers"])
97
140
  @includes = parse_includes(raw["includes"])
98
141
  @session_settings = self.class.parse_session_settings(raw["session_settings"])
99
142
  @session_setting_types = self.class.parse_session_setting_types(raw["session_settings"])
@@ -118,6 +161,28 @@ module Samagotchi
118
161
  !@includes.empty?
119
162
  end
120
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
+
121
186
  def self.read(dir:)
122
187
  manifest_path = File.join(dir, "manifest.yml")
123
188
  raise ValidationError, "manifest.yml not found in #{dir}" unless File.exist?(manifest_path)
@@ -126,12 +191,14 @@ module Samagotchi
126
191
  end
127
192
 
128
193
  # Returns the expected sha256 hex digest for a given file key
129
- # (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).
130
196
  def checksum_for(file_key)
131
197
  raw = @files[file_key]
132
198
  return nil unless raw
133
199
 
134
- raw.start_with?("sha256:") ? raw[7..] : raw
200
+ hex = raw.start_with?("sha256:") ? raw[7..] : raw
201
+ hex unless hex.nil? || hex.empty?
135
202
  end
136
203
 
137
204
  # Returns sha256 hex for a hook basename (e.g. "guardrails.rb"), nil
@@ -156,14 +223,47 @@ module Samagotchi
156
223
  return nil if Gem::Requirement.new(*requirement.to_s.split(",").map(&:strip)).satisfied_by?(Gem::Version.new(version.to_s))
157
224
 
158
225
  "it requires chi #{requirement} (this is chi #{version})"
159
- 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.
160
231
  "its requires_chi #{requirement.inspect} is not a version requirement (#{e.message})"
161
232
  end
162
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
+
163
263
  # Writes a fresh manifest.yml from computed checksums at `dir`.
164
264
  # +file_descriptions+ (name → text) writes those files as mappings.
165
265
  def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
166
- plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], file_descriptions: {},
266
+ plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], block_renderers: [], file_descriptions: {},
167
267
  session_settings: [], session_setting_types: {}, settings: nil, setting_types: {})
168
268
  FileUtils.mkdir_p(dir)
169
269
  manifest = {
@@ -187,6 +287,7 @@ module Samagotchi
187
287
  manifest["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } unless needs.empty?
188
288
  manifest["scripts"] = scripts.transform_keys(&:to_s) unless scripts.empty?
189
289
  manifest["context_providers"] = context_providers.map(&:to_h) unless context_providers.empty?
290
+ manifest["block_renderers"] = block_renderers.map(&:to_h) unless block_renderers.empty?
190
291
  manifest["session_settings"] = session_settings_entry(session_settings, session_setting_types) unless Array(session_settings).empty?
191
292
  manifest["settings"] = session_settings_entry(settings, setting_types) unless settings.nil?
192
293
  File.write(File.join(dir, "manifest.yml"), YAML.dump(manifest))
@@ -270,6 +371,51 @@ module Samagotchi
270
371
 
271
372
  private
272
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
+
273
419
  # The manifest.yml's top-level mapping ({} for an empty file); YAML that
274
420
  # doesn't parse, or isn't a mapping, raises ValidationError, as every
275
421
  # other manifest problem does.
@@ -362,6 +508,21 @@ module Samagotchi
362
508
  raise ValidationError, e.message
363
509
  end
364
510
 
511
+ # @param script_names [Array<String>] the scripts: keys each load must be
512
+ # @return [Array<BlockRenderers::Renderer>] each load is a scripts: key
513
+ def parse_block_renderers(raw, script_names = @scripts.keys)
514
+ return [] if raw.nil?
515
+
516
+ renderers = BlockRenderers.parse_list(raw)
517
+ renderers.each do |renderer|
518
+ missing = renderer.load - script_names
519
+ raise ValidationError, "block_renderers: #{renderer.lang} loads #{missing.join(", ")}, not under scripts:" unless missing.empty?
520
+ end
521
+ renderers
522
+ rescue BlockRenderers::Invalid => e
523
+ raise ValidationError, e.message
524
+ end
525
+
365
526
  # includes: → the names in order, duplicates dropped; [] when absent.
366
527
  # A meta ships nothing of its own, so files, hooks or a plugin next
367
528
  # to it is a ValidationError.
@@ -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