samagotchi 0.2.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 +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- metadata +374 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "../memory_paths"
|
|
6
|
+
require_relative "provenance"
|
|
7
|
+
require_relative "manifest"
|
|
8
|
+
require_relative "source"
|
|
9
|
+
require_relative "placeholder"
|
|
10
|
+
require_relative "index_updater"
|
|
11
|
+
require_relative "merger"
|
|
12
|
+
require_relative "../version"
|
|
13
|
+
require_relative "../bundle_needs"
|
|
14
|
+
|
|
15
|
+
module Samagotchi
|
|
16
|
+
module MemoryBundle
|
|
17
|
+
# Installs a bundle into a scoped memories directory.
|
|
18
|
+
#
|
|
19
|
+
# Flow:
|
|
20
|
+
# 1. Normalize source → directory (returns [path, owned])
|
|
21
|
+
# 2. Read manifest.yml
|
|
22
|
+
# 3. Copy .md files to target scope dir (skip or force)
|
|
23
|
+
# 4. Verify checksums (strict mode)
|
|
24
|
+
# 5. Update index.md for each entry
|
|
25
|
+
# 6. Write provenance (.bundles/<name>/)
|
|
26
|
+
# 7. Detect and report {{placeholders}}
|
|
27
|
+
class Installer
|
|
28
|
+
class InstallError < StandardError; end
|
|
29
|
+
|
|
30
|
+
attr_reader :results, :warnings, :placeholder_warnings, :conflicts
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
attr_accessor :system_dir_override, :project_dir_base_override
|
|
34
|
+
|
|
35
|
+
def system_dir
|
|
36
|
+
system_dir_override || MemoryPaths.system_dir
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def project_dir_base
|
|
40
|
+
project_dir_base_override || MemoryPaths.projects_dir
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false)
|
|
45
|
+
@source = source
|
|
46
|
+
@name = name
|
|
47
|
+
@scope = scope&.to_s&.strip&.downcase || ""
|
|
48
|
+
@force = force
|
|
49
|
+
@strict = strict
|
|
50
|
+
@upgrade = upgrade
|
|
51
|
+
@dry_run = dry_run
|
|
52
|
+
@results = {}
|
|
53
|
+
@warnings = []
|
|
54
|
+
@placeholder_warnings = []
|
|
55
|
+
@conflicts = {}
|
|
56
|
+
# Propagate scope overrides so IndexUpdater resolves the correct directories.
|
|
57
|
+
IndexUpdater.system_dir_override = self.class.system_dir_override
|
|
58
|
+
IndexUpdater.project_dir_base_override = self.class.project_dir_base_override
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run
|
|
62
|
+
normalized_dir = nil
|
|
63
|
+
source_owned = false
|
|
64
|
+
source_commit = nil
|
|
65
|
+
manifest = nil
|
|
66
|
+
begin
|
|
67
|
+
normalized_dir, source_owned, source_commit = SourceNormalizer.normalize(@source)
|
|
68
|
+
manifest = Manifest.read(dir: normalized_dir)
|
|
69
|
+
rescue SourceNormalizer::UnknownSourceError => e
|
|
70
|
+
raise InstallError, "Source normalization failed: #{e.message}"
|
|
71
|
+
rescue Manifest::ValidationError => e
|
|
72
|
+
if @strict
|
|
73
|
+
raise InstallError, e.message
|
|
74
|
+
else
|
|
75
|
+
@warnings << "No manifest found — proceeding without strict manifest validation"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Determine target scope (CLI wins over manifest).
|
|
80
|
+
cli_scope = @scope.to_s.strip.empty? ? nil : @scope
|
|
81
|
+
target_scope = cli_scope || manifest&.scope || "system"
|
|
82
|
+
target_dir = resolve_target_dir(target_scope)
|
|
83
|
+
FileUtils.mkdir_p(target_dir)
|
|
84
|
+
|
|
85
|
+
# Copy .md files and update index for each.
|
|
86
|
+
all_files_in_bundle = []
|
|
87
|
+
provenance = Provenance.new(name: @name)
|
|
88
|
+
existing_provenance = provenance.read
|
|
89
|
+
|
|
90
|
+
Dir.glob(File.join(normalized_dir, "*.md")).each do |file_path|
|
|
91
|
+
file_key = File.basename(file_path)
|
|
92
|
+
all_files_in_bundle << file_key
|
|
93
|
+
target_path = File.join(target_dir, file_key)
|
|
94
|
+
|
|
95
|
+
if @upgrade && existing_provenance && !@force && !@dry_run
|
|
96
|
+
# 3-way merge path for upgrades
|
|
97
|
+
base_path = provenance.base_path(file_key)
|
|
98
|
+
classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
|
|
99
|
+
case classification
|
|
100
|
+
when :install
|
|
101
|
+
FileUtils.cp(file_path, target_path)
|
|
102
|
+
@results[file_key] = { status: "installed" }
|
|
103
|
+
update_target_index(target_scope, target_path, file_key)
|
|
104
|
+
when :fast_forward
|
|
105
|
+
FileUtils.cp(file_path, target_path)
|
|
106
|
+
@results[file_key] = { status: "updated", reason: "auto-merged (not edited)" }
|
|
107
|
+
update_target_index(target_scope, target_path, file_key)
|
|
108
|
+
when :keep
|
|
109
|
+
@results[file_key] = { status: "kept", reason: "bundle unchanged, local edits preserved" }
|
|
110
|
+
update_target_index(target_scope, target_path, file_key)
|
|
111
|
+
when :noop
|
|
112
|
+
@results[file_key] = { status: "skipped", reason: "already up to date" }
|
|
113
|
+
update_target_index(target_scope, target_path, file_key)
|
|
114
|
+
when :conflict
|
|
115
|
+
@results[file_key] = { status: "conflict", reason: "local edits conflict with bundle update" }
|
|
116
|
+
@conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path }
|
|
117
|
+
@warnings << "Conflict in #{file_key}: local edits conflict with bundle update (use --force to overwrite or resolve interactively)"
|
|
118
|
+
update_target_index(target_scope, target_path, file_key)
|
|
119
|
+
end
|
|
120
|
+
elsif @dry_run
|
|
121
|
+
# Dry-run: classify but do not write
|
|
122
|
+
if File.exist?(target_path) && !@force
|
|
123
|
+
if @upgrade && existing_provenance
|
|
124
|
+
base_path = provenance.base_path(file_key)
|
|
125
|
+
classification = Merger.classify(base_path: base_path, current_path: target_path, incoming_path: file_path)
|
|
126
|
+
@results[file_key] = { status: classification.to_s }
|
|
127
|
+
@conflicts[file_key] = { base: base_path, current: target_path, incoming: file_path } if classification == :conflict
|
|
128
|
+
else
|
|
129
|
+
@results[file_key] = { status: "would_skip" }
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
@results[file_key] = { status: "would_install" }
|
|
133
|
+
end
|
|
134
|
+
elsif File.exist?(target_path) && !@force
|
|
135
|
+
@results[file_key] = { status: "skipped", reason: "already exists" }
|
|
136
|
+
@warnings << "Skipped #{file_key} (already exists; use --force to overwrite)"
|
|
137
|
+
# Still update the index for skipped files.
|
|
138
|
+
update_target_index(target_scope, target_path, file_key)
|
|
139
|
+
else
|
|
140
|
+
FileUtils.cp(file_path, target_path) unless @dry_run
|
|
141
|
+
@results[file_key] = { status: "installed" }
|
|
142
|
+
# Update index for newly installed files.
|
|
143
|
+
update_target_index(target_scope, target_path, file_key) unless @dry_run
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# ── Hooks: copy hooks/*.rb into <bundle_dir>/hooks/ ───────────────
|
|
148
|
+
hooks_target = File.join(provenance.bundle_dir, "hooks")
|
|
149
|
+
all_hooks_in_bundle = []
|
|
150
|
+
hooks_files_for_provenance = {}
|
|
151
|
+
if manifest
|
|
152
|
+
hook_entries = manifest.hooks
|
|
153
|
+
# Fallback: if manifest has no hooks but source has hooks/*.rb, discover them
|
|
154
|
+
if hook_entries.empty?
|
|
155
|
+
discovered = Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).map { |p| File.basename(p) }
|
|
156
|
+
discovered.each do |bn|
|
|
157
|
+
# Build minimal metadata so copy still happens (event unknown -> skipped by loader but copied for integrity)
|
|
158
|
+
hook_entries[bn] = { sha256: "", event: "", on_error: "skip", priority: 100 }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
hook_entries.each do |basename, meta|
|
|
163
|
+
next unless basename.is_a?(String) && !basename.empty?
|
|
164
|
+
all_hooks_in_bundle << basename
|
|
165
|
+
src = File.join(normalized_dir, "hooks", basename)
|
|
166
|
+
next unless File.exist?(src)
|
|
167
|
+
|
|
168
|
+
dest = File.join(hooks_target, basename)
|
|
169
|
+
|
|
170
|
+
if @dry_run
|
|
171
|
+
if File.exist?(dest) && !@force
|
|
172
|
+
@results[basename] = { status: "would_skip" }
|
|
173
|
+
else
|
|
174
|
+
@results[basename] = { status: "would_install" }
|
|
175
|
+
end
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Local-edit detection for upgrade path (overwrite + warn)
|
|
180
|
+
if @upgrade && existing_provenance && File.exist?(dest) && !@force
|
|
181
|
+
prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
|
|
182
|
+
if prev_meta
|
|
183
|
+
prev_sha = (prev_meta[:sha256] || prev_meta["sha256"] || "").to_s.sub(/\Asha256:/, "")
|
|
184
|
+
if File.exist?(dest)
|
|
185
|
+
cur_sha = Digest::SHA256.hexdigest(File.read(dest))
|
|
186
|
+
if cur_sha != prev_sha && !prev_sha.empty?
|
|
187
|
+
@warnings << "Hook #{basename} was locally modified; overwriting"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
FileUtils.mkdir_p(hooks_target)
|
|
194
|
+
FileUtils.cp(src, dest)
|
|
195
|
+
if @upgrade && existing_provenance
|
|
196
|
+
@results[basename] = { status: "updated" }
|
|
197
|
+
else
|
|
198
|
+
@results[basename] = { status: "installed" }
|
|
199
|
+
end
|
|
200
|
+
hooks_files_for_provenance[basename] = dest
|
|
201
|
+
end
|
|
202
|
+
else
|
|
203
|
+
# No manifest (non-strict): still copy any hooks/*.rb if present
|
|
204
|
+
Dir.glob(File.join(normalized_dir, "hooks", "*.rb")).each do |src|
|
|
205
|
+
basename = File.basename(src)
|
|
206
|
+
all_hooks_in_bundle << basename
|
|
207
|
+
dest = File.join(hooks_target, basename)
|
|
208
|
+
if @dry_run
|
|
209
|
+
@results[basename] = { status: "would_install" }
|
|
210
|
+
else
|
|
211
|
+
FileUtils.mkdir_p(hooks_target)
|
|
212
|
+
FileUtils.cp(src, dest)
|
|
213
|
+
@results[basename] = { status: "installed" }
|
|
214
|
+
hooks_files_for_provenance[basename] = dest
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Prune stale hooks (removed from bundle) — reuse bases pruning via provenance but also remove files
|
|
220
|
+
if @upgrade && existing_provenance && existing_provenance[:hooks] && !@dry_run
|
|
221
|
+
existing_provenance[:hooks].keys.each do |old_key|
|
|
222
|
+
old_key_str = old_key.to_s
|
|
223
|
+
next if all_hooks_in_bundle.include?(old_key_str)
|
|
224
|
+
old_dest = File.join(hooks_target, old_key_str)
|
|
225
|
+
if File.exist?(old_dest) && !@force
|
|
226
|
+
@warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
|
|
227
|
+
end
|
|
228
|
+
FileUtils.rm_f(old_dest) if File.exist?(old_dest)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Verify hook checksums in strict mode (mirror .md block)
|
|
233
|
+
if manifest && @strict
|
|
234
|
+
all_hooks_in_bundle.each do |basename|
|
|
235
|
+
next if @conflicts.key?(basename)
|
|
236
|
+
expected = manifest.checksum_for_hook(basename)
|
|
237
|
+
if expected
|
|
238
|
+
dest = File.join(hooks_target, basename)
|
|
239
|
+
next unless File.exist?(dest)
|
|
240
|
+
actual = Digest::SHA256.hexdigest(File.read(dest))
|
|
241
|
+
if actual != expected
|
|
242
|
+
@warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# ── Guardrail rules: guardrails/*.yml into <bundle_dir>/guardrails/ ─
|
|
249
|
+
# The bundle's set replaces what an earlier version installed.
|
|
250
|
+
guardrails_target = provenance.guardrails_dir
|
|
251
|
+
guardrail_files_for_provenance = {}
|
|
252
|
+
incoming_rules = Dir.glob(File.join(normalized_dir, "guardrails", "*.yml")).sort
|
|
253
|
+
if @dry_run
|
|
254
|
+
incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
|
|
255
|
+
else
|
|
256
|
+
FileUtils.rm_rf(guardrails_target)
|
|
257
|
+
unless incoming_rules.empty?
|
|
258
|
+
FileUtils.mkdir_p(guardrails_target)
|
|
259
|
+
incoming_rules.each do |src|
|
|
260
|
+
dest = File.join(guardrails_target, File.basename(src))
|
|
261
|
+
FileUtils.cp(src, dest)
|
|
262
|
+
@results["guardrails/#{File.basename(src)}"] = { status: "installed" }
|
|
263
|
+
guardrail_files_for_provenance[File.basename(src)] = dest
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# ── Plugin: <file> into <bundle_dir>/plugin/ (docs/plugins.md) ────
|
|
269
|
+
# Like the rules, the bundle's plugin replaces an earlier one.
|
|
270
|
+
plugin_file_for_provenance = install_plugin(manifest, normalized_dir, provenance)
|
|
271
|
+
|
|
272
|
+
# Files the previous version shipped and this one doesn't.
|
|
273
|
+
if @upgrade && existing_provenance && existing_provenance[:files]
|
|
274
|
+
prune_dropped_files(existing_provenance[:files], all_files_in_bundle, target_dir, target_scope, provenance)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Verify checksums if we have a manifest (strict).
|
|
278
|
+
if manifest && @strict
|
|
279
|
+
all_files_in_bundle.each do |file_key|
|
|
280
|
+
next if @conflicts.key?(file_key)
|
|
281
|
+
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
282
|
+
next if %w[kept kept_pruned fast_forward updated].include?(status)
|
|
283
|
+
# For dry-run, fast_forward would appear as "fast_forward", updated not yet
|
|
284
|
+
target_path = File.join(target_dir, file_key)
|
|
285
|
+
next unless File.exist?(target_path)
|
|
286
|
+
expected = manifest.checksum_for(file_key)
|
|
287
|
+
if expected
|
|
288
|
+
actual = Digest::SHA256.hexdigest(File.read(target_path))
|
|
289
|
+
if actual != expected
|
|
290
|
+
@warnings << "Checksum mismatch for #{file_key}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
warn_missing_needs(manifest) if manifest
|
|
297
|
+
|
|
298
|
+
# Write provenance (only if we had a manifest and not dry_run).
|
|
299
|
+
if manifest && !@dry_run
|
|
300
|
+
skip_provenance = @upgrade && @conflicts.any? && !@force
|
|
301
|
+
unless skip_provenance
|
|
302
|
+
provenance_files = {}
|
|
303
|
+
if @upgrade && existing_provenance
|
|
304
|
+
all_files_in_bundle.each do |file_key|
|
|
305
|
+
target_path = File.join(target_dir, file_key)
|
|
306
|
+
next unless File.exist?(target_path)
|
|
307
|
+
status = @results[file_key] ? @results[file_key][:status].to_s : nil
|
|
308
|
+
if %w[kept noop conflict kept_pruned].include?(status)
|
|
309
|
+
# Keep old base snapshot — do not overwrite with edited current
|
|
310
|
+
# Use base snapshot as source if it exists to preserve old checksum
|
|
311
|
+
base_path = provenance.base_path(file_key)
|
|
312
|
+
if File.exist?(base_path)
|
|
313
|
+
# Keep existing provenance entry by re-using base content
|
|
314
|
+
# We still need to include it to prevent pruning, but with base content
|
|
315
|
+
provenance_files[file_key] = base_path
|
|
316
|
+
else
|
|
317
|
+
provenance_files[file_key] = target_path
|
|
318
|
+
end
|
|
319
|
+
else
|
|
320
|
+
# installed, updated, skipped — use current target (which is incoming for updated)
|
|
321
|
+
provenance_files[file_key] = target_path
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
# Handle pruned but kept files (bundle removed file but local kept)
|
|
325
|
+
if existing_provenance[:files]
|
|
326
|
+
existing_provenance[:files].keys.each do |old_key|
|
|
327
|
+
old_key_str = old_key.to_s
|
|
328
|
+
next if all_files_in_bundle.include?(old_key_str)
|
|
329
|
+
# If it was kept_pruned, include it with base content to prevent pruning
|
|
330
|
+
if @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
|
|
331
|
+
base_path = provenance.base_path(old_key_str)
|
|
332
|
+
target_path = File.join(target_dir, old_key_str)
|
|
333
|
+
# Use base if exists to keep old checksum, else target
|
|
334
|
+
src = File.exist?(base_path) ? base_path : target_path
|
|
335
|
+
provenance_files[old_key_str] = src if File.exist?(src)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
else
|
|
340
|
+
all_files_in_bundle.each do |file_key|
|
|
341
|
+
target_path = File.join(target_dir, file_key)
|
|
342
|
+
provenance_files[file_key] = target_path if File.exist?(target_path)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
# Build hooks metadata for provenance
|
|
346
|
+
hooks_for_provenance = {}
|
|
347
|
+
if manifest && manifest.hooks && !manifest.hooks.empty?
|
|
348
|
+
hooks_for_provenance = manifest.hooks
|
|
349
|
+
elsif hooks_files_for_provenance.any?
|
|
350
|
+
hooks_files_for_provenance.each do |basename, path|
|
|
351
|
+
next unless File.exist?(path)
|
|
352
|
+
sha = Digest::SHA256.hexdigest(File.read(path))
|
|
353
|
+
hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
Provenance.new(name: @name).write(
|
|
357
|
+
files: provenance_files,
|
|
358
|
+
scope: target_scope,
|
|
359
|
+
version: manifest.version,
|
|
360
|
+
source_path: @source,
|
|
361
|
+
hooks: hooks_for_provenance,
|
|
362
|
+
trust_level: manifest.respond_to?(:trust_level) ? manifest.trust_level : nil,
|
|
363
|
+
source_commit: source_commit,
|
|
364
|
+
hooks_files: hooks_files_for_provenance,
|
|
365
|
+
guardrails_files: guardrail_files_for_provenance,
|
|
366
|
+
plugin_file: plugin_file_for_provenance,
|
|
367
|
+
requires_chi: manifest.requires_chi,
|
|
368
|
+
needs: manifest.needs
|
|
369
|
+
)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Detect placeholders.
|
|
374
|
+
all_files_in_bundle.each do |file_key|
|
|
375
|
+
file_path = File.join(target_dir, file_key)
|
|
376
|
+
next unless File.exist?(file_path)
|
|
377
|
+
names = Placeholder.detect_in_file(file_path)
|
|
378
|
+
if names.any?
|
|
379
|
+
@placeholder_warnings << "#{file_key}: {{#{names.join("}}, {{")}}}"
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
[normalized_dir, manifest]
|
|
384
|
+
ensure
|
|
385
|
+
SourceNormalizer.cleanup(normalized_dir) if source_owned
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def summary
|
|
389
|
+
lines = []
|
|
390
|
+
installed = @results.select { |_, r| r[:status].to_s == "installed" }.keys
|
|
391
|
+
updated = @results.select { |_, r| r[:status].to_s == "updated" }.keys
|
|
392
|
+
kept = @results.select { |_, r| %w[kept kept_pruned].include?(r[:status].to_s) }.keys
|
|
393
|
+
skipped = @results.select { |_, r| %w[skipped noop would_skip].include?(r[:status].to_s) }.keys
|
|
394
|
+
conflicts = @results.select { |_, r| r[:status].to_s == "conflict" }.keys
|
|
395
|
+
would_install = @results.select { |_, r| r[:status].to_s == "would_install" }.keys
|
|
396
|
+
fast_forward = @results.select { |_, r| r[:status].to_s == "fast_forward" }.keys
|
|
397
|
+
removed = @results.select { |_, r| r[:status].to_s == "removed" }.keys
|
|
398
|
+
would_remove = @results.select { |_, r| r[:status].to_s == "would_remove" }.keys
|
|
399
|
+
|
|
400
|
+
lines << "Installed: #{installed.join(', ')}" unless installed.empty?
|
|
401
|
+
lines << "Updated: #{updated.join(', ')}" unless updated.empty?
|
|
402
|
+
lines << "Would install: #{would_install.join(', ')}" unless would_install.empty?
|
|
403
|
+
lines << "Fast-forward: #{fast_forward.join(', ')}" unless fast_forward.empty?
|
|
404
|
+
lines << "Removed (no longer in the bundle): #{removed.join(', ')}" unless removed.empty?
|
|
405
|
+
lines << "Would remove (no longer in the bundle): #{would_remove.join(', ')}" unless would_remove.empty?
|
|
406
|
+
lines << "Kept (local edits preserved): #{kept.join(', ')}" unless kept.empty?
|
|
407
|
+
lines << "Skipped: #{skipped.join(', ')}" unless skipped.empty?
|
|
408
|
+
lines << "Conflicts: #{conflicts.join(', ')}" unless conflicts.empty?
|
|
409
|
+
lines.concat(@warnings) unless @warnings.empty?
|
|
410
|
+
lines.concat(@placeholder_warnings) unless @placeholder_warnings.empty?
|
|
411
|
+
|
|
412
|
+
lines.any? ? lines.join("\n") : "No files to install."
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
private
|
|
416
|
+
|
|
417
|
+
# A file the previous version installed that this one doesn't ship is
|
|
418
|
+
# removed (with its index line) when it still matches what was
|
|
419
|
+
# installed, or with --force; one the user edited is kept with a note.
|
|
420
|
+
def prune_dropped_files(previous_files, bundle_files, target_dir, scope, provenance)
|
|
421
|
+
previous_files.each do |old_key, meta|
|
|
422
|
+
key = old_key.to_s
|
|
423
|
+
next if bundle_files.include?(key)
|
|
424
|
+
target = File.join(target_dir, key)
|
|
425
|
+
next unless File.exist?(target)
|
|
426
|
+
|
|
427
|
+
if @force || installed_unchanged?(target, meta, provenance.base_path(key))
|
|
428
|
+
@results[key] = { status: @dry_run ? "would_remove" : "removed", reason: "no longer in the bundle" }
|
|
429
|
+
next if @dry_run
|
|
430
|
+
FileUtils.rm_f(target)
|
|
431
|
+
remove_target_index(scope, key)
|
|
432
|
+
else
|
|
433
|
+
@results[key] = { status: "kept_pruned", reason: "local edits preserved" }
|
|
434
|
+
@warnings << "Kept #{key}: no longer in the bundle but edited locally (use --force to remove)"
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Whether the file on disk is what the previous install wrote: its
|
|
440
|
+
# recorded checksum, else the base snapshot. Unknown counts as edited.
|
|
441
|
+
def installed_unchanged?(target, meta, base_path)
|
|
442
|
+
current = Digest::SHA256.hexdigest(File.read(target))
|
|
443
|
+
recorded = meta.is_a?(Hash) ? (meta[:checksum] || meta["checksum"]).to_s.sub(/\Asha256:/, "") : ""
|
|
444
|
+
return current == recorded unless recorded.empty?
|
|
445
|
+
File.exist?(base_path) && current == Digest::SHA256.hexdigest(File.read(base_path))
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def remove_target_index(scope, file_key)
|
|
449
|
+
IndexUpdater.remove_index(scope, file_key.delete_suffix(".md"))
|
|
450
|
+
IndexUpdater.remove_index(scope, file_key) # legacy "name.md" line
|
|
451
|
+
rescue StandardError
|
|
452
|
+
# Best-effort, like update_target_index.
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# One warning per need not found on this PATH (read-only, so dry-run
|
|
456
|
+
# too). Installing goes ahead: needs are advisory.
|
|
457
|
+
def warn_missing_needs(manifest)
|
|
458
|
+
BundleNeeds.missing(manifest.needs).each do |need|
|
|
459
|
+
hint = need[:hint] ? " (#{need[:hint]})" : ""
|
|
460
|
+
@warnings << "needs #{need[:command]}: not found on PATH#{hint}; #{@dry_run ? "would install" : "installed"} anyway"
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Copy the manifest's plugin file into the bundle's plugin/ dir,
|
|
465
|
+
# warning when its sha256 differs from the declared one or this chi
|
|
466
|
+
# doesn't meet requires_chi (the Engine then won't load it).
|
|
467
|
+
# @return [String, nil] the installed file (nil: none, or a dry run)
|
|
468
|
+
def install_plugin(manifest, source_dir, provenance)
|
|
469
|
+
plugin = manifest&.plugin
|
|
470
|
+
unless plugin
|
|
471
|
+
FileUtils.rm_rf(provenance.plugin_dir) unless @dry_run
|
|
472
|
+
return nil
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
src = File.join(source_dir, plugin[:file])
|
|
476
|
+
raise InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
|
|
477
|
+
|
|
478
|
+
if (failure = Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION))
|
|
479
|
+
@warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
|
|
480
|
+
end
|
|
481
|
+
if @dry_run
|
|
482
|
+
@results[plugin[:file]] = { status: "would_install" }
|
|
483
|
+
return nil
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
FileUtils.rm_rf(provenance.plugin_dir)
|
|
487
|
+
FileUtils.mkdir_p(provenance.plugin_dir)
|
|
488
|
+
dest = File.join(provenance.plugin_dir, plugin[:file])
|
|
489
|
+
FileUtils.cp(src, dest)
|
|
490
|
+
@results[plugin[:file]] = { status: @upgrade && provenance.read ? "updated" : "installed" }
|
|
491
|
+
expected = manifest.checksum_for_plugin
|
|
492
|
+
actual = Digest::SHA256.hexdigest(File.binread(dest))
|
|
493
|
+
if @strict && expected && actual != expected
|
|
494
|
+
@warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
495
|
+
end
|
|
496
|
+
dest
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def update_target_index(scope, file_path, file_key)
|
|
500
|
+
byte_count = File.exist?(file_path) ? File.size(file_path) : 0
|
|
501
|
+
entry_name = file_key.delete_suffix(".md")
|
|
502
|
+
begin
|
|
503
|
+
# Index lines use the entry name (as memory_write does); drop the
|
|
504
|
+
# legacy "name.md" line older installs wrote.
|
|
505
|
+
IndexUpdater.remove_index(scope, file_key) unless entry_name == file_key
|
|
506
|
+
IndexUpdater.update_index(scope, entry_name, byte_count)
|
|
507
|
+
rescue => _e
|
|
508
|
+
# Silently skip index updates — they're best-effort.
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def resolve_target_dir(scope)
|
|
513
|
+
case scope
|
|
514
|
+
when "system", ""
|
|
515
|
+
self.class.system_dir
|
|
516
|
+
when "project"
|
|
517
|
+
if self.class.project_dir_base_override
|
|
518
|
+
self.class.project_dir_base
|
|
519
|
+
else
|
|
520
|
+
File.join(self.class.project_dir_base, MemoryPaths.project_key)
|
|
521
|
+
end
|
|
522
|
+
else
|
|
523
|
+
raise InstallError, "invalid scope: #{scope}"
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "manifest"
|
|
4
|
+
require_relative "source"
|
|
5
|
+
require_relative "provenance"
|
|
6
|
+
require_relative "system_bundle"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
module MemoryBundle
|
|
10
|
+
# What `chi bundle list` shows: the installed bundles (from provenance),
|
|
11
|
+
# each with the shipped version when chi ships a newer one, and the
|
|
12
|
+
# bundles shipped with chi that aren't installed yet. The system bundle
|
|
13
|
+
# is left out of the shipped ones: chi installs and upgrades it itself.
|
|
14
|
+
module Listing
|
|
15
|
+
# error is set (and the rest nil) when the provenance manifest.json can't be read.
|
|
16
|
+
Installed = Struct.new(:name, :version, :scope, :files, :installed_at, :upgrade, :error, keyword_init: true)
|
|
17
|
+
# source is the name `chi bundle install` takes (the shipped dir's name).
|
|
18
|
+
Shipped = Struct.new(:name, :source, :version, :description, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @return [Array<Shipped>] by name, without the system bundle; a dir
|
|
23
|
+
# whose manifest doesn't read is skipped
|
|
24
|
+
def shipped(dir: SourceNormalizer::SHIPPED_DIR)
|
|
25
|
+
return [] unless File.directory?(dir)
|
|
26
|
+
|
|
27
|
+
Dir.children(dir).sort.filter_map do |source|
|
|
28
|
+
next unless File.file?(File.join(dir, source, "manifest.yml"))
|
|
29
|
+
m = Manifest.read(dir: File.join(dir, source))
|
|
30
|
+
next if m.name == SystemBundle::BUNDLE_NAME
|
|
31
|
+
Shipped.new(name: m.name, source: source, version: m.version, description: m.description)
|
|
32
|
+
rescue Manifest::ValidationError, Psych::Exception
|
|
33
|
+
nil
|
|
34
|
+
end.sort_by(&:name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<Installed>] by name; upgrade is the Shipped when it is newer
|
|
38
|
+
def installed(shipped: self.shipped)
|
|
39
|
+
dir = Provenance.bundles_dir
|
|
40
|
+
return [] unless File.directory?(dir)
|
|
41
|
+
|
|
42
|
+
by_name = shipped.to_h { |s| [s.name, s] }
|
|
43
|
+
Dir.children(dir).reject { |e| e.start_with?(".") }.sort.filter_map do |name|
|
|
44
|
+
begin
|
|
45
|
+
data = Provenance.new(name: name).read
|
|
46
|
+
rescue JSON::ParserError, SystemCallError
|
|
47
|
+
data = :unreadable
|
|
48
|
+
end
|
|
49
|
+
next unless data
|
|
50
|
+
next Installed.new(name: name, error: "manifest.json unreadable") unless data.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
ship = by_name[name]
|
|
53
|
+
Installed.new(name: name, version: data[:version], scope: data[:scope],
|
|
54
|
+
files: (data[:files] || {}).size, installed_at: data[:installed_at],
|
|
55
|
+
upgrade: ship && newer?(ship.version, data[:version]) ? ship : nil)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The shipped bundles not installed under their manifest name.
|
|
60
|
+
def available(shipped: self.shipped, installed: self.installed(shipped: shipped))
|
|
61
|
+
names = installed.map(&:name)
|
|
62
|
+
shipped.reject { |s| names.include?(s.name) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def newer?(candidate, current)
|
|
66
|
+
candidate, current = candidate.to_s, current.to_s
|
|
67
|
+
return false unless Gem::Version.correct?(candidate) && Gem::Version.correct?(current)
|
|
68
|
+
Gem::Version.new(candidate) > Gem::Version.new(current)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|