samagotchi 0.157.0 → 0.159.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/README.md +1 -1
  4. data/bin/chi +5 -1
  5. data/docs/architecture.md +14 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +2 -1
  8. data/docs/cli.md +76 -17
  9. data/docs/configuration.md +16 -5
  10. data/docs/context.md +12 -4
  11. data/docs/guardrails.md +11 -8
  12. data/docs/hooks.md +47 -80
  13. data/docs/internals/bridge.md +2 -1
  14. data/docs/internals/bundles.md +51 -36
  15. data/docs/internals/engine.md +7 -5
  16. data/docs/memory.md +43 -9
  17. data/docs/plugins.md +13 -9
  18. data/docs/releasing.md +6 -5
  19. data/docs/scheduling.md +228 -0
  20. data/docs/sessions.md +30 -6
  21. data/docs/testing.md +4 -4
  22. data/lib/samagotchi/bridge/card_places.rb +5 -13
  23. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  24. data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
  25. data/lib/samagotchi/bridge.rb +26 -2
  26. data/lib/samagotchi/bundle_command/check.rb +23 -10
  27. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  28. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  29. data/lib/samagotchi/bundle_command.rb +53 -44
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  31. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  32. data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/client_id.rb +13 -3
  35. data/lib/samagotchi/config.rb +12 -0
  36. data/lib/samagotchi/context_command.rb +7 -5
  37. data/lib/samagotchi/context_move.rb +74 -0
  38. data/lib/samagotchi/context_sources.rb +4 -2
  39. data/lib/samagotchi/engine.rb +1 -6
  40. data/lib/samagotchi/extension_load.rb +20 -41
  41. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  42. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  43. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  44. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  45. data/lib/samagotchi/hook_routes.rb +2 -2
  46. data/lib/samagotchi/hooks.rb +2 -3
  47. data/lib/samagotchi/memory_bundle/asset_installer.rb +112 -211
  48. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  49. data/lib/samagotchi/memory_bundle/builder.rb +10 -34
  50. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  51. data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
  52. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  53. data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
  54. data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
  55. data/lib/samagotchi/memory_bundle/installer.rb +32 -14
  56. data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
  57. data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
  58. data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
  59. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +41 -41
  61. data/lib/samagotchi/memory_bundle/status.rb +30 -9
  62. data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
  63. data/lib/samagotchi/memory_bundle/trash.rb +7 -12
  64. data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
  65. data/lib/samagotchi/memory_orphans.rb +4 -4
  66. data/lib/samagotchi/memory_paths.rb +102 -13
  67. data/lib/samagotchi/plugin/api.rb +1 -4
  68. data/lib/samagotchi/plugin/loader.rb +42 -5
  69. data/lib/samagotchi/project_command.rb +167 -0
  70. data/lib/samagotchi/project_init.rb +69 -0
  71. data/lib/samagotchi/project_scope.rb +6 -4
  72. data/lib/samagotchi/prompt_files.rb +10 -7
  73. data/lib/samagotchi/recap_place.rb +3 -4
  74. data/lib/samagotchi/run_options.rb +2 -0
  75. data/lib/samagotchi/schedule_command.rb +252 -0
  76. data/lib/samagotchi/schedules.rb +948 -0
  77. data/lib/samagotchi/session.rb +44 -18
  78. data/lib/samagotchi/session_access/contract.rb +1 -1
  79. data/lib/samagotchi/session_access/local.rb +20 -0
  80. data/lib/samagotchi/session_field.rb +5 -4
  81. data/lib/samagotchi/session_manager/spawn.rb +25 -11
  82. data/lib/samagotchi/session_manager/worker_command.rb +13 -13
  83. data/lib/samagotchi/steer.rb +10 -0
  84. data/lib/samagotchi/system_prompt.rb +20 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  86. data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
  87. data/lib/samagotchi/testing/bundle.rb +5 -12
  88. data/lib/samagotchi/testing/install.rb +5 -5
  89. data/lib/samagotchi/testing/recorder.rb +1 -1
  90. data/lib/samagotchi/testing.rb +1 -1
  91. data/lib/samagotchi/tool_declarations.rb +44 -1
  92. data/lib/samagotchi/toolkit_binding.rb +1 -4
  93. data/lib/samagotchi/toolkit_command.rb +1 -4
  94. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  95. data/lib/samagotchi/tools/builtins.rb +17 -0
  96. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  97. data/lib/samagotchi/tools/delegate.rb +12 -8
  98. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  99. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +35 -0
  102. data/lib/samagotchi/web/project_list.rb +3 -2
  103. data/lib/samagotchi/web/public/chains.js +32 -8
  104. data/lib/samagotchi/web/public/index.html +10 -2
  105. data/lib/samagotchi/web/public/info_bar.js +4 -3
  106. data/lib/samagotchi/web/public/project_picker.js +86 -4
  107. data/lib/samagotchi/web/public/turn_events.js +4 -0
  108. data/lib/samagotchi/web/session_hub.rb +20 -0
  109. data/lib/samagotchi/worker.rb +24 -6
  110. data/lib/samagotchi/worker_boot.rb +3 -4
  111. data/lib/samagotchi/worker_inbound.rb +23 -0
  112. data/lib/samagotchi/worker_link.rb +5 -0
  113. data/lib/samagotchi/worker_wakes.rb +9 -1
  114. metadata +10 -2
  115. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
@@ -5,7 +5,6 @@ require "digest"
5
5
  require "fileutils"
