samagotchi 0.22.0 → 0.24.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 +72 -1
- data/bin/chi +9 -4
- data/docs/cli.md +6 -1
- data/docs/configuration.md +13 -4
- data/docs/hooks.md +20 -6
- data/docs/plugins.md +1 -1
- data/docs/releasing.md +1 -1
- data/docs/sub-agent.md +4 -3
- data/docs/testing.md +6 -0
- data/lib/samagotchi/answer_command.rb +4 -2
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -1
- data/lib/samagotchi/bootstrap/probe.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +2 -2
- data/lib/samagotchi/bridge/bounded_queue.rb +0 -2
- data/lib/samagotchi/bridge/card_store.rb +4 -3
- data/lib/samagotchi/bridge/sse_writer.rb +0 -6
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -3
- data/lib/samagotchi/bridge/turn_notice.rb +1 -0
- data/lib/samagotchi/bridge.rb +19 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +2 -2
- data/lib/samagotchi/bridge_client.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +39 -29
- data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
- data/lib/samagotchi/bundles/btw/plugin.rb +2 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +4 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +6 -5
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +2 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -0
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +5 -3
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -7
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cancellation_controller.rb +1 -1
- data/lib/samagotchi/cli/command.rb +1 -1
- data/lib/samagotchi/client.rb +3 -5
- data/lib/samagotchi/commands/registry.rb +2 -2
- data/lib/samagotchi/config.rb +42 -14
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +6 -6
- data/lib/samagotchi/continue_offer.rb +15 -8
- data/lib/samagotchi/debug_log.rb +1 -1
- data/lib/samagotchi/desktop/macos.rb +5 -4
- data/lib/samagotchi/empty_answer_retry.rb +1 -1
- data/lib/samagotchi/engine.rb +97 -18
- data/lib/samagotchi/gem_update.rb +2 -1
- data/lib/samagotchi/generation_phase.rb +83 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +5 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -2
- data/lib/samagotchi/guardrails/gate.rb +2 -2
- data/lib/samagotchi/guardrails/read_only_shell.rb +5 -3
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +113 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -3
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -6
- data/lib/samagotchi/hooks/loader.rb +18 -16
- data/lib/samagotchi/hooks/registry.rb +10 -2
- data/lib/samagotchi/host_registry.rb +1 -3
- data/lib/samagotchi/idle_recap.rb +22 -17
- data/lib/samagotchi/idle_reminders.rb +6 -3
- data/lib/samagotchi/image_store.rb +5 -2
- data/lib/samagotchi/kernel_loop.rb +50 -40
- data/lib/samagotchi/llm/chat_loop.rb +29 -16
- data/lib/samagotchi/llm/errors.rb +2 -2
- data/lib/samagotchi/llm/http.rb +12 -12
- data/lib/samagotchi/llm/openai_chat.rb +42 -28
- data/lib/samagotchi/llm/prompt_cache.rb +1 -1
- data/lib/samagotchi/log.rb +2 -2
- data/lib/samagotchi/log_subscriber.rb +5 -3
- data/lib/samagotchi/memory_bundle/builder.rb +9 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +13 -1
- data/lib/samagotchi/memory_bundle/installer.rb +67 -57
- data/lib/samagotchi/memory_bundle/listing.rb +5 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +14 -4
- data/lib/samagotchi/memory_bundle/merger.rb +6 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +16 -13
- data/lib/samagotchi/memory_bundle/source.rb +7 -3
- data/lib/samagotchi/memory_bundle/status.rb +3 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +3 -2
- data/lib/samagotchi/memory_bundle/trash.rb +5 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +9 -6
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/model_overlay.rb +3 -5
- data/lib/samagotchi/model_profile.rb +12 -8
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/output_formatter.rb +18 -17
- data/lib/samagotchi/owner_lock.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +8 -5
- data/lib/samagotchi/plugin/context.rb +1 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/plugin/service.rb +1 -1
- data/lib/samagotchi/plugin/sessions.rb +1 -0
- data/lib/samagotchi/plugin_tasks.rb +1 -1
- data/lib/samagotchi/prompt.rb +72 -13
- data/lib/samagotchi/prompt_literal_guard.rb +1 -1
- data/lib/samagotchi/question_desk.rb +7 -7
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/relay_desk.rb +1 -1
- data/lib/samagotchi/reminder_store.rb +2 -1
- data/lib/samagotchi/send_command.rb +4 -2
- data/lib/samagotchi/session.rb +2 -2
- data/lib/samagotchi/session_archive_command.rb +5 -1
- data/lib/samagotchi/session_commands.rb +1 -3
- data/lib/samagotchi/session_manager.rb +2 -4
- data/lib/samagotchi/session_metrics.rb +10 -10
- data/lib/samagotchi/session_observer.rb +3 -2
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/sessions_command.rb +14 -5
- data/lib/samagotchi/steer.rb +103 -8
- data/lib/samagotchi/system_prompt.rb +19 -8
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +19 -10
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -2
- data/lib/samagotchi/terminal_ui/line_reader.rb +3 -3
- data/lib/samagotchi/terminal_ui/one_shot_sink.rb +1 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +23 -23
- data/lib/samagotchi/terminal_ui/question_slot.rb +10 -10
- data/lib/samagotchi/terminal_ui/reline_seam.rb +1 -1
- data/lib/samagotchi/terminal_ui/repl_input.rb +3 -2
- data/lib/samagotchi/terminal_ui.rb +14 -5
- data/lib/samagotchi/text_diff.rb +12 -12
- data/lib/samagotchi/thinking.rb +6 -2
- data/lib/samagotchi/thought_stream_splitter.rb +15 -6
- data/lib/samagotchi/tool_activity.rb +1 -0
- data/lib/samagotchi/tool_call_parser.rb +13 -14
- data/lib/samagotchi/tool_declarations.rb +36 -35
- data/lib/samagotchi/tool_runner.rb +7 -0
- data/lib/samagotchi/tool_view.rb +40 -0
- data/lib/samagotchi/tools/ask_user_question.rb +3 -4
- data/lib/samagotchi/tools/builtin_calls.rb +1 -1
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +0 -1
- data/lib/samagotchi/tools/edit.rb +14 -13
- data/lib/samagotchi/tools/execute.rb +3 -5
- data/lib/samagotchi/tools/list_reminders.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +21 -22
- data/lib/samagotchi/tools/read.rb +7 -7
- data/lib/samagotchi/tools/task_create.rb +1 -1
- data/lib/samagotchi/tools/task_get.rb +1 -1
- data/lib/samagotchi/tools/task_list.rb +1 -1
- data/lib/samagotchi/tools/task_runtime.rb +8 -9
- data/lib/samagotchi/tools/task_stop.rb +1 -1
- data/lib/samagotchi/tools/task_wait.rb +3 -4
- data/lib/samagotchi/tools/web_fetch.rb +2 -4
- data/lib/samagotchi/tools/write.rb +1 -1
- data/lib/samagotchi/turn_flow.rb +8 -8
- data/lib/samagotchi/turn_note.rb +5 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -2
- data/lib/samagotchi/waiting_steer.rb +64 -0
- data/lib/samagotchi/web/app.rb +13 -6
- data/lib/samagotchi/web/markdown_renderer.rb +2 -1
- data/lib/samagotchi/web/message_parts.rb +5 -0
- data/lib/samagotchi/web/public/activity.js +5 -0
- data/lib/samagotchi/web/public/app.js +2 -1
- data/lib/samagotchi/web/public/command_view.js +28 -0
- data/lib/samagotchi/web/public/copy.js +7 -0
- data/lib/samagotchi/web/public/index.html +8 -0
- data/lib/samagotchi/web/public/stage_model.js +11 -4
- data/lib/samagotchi/web/public/stage_view.js +5 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -1
- data/lib/samagotchi/web/public/turn_view.js +15 -6
- data/lib/samagotchi/web/qr.rb +7 -1
- data/lib/samagotchi/web/server.rb +6 -2
- data/lib/samagotchi/worker.rb +14 -3
- metadata +24 -20
|
@@ -46,7 +46,8 @@ module Samagotchi
|
|
|
46
46
|
type = event[:type]&.to_sym
|
|
47
47
|
return unless type
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
handler = :"on_#{type}"
|
|
50
|
+
if respond_to?(handler, true)
|
|
50
51
|
send(handler, event)
|
|
51
52
|
elsif ANNOUNCED.key?(type)
|
|
52
53
|
log(:info, type, **event.slice(*ANNOUNCED[type]), **origin(event), **items(event))
|
|
@@ -108,8 +109,9 @@ module Samagotchi
|
|
|
108
109
|
stopped_by: event[:stopped_by])
|
|
109
110
|
return unless event[:stopped_by]
|
|
110
111
|
|
|
111
|
-
# A plugin
|
|
112
|
-
|
|
112
|
+
# A plugin (stop_generation) or a steer (Engine#cut_for_steer, by
|
|
113
|
+
# "steer") cut this generation; the turn goes on.
|
|
114
|
+
log(:info, :generation_stopped, iteration: event[:iteration], by: event[:stopped_by],
|
|
113
115
|
reason: event[:stop_reason].to_s[0, 200], thinking_chars: event[:thinking_chars], ms: ms)
|
|
114
116
|
end
|
|
115
117
|
|
|
@@ -65,8 +65,9 @@ module Samagotchi
|
|
|
65
65
|
candidates_by_basename = candidates.map { |p| [File.basename(p), p] }.to_h
|
|
66
66
|
missing = allow.reject { |k| candidates_by_basename.key?(k) }
|
|
67
67
|
unless missing.empty?
|
|
68
|
-
raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(
|
|
68
|
+
raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(", ")}"
|
|
69
69
|
end
|
|
70
|
+
|
|
70
71
|
candidates = with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
|
|
71
72
|
else
|
|
72
73
|
candidates = leave_out_owned(candidates, resolved_name)
|
|
@@ -107,6 +108,7 @@ module Samagotchi
|
|
|
107
108
|
src = File.join(prov.hooks_dir, basename)
|
|
108
109
|
# Skip if hook file missing on disk
|
|
109
110
|
next unless File.exist?(src)
|
|
111
|
+
|
|
110
112
|
# meta may be symbol-keyed
|
|
111
113
|
m = meta.is_a?(Hash) ? meta.transform_keys(&:to_s) : {}
|
|
112
114
|
sha = (m["sha256"] || m["checksum"] || "").to_s
|
|
@@ -245,7 +247,7 @@ module Samagotchi
|
|
|
245
247
|
def summary
|
|
246
248
|
lines = []
|
|
247
249
|
lines << "Built #{@built_files.size} file(s) to #{@out_path}"
|
|
248
|
-
lines << "Files: #{@built_files.join(
|
|
250
|
+
lines << "Files: #{@built_files.join(", ")}" unless @built_files.empty?
|
|
249
251
|
lines.concat(@placeholder_warnings.map { |w| "Placeholder: #{w}" }) unless @placeholder_warnings.empty?
|
|
250
252
|
lines.concat(@warnings) unless @warnings.empty?
|
|
251
253
|
lines.join("\n")
|
|
@@ -275,6 +277,7 @@ module Samagotchi
|
|
|
275
277
|
|
|
276
278
|
def normalize_scope(val)
|
|
277
279
|
return nil if val.nil? || val.to_s.strip.empty?
|
|
280
|
+
|
|
278
281
|
v = val.to_s.strip.downcase
|
|
279
282
|
%w[project system].include?(v) ? v : nil
|
|
280
283
|
end
|
|
@@ -295,7 +298,7 @@ module Samagotchi
|
|
|
295
298
|
def default_name(scope)
|
|
296
299
|
if scope == "project"
|
|
297
300
|
base = File.basename(MemoryPaths.project_root).gsub(/[^A-Za-z0-9_-]/, "-").downcase
|
|
298
|
-
base = base.
|
|
301
|
+
base = base.squeeze("-").gsub(/\A-+|-+\z/, "")
|
|
299
302
|
base = "project" if base.empty?
|
|
300
303
|
"chi_#{base}_memories"
|
|
301
304
|
else
|
|
@@ -356,6 +359,7 @@ module Samagotchi
|
|
|
356
359
|
if File.exist?(expanded) && !File.directory?(expanded)
|
|
357
360
|
raise BuildError, "Output path exists and is not a directory: #{expanded}"
|
|
358
361
|
end
|
|
362
|
+
|
|
359
363
|
[expanded, :dir]
|
|
360
364
|
end
|
|
361
365
|
end
|
|
@@ -365,6 +369,7 @@ module Samagotchi
|
|
|
365
369
|
if File.exist?(out_path)
|
|
366
370
|
raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
|
|
367
371
|
end
|
|
372
|
+
|
|
368
373
|
# Use zip CLI like source.rb does with unzip
|
|
369
374
|
Dir.chdir(staging) do
|
|
370
375
|
result = system("zip", "-r", out_path, ".")
|
|
@@ -377,6 +382,7 @@ module Samagotchi
|
|
|
377
382
|
if File.exist?(out_path)
|
|
378
383
|
raise BuildError, "Output file already exists: #{out_path} — remove it or choose different --out"
|
|
379
384
|
end
|
|
385
|
+
|
|
380
386
|
# Use tar CLI
|
|
381
387
|
Dir.chdir(staging) do
|
|
382
388
|
cmd = case format
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require "fileutils"
|
|
3
4
|
require "digest"
|
|
4
5
|
require_relative "../atomic_file"
|
|
@@ -16,6 +17,7 @@ module Samagotchi
|
|
|
16
17
|
def self.update_index(scope, entry_name, byte_count, description = nil, source: nil)
|
|
17
18
|
index_path = index_path_for(scope)
|
|
18
19
|
return true unless index_path
|
|
20
|
+
|
|
19
21
|
locked_write(File.dirname(index_path)) do |content|
|
|
20
22
|
new_line = managed_line(entry_name, scope, byte_count, description, source || nil)
|
|
21
23
|
next auto_index_header + "\n\n" + new_line + "\n" if content.nil? || content.strip.empty?
|
|
@@ -32,20 +34,25 @@ module Samagotchi
|
|
|
32
34
|
end
|
|
33
35
|
true
|
|
34
36
|
end
|
|
37
|
+
|
|
35
38
|
def self.managed_pattern(name)
|
|
36
39
|
/^- \*\*#{Regexp.escape(name)}\*\*[ \t]*(?:[·•].*|:.*)?(\r?\n|\z)/
|
|
37
40
|
end
|
|
41
|
+
|
|
38
42
|
def self.remove_index(scope, entry_name)
|
|
39
43
|
index_path = index_path_for(scope)
|
|
40
44
|
return true unless index_path && File.exist?(index_path)
|
|
45
|
+
|
|
41
46
|
locked_write(File.dirname(index_path)) do |content|
|
|
42
47
|
pattern = managed_pattern(entry_name)
|
|
43
48
|
next nil unless content&.match?(pattern)
|
|
49
|
+
|
|
44
50
|
# Clean up extra blank lines: collapse 3+ newlines to 2
|
|
45
51
|
content.gsub(pattern, "").gsub(/\n{3,}/, "\n\n")
|
|
46
52
|
end
|
|
47
53
|
true
|
|
48
54
|
end
|
|
55
|
+
|
|
49
56
|
# Read-modify-write of <dir>/index.md under an exclusive flock on a sidecar
|
|
50
57
|
# <dir>/.index.lock, so sessions in several worktrees of one repository
|
|
51
58
|
# (one shared project folder) don't drop each other's lines. Yields the
|
|
@@ -61,6 +68,7 @@ module Samagotchi
|
|
|
61
68
|
Samagotchi::AtomicFile.write(index_path, updated) unless updated.nil?
|
|
62
69
|
end
|
|
63
70
|
end
|
|
71
|
+
|
|
64
72
|
def self.managed_line(name, scope, byte_count, description, source = nil)
|
|
65
73
|
line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
|
|
66
74
|
line += " · from #{source}" unless source.to_s.strip.empty?
|
|
@@ -68,6 +76,7 @@ module Samagotchi
|
|
|
68
76
|
line += " — #{desc}" unless desc.to_s.empty?
|
|
69
77
|
line
|
|
70
78
|
end
|
|
79
|
+
|
|
71
80
|
def self.extract_description(line)
|
|
72
81
|
if line =~ /\s—\s(.*)\s*\z/
|
|
73
82
|
$1
|
|
@@ -75,6 +84,7 @@ module Samagotchi
|
|
|
75
84
|
$1
|
|
76
85
|
end
|
|
77
86
|
end
|
|
87
|
+
|
|
78
88
|
# The bundle a managed line names ("· from <bundle>", before any
|
|
79
89
|
# " — description"), or nil.
|
|
80
90
|
def self.extract_source(line)
|
|
@@ -86,14 +96,16 @@ module Samagotchi
|
|
|
86
96
|
"Managed entries below are auto-maintained by memory_write " \
|
|
87
97
|
"(name, scope, last-written date, size). Free-form sections are preserved."
|
|
88
98
|
end
|
|
99
|
+
|
|
89
100
|
def self.date_str
|
|
90
101
|
require "date"
|
|
91
102
|
Date.today.iso8601
|
|
92
103
|
end
|
|
104
|
+
|
|
93
105
|
def self.index_path_for(scope)
|
|
94
106
|
base_dir = MemoryPaths.scope_dir(scope.to_s.strip)
|
|
95
107
|
base_dir && File.join(base_dir, "#{MEMORY_INDEX}.md")
|
|
96
108
|
end
|
|
97
109
|
end
|
|
98
110
|
end
|
|
99
|
-
end
|
|
111
|
+
end
|
|
@@ -80,11 +80,9 @@ module Samagotchi
|
|
|
80
80
|
rescue SourceNormalizer::UnknownSourceError => e
|
|
81
81
|
raise InstallError, "Source normalization failed: #{e.message}"
|
|
82
82
|
rescue Manifest::ValidationError => e
|
|
83
|
-
if @strict
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
@warnings << "No manifest found — proceeding without strict manifest validation"
|
|
87
|
-
end
|
|
83
|
+
raise InstallError, e.message if @strict
|
|
84
|
+
|
|
85
|
+
@warnings << "No manifest found — proceeding without strict manifest validation"
|
|
88
86
|
end
|
|
89
87
|
|
|
90
88
|
# Determine target scope (CLI wins over manifest).
|
|
@@ -185,8 +183,9 @@ module Samagotchi
|
|
|
185
183
|
end
|
|
186
184
|
end
|
|
187
185
|
|
|
188
|
-
hook_entries.each do |basename,
|
|
186
|
+
hook_entries.each do |basename, _meta|
|
|
189
187
|
next unless basename.is_a?(String) && !basename.empty?
|
|
188
|
+
|
|
190
189
|
all_hooks_in_bundle << basename
|
|
191
190
|
src = File.join(normalized_dir, "hooks", basename)
|
|
192
191
|
next unless File.exist?(src)
|
|
@@ -194,11 +193,11 @@ module Samagotchi
|
|
|
194
193
|
dest = File.join(hooks_target, basename)
|
|
195
194
|
|
|
196
195
|
if @dry_run
|
|
197
|
-
if File.exist?(dest) && !@force
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
@results[basename] = if File.exist?(dest) && !@force
|
|
197
|
+
{ status: "would_skip" }
|
|
198
|
+
else
|
|
199
|
+
{ status: "would_install" }
|
|
200
|
+
end
|
|
202
201
|
next
|
|
203
202
|
end
|
|
204
203
|
|
|
@@ -207,10 +206,8 @@ module Samagotchi
|
|
|
207
206
|
prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
|
|
208
207
|
if prev_meta
|
|
209
208
|
prev_sha = prev_meta[:sha256] || prev_meta["sha256"]
|
|
210
|
-
if File.exist?(dest)
|
|
211
|
-
|
|
212
|
-
@warnings << "Hook #{basename} was locally modified; overwriting"
|
|
213
|
-
end
|
|
209
|
+
if File.exist?(dest) && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
|
|
210
|
+
@warnings << "Hook #{basename} was locally modified; overwriting"
|
|
214
211
|
end
|
|
215
212
|
end
|
|
216
213
|
end
|
|
@@ -249,11 +246,12 @@ module Samagotchi
|
|
|
249
246
|
existing_provenance[:hooks].keys.each do |old_key|
|
|
250
247
|
old_key_str = old_key.to_s
|
|
251
248
|
next if all_hooks_in_bundle.include?(old_key_str)
|
|
249
|
+
|
|
252
250
|
old_dest = File.join(hooks_target, old_key_str)
|
|
253
251
|
if File.exist?(old_dest) && !@force
|
|
254
252
|
@warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
|
|
255
253
|
end
|
|
256
|
-
FileUtils.rm_f(old_dest)
|
|
254
|
+
FileUtils.rm_f(old_dest)
|
|
257
255
|
end
|
|
258
256
|
end
|
|
259
257
|
|
|
@@ -261,14 +259,16 @@ module Samagotchi
|
|
|
261
259
|
if manifest && @strict
|
|
262
260
|
all_hooks_in_bundle.each do |basename|
|
|
263
261
|
next if @conflicts.key?(basename)
|
|
262
|
+
|
|
264
263
|
expected = manifest.checksum_for_hook(basename)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
264
|
+
next unless expected
|
|
265
|
+
|
|
266
|
+
dest = File.join(hooks_target, basename)
|
|
267
|
+
next unless File.exist?(dest)
|
|
268
|
+
|
|
269
|
+
actual = Digest::SHA256.hexdigest(File.read(dest))
|
|
270
|
+
if actual != expected
|
|
271
|
+
@warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
272
272
|
end
|
|
273
273
|
end
|
|
274
274
|
end
|
|
@@ -313,18 +313,21 @@ module Samagotchi
|
|
|
313
313
|
if manifest && @strict
|
|
314
314
|
all_files_in_bundle.each do |file_key|
|
|
315
315
|
next if @conflicts.key?(file_key)
|
|
316
|
+
|
|
316
317
|
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
317
318
|
# skipped: not copied, so a local edit isn't a bad download.
|
|
318
319
|
next if %w[kept kept_pruned fast_forward updated skipped].include?(status)
|
|
320
|
+
|
|
319
321
|
# For dry-run, fast_forward would appear as "fast_forward", updated not yet
|
|
320
322
|
target_path = File.join(target_dir, file_key)
|
|
321
323
|
next unless File.exist?(target_path)
|
|
324
|
+
|
|
322
325
|
expected = manifest.checksum_for(file_key)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
next unless expected
|
|
327
|
+
|
|
328
|
+
actual = Digest::SHA256.hexdigest(File.read(target_path))
|
|
329
|
+
if actual != expected
|
|
330
|
+
@warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
328
331
|
end
|
|
329
332
|
end
|
|
330
333
|
end
|
|
@@ -342,18 +345,19 @@ module Samagotchi
|
|
|
342
345
|
owned.each do |file_key|
|
|
343
346
|
target_path = File.join(target_dir, file_key)
|
|
344
347
|
next unless File.exist?(target_path)
|
|
348
|
+
|
|
345
349
|
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
346
350
|
if %w[kept noop conflict kept_pruned].include?(status)
|
|
347
351
|
# Keep old base snapshot — do not overwrite with edited current
|
|
348
352
|
# Use base snapshot as source if it exists to preserve old checksum
|
|
349
353
|
base_path = provenance.base_path(file_key)
|
|
350
|
-
if File.exist?(base_path)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
354
|
+
provenance_files[file_key] = if File.exist?(base_path)
|
|
355
|
+
# Keep existing provenance entry by re-using base content
|
|
356
|
+
# We still need to include it to prevent pruning, but with base content
|
|
357
|
+
base_path
|
|
358
|
+
else
|
|
359
|
+
target_path
|
|
360
|
+
end
|
|
357
361
|
else
|
|
358
362
|
# installed, updated, skipped — use current target (which is incoming for updated)
|
|
359
363
|
provenance_files[file_key] = target_path
|
|
@@ -364,14 +368,15 @@ module Samagotchi
|
|
|
364
368
|
existing_provenance[:files].keys.each do |old_key|
|
|
365
369
|
old_key_str = old_key.to_s
|
|
366
370
|
next if all_files_in_bundle.include?(old_key_str)
|
|
371
|
+
|
|
367
372
|
# If it was kept_pruned, include it with base content to prevent pruning
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
373
|
+
next unless @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
|
|
374
|
+
|
|
375
|
+
base_path = provenance.base_path(old_key_str)
|
|
376
|
+
target_path = File.join(target_dir, old_key_str)
|
|
377
|
+
# Use base if exists to keep old checksum, else target
|
|
378
|
+
src = File.exist?(base_path) ? base_path : target_path
|
|
379
|
+
provenance_files[old_key_str] = src if File.exist?(src)
|
|
375
380
|
end
|
|
376
381
|
end
|
|
377
382
|
else
|
|
@@ -393,6 +398,7 @@ module Samagotchi
|
|
|
393
398
|
elsif hooks_files_for_provenance.any?
|
|
394
399
|
hooks_files_for_provenance.each do |basename, path|
|
|
395
400
|
next unless File.exist?(path)
|
|
401
|
+
|
|
396
402
|
sha = Digest::SHA256.hexdigest(File.read(path))
|
|
397
403
|
hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
|
|
398
404
|
end
|
|
@@ -418,6 +424,7 @@ module Samagotchi
|
|
|
418
424
|
all_files_in_bundle.each do |file_key|
|
|
419
425
|
file_path = File.join(target_dir, file_key)
|
|
420
426
|
next unless File.exist?(file_path)
|
|
427
|
+
|
|
421
428
|
names = Placeholder.detect_in_file(file_path)
|
|
422
429
|
if names.any?
|
|
423
430
|
@placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}"
|
|
@@ -451,15 +458,15 @@ module Samagotchi
|
|
|
451
458
|
removed = @results.select { |_, r| r[:status].to_s == "removed" }.keys
|
|
452
459
|
would_remove = @results.select { |_, r| r[:status].to_s == "would_remove" }.keys
|
|
453
460
|
|
|
454
|
-
lines << "Installed: #{installed.join(
|
|
455
|
-
lines << "Updated: #{updated.join(
|
|
456
|
-
lines << "Would install: #{would_install.join(
|
|
457
|
-
lines << "Fast-forward: #{fast_forward.join(
|
|
458
|
-
lines << "Removed (no longer in the bundle): #{removed.join(
|
|
459
|
-
lines << "Would remove (no longer in the bundle): #{would_remove.join(
|
|
460
|
-
lines << "Kept (local edits preserved): #{kept.join(
|
|
461
|
-
lines << "Skipped: #{skipped.join(
|
|
462
|
-
lines << "Conflicts: #{conflicts.join(
|
|
461
|
+
lines << "Installed: #{installed.join(", ")}" unless installed.empty?
|
|
462
|
+
lines << "Updated: #{updated.join(", ")}" unless updated.empty?
|
|
463
|
+
lines << "Would install: #{would_install.join(", ")}" unless would_install.empty?
|
|
464
|
+
lines << "Fast-forward: #{fast_forward.join(", ")}" unless fast_forward.empty?
|
|
465
|
+
lines << "Removed (no longer in the bundle): #{removed.join(", ")}#{" (moved to #{trash_dir})" if trash_dir}" unless removed.empty?
|
|
466
|
+
lines << "Would remove (no longer in the bundle): #{would_remove.join(", ")}" unless would_remove.empty?
|
|
467
|
+
lines << "Kept (local edits preserved): #{kept.join(", ")}" unless kept.empty?
|
|
468
|
+
lines << "Skipped: #{skipped.join(", ")}" unless skipped.empty?
|
|
469
|
+
lines << "Conflicts: #{conflicts.join(", ")}" unless conflicts.empty?
|
|
463
470
|
lines.concat(@warnings) unless @warnings.empty?
|
|
464
471
|
lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
|
|
465
472
|
lines << "#{@name} is already installed; `chi bundle upgrade #{@name}` updates it and keeps local edits" if @reinstall
|
|
@@ -494,6 +501,7 @@ module Samagotchi
|
|
|
494
501
|
previous_files.each do |old_key, meta|
|
|
495
502
|
key = old_key.to_s
|
|
496
503
|
next if bundle_files.include?(key)
|
|
504
|
+
|
|
497
505
|
target = File.join(target_dir, key)
|
|
498
506
|
next unless File.exist?(target)
|
|
499
507
|
|
|
@@ -504,6 +512,7 @@ module Samagotchi
|
|
|
504
512
|
elsif @force || installed_unchanged?(target, meta, provenance.base_path(key))
|
|
505
513
|
@results[key] = { status: @dry_run ? "would_remove" : "removed", reason: "no longer in the bundle" }
|
|
506
514
|
next if @dry_run
|
|
515
|
+
|
|
507
516
|
@trash.move(target)
|
|
508
517
|
remove_target_index(scope, key)
|
|
509
518
|
else
|
|
@@ -518,6 +527,7 @@ module Samagotchi
|
|
|
518
527
|
def installed_unchanged?(target, meta, base_path)
|
|
519
528
|
recorded = meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil
|
|
520
529
|
return Provenance.sha_matches?(target, recorded) unless Provenance.recorded_sha(recorded).empty?
|
|
530
|
+
|
|
521
531
|
File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
|
|
522
532
|
end
|
|
523
533
|
|
|
@@ -577,12 +587,12 @@ module Samagotchi
|
|
|
577
587
|
FileUtils.mkdir_p(provenance.plugin_dir)
|
|
578
588
|
FileUtils.cp(src, dest)
|
|
579
589
|
@results[plugin[:file]] = if unchanged
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
590
|
+
{ status: "skipped", reason: "already up to date" }
|
|
591
|
+
elsif @upgrade && provenance.read
|
|
592
|
+
{ status: "updated" }
|
|
593
|
+
else
|
|
594
|
+
{ status: "installed" }
|
|
595
|
+
end
|
|
586
596
|
expected = manifest.checksum_for_plugin
|
|
587
597
|
actual = Digest::SHA256.hexdigest(File.binread(dest))
|
|
588
598
|
if @strict && expected && actual != expected
|
|
@@ -30,8 +30,10 @@ module Samagotchi
|
|
|
30
30
|
|
|
31
31
|
Dir.children(dir).sort.filter_map do |source|
|
|
32
32
|
next unless File.file?(File.join(dir, source, "manifest.yml"))
|
|
33
|
+
|
|
33
34
|
m = Manifest.read(dir: File.join(dir, source))
|
|
34
35
|
next if m.name == SystemBundle::BUNDLE_NAME
|
|
36
|
+
|
|
35
37
|
Shipped.new(name: m.name, source: source, version: m.version, description: m.description, includes: m.includes)
|
|
36
38
|
rescue Manifest::ValidationError, Psych::Exception
|
|
37
39
|
nil
|
|
@@ -70,8 +72,10 @@ module Samagotchi
|
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
def newer?(candidate, current)
|
|
73
|
-
candidate
|
|
75
|
+
candidate = candidate.to_s
|
|
76
|
+
current = current.to_s
|
|
74
77
|
return false unless Gem::Version.correct?(candidate) && Gem::Version.correct?(current)
|
|
78
|
+
|
|
75
79
|
Gem::Version.new(candidate) > Gem::Version.new(current)
|
|
76
80
|
end
|
|
77
81
|
end
|
|
@@ -64,6 +64,7 @@ module Samagotchi
|
|
|
64
64
|
def self.read(dir:)
|
|
65
65
|
manifest_path = File.join(dir, "manifest.yml")
|
|
66
66
|
raise ValidationError, "manifest.yml not found in #{dir}" unless File.exist?(manifest_path)
|
|
67
|
+
|
|
67
68
|
new(path: manifest_path)
|
|
68
69
|
end
|
|
69
70
|
|
|
@@ -72,6 +73,7 @@ module Samagotchi
|
|
|
72
73
|
def checksum_for(file_key)
|
|
73
74
|
raw = @files[file_key]
|
|
74
75
|
return nil unless raw
|
|
76
|
+
|
|
75
77
|
raw.start_with?("sha256:") ? raw[7..] : raw
|
|
76
78
|
end
|
|
77
79
|
|
|
@@ -79,8 +81,10 @@ module Samagotchi
|
|
|
79
81
|
def checksum_for_hook(basename)
|
|
80
82
|
raw = @hooks[basename]
|
|
81
83
|
return nil unless raw
|
|
84
|
+
|
|
82
85
|
sha = raw[:sha256] || raw["sha256"]
|
|
83
86
|
return nil unless sha
|
|
87
|
+
|
|
84
88
|
sha.to_s.start_with?("sha256:") ? sha.to_s[7..] : sha.to_s
|
|
85
89
|
end
|
|
86
90
|
|
|
@@ -99,18 +103,18 @@ module Samagotchi
|
|
|
99
103
|
return nil if Gem::Requirement.new(*requirement.to_s.split(",").map(&:strip)).satisfied_by?(Gem::Version.new(version.to_s))
|
|
100
104
|
|
|
101
105
|
"it requires chi #{requirement} (this is chi #{version})"
|
|
102
|
-
rescue ArgumentError
|
|
106
|
+
rescue ArgumentError => e # Gem::Requirement::BadRequirementError is one
|
|
103
107
|
"its requires_chi #{requirement.inspect} is not a version requirement (#{e.message})"
|
|
104
108
|
end
|
|
105
109
|
|
|
106
110
|
# Writes a fresh manifest.yml from computed checksums at `dir`.
|
|
107
|
-
def self.write(dir:, name:, version:, scope: nil, description: "",
|
|
111
|
+
def self.write(dir:, name:, version:, files:, scope: nil, description: "", hooks: nil, trust_level: nil,
|
|
108
112
|
plugin: nil, requires_chi: nil, needs: nil)
|
|
109
113
|
FileUtils.mkdir_p(dir)
|
|
110
114
|
manifest = {
|
|
111
115
|
"name" => name,
|
|
112
116
|
"version" => version,
|
|
113
|
-
"description" => description
|
|
117
|
+
"description" => description
|
|
114
118
|
}
|
|
115
119
|
manifest["scope"] = scope if scope
|
|
116
120
|
manifest["files"] = files # { "file.md" => "sha256:abc..." }
|
|
@@ -167,19 +171,23 @@ module Samagotchi
|
|
|
167
171
|
def require_str(hash, key)
|
|
168
172
|
val = hash[key]
|
|
169
173
|
return val if val.is_a?(String) && !val.empty?
|
|
174
|
+
|
|
170
175
|
raise ValidationError, "manifest missing required field: #{key}"
|
|
171
176
|
end
|
|
172
177
|
|
|
173
178
|
def normalize_scope(val)
|
|
174
179
|
return nil if val.nil?
|
|
180
|
+
|
|
175
181
|
v = val.to_s.strip.downcase
|
|
176
182
|
%w[project system].include?(v) ? v : nil
|
|
177
183
|
end
|
|
178
184
|
|
|
179
185
|
def parse_files(raw)
|
|
180
186
|
return {} unless raw.is_a?(Hash)
|
|
187
|
+
|
|
181
188
|
raw.each_with_object({}) do |(k, v), acc|
|
|
182
189
|
next unless k.is_a?(String) && !k.empty?
|
|
190
|
+
|
|
183
191
|
str = v.to_s
|
|
184
192
|
acc[k] = if str.start_with?("sha256:")
|
|
185
193
|
str
|
|
@@ -234,11 +242,13 @@ module Samagotchi
|
|
|
234
242
|
|
|
235
243
|
def parse_hooks(raw)
|
|
236
244
|
return {} unless raw.is_a?(Hash)
|
|
245
|
+
|
|
237
246
|
raw.each_with_object({}) do |(k, v), acc|
|
|
238
247
|
next unless k.is_a?(String) && !k.empty?
|
|
239
248
|
next unless v.is_a?(Hash)
|
|
249
|
+
|
|
240
250
|
sha = (v["sha256"] || v[:sha256] || "").to_s
|
|
241
|
-
sha =
|
|
251
|
+
sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
|
|
242
252
|
event = (v["event"] || v[:event] || "").to_s.strip
|
|
243
253
|
on_error = (v["on_error"] || v[:on_error] || "skip").to_s.strip
|
|
244
254
|
on_error = "skip" if on_error.empty?
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require "digest"
|
|
3
4
|
|
|
4
5
|
module Samagotchi
|
|
@@ -33,18 +34,19 @@ module Samagotchi
|
|
|
33
34
|
incoming_digest = Digest::SHA256.hexdigest(File.read(incoming_path))
|
|
34
35
|
|
|
35
36
|
if current_digest == incoming_digest
|
|
36
|
-
|
|
37
|
+
:noop
|
|
37
38
|
elsif current_digest == base_digest
|
|
38
|
-
|
|
39
|
+
:fast_forward
|
|
39
40
|
elsif incoming_digest == base_digest
|
|
40
|
-
|
|
41
|
+
:keep
|
|
41
42
|
else
|
|
42
|
-
|
|
43
|
+
:conflict
|
|
43
44
|
end
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def self.current_modified?(base_path, current_path)
|
|
47
48
|
return true unless base_path && File.exist?(base_path) && File.exist?(current_path)
|
|
49
|
+
|
|
48
50
|
Digest::SHA256.hexdigest(File.read(base_path)) != Digest::SHA256.hexdigest(File.read(current_path))
|
|
49
51
|
end
|
|
50
52
|
end
|
|
@@ -154,11 +154,11 @@ module Samagotchi
|
|
|
154
154
|
if existing && existing[:files]
|
|
155
155
|
existing[:files].keys.each do |old_key|
|
|
156
156
|
old_key_str = old_key.to_s
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
157
|
+
next if files.key?(old_key_str) || files.key?(old_key)
|
|
158
|
+
|
|
159
|
+
old_base = File.join(bases_dir, old_key_str)
|
|
160
|
+
FileUtils.rm_f(old_base)
|
|
161
|
+
merged_entries.delete(old_key_str)
|
|
162
162
|
end
|
|
163
163
|
end
|
|
164
164
|
|
|
@@ -172,9 +172,10 @@ module Samagotchi
|
|
|
172
172
|
hooks.each do |k, v|
|
|
173
173
|
next unless k.is_a?(String) && !k.empty?
|
|
174
174
|
next unless v.is_a?(Hash)
|
|
175
|
+
|
|
175
176
|
hv = v.transform_keys(&:to_sym)
|
|
176
177
|
sha = (hv[:sha256] || "").to_s
|
|
177
|
-
sha =
|
|
178
|
+
sha = "sha256:#{sha}" unless sha.empty? || sha.start_with?("sha256:")
|
|
178
179
|
normalized_hooks[k] = {
|
|
179
180
|
sha256: sha,
|
|
180
181
|
event: (hv[:event] || "").to_s,
|
|
@@ -189,7 +190,7 @@ module Samagotchi
|
|
|
189
190
|
old_key_str = old_key.to_s
|
|
190
191
|
unless normalized_hooks.key?(old_key_str) || normalized_hooks.key?(old_key_str.to_sym)
|
|
191
192
|
old_base = File.join(bases_dir, old_key_str)
|
|
192
|
-
FileUtils.rm_f(old_base)
|
|
193
|
+
FileUtils.rm_f(old_base)
|
|
193
194
|
end
|
|
194
195
|
end
|
|
195
196
|
end
|
|
@@ -204,12 +205,12 @@ module Samagotchi
|
|
|
204
205
|
meta = meta.merge(sha256: "sha256:#{Digest::SHA256.hexdigest(File.read(src.to_s))}")
|
|
205
206
|
end
|
|
206
207
|
hooks_map[k] = meta.transform_keys(&:to_s)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
208
|
+
next unless src && File.exist?(src.to_s)
|
|
209
|
+
|
|
210
|
+
begin
|
|
211
|
+
File.write(File.join(bases_dir, k), File.read(src.to_s))
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
213
214
|
end
|
|
214
215
|
end
|
|
215
216
|
|
|
@@ -256,6 +257,7 @@ module Samagotchi
|
|
|
256
257
|
conflicts.each do |file_key, info|
|
|
257
258
|
key = file_key.to_s
|
|
258
259
|
next unless raw["files"].key?(key)
|
|
260
|
+
|
|
259
261
|
src = [info[:incoming], info[:current]].find { |p| p && File.exist?(p) }
|
|
260
262
|
next unless src
|
|
261
263
|
|
|
@@ -270,6 +272,7 @@ module Samagotchi
|
|
|
270
272
|
def read
|
|
271
273
|
manifest_path = File.join(@bundle_dir, "manifest.json")
|
|
272
274
|
return nil unless File.exist?(manifest_path)
|
|
275
|
+
|
|
273
276
|
JSON.parse(File.read(manifest_path), symbolize_names: true)
|
|
274
277
|
end
|
|
275
278
|
|