samagotchi 0.160.0 → 0.161.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -1
- data/docs/architecture.md +7 -3
- data/docs/cli.md +6 -4
- data/docs/context.md +10 -1
- data/docs/guardrails.md +12 -3
- data/docs/internals/bridge.md +21 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +4 -2
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +37 -5
- data/docs/sessions.md +14 -11
- data/docs/testing.md +9 -1
- data/lib/samagotchi/bridge/card_store.rb +31 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
- data/lib/samagotchi/bridge/sse_writer.rb +8 -5
- data/lib/samagotchi/bridge.rb +92 -42
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +56 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +2 -0
- data/lib/samagotchi/compat.rb +32 -0
- data/lib/samagotchi/context_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/engine/events.rb +12 -0
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/targets.rb +25 -6
- data/lib/samagotchi/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
- data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
- data/lib/samagotchi/memory_bundle/installer.rb +79 -12
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/status.rb +1 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
- data/lib/samagotchi/memory_orphans.rb +40 -9
- data/lib/samagotchi/note_command.rb +9 -1
- data/lib/samagotchi/note_delivery.rb +34 -24
- data/lib/samagotchi/note_move.rb +205 -0
- data/lib/samagotchi/objectives_command.rb +3 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/project_init.rb +3 -3
- data/lib/samagotchi/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +286 -48
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +42 -5
- data/lib/samagotchi/session_access/local.rb +45 -17
- data/lib/samagotchi/session_access/remote.rb +11 -4
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +16 -3
- data/lib/samagotchi/session_continue.rb +155 -21
- data/lib/samagotchi/session_delete_command.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +41 -1
- data/lib/samagotchi/session_inbox_delivery.rb +254 -0
- data/lib/samagotchi/session_manager/delivery.rb +66 -12
- data/lib/samagotchi/session_manager/emptiness.rb +7 -2
- data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +4 -1
- data/lib/samagotchi/session_manager/removal.rb +130 -26
- data/lib/samagotchi/session_manager/spawn.rb +164 -104
- data/lib/samagotchi/session_manager.rb +1 -0
- data/lib/samagotchi/session_retention.rb +53 -23
- data/lib/samagotchi/session_setup.rb +27 -7
- data/lib/samagotchi/starting_marker.rb +93 -24
- data/lib/samagotchi/state_format.rb +18 -7
- data/lib/samagotchi/system_one/runs.rb +6 -1
- data/lib/samagotchi/system_one/runs_store.rb +18 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
- data/lib/samagotchi/terminal_ui.rb +2 -3
- data/lib/samagotchi/tools/delegate.rb +9 -4
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +7 -2
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- data/lib/samagotchi/web/public/app.js +2 -7
- data/lib/samagotchi/web/public/chains.js +4 -4
- data/lib/samagotchi/web/public/data.js +18 -2
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/session_fields.js +3 -2
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/update_notice.js +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +12 -8
- data/lib/samagotchi/web/session_control.rb +9 -3
- data/lib/samagotchi/web/session_create.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +50 -5
- data/lib/samagotchi/web/session_summary.rb +5 -2
- data/lib/samagotchi/worker.rb +45 -7
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +41 -8
- data/lib/samagotchi/worker_link.rb +4 -1
- data/lib/samagotchi/worker_presence.rb +106 -0
- data/lib/samagotchi/worker_sidecar.rb +3 -4
- metadata +5 -1
|
@@ -50,7 +50,8 @@ module Samagotchi
|
|
|
50
50
|
# @param chi_version [String] the chi this install is for (the Installer's;
|
|
51
51
|
# a caller that checks its own bundles against another version passes it)
|
|
52
52
|
def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:, trash:,
|
|
53
|
-
force: false, strict: true, upgrade: false, dry_run: false, dev: false, chi_version: Samagotchi::VERSION
|
|
53
|
+
force: false, strict: true, upgrade: false, dry_run: false, dev: false, chi_version: Samagotchi::VERSION,
|
|
54
|
+
refuse_mismatch: false)
|
|
54
55
|
@name = name
|
|
55
56
|
@bundle_dir = source_dir
|
|
56
57
|
@manifest = manifest
|
|
@@ -66,11 +67,42 @@ module Samagotchi
|
|
|
66
67
|
@dry_run = dry_run
|
|
67
68
|
@dev = dev
|
|
68
69
|
@chi_version = chi_version
|
|
70
|
+
@refuse_mismatch = refuse_mismatch
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Raises for the first plugin or script the manifest names that the
|
|
74
|
+
# bundle doesn't have (or ships as a link), writing nothing: the
|
|
75
|
+
# Installer runs it before its first write.
|
|
76
|
+
# @raise [Installer::InstallError]
|
|
77
|
+
def check_named_files
|
|
78
|
+
plugin_source
|
|
79
|
+
scripts_sources
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The same early pass on the shas: the plugin and each script against the
|
|
83
|
+
# bundle's own bytes, before anything is written. Only when the caller
|
|
84
|
+
# refuses a mismatch (`chi bundle install/upgrade`; chi update, a profile
|
|
85
|
+
# and a dev install keep their warning), and not with --force, which
|
|
86
|
+
# installs a mismatch anyway.
|
|
87
|
+
# @raise [Installer::InstallError] one line per mismatched file
|
|
88
|
+
def check_named_checksums
|
|
89
|
+
return unless @refuse_mismatch && @strict && !@dev && !@force && @manifest
|
|
90
|
+
|
|
91
|
+
lines = []
|
|
92
|
+
if @manifest.plugin
|
|
93
|
+
lines << Installer.mismatch_refusal("plugin #{@manifest.plugin[:file]}", plugin_source,
|
|
94
|
+
@manifest.checksum_for_plugin, upgrade: @upgrade)
|
|
95
|
+
end
|
|
96
|
+
scripts_sources.each do |name, src|
|
|
97
|
+
lines << Installer.mismatch_refusal("script #{name}", src, declared_sha(:script, name), upgrade: @upgrade)
|
|
98
|
+
end
|
|
99
|
+
lines = lines.compact
|
|
100
|
+
raise Installer::InstallError, lines.join("\n") unless lines.empty?
|
|
69
101
|
end
|
|
70
102
|
|
|
71
103
|
# @return [Installed]
|
|
72
|
-
# @raise [Installer::InstallError] the
|
|
73
|
-
#
|
|
104
|
+
# @raise [Installer::InstallError] the same, when #check_named_files
|
|
105
|
+
# wasn't run first
|
|
74
106
|
def run
|
|
75
107
|
incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
|
|
76
108
|
.reject { |src| linked_out?(src, "guardrails/#{File.basename(src)}") }
|
|
@@ -137,30 +169,45 @@ module Samagotchi
|
|
|
137
169
|
plugin = @manifest&.plugin
|
|
138
170
|
return {} unless plugin
|
|
139
171
|
|
|
140
|
-
src =
|
|
141
|
-
raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
|
|
142
|
-
raise Installer::InstallError, "plugin #{plugin[:file]} is a symlink (not installed)" if linked?(src)
|
|
143
|
-
|
|
172
|
+
src = plugin_source
|
|
144
173
|
if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, @chi_version))
|
|
145
174
|
@warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
|
|
146
175
|
end
|
|
147
176
|
{ plugin[:file] => src }
|
|
148
177
|
end
|
|
149
178
|
|
|
150
|
-
# The manifest's
|
|
179
|
+
# The manifest's plugin file in the source, checked; nil without one.
|
|
180
|
+
def plugin_source
|
|
181
|
+
plugin = @manifest&.plugin
|
|
182
|
+
return unless plugin
|
|
183
|
+
|
|
184
|
+
src = File.join(@bundle_dir, plugin[:file])
|
|
185
|
+
raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
|
|
186
|
+
raise Installer::InstallError, "plugin #{plugin[:file]} is a symlink (not installed)" if linked?(src)
|
|
187
|
+
|
|
188
|
+
src
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The manifest's scripts in the source, checked.
|
|
151
192
|
# @return [Hash{String => String}] file name => the source file
|
|
152
|
-
def
|
|
153
|
-
|
|
154
|
-
scripts.keys.to_h do |file|
|
|
193
|
+
def scripts_sources
|
|
194
|
+
(@manifest&.scripts || {}).keys.to_h do |file|
|
|
155
195
|
src = File.join(@bundle_dir, "scripts", file)
|
|
156
196
|
raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
|
|
157
197
|
raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
|
|
158
198
|
|
|
159
|
-
@warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
|
|
160
199
|
[file, src]
|
|
161
200
|
end
|
|
162
201
|
end
|
|
163
202
|
|
|
203
|
+
# The manifest's scripts (a dev install warns of one it can't run).
|
|
204
|
+
# @return [Hash{String => String}] file name => the source file
|
|
205
|
+
def scripts_incoming
|
|
206
|
+
scripts_sources.each do |file, src|
|
|
207
|
+
@warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src) && !page_only?(file)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
164
211
|
# One folder of the bundle's own dir (guardrails/, plugin/, scripts/),
|
|
165
212
|
# replaced whole by +incoming+: each file's AssetState and its action
|
|
166
213
|
# (FileActions' ASSET_INSTALL, or ASSET_UPGRADE over an installed
|
|
@@ -11,6 +11,10 @@ module Samagotchi
|
|
|
11
11
|
# the record's entry (R). Derived, never stored. FileActions says what each
|
|
12
12
|
# acting command does in each state.
|
|
13
13
|
#
|
|
14
|
+
# A file whose disk bytes are the text an interrupted run journaled
|
|
15
|
+
# (Provenance#write_pending) reads as the bundle's, at that run's
|
|
16
|
+
# version: owned (an entry made up if the record has none), its own base.
|
|
17
|
+
#
|
|
14
18
|
# kind, with a source (install, upgrade, remerge, restore, plan):
|
|
15
19
|
# :new not the bundle's, not on disk
|
|
16
20
|
# :user_same not the bundle's, on disk with I's bytes
|
|
@@ -82,6 +86,7 @@ module Samagotchi
|
|
|
82
86
|
# @return [FileState]
|
|
83
87
|
def of(key, record:, provenance:, target_dir:, source_dir:, claimants: nil, shipped: true)
|
|
84
88
|
entry = record&.files&.fetch(key, nil)
|
|
89
|
+
pending = provenance.pending_shas(key, target_dir: target_dir)
|
|
85
90
|
paths = { base: provenance.base_path(key), current: File.join(target_dir, key),
|
|
86
91
|
incoming: source_dir && File.join(source_dir, key) }
|
|
87
92
|
mark = mark_of(entry)
|
|
@@ -90,6 +95,12 @@ module Samagotchi
|
|
|
90
95
|
incoming: source_dir && shipped ? digest(paths[:incoming]) : nil, recorded: entry && Provenance.recorded_sha(entry[:checksum]),
|
|
91
96
|
marked_incoming: mark == :none ? nil : entry[:incoming_sha]
|
|
92
97
|
)
|
|
98
|
+
if !pending.empty? && pending.include?(digests.disk)
|
|
99
|
+
# An interrupted run put this text here (Provenance#write_pending): it is the bundle's at
|
|
100
|
+
# that version, owned and its own base, not the user's file or an edit of the recorded one.
|
|
101
|
+
entry = (entry || {}).merge(checksum: digests.disk)
|
|
102
|
+
digests = digests.with(base: digests.disk, recorded: digests.disk)
|
|
103
|
+
end
|
|
93
104
|
shared = nil
|
|
94
105
|
lookup = -> { shared ||= claimants ? claimants.call(key) : [] }
|
|
95
106
|
state = FileState.new(key: key, kind: nil, mark: mark, digests: digests, entry: entry, paths: paths, claimants: lookup)
|
|
@@ -14,8 +14,11 @@ module Samagotchi
|
|
|
14
14
|
# @param source [String, false, nil] the bundle the memory came from
|
|
15
15
|
# ("· from <bundle>"): a name sets it, false drops it, nil keeps the
|
|
16
16
|
# line's (memory_write and write/edit pass nil)
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
# @param target_dir [String, nil] the folder whose index.md to edit, when
|
|
18
|
+
# it is not the one +scope+ resolves from the current folder (an
|
|
19
|
+
# uninstall of a bundle journaled into another project)
|
|
20
|
+
def self.update_index(scope, entry_name, byte_count, description = nil, source: nil, target_dir: nil)
|
|
21
|
+
index_path = target_dir ? File.join(target_dir, "#{MEMORY_INDEX}.md") : index_path_for(scope)
|
|
19
22
|
return true unless index_path
|
|
20
23
|
|
|
21
24
|
locked_write(File.dirname(index_path)) do |content|
|
|
@@ -39,8 +42,8 @@ module Samagotchi
|
|
|
39
42
|
/^- \*\*#{Regexp.escape(name)}\*\*[ \t]*(?:[·•].*|:.*)?(\r?\n|\z)/
|
|
40
43
|
end
|
|
41
44
|
|
|
42
|
-
def self.remove_index(scope, entry_name)
|
|
43
|
-
index_path = index_path_for(scope)
|
|
45
|
+
def self.remove_index(scope, entry_name, target_dir: nil)
|
|
46
|
+
index_path = target_dir ? File.join(target_dir, "#{MEMORY_INDEX}.md") : index_path_for(scope)
|
|
44
47
|
return true unless index_path && File.exist?(index_path)
|
|
45
48
|
|
|
46
49
|
locked_write(File.dirname(index_path)) do |content|
|
|
@@ -61,7 +61,8 @@ module Samagotchi
|
|
|
61
61
|
# chi_version: the chi this install is for (Profile and ShippedUpdate
|
|
62
62
|
# check their bundles against a version they are given); default this chi.
|
|
63
63
|
def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false, prepared: nil,
|
|
64
|
-
shipped_dir: SourceNormalizer::SHIPPED_DIR, dev: false, chi_version: Samagotchi::VERSION
|
|
64
|
+
shipped_dir: SourceNormalizer::SHIPPED_DIR, dev: false, chi_version: Samagotchi::VERSION,
|
|
65
|
+
refuse_mismatch: false)
|
|
65
66
|
@source = source
|
|
66
67
|
@shipped_dir = shipped_dir
|
|
67
68
|
@prepared = prepared
|
|
@@ -73,6 +74,10 @@ module Samagotchi
|
|
|
73
74
|
@dry_run = dry_run
|
|
74
75
|
@dev = dev
|
|
75
76
|
@chi_version = chi_version
|
|
77
|
+
# `chi bundle install/upgrade` refuses a file whose bytes disagree with
|
|
78
|
+
# its manifest sha before writing anything; the callers that only warn
|
|
79
|
+
# (ShippedUpdate, SystemBundle, Profile) leave it unset.
|
|
80
|
+
@refuse_mismatch = refuse_mismatch
|
|
76
81
|
@results = {}
|
|
77
82
|
@warnings = []
|
|
78
83
|
@placeholder_warnings = []
|
|
@@ -119,10 +124,11 @@ module Samagotchi
|
|
|
119
124
|
def run
|
|
120
125
|
load_source
|
|
121
126
|
refuse_too_new
|
|
127
|
+
@provenance = Provenance.new(name: @name)
|
|
128
|
+
refuse_newer_record_format
|
|
122
129
|
warn_unknown_keys(@manifest) if @manifest
|
|
123
130
|
@target_scope, @target_dir = resolve_target
|
|
124
131
|
@file_descriptions = @manifest&.file_descriptions || {}
|
|
125
|
-
@provenance = Provenance.new(name: @name)
|
|
126
132
|
@existing = @provenance.record
|
|
127
133
|
check_source
|
|
128
134
|
# A plain install over an installed bundle: its summary says so.
|
|
@@ -212,6 +218,18 @@ module Samagotchi
|
|
|
212
218
|
"(to leave dev mode: chi bundle uninstall #{name}, then chi bundle install without --dev)"
|
|
213
219
|
end
|
|
214
220
|
|
|
221
|
+
# The line a refused install names one file with: the checksum warning and
|
|
222
|
+
# the way past it. Only `chi bundle install/upgrade` refuses a mismatch
|
|
223
|
+
# (--force skips the early check and keeps the post-write warning); the
|
|
224
|
+
# callers that only warn (chi update, SystemBundle, a profile) never reach
|
|
225
|
+
# this.
|
|
226
|
+
# @param label [String] "a.md", "plugin plugin.rb", "script s.sh"
|
|
227
|
+
# @param upgrade [Boolean] which command the hint names
|
|
228
|
+
def self.mismatch_refusal(label, path, expected, upgrade:)
|
|
229
|
+
warning = FileDigest.mismatch(label, path, expected)
|
|
230
|
+
warning && "#{warning}; chi bundle #{upgrade ? "upgrade" : "install"} --force installs it anyway"
|
|
231
|
+
end
|
|
232
|
+
|
|
215
233
|
private
|
|
216
234
|
|
|
217
235
|
# The files whose result has one of +words+, in the order they ran.
|
|
@@ -233,28 +251,71 @@ module Samagotchi
|
|
|
233
251
|
line
|
|
234
252
|
end
|
|
235
253
|
|
|
236
|
-
# The steps that replace files
|
|
237
|
-
# the files it dropped. The record isn't written yet, so a failed run is
|
|
254
|
+
# The steps that replace files, after the check of the files the manifest
|
|
255
|
+
# names: memories, the bundle's own folder, then the files it dropped. The record isn't written yet, so a failed run is
|
|
238
256
|
# retried as the same upgrade.
|
|
239
257
|
# @return [AssetInstaller::Installed]
|
|
240
258
|
# @raise [InstallError] a file system error (a file it can't read, a
|
|
241
259
|
# .trash it can't write)
|
|
242
260
|
def replace_files
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
installed = AssetInstaller.new(
|
|
261
|
+
assets = AssetInstaller.new(
|
|
246
262
|
name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
|
|
247
263
|
results: @results, warnings: @warnings, conflicts: @conflicts, trash: @trash,
|
|
248
|
-
force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev, chi_version: @chi_version
|
|
249
|
-
|
|
264
|
+
force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev, chi_version: @chi_version,
|
|
265
|
+
refuse_mismatch: @refuse_mismatch
|
|
266
|
+
)
|
|
267
|
+
# Before the first write: a plugin or script the bundle lacks refuses the run with disk and record as they were.
|
|
268
|
+
assets.check_named_files
|
|
269
|
+
# And, for the commands that refuse it (--force aside), a file whose
|
|
270
|
+
# bytes disagree with the sha its manifest declares.
|
|
271
|
+
refuse_memory_mismatches
|
|
272
|
+
assets.check_named_checksums
|
|
273
|
+
write_pending
|
|
274
|
+
install_memories
|
|
275
|
+
installed = assets.run
|
|
250
276
|
|
|
251
277
|
# Files the previous version shipped and this one doesn't.
|
|
252
|
-
|
|
278
|
+
previous_files = @provenance.files_with_pending(@existing, target_dir: @target_dir)
|
|
279
|
+
prune_dropped_files(previous_files, @bundle_md, @target_dir, @target_scope, @provenance) if @upgrade && @existing
|
|
253
280
|
installed
|
|
254
281
|
rescue SystemCallError => e
|
|
255
282
|
raise InstallError, Trash.failure_message(e)
|
|
256
283
|
end
|
|
257
284
|
|
|
285
|
+
# The intent journal (Provenance#write_pending): the text of each memory
|
|
286
|
+
# this run will change, noted before the first write, so a run that
|
|
287
|
+
# stops before its record leaves files a retry still knows as the
|
|
288
|
+
# bundle's.
|
|
289
|
+
def write_pending
|
|
290
|
+
return if @dry_run || @manifest.nil?
|
|
291
|
+
|
|
292
|
+
incoming = memory_files.to_h { |key| [key, FileDigest.hex(File.join(@bundle_dir, key))] }
|
|
293
|
+
incoming.reject! do |key, sha|
|
|
294
|
+
sha.nil? || sha == FileDigest.hex(File.join(@target_dir, key))
|
|
295
|
+
rescue SystemCallError
|
|
296
|
+
true
|
|
297
|
+
end
|
|
298
|
+
@provenance.write_pending(version: @manifest.version, scope: @target_scope, target_dir: @target_dir, files: incoming) unless incoming.empty?
|
|
299
|
+
rescue SystemCallError => e
|
|
300
|
+
raise InstallError, Trash.failure_message(e)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Before the first write, the bundle's own bytes against the sha its
|
|
304
|
+
# manifest declares for each memory: `chi bundle install/upgrade` refuses
|
|
305
|
+
# a mismatch (--force skips the check, and #verify_memory_checksums keeps
|
|
306
|
+
# the post-write warning for the callers that only warn). A dev install
|
|
307
|
+
# follows its own tree; a non-strict run has no shas to trust.
|
|
308
|
+
# @raise [InstallError] one line per mismatched file
|
|
309
|
+
def refuse_memory_mismatches
|
|
310
|
+
return unless @refuse_mismatch && @strict && !@dev && !@force && @manifest
|
|
311
|
+
|
|
312
|
+
lines = memory_files.filter_map do |file_key|
|
|
313
|
+
self.class.mismatch_refusal(file_key, File.join(@bundle_dir, file_key),
|
|
314
|
+
@manifest.checksum_for(file_key), upgrade: @upgrade)
|
|
315
|
+
end
|
|
316
|
+
raise InstallError, lines.join("\n") unless lines.empty?
|
|
317
|
+
end
|
|
318
|
+
|
|
258
319
|
# Who may install under this name (a manifest can take any name: a
|
|
259
320
|
# repo calling itself guardrails). Sources compare by SourceIdentity
|
|
260
321
|
# (a shipped dir by name, in any gem or checkout; a git URL without
|
|
@@ -384,6 +445,12 @@ module Samagotchi
|
|
|
384
445
|
raise InstallError, "#{@name} can't be installed: #{failure} (chi update, or --force to install it anyway)"
|
|
385
446
|
end
|
|
386
447
|
|
|
448
|
+
def refuse_newer_record_format
|
|
449
|
+
@provenance.ensure_supported_record_format!
|
|
450
|
+
rescue StateFormat::InvalidDocument => e
|
|
451
|
+
raise InstallError, e.message
|
|
452
|
+
end
|
|
453
|
+
|
|
387
454
|
# The scope the files go to (the CLI's wins over the manifest's) and
|
|
388
455
|
# its dir, created.
|
|
389
456
|
# @return [Array(String, String)] scope, dir
|
|
@@ -785,8 +852,8 @@ module Samagotchi
|
|
|
785
852
|
|
|
786
853
|
# Not fatal, like update_target_index: a warning says so.
|
|
787
854
|
def remove_target_index(scope, file_key)
|
|
788
|
-
IndexUpdater.remove_index(scope, file_key.delete_suffix(".md"))
|
|
789
|
-
IndexUpdater.remove_index(scope, file_key) # legacy "name.md" line
|
|
855
|
+
IndexUpdater.remove_index(scope, file_key.delete_suffix(".md"), target_dir: @target_dir)
|
|
856
|
+
IndexUpdater.remove_index(scope, file_key, target_dir: @target_dir) # legacy "name.md" line
|
|
790
857
|
rescue StandardError => e
|
|
791
858
|
@warnings << "index.md: line for #{file_key.delete_suffix(".md")} not removed (#{e.message})"
|
|
792
859
|
end
|
|
@@ -7,6 +7,7 @@ require "fileutils"
|
|
|
7
7
|
require "tmpdir"
|
|
8
8
|
require_relative "../atomic_file"
|
|
9
9
|
require_relative "../memory_paths"
|
|
10
|
+
require_relative "../state_format"
|
|
10
11
|
require_relative "../version"
|
|
11
12
|
require_relative "file_digest"
|
|
12
13
|
require_relative "installed_bundle"
|
|
@@ -32,6 +33,38 @@ module Samagotchi
|
|
|
32
33
|
MARK_FIELDS = %w[conflict incoming_sha legacy_base why missed missed_places].freeze
|
|
33
34
|
# What a merge no report has shown adds to a file's entry (#note_merged).
|
|
34
35
|
MERGE_NOTE_FIELDS = %w[merged_version merged_at merged_trash].freeze
|
|
36
|
+
RECORD_FORMAT_VERSION = 1
|
|
37
|
+
RECORD_FORMAT = StateFormat.new(
|
|
38
|
+
version: RECORD_FORMAT_VERSION,
|
|
39
|
+
members: [
|
|
40
|
+
StateFormat.required(:name),
|
|
41
|
+
StateFormat.required(:version),
|
|
42
|
+
StateFormat.required(:scope),
|
|
43
|
+
StateFormat.required(:source),
|
|
44
|
+
StateFormat.required(:installed_at),
|
|
45
|
+
StateFormat.required(:files),
|
|
46
|
+
StateFormat.required(:hooks),
|
|
47
|
+
StateFormat.added(:record_format, in_version: 1, default: 1),
|
|
48
|
+
StateFormat.added(:dev, in_version: 1, default: false),
|
|
49
|
+
StateFormat.added(:guardrails, in_version: 1, default: {}),
|
|
50
|
+
StateFormat.added(:source_commit, in_version: 1, default: nil),
|
|
51
|
+
StateFormat.added(:plugin, in_version: 1, default: nil),
|
|
52
|
+
StateFormat.added(:includes, in_version: 1, default: nil),
|
|
53
|
+
StateFormat.added(:scripts, in_version: 1, default: {}),
|
|
54
|
+
StateFormat.added(:manifest_sha, in_version: 1, default: nil),
|
|
55
|
+
StateFormat.added(:installed_by, in_version: 1, default: nil),
|
|
56
|
+
StateFormat.added(:requires_chi, in_version: 1, default: nil),
|
|
57
|
+
StateFormat.added(:trust_level, in_version: 1, default: nil),
|
|
58
|
+
StateFormat.added(:needs, in_version: 1, default: nil),
|
|
59
|
+
StateFormat.added(:context_providers, in_version: 1, default: nil),
|
|
60
|
+
StateFormat.added(:block_renderers, in_version: 1, default: nil),
|
|
61
|
+
StateFormat.added(:session_settings, in_version: 1, default: nil),
|
|
62
|
+
StateFormat.added(:session_setting_types, in_version: 1, default: nil),
|
|
63
|
+
StateFormat.added(:settings, in_version: 1, default: nil),
|
|
64
|
+
StateFormat.added(:setting_types, in_version: 1, default: nil),
|
|
65
|
+
StateFormat.extra(:extra, in_version: 1)
|
|
66
|
+
]
|
|
67
|
+
)
|
|
35
68
|
|
|
36
69
|
def initialize(name:)
|
|
37
70
|
@name = name
|
|
@@ -65,6 +98,55 @@ module Samagotchi
|
|
|
65
98
|
File.join(@bundle_dir, "scripts")
|
|
66
99
|
end
|
|
67
100
|
|
|
101
|
+
# The intent journal of a run that has not written its record yet
|
|
102
|
+
# (#write_pending): its scope and target dir, plus the files and sha256s
|
|
103
|
+
# of the texts a run meant to put there. #write removes it.
|
|
104
|
+
def pending_path
|
|
105
|
+
File.join(@bundle_dir, "pending.json")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Notes, before an install's first write, the texts it is about to put
|
|
109
|
+
# in the memories dir, atomically. A journal an earlier interrupted run
|
|
110
|
+
# left keeps its shas (that run's files are still on disk).
|
|
111
|
+
# @param files [Hash{String => String}] memory => the incoming sha256
|
|
112
|
+
def write_pending(version:, scope:, target_dir:, files:)
|
|
113
|
+
shas = pending_files(target_dir: target_dir)
|
|
114
|
+
files.each { |key, sha| shas[key] = (shas.fetch(key, []) + [sha]).uniq }
|
|
115
|
+
FileUtils.mkdir_p(@bundle_dir)
|
|
116
|
+
AtomicFile.write(pending_path, JSON.pretty_generate(
|
|
117
|
+
"version" => version.to_s, "scope" => scope.to_s, "target_dir" => File.expand_path(target_dir), "files" => shas
|
|
118
|
+
))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The shas the journal names for +file_key+ (none without a journal, or
|
|
122
|
+
# with one that doesn't read or belongs to another target).
|
|
123
|
+
def pending_shas(file_key, target_dir:)
|
|
124
|
+
pending_files(target_dir: target_dir).fetch(file_key.to_s, [])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def pending_scope
|
|
128
|
+
pending_metadata&.fetch("scope")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def pending_target_dir
|
|
132
|
+
pending_metadata&.fetch("target_dir")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The record's files plus interrupted files whose current bytes match a
|
|
136
|
+
# journaled sha. Files whose bytes no longer match are not claimed.
|
|
137
|
+
def files_with_pending(record, target_dir:)
|
|
138
|
+
files = record&.files&.dup || {}
|
|
139
|
+
pending_files(target_dir: target_dir).each do |key, shas|
|
|
140
|
+
next if files.key?(key)
|
|
141
|
+
|
|
142
|
+
disk_sha = FileDigest.hex(File.join(target_dir, key))
|
|
143
|
+
files[key] = {} if shas.include?(disk_sha)
|
|
144
|
+
rescue SystemCallError
|
|
145
|
+
next
|
|
146
|
+
end
|
|
147
|
+
files
|
|
148
|
+
end
|
|
149
|
+
|
|
68
150
|
# The bundle's manifest.yml as installed (InstalledBundle#kept_manifest_path).
|
|
69
151
|
def kept_manifest_path
|
|
70
152
|
File.join(@bundle_dir, "manifest.yml")
|
|
@@ -233,6 +315,7 @@ module Samagotchi
|
|
|
233
315
|
guardrails_files: {}, plugin_file: nil, plugin_required: false, requires_chi: nil, needs: nil, marks: [], incoming_dir: nil, includes: nil,
|
|
234
316
|
scripts_files: {}, context_providers: [], block_renderers: [], file_descriptions: {}, session_settings: [], session_setting_types: {}, settings: nil, setting_types: {},
|
|
235
317
|
dev: false, manifest_from: nil)
|
|
318
|
+
record_extra = format_record(read)&.extra || {}
|
|
236
319
|
FileUtils.mkdir_p(@bundle_dir)
|
|
237
320
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
238
321
|
FileUtils.mkdir_p(bases_dir)
|
|
@@ -280,6 +363,7 @@ module Samagotchi
|
|
|
280
363
|
|
|
281
364
|
manifest_data = {
|
|
282
365
|
"name" => @name,
|
|
366
|
+
"record_format" => RECORD_FORMAT_VERSION,
|
|
283
367
|
"version" => version.to_s,
|
|
284
368
|
"scope" => scope.to_s,
|
|
285
369
|
"source" => source_path.to_s,
|
|
@@ -314,8 +398,10 @@ module Samagotchi
|
|
|
314
398
|
end
|
|
315
399
|
end
|
|
316
400
|
manifest_data.merge!(kept_manifest_fields(existing, manifest_from, dev))
|
|
401
|
+
manifest_data = record_extra.merge(manifest_data)
|
|
317
402
|
|
|
318
403
|
write_manifest(manifest_data)
|
|
404
|
+
FileUtils.rm_f(pending_path)
|
|
319
405
|
marked.each_key { |key| save_incoming(key, incoming_dir) }
|
|
320
406
|
prune_incoming(marked.keys)
|
|
321
407
|
end
|
|
@@ -330,7 +416,7 @@ module Samagotchi
|
|
|
330
416
|
def resolve_conflicts(conflicts)
|
|
331
417
|
return [] unless installed?
|
|
332
418
|
|
|
333
|
-
raw =
|
|
419
|
+
raw = writable_record
|
|
334
420
|
raw["files"] ||= {}
|
|
335
421
|
settled = conflicts.filter_map do |file_key, info|
|
|
336
422
|
key = file_key.to_s
|
|
@@ -349,7 +435,7 @@ module Samagotchi
|
|
|
349
435
|
# A missing file was put back with the bundle's bytes: +path+ is its base,
|
|
350
436
|
# its entry is made or rebuilt (no mark), and the rest of the manifest stays.
|
|
351
437
|
def record_restored(file_key, path, description: nil)
|
|
352
|
-
raw =
|
|
438
|
+
raw = writable_record
|
|
353
439
|
content = File.binread(path)
|
|
354
440
|
File.binwrite(base_path(file_key), content)
|
|
355
441
|
entry = { "checksum" => Digest::SHA256.hexdigest(content) }
|
|
@@ -369,7 +455,7 @@ module Samagotchi
|
|
|
369
455
|
def refresh_marks(marks, incoming_dir)
|
|
370
456
|
return unless installed?
|
|
371
457
|
|
|
372
|
-
raw =
|
|
458
|
+
raw = writable_record
|
|
373
459
|
marks.each do |mark|
|
|
374
460
|
entry = raw.dig("files", mark.file)
|
|
375
461
|
next unless entry&.fetch("conflict", nil)
|
|
@@ -393,7 +479,7 @@ module Samagotchi
|
|
|
393
479
|
def note_merged(files, version:, trash:, at: Time.now)
|
|
394
480
|
return unless installed?
|
|
395
481
|
|
|
396
|
-
raw =
|
|
482
|
+
raw = writable_record
|
|
397
483
|
files.each do |key|
|
|
398
484
|
raw.dig("files", key)&.merge!({ "merged_version" => version.to_s, "merged_at" => at.iso8601, "merged_trash" => trash }.compact)
|
|
399
485
|
end
|
|
@@ -409,7 +495,7 @@ module Samagotchi
|
|
|
409
495
|
def clear_merge_notes(notes, files: [])
|
|
410
496
|
return if notes.empty? && files.empty?
|
|
411
497
|
|
|
412
|
-
raw =
|
|
498
|
+
raw = writable_record
|
|
413
499
|
stale = notes.select do |note|
|
|
414
500
|
entry = raw.dig("files", note.file)
|
|
415
501
|
entry && entry["merged_version"] == note.version && entry["merged_at"] == note.at
|
|
@@ -419,6 +505,8 @@ module Samagotchi
|
|
|
419
505
|
MERGE_NOTE_FIELDS.each { |field| entry.delete(field) } if entry
|
|
420
506
|
end
|
|
421
507
|
write_manifest(raw)
|
|
508
|
+
rescue StateFormat::InvalidDocument => e
|
|
509
|
+
e.message
|
|
422
510
|
rescue JSON::ParserError, SystemCallError
|
|
423
511
|
nil
|
|
424
512
|
end
|
|
@@ -437,7 +525,10 @@ module Samagotchi
|
|
|
437
525
|
def adopt_manifest(dir, dry_run: false)
|
|
438
526
|
bundle = record
|
|
439
527
|
return "#{@name} isn't installed" unless bundle
|
|
528
|
+
|
|
440
529
|
return "#{@name} is a dev install" if bundle.dev
|
|
530
|
+
|
|
531
|
+
raw = writable_record
|
|
441
532
|
return "#{@name} keeps its manifest already" unless bundle.manifest_unkept?
|
|
442
533
|
|
|
443
534
|
source = File.join(dir.to_s, "manifest.yml")
|
|
@@ -449,9 +540,10 @@ module Samagotchi
|
|
|
449
540
|
return why if why || dry_run
|
|
450
541
|
|
|
451
542
|
AtomicFile.write(kept_manifest_path, bytes)
|
|
452
|
-
raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
|
|
453
543
|
write_manifest(raw.merge("manifest_sha" => "sha256:#{Digest::SHA256.hexdigest(bytes)}"))
|
|
454
544
|
nil
|
|
545
|
+
rescue StateFormat::InvalidDocument => e
|
|
546
|
+
e.message
|
|
455
547
|
rescue JSON::ParserError, SystemCallError => e
|
|
456
548
|
"#{@name}'s record can't take it: #{e.message}"
|
|
457
549
|
end
|
|
@@ -463,6 +555,32 @@ module Samagotchi
|
|
|
463
555
|
InstalledBundle.read(@name, File.join(@bundle_dir, "manifest.json"))
|
|
464
556
|
end
|
|
465
557
|
|
|
558
|
+
def ensure_supported_record_format!
|
|
559
|
+
format_record(read)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# The journal's memory => shas, {} when it doesn't belong to this target
|
|
563
|
+
# or doesn't read.
|
|
564
|
+
def pending_files(target_dir:)
|
|
565
|
+
return {} if target_dir.to_s.empty?
|
|
566
|
+
|
|
567
|
+
metadata = pending_metadata
|
|
568
|
+
return {} unless metadata && File.expand_path(target_dir.to_s) == metadata["target_dir"]
|
|
569
|
+
|
|
570
|
+
files = metadata["files"]
|
|
571
|
+
files.is_a?(Hash) ? files.to_h { |key, shas| [key, Array(shas).grep(String)] } : {}
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def pending_metadata
|
|
575
|
+
data = JSON.parse(File.read(pending_path))
|
|
576
|
+
return unless data.is_a?(Hash) && data["scope"].is_a?(String) && data["target_dir"].is_a?(String)
|
|
577
|
+
|
|
578
|
+
data
|
|
579
|
+
rescue JSON::ParserError, EncodingError, SystemCallError
|
|
580
|
+
nil
|
|
581
|
+
end
|
|
582
|
+
private :pending_files, :pending_metadata
|
|
583
|
+
|
|
466
584
|
# manifest.json as it is on disk, symbol keys (nil if not installed).
|
|
467
585
|
# Readers take #record.
|
|
468
586
|
def read
|
|
@@ -472,6 +590,40 @@ module Samagotchi
|
|
|
472
590
|
JSON.parse(File.read(manifest_path), symbolize_names: true)
|
|
473
591
|
end
|
|
474
592
|
|
|
593
|
+
def writable_record
|
|
594
|
+
raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
|
|
595
|
+
format_record(raw)
|
|
596
|
+
raw
|
|
597
|
+
end
|
|
598
|
+
private :writable_record
|
|
599
|
+
|
|
600
|
+
def format_record(data)
|
|
601
|
+
return unless data
|
|
602
|
+
raise StateFormat::InvalidDocument, "manifest.json must contain a JSON object" unless data.is_a?(Hash)
|
|
603
|
+
|
|
604
|
+
data = data.transform_keys(&:to_s)
|
|
605
|
+
version = data["record_format"]
|
|
606
|
+
unless version.nil? || (version.is_a?(Integer) && version >= 0)
|
|
607
|
+
raise StateFormat::InvalidDocument, "manifest.json has an invalid record_format"
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
document = RECORD_FORMAT.read({ "version" => version, "records" => [data] }, collection: "records")
|
|
611
|
+
if document.version > RECORD_FORMAT_VERSION
|
|
612
|
+
raise StateFormat::InvalidDocument,
|
|
613
|
+
"manifest.json record_format #{document.version} is newer than supported #{RECORD_FORMAT_VERSION}; update chi before writing it"
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
record = document.entries.first
|
|
617
|
+
unless record&.readable?
|
|
618
|
+
raise StateFormat::InvalidDocument,
|
|
619
|
+
"manifest.json is missing required record fields; repair with chi bundle uninstall #{@name} --force, " \
|
|
620
|
+
"then chi bundle install <source> --force"
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
record
|
|
624
|
+
end
|
|
625
|
+
private :format_record
|
|
626
|
+
|
|
475
627
|
# Write aside and rename, so a reader in another process (a parallel
|
|
476
628
|
# chi start) never parses a truncated manifest.json.
|
|
477
629
|
def write_manifest(manifest_data)
|
|
@@ -292,8 +292,9 @@ module Samagotchi
|
|
|
292
292
|
# Merger::Upgrade#forecast.
|
|
293
293
|
def forecast(row, bundle)
|
|
294
294
|
shipped = Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort
|
|
295
|
+
files = Provenance.new(name: row.name).files_with_pending(bundle, target_dir: MemoryPaths.scope_dir!(row.scope))
|
|
295
296
|
Merger::Upgrade.new(provenance: Provenance.new(name: row.name), target_dir: MemoryPaths.scope_dir!(row.scope),
|
|
296
|
-
source_dir: row.source_dir).forecast(shipped &
|
|
297
|
+
source_dir: row.source_dir).forecast(shipped & files.keys, version: row.to)
|
|
297
298
|
end
|
|
298
299
|
|
|
299
300
|
# What the upgrade from +source_dir+ copies to the trash, as
|
|
@@ -39,7 +39,7 @@ module Samagotchi
|
|
|
39
39
|
|
|
40
40
|
scope = (scope_override ? bundle.with(scope: scope_override) : bundle).effective_scope
|
|
41
41
|
target_dir = MemoryPaths.scope_dir(scope)
|
|
42
|
-
files = bundle
|
|
42
|
+
files = provenance.files_with_pending(bundle, target_dir: target_dir)
|
|
43
43
|
details = {}
|
|
44
44
|
behind = bundle.behind_files(target_dir).to_h { |file| [file.file, file] }
|
|
45
45
|
notes = bundle.merge_notes.to_h { |note| [note.file, note] }
|
|
@@ -84,6 +84,8 @@ module Samagotchi
|
|
|
84
84
|
# Those notes have been shown (Provenance#clear_merge_notes), under the lock.
|
|
85
85
|
def clear_merge_notes(notes, files: [])
|
|
86
86
|
with_lock { Provenance.new(name: BUNDLE_NAME).clear_merge_notes(notes, files: files) }
|
|
87
|
+
rescue StateFormat::InvalidDocument => e
|
|
88
|
+
e.message
|
|
87
89
|
rescue StandardError
|
|
88
90
|
nil
|
|
89
91
|
end
|