6
6
  require_relative "../context_providers"
7
7
  require_relative "../version"
8
- require_relative "bundle_hook"
9
8
 
10
9
  module Samagotchi
11
10
  autoload :BlockRenderers, File.expand_path("../block_renderers", __dir__)
@@ -66,6 +65,26 @@ module Samagotchi
66
65
  class Manifest
67
66
  class ValidationError < StandardError; end
68
67
 
68
+ # Class-file bundle hooks (hooks/*.rb, the manifest's hooks:) are
69
+ # retired: chi installs and loads none. WORDS is the one wording of
70
+ # it, wherever chi meets one (the Engine's first-turn line, install,
71
+ # chi bundle check and status, chi update's row). A strict read of a
72
+ # manifest with a hooks: entry raises it (.for_key's message); a
73
+ # tolerant read (a kept manifest.yml) ignores the key.
74
+ class HooksRetired < ValidationError
75
+ WORDS = "class-file bundle hooks are retired: chi loads no hooks/*.rb; a bundle hooks events from its " \
76
+ "plugin.rb with chi.on, see docs/plugins.md"
77
+
78
+ # The manifest's hooks: key.
79
+ def self.for_key = new("hooks: #{WORDS}")
80
+
81
+ # A source's hooks/*.rb (+files+, relative), with or without the key.
82
+ def self.for_files(files) = new("#{files.join(", ")}: #{WORDS}")
83
+
84
+ # The source's hooks/*.rb, relative and sorted ([] for none).
85
+ def self.files_in(dir) = Dir.glob(File.join(dir, "hooks", "*.rb")).map { |f| "hooks/#{File.basename(f)}" }.sort
86
+ end
87
+
69
88
  # A plugin file is a plain .rb name in the bundle's top directory.
70
89
  PLUGIN_FILE = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\.rb\z/
71
90
  # A script is a plain file name in the bundle's scripts/.
@@ -88,7 +107,7 @@ module Samagotchi
88
107
  # admits a chi that never saw the key. A key the first tag (v0.2.0)
89
108
  # already reads has no entry. `files.description` is a files: entry given
90
109
  # as a mapping, `*.types` a session_settings:/settings: given as a mapping
91
- # of keys to types, `hooks.events` a hook entry with an events: list. A key
110
+ # of keys to types. A key
92
111
  # whose release is not out yet waits here as a comment naming it, the way
93
112
  # Plugin::Api::SINCE does, until `release:bump` gives it its version
94
113
  # (docs/releasing.md).
@@ -100,16 +119,14 @@ module Samagotchi
100
119
  "session_settings" => "0.84.0",
101
120
  "session_settings.types" => "0.92.0",
102
121
  "settings.types" => "0.92.0",
103
- "hooks.events" => "0.98.0",
104
122
  "settings" => "0.143.0",
105
123
  "block_renderers" => "0.151.0",
106
124
  "plugin.required" => "0.157.0"
107
125
  }.freeze
108
126
 
109
127
  # files: name → "sha256:<hex>" (both forms); file_descriptions: name →
110
- # the description a mapping gave, for the files that have one; hooks:
111
- # name → BundleHook.
112
- attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes,
128
+ # the description a mapping gave, for the files that have one.
129
+ attr_reader :name, :version, :scope, :description, :files, :file_descriptions, :trust_level, :plugin, :requires_chi, :needs, :includes,
113
130
  :scripts, :context_providers, :block_renderers, :session_settings, :session_setting_types, :settings, :setting_types, :unknown_keys
114
131
 
115
132
  def initialize(path:)
@@ -131,7 +148,8 @@ module Samagotchi
131
148
  @scope = normalize_scope(raw["scope"])
132
149
  @description = (raw["description"] || "").to_s
133
150
  @files = parse_files(raw["files"] || {})
134
- @hooks = parse_hooks(raw["hooks"] || {})
151
+ raise HooksRetired.for_key if self.class.lists_hooks?(raw["hooks"])
152
+
135
153
  @file_descriptions = parse_file_descriptions(raw["files"])
136
154
  @trust_level = (raw["trust_level"] || "experimental").to_s
137
155
  @plugin = parse_plugin(raw["plugin"])
@@ -170,8 +188,8 @@ module Samagotchi
170
188
  # chi (a newer chi tightened it, or an older one wrote it) costs only
171
189
  # that key, which reads as empty with its message in the problems:
172
190
  # name, version (never checked: identity is the record's), scope;
173
- # files and their descriptions, scripts, plugin; hooks (one bad entry
174
- # fails the whole key); needs, context_providers; block_renderers
191
+ # files and their descriptions, scripts, plugin (hooks: is ignored,
192
+ # HooksRetired); needs, context_providers; block_renderers
175
193
  # (against +fallback_scripts+ when scripts: failed); includes (after
176
194
  # what its check reads); session_settings with its types, settings
177
195
  # with theirs (one raw value each, so one problem); requires_chi,
@@ -186,6 +204,10 @@ module Samagotchi
186
204
  [manifest, manifest.send(:read_tolerantly, path, fallback_scripts)]
187
205
  end
188
206
 
207
+ # Whether a manifest's hooks: value lists anything (none, {} and []
208
+ # don't: a manifest may keep an empty key).
209
+ def self.lists_hooks?(raw) = !(raw.nil? || ((raw.is_a?(Hash) || raw.is_a?(Array)) && raw.empty?))
210
+
189
211
  def self.read(dir:)
