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
@@ -52,7 +52,7 @@ module Samagotchi
52
52
  RULE = "pair-boundary"
53
53
  SOURCE = "core"
54
54
  OWNER_VOICED = %w[delegate register_reminder cancel_reminder send_note forget_outputs task_create task_stop
55
- find_tools objectives].freeze
55
+ find_tools objectives schedule_task list_schedules cancel_schedule].freeze
56
56
  OWNER_VOICED_PREFIX = "delegate_"
57
57
  # The background task tools that read the owner's tasks (a pair turn
58
58
  # starts none: task_create is owner-voiced, and its execute is never
@@ -153,6 +153,17 @@ module Samagotchi
153
153
  (low == low_dir || low.start_with?(File.join(low_dir, ""))) && folds_case?(dir)
154
154
  end
155
155
 
156
+ # Whether +root+ is the home folder or a folder above it (symlinks
157
+ # resolved on both; false for a blank one). ReviewPaths (a participant
158
+ # turn's project) and MemoryPaths (where a chi project marker is
159
+ # ignored) ask this.
160
+ # @param root [String, nil] @param home [String, nil]
161
+ def home_root?(root, home)
162
+ return false if home.to_s.empty? || root.to_s.empty?
163
+
164
+ under?(real(home.to_s), real(root.to_s))
165
+ end
166
+
156
167
  # Whether +path+ is in +dir+, not +dir+ itself (.under?'s terms).
157
168
  def strictly_under?(path, dir, fold: true)
158
169
  return true if path.start_with?(File.join(dir, ""))
@@ -59,11 +59,7 @@ module Samagotchi
59
59
  # review turn reads nothing there (#refusal: HOME_ROOT); a pair turn
60
60
  # asks the owner before every write (PairBoundary).
61
61
  # @param root [String] @param home [String, nil]
62
- def self.home_root?(root, home)
63
- return false if home.to_s.empty? || root.to_s.empty?
64
-
65
- PathResolve.under?(PathResolve.real(home.to_s), PathResolve.real(root.to_s))
66
- end
62
+ def self.home_root?(root, home) = PathResolve.home_root?(root, home)
67
63
 
68
64
  # @param root [String, nil] the session's project root (the work tree
69
65
  # top of its folder); nil or blank refuses every path
@@ -21,7 +21,7 @@ module Samagotchi
21
21
  # How much of a failed hook's error #plugin_hook_failed shows.
22
22
  HOOK_FAILURE_CHARS = 300
23
23
  # What the notice says the hook now does, by the policy Hooks.wrap gave it
