samagotchi 0.157.0 → 0.158.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 +27 -1
- data/README.md +1 -1
- data/bin/chi +3 -1
- data/docs/architecture.md +7 -6
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +2 -1
- data/docs/cli.md +21 -17
- data/docs/configuration.md +7 -5
- data/docs/context.md +5 -3
- data/docs/guardrails.md +8 -7
- data/docs/hooks.md +44 -80
- data/docs/internals/bridge.md +1 -1
- data/docs/internals/bundles.md +47 -34
- data/docs/internals/engine.md +7 -5
- data/docs/memory.md +39 -9
- data/docs/plugins.md +13 -9
- data/docs/releasing.md +6 -5
- data/docs/sessions.md +5 -5
- data/docs/testing.md +4 -4
- data/lib/samagotchi/bridge/card_places.rb +5 -13
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
- data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
- data/lib/samagotchi/bundle_command/check.rb +23 -10
- data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
- data/lib/samagotchi/bundle_command/try/run.rb +4 -1
- data/lib/samagotchi/bundle_command.rb +15 -43
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/context_command.rb +7 -5
- data/lib/samagotchi/context_sources.rb +4 -2
- data/lib/samagotchi/engine.rb +1 -6
- data/lib/samagotchi/extension_load.rb +20 -41
- data/lib/samagotchi/guardrails/load_failures.rb +3 -2
- data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
- data/lib/samagotchi/guardrails/review_paths.rb +1 -5
- data/lib/samagotchi/hook_routes.rb +2 -2
- data/lib/samagotchi/hooks.rb +2 -3
- data/lib/samagotchi/memory_bundle/asset_installer.rb +39 -119
- data/lib/samagotchi/memory_bundle/builder.rb +10 -34
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
- data/lib/samagotchi/memory_bundle/installer.rb +32 -14
- data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
- data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
- data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
- data/lib/samagotchi/memory_bundle/shas.rb +5 -8
- data/lib/samagotchi/memory_bundle/shipped_update.rb +30 -35
- data/lib/samagotchi/memory_bundle/status.rb +12 -10
- data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
- data/lib/samagotchi/memory_bundle/trash.rb +7 -12
- data/lib/samagotchi/memory_orphans.rb +4 -4
- data/lib/samagotchi/memory_paths.rb +102 -13
- data/lib/samagotchi/plugin/api.rb +1 -4
- data/lib/samagotchi/plugin/loader.rb +42 -5
- data/lib/samagotchi/project_command.rb +167 -0
- data/lib/samagotchi/project_init.rb +69 -0
- data/lib/samagotchi/project_scope.rb +6 -4
- data/lib/samagotchi/prompt_files.rb +10 -7
- data/lib/samagotchi/recap_place.rb +3 -4
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +44 -18
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_field.rb +5 -4
- data/lib/samagotchi/steer.rb +10 -0
- data/lib/samagotchi/system_prompt.rb +20 -11
- data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
- data/lib/samagotchi/testing/bundle.rb +5 -12
- data/lib/samagotchi/testing/install.rb +5 -5
- data/lib/samagotchi/testing/recorder.rb +1 -1
- data/lib/samagotchi/testing.rb +1 -1
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- data/lib/samagotchi/tools/delegate.rb +12 -8
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +35 -0
- data/lib/samagotchi/web/project_list.rb +3 -2
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- metadata +3 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
|
@@ -27,8 +27,8 @@ module Samagotchi
|
|
|
27
27
|
# bundle that isn't installed and never downgrades.
|
|
28
28
|
#
|
|
29
29
|
# .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).
|
|
31
|
-
# the plugin and scripts are replaced; an edited one (Trash.edit?: its
|
|
30
|
+
# the file (merged), a real conflict keeps the user's (behind). Rules,
|
|
31
|
+
# the plugin and scripts are replaced, an older chi's hooks removed; an edited one (Trash.edit?: its
|
|
32
32
|
# bytes neither the recorded sha, none recorded counting as edited, nor
|
|
33
33
|
# the incoming file's) is copied to the trash first and listed in
|
|
34
34
|
# replaced (#trash_bound).
|
|
@@ -119,10 +119,7 @@ module Samagotchi
|
|
|
119
119
|
r.merged = installer.merged_files
|
|
120
120
|
r.behind = installer.behind
|
|
121
121
|
r.trash_copy = installer.trash_copy_line
|
|
122
|
-
|
|
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?
|
|
122
|
+
r.note = mismatch_note(installer)
|
|
126
123
|
end
|
|
127
124
|
rescue StandardError => e
|
|
128
125
|
row.dup.tap do |r|
|
|
@@ -202,6 +199,10 @@ module Samagotchi
|
|
|
202
199
|
if Manifest.requires_chi_failure(manifest.requires_chi, chi_version)
|
|
203
200
|
return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
|
|
204
201
|
end
|
|
202
|
+
|
|
203
|
+
# The upgrade would refuse it (Installer#refuse_hooks).
|
|
204
|
+
hooks = Manifest::HooksRetired.files_in(row.source_dir)
|
|
205
|
+
return skip(row, Manifest::HooksRetired.for_files(hooks).message, keep_to: true) unless hooks.empty?
|
|
205
206
|
return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
|
|
206
207
|
return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
|
|
207
208
|
|
|
@@ -210,6 +211,7 @@ module Samagotchi
|
|
|
210
211
|
row.behind = forecast.behind
|
|
211
212
|
row.replaced = trash_bound(name, bundle, manifest, row.source_dir)
|
|
212
213
|
row.status = :would_update
|
|
214
|
+
row.note = planned_mismatch_note(row)
|
|
213
215
|
row
|
|
214
216
|
rescue SystemCallError => e
|
|
215
217
|
# A file it can't read: the upgrade would fail on it, so this is that
|
|
@@ -242,6 +244,20 @@ module Samagotchi
|
|
|
242
244
|
|
|
243
245
|
def note_of(remerged) = remerged.warnings.empty? ? nil : remerged.warnings.join("; ")
|
|
244
246
|
|
|
247
|
+
def mismatch_note(installer)
|
|
248
|
+
mismatches = installer.warnings.grep(/\AChecksum mismatch/)
|
|
249
|
+
mismatches.join("; ") unless mismatches.empty?
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def planned_mismatch_note(row)
|
|
253
|
+
installer = Installer.new(source: row.source_dir, name: row.name, scope: row.scope, strict: true, upgrade: true, dry_run: true,
|
|
254
|
+
shipped_dir: File.dirname(row.source_dir))
|
|
255
|
+
installer.run
|
|
256
|
+
mismatch_note(installer)
|
|
257
|
+
rescue Installer::InstallError
|
|
258
|
+
nil
|
|
259
|
+
end
|
|
260
|
+
|
|
245
261
|
def profile_row(row, bundle, shipped_dir, chi_version)
|
|
246
262
|
manifest = Manifest.read(dir: row.source_dir)
|
|
247
263
|
newer = Listing.newer?(row.to, row.from)
|
|
@@ -277,49 +293,28 @@ module Samagotchi
|
|
|
277
293
|
end
|
|
278
294
|
|
|
279
295
|
# What the upgrade from +source_dir+ copies to the trash, as
|
|
280
|
-
# hooks/F, guardrails/F, plugin/F, scripts/F: Trash.
|
|
281
|
-
# AssetInstaller replaces or removes, each against the
|
|
282
|
-
# its place.
|
|
283
|
-
# hooks
|
|
284
|
-
# removes (
|
|
285
|
-
# and scripts: their whole folder, which the upgrade replaces.
|
|
296
|
+
# hooks/F, guardrails/F, plugin/F, scripts/F: Trash.edits over the
|
|
297
|
+
# folders AssetInstaller replaces or removes, each file against the one
|
|
298
|
+
# it puts in its place. Rules, the plugin and scripts: their whole
|
|
299
|
+
# folder, which the upgrade replaces. hooks/: an older chi's, which the
|
|
300
|
+
# upgrade removes whole (AssetInstaller#retire_hooks; no incoming).
|
|
286
301
|
def trash_bound(name, bundle, manifest, source_dir)
|
|
287
302
|
provenance = Provenance.new(name: name)
|
|
288
303
|
rules = Dir.glob(File.join(source_dir, "guardrails", "*.yml")).sort
|
|
289
304
|
.reject { |file| AssetInstaller.linked?(file, source_dir) }
|
|
290
305
|
plugin = manifest.plugin ? { manifest.plugin[:file] => File.join(source_dir, manifest.plugin[:file]) } : {}
|
|
291
306
|
scripts = manifest.scripts.keys.to_h { |file| [file, File.join(source_dir, "scripts", file)] }
|
|
292
|
-
|
|
307
|
+
Trash.edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256)) +
|
|
293
308
|
Trash.edits(provenance.guardrails_dir, "guardrails", bundle.guardrails, rules.to_h { |file| [File.basename(file), file] }) +
|
|
294
309
|
Trash.edits(provenance.plugin_dir, "plugin", bundle.plugin_shas, plugin) +
|
|
295
310
|
Trash.edits(provenance.scripts_dir, "scripts", bundle.scripts, scripts)
|
|
296
311
|
end
|
|
297
312
|
|
|
298
|
-
#
|
|
299
|
-
#
|
|
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
|
|
313
|
-
end
|
|
314
|
-
|
|
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.
|
|
313
|
+
# Installed rules and the plugin whose sha differs from the recorded
|
|
314
|
+
# one (they didn't load), as guardrails/F, plugin/F.
|
|
317
315
|
def edited_executables(name, bundle)
|
|
318
316
|
provenance = Provenance.new(name: name)
|
|
319
317
|
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
318
|
bundle.guardrails.each do |file, sha|
|
|
324
319
|
edited << "guardrails/#{file}" if edited?(File.join(provenance.guardrails_dir, file), sha)
|
|
325
320
|
end
|
|
@@ -16,14 +16,15 @@ module Samagotchi
|
|
|
16
16
|
# A bundle as this chi reads it now (what chi bundle status prints):
|
|
17
17
|
# its stored record, the scope it resolves to (scope_error when it
|
|
18
18
|
# can't), the folder its files belong in, what each file looks like on
|
|
19
|
-
# disk, its installed plugin,
|
|
19
|
+
# disk, its installed plugin, the class-file hooks its record still
|
|
20
|
+
# has (retired_hooks: they don't load), and its needs
|
|
20
21
|
# with what this process's PATH found. The +files+ entries and +plugin+
|
|
21
22
|
# stay Hashes: their fields differ per case (an unchecked file has no
|
|
22
23
|
# paths at all). An entry's behind is its FileStanding::Behind, nil for a
|
|
23
24
|
# file that isn't; merge_note its FileStanding::MergeNote, nil without one;
|
|
24
25
|
# unreadable: the file is there, but this chi can't read it.
|
|
25
26
|
Report = Data.define(:provenance, :scope, :scope_error, :target_dir, :files, :plugin,
|
|
26
|
-
:
|
|
27
|
+
:retired_hooks, :needs)
|
|
27
28
|
|
|
28
29
|
# A scope this chi can't resolve (a newer chi's, a hand edit) isn't
|
|
29
30
|
# guessed: scope_error says so, target_dir is nil and each file is
|
|
@@ -69,7 +70,7 @@ module Samagotchi
|
|
|
69
70
|
end
|
|
70
71
|
Report.new(provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
|
|
71
72
|
target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
|
|
72
|
-
|
|
73
|
+
retired_hooks: retired_hooks(provenance, bundle), needs: needs_status(bundle))
|
|
73
74
|
end
|
|
74
75
|
|
|
75
76
|
# A FileState digest as the Report has it: nil for none, or one that
|
|
@@ -77,13 +78,14 @@ module Samagotchi
|
|
|
77
78
|
def self.digest_or_nil(digest) = digest == :unreadable ? nil : digest
|
|
78
79
|
private_class_method :digest_or_nil
|
|
79
80
|
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
# The class-file hooks the record still has (a 0.157 install's; chi
|
|
82
|
+
# loads none, Manifest::HooksRetired), each "ok" or "missing" in
|
|
83
|
+
# hooks/, by name; {} for none.
|
|
84
|
+
# @return [Hash{String => String}]
|
|
85
|
+
def self.retired_hooks(provenance, bundle)
|
|
86
|
+
bundle.hooks.keys.sort.to_h do |file|
|
|
87
|
+
[file, File.exist?(File.join(provenance.hooks_dir, file)) ? "ok" : "missing"]
|
|
88
|
+
end
|
|
87
89
|
end
|
|
88
90
|
|
|
89
91
|
# The stored needs, each with found: from this process's PATH (the
|
|
@@ -214,7 +214,11 @@ module Samagotchi
|
|
|
214
214
|
missing = MemoryFiles.select(GEM_BUNDLE_DIR, gem_manifest).reject { |key| File.exist?(File.join(target_dir, key)) }
|
|
215
215
|
missing = restored_rows(missing, target_dir) unless missing.empty?
|
|
216
216
|
return result(:up_to_date, from: version, to: version) if missing.empty?
|
|
217
|
-
|
|
217
|
+
|
|
218
|
+
if dry_run
|
|
219
|
+
warnings = missing.filter_map { |key| restore_mismatch(key, gem_manifest) }
|
|
220
|
+
return result(:restored, from: version, to: version, warnings: warnings)
|
|
221
|
+
end
|
|
218
222
|
|
|
219
223
|
warnings = missing.flat_map { |key| restore_file(key, gem_manifest) }
|
|
220
224
|
result(:restored, from: version, to: version, warnings: warnings)
|
|
@@ -238,7 +242,7 @@ module Samagotchi
|
|
|
238
242
|
description = gem_manifest.file_descriptions[key]
|
|
239
243
|
FileUtils.cp(source, target)
|
|
240
244
|
Provenance.new(name: BUNDLE_NAME).record_restored(key, source, description: description)
|
|
241
|
-
warnings = [
|
|
245
|
+
warnings = [restore_mismatch(key, gem_manifest)].compact
|
|
242
246
|
begin
|
|
243
247
|
IndexUpdater.update_index(SCOPE, key.delete_suffix(".md"), File.size(target), description, source: BUNDLE_NAME)
|
|
244
248
|
rescue StandardError => e
|
|
@@ -247,6 +251,10 @@ module Samagotchi
|
|
|
247
251
|
warnings
|
|
248
252
|
end
|
|
249
253
|
|
|
254
|
+
def restore_mismatch(key, gem_manifest)
|
|
255
|
+
FileDigest.mismatch(key, File.join(GEM_BUNDLE_DIR, key), gem_manifest.checksum_for(key))
|
|
256
|
+
end
|
|
257
|
+
|
|
250
258
|
# An up-to-date bundle's marked files, retried. No marks: nothing is read.
|
|
251
259
|
def remerge(bundle, verified, dry_run)
|
|
252
260
|
done = Remerge.new(name: BUNDLE_NAME, scope: SCOPE, target_dir: MemoryPaths.system_dir, source_dir: GEM_BUNDLE_DIR, record: bundle)
|
|
@@ -264,7 +272,10 @@ module Samagotchi
|
|
|
264
272
|
owned = provenance.record&.files&.keys || []
|
|
265
273
|
forecast = Merger::Upgrade.new(provenance: provenance, target_dir: MemoryPaths.system_dir, source_dir: GEM_BUNDLE_DIR)
|
|
266
274
|
.forecast(gem_manifest.files.keys.map(&:to_s).sort & owned, version: to)
|
|
267
|
-
|
|
275
|
+
installer = upgrade_installer(dry_run: true)
|
|
276
|
+
installer.run
|
|
277
|
+
warnings = installer.warnings.grep(/\AChecksum mismatch/)
|
|
278
|
+
result(:updated, from: from, to: to, merged: forecast.merged, behind: forecast.behind, warnings: warnings)
|
|
268
279
|
end
|
|
269
280
|
|
|
270
281
|
# Provenance#adopt_manifest from the shipped dir, for a record without a
|
|
@@ -276,9 +287,9 @@ module Samagotchi
|
|
|
276
287
|
Provenance.new(name: BUNDLE_NAME).adopt_manifest(GEM_BUNDLE_DIR)
|
|
277
288
|
end
|
|
278
289
|
|
|
279
|
-
def upgrade_installer
|
|
290
|
+
def upgrade_installer(dry_run: false)
|
|
280
291
|
Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true,
|
|
281
|
-
shipped_dir: File.dirname(GEM_BUNDLE_DIR))
|
|
292
|
+
dry_run: dry_run, shipped_dir: File.dirname(GEM_BUNDLE_DIR))
|
|
282
293
|
end
|
|
283
294
|
private_class_method :with_lock, :note_newer_installed, :install_fresh, :keep_manifest, :upgrade_existing, :verify_files_present, :restore_file, :remerge,
|
|
284
295
|
:upgrade_installer, :result, :planned_update
|
|
@@ -99,24 +99,19 @@ module Samagotchi
|
|
|
99
99
|
dest
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
-
# Copies
|
|
103
|
-
# that would lose something: its bytes are neither what
|
|
104
|
-
# was installed (no sha recorded: unknown, so it counts
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
|
|
108
|
-
copy(path, as: as) if self.class.edit?(path, recorded: recorded, incoming: incoming)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
# copy_edit for every file under +dir+, which is about to go, at any depth
|
|
112
|
-
# (kept as <label>/<its path in dir>). A linked dir isn't followed.
|
|
102
|
+
# Copies every file under +dir+, which is about to be replaced or
|
|
103
|
+
# removed, when that would lose something: its bytes are neither what
|
|
104
|
+
# the record says was installed (no sha recorded: unknown, so it counts
|
|
105
|
+
# as edited) nor the incoming source's, at any depth (kept as
|
|
106
|
+
# <label>/<its path in dir>). A link (a dev install's) points at its
|
|
107
|
+
# source, which nothing here touches; a linked dir isn't followed.
|
|
113
108
|
# @param recorded [Hash, nil] path in dir => its recorded sha256
|
|
114
109
|
# @param incoming [Hash] path in dir => the source replacing it
|
|
115
110
|
def copy_edits(dir, label, recorded = nil, incoming = {})
|
|
116
111
|
self.class.edited_in(dir, recorded, incoming).each { |file| copy(File.join(dir, file), as: "#{label}/#{file}") }
|
|
117
112
|
end
|
|
118
113
|
|
|
119
|
-
# Whether #
|
|
114
|
+
# Whether #copy_edits would copy the file at +path+ (read-only): the rule
|
|
120
115
|
# ShippedUpdate's plan names its replaced files by.
|
|
121
116
|
def self.edit?(path, recorded: nil, incoming: nil)
|
|
122
117
|
return false if File.symlink?(path) || !File.file?(path)
|
|
@@ -19,14 +19,14 @@ module Samagotchi
|
|
|
19
19
|
|
|
20
20
|
module_function
|
|
21
21
|
|
|
22
|
-
# The orphan candidates for +cwd+'s project, or nil: only inside a
|
|
23
|
-
#
|
|
24
|
-
# match), and only while the computed folder holds no *.md (a
|
|
22
|
+
# The orphan candidates for +cwd+'s project, or nil: only inside a
|
|
23
|
+
# project, a git repository or a marked folder (outside both the root is
|
|
24
|
+
# cwd, and every src/ or tmp/ would match), and only while the computed folder holds no *.md (a
|
|
25
25
|
# memory_write's mkdir_p or a project-scope bundle install alone must
|
|
26
26
|
# not silence it).
|
|
27
27
|
# @return [Found, nil]
|
|
28
28
|
def find(env: ENV, cwd: Dir.pwd)
|
|
29
|
-
return nil unless MemoryPaths.
|
|
29
|
+
return nil unless MemoryPaths.in_project?(cwd)
|
|
30
30
|
|
|
31
31
|
projects = MemoryPaths.projects_dir(env: env)
|
|
32
32
|
computed = MemoryPaths.project_dir(env: env, cwd: cwd)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require_relative "config"
|
|
5
|
+
require_relative "guardrails/path_resolve"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# Where memories live: <config dir>/memories, i.e. $XDG_CONFIG_HOME/samagotchi/memories
|
|
@@ -9,11 +10,61 @@ module Samagotchi
|
|
|
9
10
|
# ConfigFile.global_path, so an XDG_CONFIG_HOME set after load (specs, smoke runs,
|
|
10
11
|
# a fixture chi) moves memories together with config.yml.
|
|
11
12
|
#
|
|
12
|
-
# Project memories are keyed by the project root (see
|
|
13
|
-
#
|
|
13
|
+
# Project memories are keyed by the project root (see #lookup): one folder
|
|
14
|
+
# per project root, a git repository (every worktree and subdirectory of it
|
|
15
|
+
# shares one folder) or a folder marked by `chi project init` (and its
|
|
16
|
+
# subfolders).
|
|
14
17
|
module MemoryPaths
|
|
18
|
+
# The file that makes a folder in no git repository a chi project
|
|
19
|
+
# (`chi project init`). The file, not the bare .chi/ dir, is the marker.
|
|
20
|
+
MARKER = File.join(".chi", "project.yml").freeze
|
|
21
|
+
|
|
22
|
+
# How a folder's project was found (#lookup): +kind+ is :git, :marker or
|
|
23
|
+
# :none; +root+ is what memories are keyed on (#project_root: git's
|
|
24
|
+
# common-dir checkout, the marked folder's realpath, or the folder
|
|
25
|
+
# itself); +top+ is the folder a session reads its AGENTS.md chain from
|
|
26
|
+
# (the work tree's top, the marked root; nil for :none).
|
|
27
|
+
ProjectLookup = Data.define(:kind, :root, :top) do
|
|
28
|
+
def project? = kind != :none
|
|
29
|
+
def git? = kind == :git
|
|
30
|
+
def marker? = kind == :marker
|
|
31
|
+
end
|
|
32
|
+
|
|
15
33
|
module_function
|
|
16
34
|
|
|
35
|
+
# The project +cwd+ belongs to, looked up once: a .git above it wins
|
|
36
|
+
# (git is the identity wherever it exists); only when there is none, the
|
|
37
|
+
# nearest MARKER above it, ignoring one in $HOME or any folder above it
|
|
38
|
+
# (and /), so a marker in ~ never makes every folder under home one
|
|
39
|
+
# project. The marker walk starts from +cwd+'s real path and the marked
|
|
40
|
+
# root is a realpath, as git's root is, so every spelling of a folder
|
|
41
|
+
# (/tmp, /var on macOS, a symlink into or out of a marked tree) gets the
|
|
42
|
+
# answer the worker's physical Dir.pwd gets.
|
|
43
|
+
# @param home [String, nil] $HOME by default; nil or blank ignores only /
|
|
44
|
+
# @return [ProjectLookup]
|
|
45
|
+
def lookup(cwd = Dir.pwd, home: default_home)
|
|
46
|
+
dot_git = find_dot_git(cwd)
|
|
47
|
+
return ProjectLookup.new(kind: :git, root: git_root(dot_git), top: File.dirname(dot_git)) if dot_git
|
|
48
|
+
|
|
49
|
+
marked = nearest_marker(cwd, home: home)
|
|
50
|
+
return ProjectLookup.new(kind: :marker, root: marked, top: marked) if marked
|
|
51
|
+
|
|
52
|
+
ProjectLookup.new(kind: :none, root: cwd, top: nil)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether a marker at +dir+ is ignored: +dir+ is / or holds $HOME (both
|
|
56
|
+
# resolved; Guardrails::PathResolve.home_root?).
|
|
57
|
+
def above_home?(dir, home: default_home)
|
|
58
|
+
Guardrails::PathResolve.real(dir) == "/" || Guardrails::PathResolve.home_root?(dir, home)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# $HOME, or nil when it can't be found (no HOME, no passwd entry).
|
|
62
|
+
def default_home
|
|
63
|
+
Dir.home
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
17
68
|
def system_dir(env: ENV)
|
|
18
69
|
File.join(ConfigFile.config_dir(env: env), "memories")
|
|
19
70
|
end
|
|
@@ -28,16 +79,31 @@ module Samagotchi
|
|
|
28
79
|
# #project_key for a root already resolved (a session's project_root).
|
|
29
80
|
def key_for_root(root) = "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
|
|
30
81
|
|
|
31
|
-
# The
|
|
32
|
-
# worktree shares it. Returns the
|
|
33
|
-
#
|
|
34
|
-
# --separate-git-dir, a submodule's
|
|
35
|
-
# the "project root" is a git
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
82
|
+
# The project root (#lookup): in a repository, its common git directory
|
|
83
|
+
# identifies the project; every linked worktree shares it. Returns the
|
|
84
|
+
# checkout holding it when it is named .git, otherwise the git dir itself
|
|
85
|
+
# (a bare repo, a proj/.bare layout, --separate-git-dir, a submodule's
|
|
86
|
+
# .git/modules/<name>), so for those layouts the "project root" is a git
|
|
87
|
+
# dir, not a checkout. Outside any repository, the marked folder holding
|
|
88
|
+
# +cwd+, else cwd. No git subprocess: at most two small files are read.
|
|
89
|
+
def project_root(cwd = Dir.pwd) = lookup(cwd).root
|
|
90
|
+
|
|
91
|
+
# Is +cwd+ in a project: a git repository or a marked folder?
|
|
92
|
+
def in_project?(cwd = Dir.pwd) = lookup(cwd).project?
|
|
93
|
+
|
|
94
|
+
# The marked folder (realpath) +cwd+'s project is, or nil (in a git
|
|
95
|
+
# repository, or in no project).
|
|
96
|
+
def marked_root(cwd = Dir.pwd)
|
|
97
|
+
found = lookup(cwd)
|
|
98
|
+
found.marker? ? found.root : nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The top of +cwd+'s project: the git work tree's top (#work_tree_root) or
|
|
102
|
+
# the marked root; nil in no project.
|
|
103
|
+
def project_top(cwd = Dir.pwd) = lookup(cwd).top
|
|
40
104
|
|
|
105
|
+
# The project root of the checkout whose .git is +dot_git+ (#project_root).
|
|
106
|
+
def git_root(dot_git)
|
|
41
107
|
holder = File.dirname(dot_git)
|
|
42
108
|
gitdir = File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
|
|
43
109
|
return holder unless gitdir
|
|
@@ -78,7 +144,8 @@ module Samagotchi
|
|
|
78
144
|
File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
|
|
79
145
|
end
|
|
80
146
|
|
|
81
|
-
# Is +cwd+ inside a git repository (a .git somewhere above it)?
|
|
147
|
+
# Is +cwd+ inside a git repository (a .git somewhere above it)? Git only:
|
|
148
|
+
# a marked folder is not a repository (#in_project?).
|
|
82
149
|
def in_repo?(cwd = Dir.pwd)
|
|
83
150
|
!find_dot_git(cwd).nil?
|
|
84
151
|
end
|
|
@@ -96,6 +163,28 @@ module Samagotchi
|
|
|
96
163
|
end
|
|
97
164
|
end
|
|
98
165
|
|
|
166
|
+
# The realpath of the nearest folder at or above +start+ holding MARKER,
|
|
167
|
+
# git or not (#lookup asks only outside a repository; `chi project` tells
|
|
168
|
+
# of one a repository makes ignored), or nil. A marker at or above +home+
|
|
169
|
+
# (#above_home?) ends the walk with nil: every folder above it is above
|
|
170
|
+
# home too.
|
|
171
|
+
def nearest_marker(start, home: default_home)
|
|
172
|
+
dir = Guardrails::PathResolve.real(start)
|
|
173
|
+
loop do
|
|
174
|
+
if File.file?(File.join(dir, MARKER))
|
|
175
|
+
return nil if above_home?(dir, home: home)
|
|
176
|
+
|
|
177
|
+
return File.realpath(dir)
|
|
178
|
+
end
|
|
179
|
+
parent = File.dirname(dir)
|
|
180
|
+
return nil if parent == dir
|
|
181
|
+
|
|
182
|
+
dir = parent
|
|
183
|
+
end
|
|
184
|
+
rescue SystemCallError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
99
188
|
# A ".git" file holds "gitdir: <path>", relative to the file's directory.
|
|
100
189
|
def gitdir_from_file(path)
|
|
101
190
|
line = File.read(path, 4096).to_s[/\Agitdir:\s*(.+)$/, 1]&.strip
|
|
@@ -115,7 +204,7 @@ module Samagotchi
|
|
|
115
204
|
rescue SystemCallError
|
|
116
205
|
gitdir
|
|
117
206
|
end
|
|
118
|
-
private_class_method :find_dot_git, :gitdir_from_file, :common_dir
|
|
207
|
+
private_class_method :find_dot_git, :git_root, :gitdir_from_file, :common_dir
|
|
119
208
|
|
|
120
209
|
def project_dir(env: ENV, cwd: Dir.pwd)
|
|
121
210
|
File.join(projects_dir(env: env), project_key(cwd))
|
|
@@ -32,10 +32,7 @@ module Samagotchi
|
|
|
32
32
|
# plugin writes it (ApiCalls#keywords: `ctx.sessions.send(…,
|
|
33
33
|
# delivery:)` is seen, a `sessions = ctx.sessions` alias isn't).
|
|
34
34
|
# Events aren't listed: chi.on takes any symbol and an older chi never
|
|
35
|
-
# fires one it doesn't know (harmless; chi bundle check warns on it)
|
|
36
|
-
# and a manifest hook's events: list (BundleHook) needs the bundle's
|
|
37
|
-
# requires_chi set by hand (docs/hooks.md, Bundle Hooks): no source
|
|
38
|
-
# scan covers manifest.yml.
|
|
35
|
+
# fires one it doesn't know (harmless; chi bundle check warns on it).
|
|
39
36
|
SINCE = {
|
|
40
37
|
"ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
|
|
41
38
|
"Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require_relative "api"
|
|
4
5
|
require_relative "context"
|
|
5
6
|
require_relative "../hooks"
|
|
@@ -10,6 +11,11 @@ require_relative "../memory_bundle/manifest"
|
|
|
10
11
|
require_relative "../memory_bundle/provenance"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
14
|
+
# Where installed bundles' plugin code is evaluated: one module per
|
|
15
|
+
# bundle (Plugin::Loader.namespace_for), so two bundles defining the same
|
|
16
|
+
# class name can't collide on a top-level constant.
|
|
17
|
+
module Bundles; end
|
|
18
|
+
|
|
13
19
|
module Plugin
|
|
14
20
|
# What a plugin registers into: an Engine's own registries.
|
|
15
21
|
# +context_for+ is (bundle_name, settings, label) → the Plugin::Context
|
|
@@ -36,7 +42,7 @@ module Samagotchi
|
|
|
36
42
|
#
|
|
37
43
|
# For each bundle, by name: the file must match the sha256 recorded at
|
|
38
44
|
# install and chi must meet the bundle's requires_chi. The file is
|
|
39
|
-
# module_eval'd into the bundle's namespace (
|
|
45
|
+
# module_eval'd into the bundle's namespace (#namespace_for), the
|
|
40
46
|
# class named like the file (plugin.rb → Plugin) is built with the
|
|
41
47
|
# bundle's settings, and its #register gets a Plugin::Api. What it
|
|
42
48
|
# registered takes effect only when #register returns: a plugin that
|
|
@@ -140,14 +146,45 @@ module Samagotchi
|
|
|
140
146
|
return failure if failure
|
|
141
147
|
return "the file is missing" unless file && File.file?(file)
|
|
142
148
|
|
|
143
|
-
|
|
149
|
+
sha_mismatch(file, bundle.plugin.sha256)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Why +file+ doesn't match the sha256 recorded at install (+recorded+,
|
|
153
|
+
# "sha256:<hex>" or bare hex), or nil. None recorded is a mismatch;
|
|
154
|
+
# Provenance.unchecked? (a dev install) is none.
|
|
155
|
+
def sha_mismatch(file, recorded)
|
|
156
|
+
return nil if MemoryBundle::Provenance.unchecked?(recorded)
|
|
157
|
+
|
|
158
|
+
expected = MemoryBundle::Provenance.recorded_sha(recorded)
|
|
159
|
+
return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
|
|
160
|
+
|
|
161
|
+
actual = MemoryBundle::Provenance.file_sha(file)
|
|
162
|
+
return nil if actual == expected
|
|
163
|
+
|
|
164
|
+
"its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The (lazily created) namespace module for a bundle, under
|
|
168
|
+
# Samagotchi::Bundles. The name is sanitized and made injective by a
|
|
169
|
+
# short digest, so `my-bundle` and `my_bundle` don't collide.
|
|
170
|
+
def namespace_for(bundle_name)
|
|
171
|
+
raw = bundle_name.to_s
|
|
172
|
+
base = raw.gsub(/[^A-Za-z0-9_]/, "_")
|
|
173
|
+
base = "B_#{base}" unless base.match?(/\A[A-Z]/)
|
|
174
|
+
ns_name = :"#{base}_#{Digest::MD5.hexdigest(raw)[0, 4]}"
|
|
175
|
+
if Samagotchi::Bundles.const_defined?(ns_name, false)
|
|
176
|
+
Samagotchi::Bundles.const_get(ns_name)
|
|
177
|
+
else
|
|
178
|
+
Samagotchi::Bundles.const_set(ns_name, Module.new)
|
|
179
|
+
end
|
|
144
180
|
end
|
|
145
181
|
|
|
146
182
|
# module_eval the file into a fresh module in the bundle's namespace
|
|
147
183
|
# (Samagotchi::Bundles::<bundle>::PluginLoad<n>: each load gets new
|
|
148
184
|
# classes, not the last load's reopened) and build its class
|
|
149
|
-
# (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as hooks are
|
|
150
|
-
# an initialize that takes an argument
|
|
185
|
+
# (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as config hooks are
|
|
186
|
+
# built (Hooks.build_plugin): an initialize that takes an argument
|
|
187
|
+
# gets the settings.
|
|
151
188
|
def instantiate(bundle_name, file, settings, namespace: nil)
|
|
152
189
|
namespace ||= fresh_namespace(bundle_name)
|
|
153
190
|
namespace.module_eval(File.read(file), file, 1)
|
|
@@ -163,7 +200,7 @@ module Samagotchi
|
|
|
163
200
|
def fresh_namespace(bundle_name)
|
|
164
201
|
LOADS.synchronize do
|
|
165
202
|
@loads = (@loads || 0) + 1
|
|
166
|
-
|
|
203
|
+
namespace_for(bundle_name).const_set(:"PluginLoad#{@loads}", Module.new)
|
|
167
204
|
end
|
|
168
205
|
end
|
|
169
206
|
|