190
212
  manifest_path = File.join(dir, "manifest.yml")
191
213
  raise ValidationError, "manifest.yml not found in #{dir}" unless File.exist?(manifest_path)
@@ -204,13 +226,6 @@ module Samagotchi
204
226
  hex unless hex.nil? || hex.empty?
205
227
  end
206
228
 
207
- # Returns sha256 hex for a hook basename (e.g. "guardrails.rb"), nil
208
- # when it declares none (nothing to verify).
209
- def checksum_for_hook(basename)
210
- hex = @hooks[basename]&.hex
211
- hex unless hex.nil? || hex.empty?
212
- end
213
-
214
229
  # @return [String, nil] the plugin's sha256 hex, "sha256:" stripped
215
230
  def checksum_for_plugin
216
231
  sha = @plugin && @plugin[:sha256].to_s
@@ -242,8 +257,8 @@ module Samagotchi
242
257
 
243
258
  # The SINCE keys this manifest actually uses, each with the chi release
244
259
  # that first read it: the plain keys, and the shapes only a newer chi
245
- # reads — a files: entry given as a mapping, a hook entry with an events:
246
- # list, a session_settings:/settings: given as a mapping of keys to types.
260
+ # reads — a files: entry given as a mapping, a session_settings:/settings:
261
+ # given as a mapping of keys to types.
247
262
  # @return [Hash{String => String}]
248
263
  def floor_keys
249
264
  raw = @raw
@@ -258,15 +273,12 @@ module Samagotchi
258
273
  found["files.description"] = SINCE["files.description"] if raw["files"].is_a?(Hash) && raw["files"].values.any?(Hash)
259
274
  found["session_settings.types"] = SINCE["session_settings.types"] if raw["session_settings"].is_a?(Hash)
260
275
  found["settings.types"] = SINCE["settings.types"] if raw["settings"].is_a?(Hash)
261
- if raw["hooks"].is_a?(Hash) && raw["hooks"].values.any? { |entry| entry.is_a?(Hash) && entry.key?("events") }
262
- found["hooks.events"] = SINCE["hooks.events"]
263
- end
264
276
  found
265
277
  end
266
278
 
267
279
  # Writes a fresh manifest.yml from computed checksums at `dir`.
268
280
  # +file_descriptions+ (name → text) writes those files as mappings.
269
- def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
281
+ def self.write(dir:, name:, version:, files:, scope: nil, description: "", trust_level: nil,
270
282
  plugin: nil, requires_chi: nil, needs: nil, scripts: {}, context_providers: [], block_renderers: [], file_descriptions: {},
271
283
  session_settings: [], session_setting_types: {}, settings: nil, setting_types: {})
272
284
  FileUtils.mkdir_p(dir)
@@ -280,7 +292,6 @@ module Samagotchi
280
292
  text = file_descriptions[key].to_s.strip
281
293
  [key, text.empty? ? sha : { "sha256" => sha, "description" => text }]
282
294
  end
283
- manifest["hooks"] = hooks.to_h { |k, v| [k.to_s, BundleHook.parse(v).to_record] } if hooks && !hooks.empty?
284
295
  manifest["trust_level"] = trust_level.to_s if trust_level && !trust_level.to_s.empty?
285
296
  if plugin
286
297
  sha = plugin[:sha256].to_s
@@ -389,7 +400,7 @@ module Samagotchi
389
400
  end
390
401
  @scripts = tolerate(problems, "scripts", {}) { parse_scripts(raw["scripts"]) }
391
402
  @plugin = tolerate(problems, "plugin", nil) { parse_plugin(raw["plugin"]) }
392
- @hooks = tolerate(problems, "hooks", {}) { parse_hooks(raw["hooks"] || {}) }
403
+ # An old kept manifest's hooks: is no problem: it isn't read (HooksRetired).
393
404
  @needs = tolerate(problems, "needs", []) { self.class.parse_needs(raw["needs"]) }
394
405
  @context_providers = tolerate(problems, "context_providers", []) { parse_context_providers(raw["context_providers"]) }
395
406
  script_names = problems.key?("scripts") ? fallback_scripts.map(&:to_s) : @scripts.keys
@@ -546,8 +557,8 @@ module Samagotchi
546
557
 
547
558
  name
548
559
  end.uniq
549
- if !names.empty? && (!@files.empty? || !@hooks.empty? || @plugin || !@scripts.empty?)
550
- raise ValidationError, "a bundle with includes: holds only its includes (no files, hooks, plugin or scripts)"
560
+ if !names.empty? && (!@files.empty? || @plugin || !@scripts.empty?)
561
+ raise ValidationError, "a bundle with includes: holds only its includes (no files, plugin or scripts)"
551
562
  end
552
563
 
553
564
  names
@@ -559,34 +570,6 @@ module Samagotchi
559
570
  text = raw.to_s.strip
560
571
  text.empty? ? nil : text
561
572
  end