24
- # (a chi.on handler is always :log; a bundle's hook file any of the three).
24
+ # (a chi.on handler :log, or :deny for an on_error: :fail_closed one).
25
25
  HOOK_FAILURE_EFFECTS = {
26
26
  log: "it's skipped, and only the log has its next failures",
27
27
  skip: "it's skipped, and its next failures are silent",
@@ -98,7 +98,7 @@ module Samagotchi
98
98
  nil
99
99
  end
100
100
 
101
- # A bundle hook file or a chi.on handler raised (Hooks.wrap; the log has
101
+ # A chi.on handler raised (Hooks.wrap; the log has
102
102
  # every firing of a :log one). Its first failure on this Engine, so in
103
103
  # this worker, tells the user, as a plugin that failed to load does; then
104
104
  # only the log. One line per label — file, bundle and event — so a
@@ -2,12 +2,11 @@
2
2
 
3
3
  require_relative "hooks/registry"
4
4
  require_relative "hooks/loader"
5
- require_relative "hooks/bundle_loader"
6
5
  require_relative "hooks/stream_watch"
7
6
 
8
7
  module Samagotchi
9
- # Thin wrapper that exposes the Hooks::Registry, Hooks::Loader and
10
- # Hooks::BundleLoader (bundle-owned hook plugins).
8
+ # Thin wrapper that exposes the Hooks::Registry and Hooks::Loader
9
+ # (config.yml's hooks; a bundle's hook through its plugin, Plugin::Api#on).
11
10
  #
12
11
  # The Engine holds an instance of Hooks::Registry and provides:
13
12
  # - #register_hook(name, &block) — register a turn-scoped hook
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require_relative "asset_state"
5
+ require_relative "file_actions"
4
6
  require_relative "file_digest"
5
- require_relative "bundle_hook"
6
7
  require_relative "manifest"
7
8
  require_relative "provenance"
8
9
  require_relative "../version"
@@ -13,22 +14,32 @@ module Samagotchi
13
14
 
14
15
  # The install of a bundle's parts that live in its own dir
15
16
  # (<bundles>/<name>/, Provenance#bundle_dir) rather than in the
16
- # memories dir: hooks/*.rb, guardrails/*.yml, the plugin file and
17
- # scripts/*. Each replaces what an earlier version installed. Installer
17
+ # memories dir: guardrails/*.yml, the plugin file and scripts/*. Each
18
+ # replaces what an earlier version installed. An older chi's record
19
+ # with class-file hooks has them removed (#retire_hooks): chi no longer
20
+ # loads them, and the Installer refuses a source that has any. Installer
18
21
  # runs it after the memories; it adds to the installer's results and
19
22
  # warnings, and #run returns what the record (Provenance#write) keeps.
20
23
  # A dev install (+dev+, `chi bundle install --dev`) links each file to
21
24
  # the source instead of copying it, and warns of no checksum mismatch.
22
- # An edited file it replaces or removes is copied to the trash first,
23
- # under its subfolder (Trash#copy_edit); a link never is.
25
+ # Each folder is one loop over its files' AssetStates (#install_set):
26
+ # FileActions' asset tables say what each gets, the dry run reading the
27
+ # same row. An edited file it replaces or removes is copied to the trash
28
+ # first, under its subfolder (AssetState#edited?, Trash.edit?'s rule); a
29
+ # link never is.
24
30
  class AssetInstaller
25
31
  # What was installed: the paths Provenance#write records (each
26
32
  # empty / nil on a dry run).
27
- # hooks every hook basename the bundle ships
28
- # hook_files, guardrail_files, script_files
29
- # basename => installed path
33
+ # guardrail_files, script_files basename => installed path
30
34
  # plugin_file the installed plugin file, nil without one
31
- Installed = Data.define(:hooks, :hook_files, :guardrail_files, :plugin_file, :script_files)
35
+ Installed = Data.define(:guardrail_files, :plugin_file, :script_files)
36
+
37
+ # The retire's summary words (Installer#summary): why the hooks went.
38
+ RETIRED = "chi no longer loads class-file bundle hooks"
39
+
40
+ # A dry run's word for a real run's (Installer#summary): nothing would
41
+ # be written to a file already up to date.
42
+ DRY_WORDS = { installed: :would_install, updated: :would_update, up_to_date: :up_to_date }.freeze
32
43
 
33
44
  # @param source_dir [String] the normalized bundle source
34
45
  # @param manifest [Manifest, nil] nil for a non-strict install without one
@@ -61,18 +72,16 @@ module Samagotchi
61
72
  # @raise [Installer::InstallError] the manifest names a plugin or
62
73
  # script the bundle doesn't have
63
74
  def run
64
- install_hooks
65
- prune_dropped_hooks if (@upgrade || (@dev && @existing&.dev)) && @existing && !@dry_run
66
- verify_hook_checksums if @manifest && @strict && !@dev
67
75
  incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
68
76
  .reject { |src| linked_out?(src, "guardrails/#{File.basename(src)}") }
69
- guardrail_files = install_guardrails(incoming_rules)
70
- plugin_file = install_plugin
71
- script_files = install_scripts
72
- warn_hooks_requires_chi
77
+ guardrail_files = install_set(:rule, rules_incoming(incoming_rules))
78
+ plugin_file = install_set(:plugin, plugin_incoming).values.first
79
+ script_files = install_set(:script, scripts_incoming)
80
+ # After the plugin and scripts, which raise for a file the manifest
81
+ # names and the bundle lacks: a refused install leaves hooks/ as it was.
82
+ retire_hooks
73
83
  warn_rules_requires_chi(incoming_rules)
74
- Installed.new(hooks: @bundle_hooks, hook_files: @hook_files, guardrail_files: guardrail_files,
75
- plugin_file: plugin_file, script_files: script_files)
84
+ Installed.new(guardrail_files: guardrail_files, plugin_file: plugin_file, script_files: script_files)
76
85
  end
77
86
 
78
87
  # Whether the bundle file +src+ reads through a symlink: it is one, or
@@ -91,138 +100,38 @@ module Samagotchi
91
100
 
92
101
  private
93
102
 
94
- # Copies the bundle's hooks/*.rb into <bundle_dir>/hooks/: the ones
95
- # its manifest lists, else (no hooks: in it) every hooks/*.rb, which is
96
- # then added to manifest.hooks with no event (the loader skips it, the
97
- # record keeps it). Without a manifest every hooks/*.rb is copied.
98
- # Sets @bundle_hooks (every hook the bundle ships) and @hook_files
99
- # (basename => installed path, for the record).
100
- def install_hooks
101
- @bundle_hooks = []
102
- @hook_files = {}
103
- return install_unlisted_hooks unless @manifest
104
-
105
- hook_entries = @manifest.hooks
106
- if hook_entries.empty?
107
- Dir.glob(File.join(@bundle_dir, "hooks", "*.rb")).each { |p| hook_entries[File.basename(p)] = BundleHook.new }
108
- end
109
- hook_entries.keys.each do |basename|
110
- next unless basename.is_a?(String) && !basename.empty?
111
-
112
- src = File.join(@bundle_dir, "hooks", basename)
113
- # A linked hook isn't the bundle's: not installed, counted or recorded.
114
- next hook_entries.delete(basename) if (File.symlink?(src) || File.exist?(src)) && linked_out?(src, "hooks/#{basename}")
115
-
116
- @bundle_hooks << basename
117
- install_hook(basename, src) if File.exist?(src)
118
- end
119
- end
120
-
121
- # One listed hook; an upgrade overwrites a local edit with a warning.
122
- def install_hook(basename, src)
123
- dest = File.join(@provenance.hooks_dir, basename)
124
- result = result_for(src, dest, updated: @upgrade && @existing)
125
- return @results[basename] = planned(result) if @dry_run
126
-
127
- prev_sha = @existing&.hooks&.dig(basename)&.sha256
128
- if @upgrade && File.exist?(dest) && !@force && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
129
- @warnings << "Hook #{basename} was locally modified; overwriting"
103
+ # The hooks/ an older chi installed (class-file hooks, which no longer
104
+ # load): every file in it goes, an edited one (or one the record
105
+ # doesn't know) copied to the trash first, a dev link never (it points
106
+ # at the source, which stays). Each is a :removed result (a dry run's
107
+ # :would_remove) with the RETIRED note; an empty hooks/ dir goes
108
+ # without a word. The record keeps no hooks
109
+ # (Provenance#write).
110
+ def retire_hooks
111
+ dir = @provenance.hooks_dir
112
+ return unless File.directory?(dir) || File.symlink?(dir)
113
+
114
+ files = File.symlink?(dir) ? [] : Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).sort.reject { |f| File.directory?(File.join(dir, f)) }
115
+ files.each do |file|
116
+ @results["hooks/#{file}"] = @dry_run ? retired.with(word: :would_remove) : retired
130
117
  end