562
-
563
- def parse_hooks(raw)
564
- return {} unless raw.is_a?(Hash)
565
-
566
- raw.each_with_object({}) do |(k, v), acc|
567
- next unless k.is_a?(String) && !k.empty?
568
- next unless v.is_a?(Hash)
569
-
570
- check_hook_events(k, v)
571
- acc[k] = BundleHook.parse(v)
572
- end
573
- end
574
-
575
- HOOK_EVENT_NAME = /\A[a-z][a-z0-9_]*\z/
576
-
577
- # A hook's events: list holds event names (one instance registered
578
- # for each); it and event: are one or the other.
579
- def check_hook_events(file, entry)
580
- return unless entry.key?("events")
581
-
582
- raw = entry["events"]
583
- raise ValidationError, "hooks: #{file}: give event: or events:, not both" if entry.key?("event")
584
- raise ValidationError, "hooks: #{file}: events: must be a list of event names" unless raw.is_a?(Array) && !raw.empty?
585
-
586
- raw.each do |item|
587
- raise ValidationError, "hooks: #{file}: events: #{item.inspect} is not an event name" unless item.is_a?(String) && item.strip.match?(HOOK_EVENT_NAME)
588
- end
589
- end
590
573
  end
591
574
  end
592
575
  end
@@ -8,7 +8,6 @@ require "tmpdir"
8
8
  require_relative "../atomic_file"
9
9
  require_relative "../memory_paths"
10
10
  require_relative "../version"
11
- require_relative "bundle_hook"
12
11
  require_relative "file_digest"
13
12
  require_relative "installed_bundle"
14
13
  require_relative "manifest"
@@ -195,7 +194,6 @@ module Samagotchi
195
194
  # the record. nil keeps an entry with no base (a kept edit that has
196
195
  # none: the user's file must never become it), with its recorded
197
196
  # checksum.
198
- # @param hooks_files [Hash] basename => path for hook file snapshots (optional)
199
197
  # @param guardrails_files [Hash] basename => installed rule file; the
200
198
  # sha256 of each is recorded (the Engine checks it at load)
201
199
  # @param plugin_file [String, nil] the installed plugin file; its name
@@ -229,7 +227,9 @@ module Samagotchi
229
227
  # (requires_chi … setting_types) are still written from the kwargs:
230
228
  # an older chi sharing this memories dir reads only them
231
229
  # (docs/internals/bundles.md); a key a later manifest gains is never added
232
- def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
230
+ # The record has no hooks ("hooks" => {}: class-file hooks are retired)
231
+ # and an older record's hook bases go.
232
+ def write(files:, scope:, version:, source_path:, trust_level: nil, source_commit: nil,
233
233
  guardrails_files: {}, plugin_file: nil, plugin_required: false, requires_chi: nil, needs: nil, marks: [], incoming_dir: nil, includes: nil,
234
234
  scripts_files: {}, context_providers: [], block_renderers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
235
235
  dev: false, manifest_from: nil)
@@ -275,40 +275,8 @@ module Samagotchi
275
275
  end
276
276
  end
277
277
 
278
- # Hooks provenance: persist metadata and base snapshots for hooks
279
- hooks_map = {}
280
- # Incoming hooks: basename => BundleHook (or its metadata hash)
281
- normalized_hooks = {}
282
- if hooks.is_a?(Hash)
283
- hooks.each do |k, v|
284
- next unless k.is_a?(String) && !k.empty?
285
- next unless v.is_a?(Hash) || v.is_a?(BundleHook)
286
-
287
- normalized_hooks[k] = BundleHook.parse(v)
288
- end
289
- end
290
- # Prune stale hook bases
291
- existing&.hooks&.each_key do |old_key_str|
292
- FileUtils.rm_f(File.join(bases_dir, old_key_str)) unless normalized_hooks.key?(old_key_str)
293
- end
294
- # Save base snapshots for hooks where a file path was provided, and
295
- # record the sha256 of the installed file itself: the load-time check
296
- # compares against it, and a declared sha can be wrong (installing
297
- # only warns about that).
298
- hooks_files = {} unless hooks_files.is_a?(Hash)
299
- normalized_hooks.each do |k, hook|
300
- src = hooks_files[k] || hooks_files[k.to_sym]
301
- hook = hook.with(sha256: BundleHook.of_file(src.to_s).sha256) if src && File.exist?(src.to_s)
302
- hook = hook.with(sha256: DEV_SHA) if dev
303
- hooks_map[k] = hook.to_record
304
- next unless src && File.exist?(src.to_s)
305
-
306
- begin
307
- File.write(File.join(bases_dir, k), File.read(src.to_s))
308
- rescue StandardError
309
- nil
310
- end
311
- end
278
+ # An older chi's hooks: their bases go with them (AssetInstaller#retire_hooks).
279
+ existing&.hooks&.each_key { |old_key_str| FileUtils.rm_f(File.join(bases_dir, old_key_str)) }
312
280
 
313
281
  manifest_data = {
314
282
  "name" => @name,
@@ -317,7 +285,7 @@ module Samagotchi
317
285
  "source" => source_path.to_s,
318
286
  "installed_at" => DateTime.now.iso8601,
319
287
  "files" => merged_entries,
320
- "hooks" => hooks_map
288
+ "hooks" => {}
321
289
  }
322
290
  manifest_data["dev"] = true if dev
323
291
  if guardrails_files.is_a?(Hash) && !guardrails_files.empty?
@@ -460,7 +428,7 @@ module Samagotchi
460
428
  # wrote it, or the file is gone), when +dir+ is the source of what's
461
429
  # installed: its manifest names this bundle at the recorded version and
462
430
  # says what the record keeps (#manifest_difference), and every sha the
463
- # record has for a memory, plugin, hook, script or rule is the source
431
+ # record has for a memory, plugin, script or rule is the source
464
432
  # file's. Then the file is copied to #kept_manifest_path and the