118
+ return if @dry_run
131
119
 
132
- FileUtils.mkdir_p(@provenance.hooks_dir)
133
- @trash.copy_edit(dest, as: "hooks/#{basename}", recorded: prev_sha, incoming: src)
134
- place(src, dest)
135
- @results[basename] = result
136
- @hook_files[basename] = dest
120
+ @trash.copy_edits(dir, "hooks", @existing&.hooks&.transform_values(&:sha256))
121
+ FileUtils.rm_rf(dir)
137
122
  end
138
123
 
139
- # No manifest (a non-strict install): every hooks/*.rb is copied.
140
- def install_unlisted_hooks
141
- Dir.glob(File.join(@bundle_dir, "hooks", "*.rb")).each do |src|
142
- basename = File.basename(src)
143
- next if linked_out?(src, "hooks/#{basename}")
144
-
145
- @bundle_hooks << basename
146
- if @dry_run
147
- @results[basename] = Installer::FileResult.new(word: :would_install)
148
- else
149
- FileUtils.mkdir_p(@provenance.hooks_dir)
150
- dest = File.join(@provenance.hooks_dir, basename)
151
- @trash.copy_edit(dest, as: "hooks/#{basename}", recorded: @existing&.hooks&.dig(basename)&.sha256, incoming: src)
152
- place(src, dest)
153
- @results[basename] = Installer::FileResult.new(word: :installed)
154
- @hook_files[basename] = dest
155
- end
156
- end
157
- end
124
+ def retired = Installer::FileResult.new(word: :removed, note: RETIRED)
158
125
 