465
433
  # record gains its manifest_sha, and nothing else changes (installed_at,
466
434
  # the mirror, installed_by). A dev record is never adopted: its next
@@ -585,8 +553,8 @@ module Samagotchi
585
553
  # The first manifest key in which +manifest+ says other than what the
586
554
  # record keeps, nil when none: each mirrored key as #write records it (one
587
555
  # the record lacks is fine only when an older chi couldn't have known it:
588
- # Manifest::SINCE has it), then each recorded hook's events, on_error and
589
- # priority.
556
+ # Manifest::SINCE has it). An older record's hooks don't count: they
557
+ # no longer load, and the source can't list any (Manifest::HooksRetired).
590
558
  def manifest_difference(bundle, manifest)
591
559
  recorded = plain(bundle.mirror)
592
560
  source = plain(self.class.mirror_fields(
@@ -595,18 +563,11 @@ module Samagotchi
595
563
  session_settings: manifest.session_settings, session_setting_types: manifest.session_setting_types,
596
564
  settings: manifest.settings, setting_types: manifest.setting_types
597
565
  ))
598
- differing = InstalledBundle::MIRROR_KEYS.find do |key|
566
+ InstalledBundle::MIRROR_KEYS.find do |key|
599
567
  next false if recorded[key] == source[key]
600
568
 
601
569
  !(recorded[key].nil? && Manifest::SINCE.key?(MIRROR_SINCE.fetch(key, key)))
602
570
  end
603
- return differing if differing
604
-
605
- same_hooks = bundle.hooks.all? do |file, hook|
606
- theirs = manifest.hooks.fetch(file, BundleHook.new)
607
- [hook.events, hook.on_error, hook.priority] == [theirs.events, theirs.on_error, theirs.priority]
608
- end
609
- same_hooks ? nil : "hooks"
610
571
  end
611
572
  private :manifest_difference
612
573
 
@@ -614,7 +575,7 @@ module Samagotchi
614
575
  private :plain
615
576
 
616
577
  # Each file the record has a sha for, by its path in the bundle's source
617
- # => that sha: the plugin, hooks, scripts, rules, and each memory whose
578
+ # => that sha: the plugin, scripts, rules, and each memory whose
618
579
  # entry has a checksum and no conflict mark (its base is the bundle's text).
619
580
  def installed_shas(bundle, manifest)
620
581
  shas = bundle.files.each_with_object({}) do |(key, entry), acc|
@@ -624,7 +585,6 @@ module Samagotchi
624
585
  file = manifest.plugin && manifest.plugin[:file].to_s
625
586
  shas[file && File.basename(file) == bundle.plugin.file ? file : bundle.plugin.file] = bundle.plugin.sha256
626
587
  end
627
- bundle.hooks.each { |file, hook| shas["hooks/#{file}"] = hook.sha256 }
628
588
  bundle.scripts.each { |file, sha| shas["scripts/#{file}"] = sha }
629
589
  bundle.guardrails.each { |file, sha| shas["guardrails/#{file}"] = sha }
630
590
  shas
@@ -4,6 +4,7 @@ require_relative "index_updater"
4
4
  require_relative "manifest"
5
5
  require_relative "memory_files"
6
6
  require_relative "file_actions"
7
+ require_relative "file_digest"
7
8
  require_relative "merger"
8
9
  require_relative "provenance"
9
10
  require_relative "trash"
@@ -77,7 +78,8 @@ module Samagotchi
77
78
  @behind = []
78
79
  @warnings = []
79
80
  @trash = Trash.new(@name)
80
- shipped = MemoryFiles.select(@source_dir, Manifest.read(dir: @source_dir))
81
+ @manifest = Manifest.read(dir: @source_dir)
82
+ shipped = MemoryFiles.select(@source_dir, @manifest)
81
83
  marked.each { |key, entry| retry_file(key, entry, shipped) }
82
84
  settle unless dry_run
83
85
  Result.new(merged: @merged, settled: @settled, stamped: @stamped.map(&:file), behind: @behind, warnings: @warnings, trash_copy: @trash.copy_line)
@@ -111,7 +113,7 @@ module Samagotchi
111
113
  def retry_row(key, entry, state, action)
112
114
  decision = view.decision(key, state) if action.do == :decide || action.behind?
113
115
  action = FileActions.decided(decision.outcome, :remerge) if action.do == :decide
114
- if action.do == :merge then merge_file(key, entry, decision.outcome)
116
+ if action.do == :merge then merge_file(key, entry, state, decision.outcome)
115
117
  elsif action.behind?
116
118
  waits = decision.outcome.status == :fast_forward
117
119
  mark(key, entry, waits ? behind_for(key, FileStanding::WAITS_FOR_UPGRADE) : view.behind(key, decision.outcome, @record.version))
@@ -141,7 +143,9 @@ module Samagotchi
141
143
  @stamped << behind unless entry[:incoming_sha] && @record.behind_entry(key, entry) == behind && File.exist?(@provenance.incoming_path(key))
142
144
  end
143
145
 
144
- def merge_file(key, entry, outcome)
146
+ def merge_file(key, entry, state, outcome)
147
+ warning = FileDigest.mismatch(key, state.paths[:incoming], @manifest.checksum_for(key))
148
+ @warnings << warning if warning
145
149
  return @merged << key if @dry_run
146
150
  return @merged << key if Merger.write_merged(view.paths(key)[:current], outcome, @trash)