159
- # An upgrade removes the hooks the previous version shipped and this
160
- # one doesn't (a local file too, with a warning; a dev link silently).
161
- def prune_dropped_hooks
162
- @existing.hooks.each_key do |old_key_str|
163
- next if @bundle_hooks.include?(old_key_str)
164
-
165
- old_dest = File.join(@provenance.hooks_dir, old_key_str)
166
- if File.exist?(old_dest) && !@force && !File.symlink?(old_dest)
167
- @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
168
- end
169
- @trash.copy_edit(old_dest, as: "hooks/#{old_key_str}", recorded: @existing.hooks[old_key_str]&.sha256)
170
- FileUtils.rm_f(old_dest)
171
- end
172
- end
173
-
174
- # Strict mode: a hook whose sha256 differs from the one its manifest
175
- # declares warns (as verify_memory_checksums does). The bundle's file is
176
- # checked, the one the install copies, so a dry run checks what it
177
- # would install, not the old hook in place.
178
- def verify_hook_checksums
179
- @bundle_hooks.each do |basename|
180
- next if @conflicts.key?(basename)
181
-
182
- warning = FileDigest.mismatch("hook #{basename}", File.join(@bundle_dir, "hooks", basename),
183
- @manifest.checksum_for_hook(basename))
184
- @warnings << warning if warning
185
- end
186
- end
187
-
188
- # The bundle's guardrails/*.yml into <bundle_dir>/guardrails/: the
189
- # set replaces what an earlier version installed. Same bytes as
190
- # installed are reported up to date, not "Installed".
191
- # @return [Hash{String => String}] basename => installed path ({} on a dry run)
192
- def install_guardrails(incoming_rules)
193
- target = @provenance.guardrails_dir
194
- results = incoming_rules.to_h { |src| [src, result_for(src, File.join(target, File.basename(src)), updated: false)] }
195
- if @dry_run
196
- results.each { |src, result| @results["guardrails/#{File.basename(src)}"] = planned(result) }
197
- return {}
198
- end
126
+ # The bundle's guardrails/*.yml, by basename.
127
+ def rules_incoming(incoming_rules) = incoming_rules.to_h { |src| [File.basename(src), src] }
199
128
 
200
- @trash.copy_edits(target, "guardrails", @existing&.guardrails, incoming_rules.to_h { |src| [File.basename(src), src] })
201
- FileUtils.rm_rf(target)
202
- return {} if incoming_rules.empty?
203
-
204
- FileUtils.mkdir_p(target)
205
- incoming_rules.to_h do |src|
206
- dest = File.join(target, File.basename(src))
207
- place(src, dest)
208
- @results["guardrails/#{File.basename(src)}"] = results[src]
209
- [File.basename(src), dest]
210
- end
211
- end
212
-
213
- # Copy the manifest's plugin file into the bundle's plugin/ dir,
214
- # warning when its sha256 differs from the declared one or this chi
215
- # doesn't meet requires_chi (the Engine then won't load it).
216
- # @return [String, nil] the installed file (nil: none, or a dry run)
217
- def install_plugin
129
+ # The manifest's plugin file, warning when this chi doesn't meet
130
+ # requires_chi (the Engine then won't load it). {} without one.
131
+ # @return [Hash{String => String}] its file => the source file
132
+ def plugin_incoming
218
133
  plugin = @manifest&.plugin
219
- unless plugin
220
- unless @dry_run
221
- @trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas)
222
- FileUtils.rm_rf(@provenance.plugin_dir)
223
- end
224
- return nil
225
- end
134
+ return {} unless plugin
226
135
 
227
136
  src = File.join(@bundle_dir, plugin[:file])
228
137
  raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
@@ -231,81 +140,84 @@ module Samagotchi
231
140
  if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
232
141
  @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
233
142
  end
234
- dest = File.join(@provenance.plugin_dir, plugin[:file])
235
- result = result_for(src, dest, updated: @upgrade && @provenance.installed?)
236
- if @dry_run
237
- @results[plugin[:file]] = planned(result)
238
- return nil
239
- end
240
-
241
- @trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas, { plugin[:file] => src })
242
- FileUtils.rm_rf(@provenance.plugin_dir)
243
- FileUtils.mkdir_p(@provenance.plugin_dir)
244
- place(src, dest)
245
- @results[plugin[:file]] = result
246
- warning = FileDigest.mismatch("plugin #{plugin[:file]}", dest, @manifest.checksum_for_plugin) if @strict && !@dev
247
- @warnings << warning if warning
248
- dest
143
+ { plugin[:file] => src }
249
144
  end
250
145
 
251
- # The manifest's scripts, copied into <bundle_dir>/scripts/ (the
252
- # folder replaced: a script the new version dropped goes). A declared
253
- # sha256 that differs warns, as a plugin's does.
254
- # @return [Hash{String => String}] file name => installed path
255
- def install_scripts
146
+ # The manifest's scripts (a dev install warns of one it can't run).
147
+ # @return [Hash{String => String}] file name => the source file
148
+ def scripts_incoming
256
149
  scripts = @manifest&.scripts || {}
257
- scripts.each_key do |file|
150
+ scripts.keys.to_h do |file|
258
151
  src = File.join(@bundle_dir, "scripts", file)
259
152
  raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
260
153
  raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
261
154
 
262
155
  @warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
156
+ [file, src]
263
157
  end
264
- results = scripts.keys.to_h do |file|
265
- [file, result_for(File.join(@bundle_dir, "scripts", file), File.join(@provenance.scripts_dir, file),
266
- updated: @upgrade && @provenance.installed?)]
158
+ end
159
+
160
+ # One folder of the bundle's own dir (guardrails/, plugin/, scripts/),
161
+ # replaced whole by +incoming+: each file's AssetState and its action
162
+ # (FileActions' ASSET_INSTALL, or ASSET_UPGRADE over an installed
163
+ # bundle), one result per incoming file (a dry run's DRY_WORDS of it,
164
+ # and nothing written), an edit copied to the trash first (save_first),
165
+ # then the folder replaced. A file the source dropped goes without a
166
+ # word. A plugin or script whose sha256 differs from the declared one
167
+ # warns (not a dev link: it is the source).
168
+ # @param incoming [Hash{String => String}] name => the source file, in
169
+ # the order the results list them
170
+ # @return [Hash{String => String}] name => installed path ({} on a dry run)
171
+ def install_set(kind_of_file, incoming)
172
+ dir, recorded = folder(kind_of_file)
173
+ command = @upgrade && @provenance.installed? ? :upgrade : :install
174
+ states = AssetState.in(dir, kind_of_file: kind_of_file, recorded: recorded, incoming: incoming)
175
+ .to_h { |asset| [asset.name, [asset, FileActions.for_asset(command, asset)]] }
176
+ results = incoming.keys.to_h do |name|
177
+ asset, action = states.fetch(name)
178
+ [name, Installer::FileResult.new(word: @dry_run ? DRY_WORDS.fetch(action.word) : action.word, state: asset, action: action)]
267
179
  end
268
180
  if @dry_run
269
- results.each { |file, result| @results["scripts/#{file}"] = planned(result) }
181
+ results.each { |name, result| @results[result_key(kind_of_file, name)] = result }
270
182
  return {}
271
183
  end
272
184
 