147
151
 
@@ -14,7 +14,7 @@ module Samagotchi
14
14
 
15
15
  # A sha line that doesn't match its file: +old+ is the hex the line
16
16
  # holds ("" for a placeholder `sha256: sha256:`), nil when the entry
17
- # has no sha line at all (a plugin:, hooks: or scripts: entry the
17
+ # has no sha line at all (a plugin: or scripts: entry the
18
18
  # installer then takes unchecked; .refresh can't write one).
19
19
  Stale = Data.define(:file, :old, :new)
20
20
 
@@ -30,7 +30,6 @@ module Samagotchi
30
30
  # names, the layout otherwise untouched. The shapes:
31
31
  # files: a.md: sha256:<hex> (dir/a.md)
32
32
  # a.md:\n sha256: sha256:<hex> (dir/a.md, with description:)
33
- # hooks: h.rb:\n sha256: sha256:<hex> (dir/hooks/h.rb)
34
33
  # plugin: file: p.rb\n sha256: sha256:<hex> (dir/p.rb)
35
34
  # scripts: s.rb: sha256:<hex> (dir/scripts/s.rb)
36
35
  # Returns [text, [[relative file, old sha, new sha], ...]] for the lines
@@ -43,8 +42,9 @@ module Samagotchi
43
42
  [text, changes]
44
43
  end
45
44
 
46
- # The stale sha lines of the bundle at +dir+, then the plugin:, hooks:
47
- # and scripts: entries with no sha line, as [Stale].
45
+ # The stale sha lines of the bundle at +dir+, then the plugin: and
46
+ # scripts: entries with no sha line, as [Stale]. (A hooks: key, which
47
+ # chi refuses, has no sha lines here.)
48
48
  def stale(dir, text = File.read(File.join(dir, "manifest.yml")))
49
49
  _, changes, unlined = scan(dir, text)
50
50
  changes.map { |file, old, new| Stale.new(file, old, new) } +
@@ -86,11 +86,10 @@ module Samagotchi
86
86
  # take a placeholder (`chi bundle check`) parse this text.