273
- @trash.copy_edits(@provenance.scripts_dir, "scripts", @existing&.scripts,
274
- scripts.keys.to_h { |file| [file, File.join(@bundle_dir, "scripts", file)] })
275
- FileUtils.rm_rf(@provenance.scripts_dir)
276
- return {} if scripts.empty?
277
-
278
- FileUtils.mkdir_p(@provenance.scripts_dir)
279
- scripts.to_h do |file, declared|
280
- dest = File.join(@provenance.scripts_dir, file)
281
- place(File.join(@bundle_dir, "scripts", file), dest)
282
- @results["scripts/#{file}"] = results[file]
283
- FileUtils.chmod(0o755, dest) unless @dev
284
- warning = FileDigest.mismatch("script #{file}", dest, declared.to_s.delete_prefix("sha256:")) if @strict && !@dev
285
- @warnings << warning if warning
286
- [file, dest]
185
+ states.each_value { |asset, action| @trash.copy(asset.path, as: asset.label) if action.save_first }
186
+ FileUtils.rm_rf(dir)
187
+ return {} if incoming.empty?
188
+
189
+ FileUtils.mkdir_p(dir)
190
+ incoming.to_h do |name, src|
191
+ dest = File.join(dir, name)
192
+ place(src, dest)
193
+ @results[result_key(kind_of_file, name)] = results[name]
194
+ FileUtils.chmod(0o755, dest) if kind_of_file == :script && !@dev
195
+ if (declared = declared_sha(kind_of_file, name)) && results[name].action.do == :write_incoming && @strict && !@dev
196
+ warning = FileDigest.mismatch("#{kind_of_file} #{name}", dest, declared)
197
+ @warnings << warning if warning
198
+ end
199
+ [name, dest]
287
200
  end
288
201
  end
289
202
 
290
- # What the real run reports for +src+ over +dest+: up to date when the
291
- # bytes are the same, else updated (+updated+: an upgrade over an
292
- # install) or installed. A dry run records #planned of it, so the two
293
- # can't drift.
294
- def result_for(src, dest, updated:)
295
- word = if File.file?(dest) && FileUtils.identical?(src, dest) then :up_to_date
296
- elsif updated then :updated
297
- else :installed
298
- end
299
- Installer::FileResult.new(word: word)
203
+ # The kind's folder and the record's shas for it.
204
+ def folder(kind_of_file)
205
+ case kind_of_file
206
+ when :rule then [@provenance.guardrails_dir, @existing&.guardrails]
207
+ when :plugin then [@provenance.plugin_dir, @existing&.plugin_shas]
208
+ else [@provenance.scripts_dir, @existing&.scripts]
209
+ end
300
210
  end
301
211
 
302
- # A dry run's word for a result: would_install, would_update, or the
303
- # up to date result as it is (nothing would be written).
304
- def planned(result)
305
- case result.word
306
- when :installed then result.with(word: :would_install)
307
- when :updated then result.with(word: :would_update)
308
- else result
212
+ # Its key in the Installer's results (the summary's name): a rule's
213
+ # guardrails/r.yml, the plugin's own file name, a script's scripts/s.sh.
214
+ def result_key(kind_of_file, name) = kind_of_file == :plugin ? name : "#{AssetState::FOLDERS.fetch(kind_of_file)}/#{name}"
215
+
216
+ # The manifest's sha256 for a plugin or script (hex), nil for a rule.
217
+ def declared_sha(kind_of_file, name)
218
+ case kind_of_file
219
+ when :plugin then @manifest.checksum_for_plugin
220
+ when :script then @manifest.scripts[name].to_s.delete_prefix("sha256:")
309
221
  end
310
222
  end
311
223
 
@@ -333,21 +245,10 @@ module Samagotchi
333
245
  true
334
246
  end
335
247
 
336
- # The hook loader skips every hook of a bundle whose requires_chi this
337
- # chi doesn't meet (Hooks::BundleLoader); say so at install, as
338
- # install_plugin does for the plugin.
339
- def warn_hooks_requires_chi
340
- return if @manifest.nil? || @bundle_hooks.empty?
341
-
342
- if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
343
- @warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
344
- end
345
- end
346
-
347
248
  # GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
348
249
  # requires_chi this chi doesn't meet, and a session that never loaded
349
250
  # them records a required load failure (the gate refuses guarded calls
350
- # until chi is updated): say so at install, as the hooks warning does.
251
+ # until chi is updated): say so at install, as #plugin_incoming does.
351
252
  def warn_rules_requires_chi(rule_files)
352
253
  return if @manifest.nil? || rule_files.empty?
353
254
 
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "trash"
5
+
6
+ module Samagotchi
7
+ module MemoryBundle
8
+ # One file of a bundle's own folder (a guardrail rule, the plugin, a
9
+ # script) as a command finds it: derived from the file on disk, the sha
10
+ # the record holds and the source file that would take its place, never
11
+ # stored. FileActions' ASSET_* tables say what each command does in each
12
+ # state; AssetInstaller, ShippedUpdate's plan, Uninstaller and Status read
13
+ # it. Whether it is edited is Trash.edit?'s answer (what the trash keeps a
14
+ # copy of), asked once in .of.
15
+ #
16
+ # name its path in its folder (r.yml, plugin.rb, s.sh)
17
+ # path where it is installed
18
+ # kind_of_file :rule, :plugin or :script
19
+ # state :new not there (or a broken link), the source has it
20
+ # :unchanged the source's bytes already (a dev link to them too)
21
+ # :pristine the recorded sha, the source has other bytes
22
+ # :edited neither the recorded sha (none recorded: unknown,
23
+ # so edited) nor the source's bytes
24
+ # :linked a link (a dev install's) to other bytes than the
25
+ # source's: it points at a source nothing here touches
26
+ # :dropped_pristine, :dropped_edited there, the source hasn't
27
+ # it (with no source read, as uninstall and status
28
+ # do, every file there is one of these)
29
+ # :missing recorded, not there, the source hasn't it
30
+ # recorded the record's sha256 for it (nil: not in the record)
31
+ # incoming the source file that replaces it (nil: none)
32
+ AssetState = Data.define(:name, :path, :kind_of_file, :state, :recorded, :incoming) do
33
+ # @param path [String] the installed file
34
+ # @param recorded [String, nil] its recorded sha256
35
+ # @param incoming [String, nil] the source file put in its place
36
+ def self.of(path, kind_of_file:, recorded: nil, incoming: nil, name: File.basename(path))
37
+ new(name: name, path: path, kind_of_file: kind_of_file, state: state_of(path, recorded, incoming),
38
+ recorded: recorded, incoming: incoming)
39
+ end
40
+
41
+ # Every file of the folder +dir+ a command meets: each file in it (at
42
+ # any depth, as Trash#copy_edits reads it; a linked dir isn't followed),
43
+ # each incoming one and each recorded one, by name.
44
+ # @param recorded [Hash, nil] name => recorded sha256
45
+ # @param incoming [Hash] name => the source file
46
+ # @return [Array<AssetState>]
47
+ def self.in(dir, kind_of_file:, recorded: nil, incoming: {})
48
+ recorded ||= {}
49
+ on_disk = if File.symlink?(dir) || !File.directory?(dir) then []
50
+ else Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).reject { |file| File.directory?(File.join(dir, file)) }
51
+ end
52
+ (on_disk | incoming.keys | recorded.keys).sort.map do |name|
53
+ of(File.join(dir, name), kind_of_file: kind_of_file, recorded: recorded[name], incoming: incoming[name], name: name)
54
+ end
55
+ end
56
+
57
+ def self.state_of(path, recorded, incoming)
58
+ if incoming && File.file?(path) && FileUtils.identical?(incoming, path) then :unchanged
59
+ elsif File.symlink?(path) && File.exist?(path) then :linked
60
+ elsif !File.file?(path) then incoming ? :new : :missing
61
+ elsif Trash.edit?(path, recorded: recorded, incoming: incoming) then incoming ? :edited : :dropped_edited
62
+ else incoming ? :pristine : :dropped_pristine
63
+ end
64
+ end
65
+ private_class_method :state_of
66
+
67
+ # Trash.edit?'s rule (asked in .of): an edit a replace or a removal
68
+ # would lose, so the trash keeps a copy first.
69
+ def edited? = %i[edited dropped_edited].include?(state)
70
+
71
+ # Where the trash keeps its copy, and the plan names it: guardrails/r.yml,
72
+ # plugin/plugin.rb, scripts/s.sh.
73
+ def label = "#{self.class::FOLDERS.fetch(kind_of_file)}/#{name}"
74
+ end
75
+
76
+ class AssetState
77
+ # Each kind's folder in the bundle's own dir (and in the trash).
78
+ FOLDERS = { rule: "guardrails", plugin: "plugin", script: "scripts" }.freeze
79
+ end
80
+ end
81
+ end