87
87
  def parseable(text) = text.gsub(/^(\s*(?:["']?[^"':\s][^"':]*["']?: )?)sha256:(?=[ \t]*(?:#[^\r\n]*)?\r?$)/, "\\1sha256:0")
88
88
 
89
- # The code files the manifest names (plugin, hooks, scripts), as paths
89
+ # The code files the manifest names (plugin, scripts), as paths
90
90
  # relative to the bundle dir.
91
91
  def coded(parsed, plugin_file)
92
92
  (plugin_file.is_a?(String) ? [plugin_file] : []) +
93
- keys(parsed["hooks"]).map { |h| File.join("hooks", h) } +
94
93
  keys(parsed["scripts"]).map { |s| File.join("scripts", s) }
95
94
  end
96
95
  private_class_method :coded
@@ -114,8 +113,6 @@ module Samagotchi
114
113
  when "files"
115
114
  m = line.match(/\A( sha256: )sha256:\h*#{TAIL}/) and return sub && [sub.delete("\"'"), m[1]]
116
115
  m = line.match(/\A( ["']?([^"':]+)["']?: )sha256:\h*#{TAIL}/) and [m[2], m[1]]
117
- when "hooks"
118
- m = line.match(/\A( sha256: )sha256:\h*#{TAIL}/) and sub and [File.join("hooks", sub.delete("\"'")), m[1]]
119
116
  when "plugin"
120
117
  m = line.match(/\A( sha256: )sha256:\h*#{TAIL}/) and plugin_file and [plugin_file, m[1]]
121
118
  when "scripts"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "asset_installer"
4
+ require_relative "asset_state"
4
5
  require_relative "installer"
5
6
  require_relative "listing"
6
7
  require_relative "manifest"
@@ -27,11 +28,12 @@ module Samagotchi
27
28
  # bundle that isn't installed and never downgrades.
28
29
  #
29
30
  # .md files take the Installer's 3-way merge: a clean text merge replaces
30
- # the file (merged), a real conflict keeps the user's (behind). Hooks, rules,
31
- # the plugin and scripts are replaced; an edited one (Trash.edit?: its
32
- # bytes neither the recorded sha, none recorded counting as edited, nor
33
- # the incoming file's) is copied to the trash first and listed in
34
- # replaced (#trash_bound).
31
+ # the file (merged), a real conflict keeps the user's (behind). Rules,
32
+ # the plugin and scripts are replaced, an older chi's hooks removed; an
33
+ # edited one (AssetState#edited?, Trash.edit?'s rule: its bytes neither
34
+ # the recorded sha, none recorded counting as edited, nor the incoming
35
+ # file's) is copied to the trash first and listed in replaced
36
+ # (#trash_bound).
35
37
  #
36
38
  # A profile (a shipped meta bundle) is updated through Profile: its row
37
39
  # is :would_update when the shipped version is newer or it has members
@@ -119,10 +121,7 @@ module Samagotchi
119
121
  r.merged = installer.merged_files
120
122
  r.behind = installer.behind
121
123
  r.trash_copy = installer.trash_copy_line
122
- # The integrity check's warnings; the rest of the Installer's say
123
- # what the row's merged and behind already do.
124
- mismatches = installer.warnings.grep(/\AChecksum mismatch/)
125
- r.note = mismatches.join("; ") unless mismatches.empty?
124
+ r.note = mismatch_note(installer)
126
125
  end
127
126
  rescue StandardError => e
128
127
  row.dup.tap do |r|
@@ -202,6 +201,10 @@ module Samagotchi
202
201
  if Manifest.requires_chi_failure(manifest.requires_chi, chi_version)
203
202
  return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
204
203
  end
204
+
205
+ # The upgrade would refuse it (Installer#refuse_hooks).
206
+ hooks = Manifest::HooksRetired.files_in(row.source_dir)
207
+ return skip(row, Manifest::HooksRetired.for_files(hooks).message, keep_to: true) unless hooks.empty?
205
208
  return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
206
209
  return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
207
210
 
@@ -210,6 +213,7 @@ module Samagotchi
210
213
  row.behind = forecast.behind
211
214
  row.replaced = trash_bound(name, bundle, manifest, row.source_dir)
212
215
  row.status = :would_update
216
+ row.note = planned_mismatch_note(row)
213
217
  row
214
218
  rescue SystemCallError => e
215
219
  # A file it can't read: the upgrade would fail on it, so this is that
@@ -242,6 +246,20 @@ module Samagotchi
242
246
 
243
247
  def note_of(remerged) = remerged.warnings.empty? ? nil : remerged.warnings.join("; ")
244
248
 
249
+ def mismatch_note(installer)
250
+ mismatches = installer.warnings.grep(/\AChecksum mismatch/)
251
+ mismatches.join("; ") unless mismatches.empty?
252
+ end
253
+
254
+ def planned_mismatch_note(row)
255
+ installer = Installer.new(source: row.source_dir, name: row.name, scope: row.scope, strict: true, upgrade: true, dry_run: true,
256
+ shipped_dir: File.dirname(row.source_dir))
257
+ installer.run
258
+ mismatch_note(installer)
259
+ rescue Installer::InstallError
260
+ nil
261
+ end
262
+
245
263
  def profile_row(row, bundle, shipped_dir, chi_version)
246
264
  manifest = Manifest.read(dir: row.source_dir)
247
265
  newer = Listing.newer?(row.to, row.from)
@@ -277,49 +295,31 @@ module Samagotchi
277
295
  end
278
296
 
279
297
  # What the upgrade from +source_dir+ copies to the trash, as
280
- # hooks/F, guardrails/F, plugin/F, scripts/F: Trash.edit? over the files
281
- # AssetInstaller replaces or removes, each against the file it puts in
282
- # its place. Hooks: the ones install_hooks copies (listed, else every
283
- # hooks/*.rb; present, not links) and the ones prune_dropped_hooks
284
- # removes (recorded, no longer shipped; no incoming). Rules, the plugin
285
- # and scripts: their whole folder, which the upgrade replaces.
298
+ # hooks/F, guardrails/F, plugin/F, scripts/F. Rules, the plugin and
299
+ # scripts: each file of the folders AssetInstaller replaces, against the
300
+ # one it puts in its place, that is AssetState#edited? (the upgrade's
301
+ # save_first: the same files, the same rule). hooks/: an older chi's,
302
+ # which the upgrade removes whole (AssetInstaller#retire_hooks; no
303
+ # incoming): Trash.edits over it.
286
304
  def trash_bound(name, bundle, manifest, source_dir)
287
305
  provenance = Provenance.new(name: name)
288
306
  rules = Dir.glob(File.join(source_dir, "guardrails", "*.yml")).sort
289
307
  .reject { |file| AssetInstaller.linked?(file, source_dir) }
290
308
  plugin = manifest.plugin ? { manifest.plugin[:file] => File.join(source_dir, manifest.plugin[:file]) } : {}
291
309
  scripts = manifest.scripts.keys.to_h { |file| [file, File.join(source_dir, "scripts", file)] }
292
- trash_bound_hooks(provenance, bundle, manifest, source_dir) +
293
- Trash.edits(provenance.guardrails_dir, "guardrails", bundle.guardrails, rules.to_h { |file| [File.basename(file), file] }) +
294
- Trash.edits(provenance.plugin_dir, "plugin", bundle.plugin_shas, plugin) +
295
- Trash.edits(provenance.scripts_dir, "scripts", bundle.scripts, scripts)
296
- end
297
-
298
- # #trash_bound's hooks: AssetInstaller#install_hooks' set (incoming: the
299
- # source's file), then #prune_dropped_hooks' (incoming: none). A stray
300
- # file in hooks/ is in neither: the upgrade leaves it.
301
- def trash_bound_hooks(provenance, bundle, manifest, source_dir)
302
- src = ->(file) { File.join(source_dir, "hooks", file) }
303
- listed = manifest.hooks.keys.select { |file| file.is_a?(String) && !file.empty? }
304
- listed = Dir.glob(src.call("*.rb")).map { |file| File.basename(file) } if manifest.hooks.empty?
305
- shipped = listed.reject do |file|
306
- (File.symlink?(src.call(file)) || File.exist?(src.call(file))) && AssetInstaller.linked?(src.call(file), source_dir)
307
- end
308
- incoming = shipped.select { |file| File.exist?(src.call(file)) }.to_h { |file| [file, src.call(file)] }
309
- incoming.merge!((bundle.hooks.keys - shipped).to_h { |file| [file, nil] })
310
- incoming.filter_map do |file, from|
311
- "hooks/#{file}" if Trash.edit?(File.join(provenance.hooks_dir, file), recorded: bundle.hooks[file]&.sha256, incoming: from)
312
- end
310
+ assets = AssetState.in(provenance.guardrails_dir, kind_of_file: :rule, recorded: bundle.guardrails,
311
+ incoming: rules.to_h { |file| [File.basename(file), file] }) +
312
+ AssetState.in(provenance.plugin_dir, kind_of_file: :plugin, recorded: bundle.plugin_shas, incoming: plugin) +
313
+ AssetState.in(provenance.scripts_dir, kind_of_file: :script, recorded: bundle.scripts, incoming: scripts)
314
+ Trash.edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256)) +
315
+ assets.select(&:edited?).map(&:label)
313
316
  end
314
317
 
315
- # Installed hooks, rules and the plugin whose sha differs from the
316
- # recorded one (they didn't load), as hooks/F, guardrails/F, plugin/F.
318
+ # Installed rules and the plugin whose sha differs from the recorded
319
+ # one (they didn't load), as guardrails/F, plugin/F.
317
320
  def edited_executables(name, bundle)
318
321
  provenance = Provenance.new(name: name)
319
322
  edited = []
320
- bundle.hooks.each do |file, hook|
321
- edited << "hooks/#{file}" if edited?(File.join(provenance.hooks_dir, file), hook.sha256)
322
- end
323
323
  bundle.guardrails.each do |file, sha|
324
324
  edited << "guardrails/#{file}" if edited?(File.join(provenance.guardrails_dir, file), sha)
325
325
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "file_digest"
4
4
  require_relative "provenance"
5
5
  require_relative "file_state"
6
+ require_relative "asset_state"
6
7
  require_relative "manifest"
7
8
  require_relative "../version"
8
9
  require_relative "index_updater"
@@ -16,14 +17,17 @@ module Samagotchi
16
17
  # A bundle as this chi reads it now (what chi bundle status prints):
17
18
  # its stored record, the scope it resolves to (scope_error when it
18
19
  # can't), the folder its files belong in, what each file looks like on
19
- # disk, its installed plugin, why none of its hooks load, and its needs
20
+ # disk, its installed plugin, the class-file hooks its record still
21
+ # has (retired_hooks: they don't load), and its needs
20
22
  # with what this process's PATH found. The +files+ entries and +plugin+
21
23
  # stay Hashes: their fields differ per case (an unchecked file has no
22
24
  # paths at all). An entry's behind is its FileStanding::Behind, nil for a
23
25
  # file that isn't; merge_note its FileStanding::MergeNote, nil without one;
24
26
  # unreadable: the file is there, but this chi can't read it.
27
+ # rules and scripts: each recorded one by name, "ok", "modified" or
28
+ # "missing" (asset_states).
25
29
  Report = Data.define(:provenance, :scope, :scope_error, :target_dir, :files, :plugin,
26
- :hooks_requires_failure, :needs)
30
+ :rules, :scripts, :retired_hooks, :needs)
27
31
 
28
32
  # A scope this chi can't resolve (a newer chi's, a hand edit) isn't
29
33
  # guessed: scope_error says so, target_dir is nil and each file is
@@ -69,7 +73,9 @@ module Samagotchi
69
73
  end
70
74
  Report.new(provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
71
75
  target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
72
- hooks_requires_failure: hooks_requires_failure(bundle), needs: needs_status(bundle))
76
+ rules: asset_states(provenance.guardrails_dir, :rule, bundle.guardrails),
77
+ scripts: asset_states(provenance.scripts_dir, :script, bundle.scripts),
78
+ retired_hooks: retired_hooks(provenance, bundle), needs: needs_status(bundle))
73
79
  end
74
80
 
75
81
  # A FileState digest as the Report has it: nil for none, or one that
@@ -77,13 +83,28 @@ module Samagotchi
77
83
  def self.digest_or_nil(digest) = digest == :unreadable ? nil : digest
78
84
  private_class_method :digest_or_nil
79
85
 
80
- # Why none of the bundle's hooks load (this chi doesn't meet its
81
- # requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
82
- def self.hooks_requires_failure(bundle)
83
- manifest = bundle.manifest
84
- return nil if manifest.hooks.empty?
86
+ # The class-file hooks the record still has (a 0.157 install's; chi
87
+ # loads none, Manifest::HooksRetired), each "ok" or "missing" in
88
+ # hooks/, by name; {} for none.
89
+ # @return [Hash{String => String}]
90
+ def self.retired_hooks(provenance, bundle)
91
+ bundle.hooks.keys.sort.to_h do |file|
92
+ [file, File.exist?(File.join(provenance.hooks_dir, file)) ? "ok" : "missing"]
93
+ end
94
+ end
85
95
 
86
- manifest.requires_chi_failure
96
+ # Each recorded rule or script: "missing" (not there), "modified" (its
97
+ # AssetState is edited: the next install replaces it, a copy kept in
98
+ # the trash; an edited rule doesn't load) or "ok"; {} for none.
99
+ # @return [Hash{String => String}]
100
+ def self.asset_states(dir, kind_of_file, recorded)
101
+ recorded.keys.sort.to_h do |name|
102
+ asset = AssetState.of(File.join(dir, name), kind_of_file: kind_of_file, recorded: recorded[name], name: name)
103
+ [name, if asset.state == :missing then "missing"
104
+ elsif asset.edited? then "modified"
105
+ else "ok"
106
+ end]
107
+ end
87
108
  end
88
109
 
89
110
  # The stored needs, each with found: from this process's PATH (the