samagotchi 0.6.0 → 0.8.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/README.md +7 -3
- data/docs/architecture.md +4 -4
- data/docs/cli.md +19 -4
- data/docs/configuration.md +20 -0
- data/docs/desktop.md +49 -0
- data/docs/guardrails.md +2 -2
- data/docs/hooks.md +2 -2
- data/docs/internals/context-telemetry.md +3 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +7 -5
- data/docs/releasing.md +5 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/archive_store.rb +3 -3
- data/lib/samagotchi/atomic_file.rb +34 -0
- data/lib/samagotchi/bootstrap/bundles.rb +37 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
- data/lib/samagotchi/bootstrap/probe.rb +9 -5
- data/lib/samagotchi/bootstrap_command.rb +78 -8
- data/lib/samagotchi/bridge/pending_card.rb +2 -3
- data/lib/samagotchi/bridge.rb +56 -116
- data/lib/samagotchi/bridge_client.rb +7 -11
- data/lib/samagotchi/bundle_command.rb +81 -21
- data/lib/samagotchi/bundle_needs.rb +2 -11
- data/lib/samagotchi/bundles/core/manifest.yml +11 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
- data/lib/samagotchi/bundles/system/identity.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
- data/lib/samagotchi/client.rb +3 -23
- data/lib/samagotchi/config.rb +11 -11
- data/lib/samagotchi/context_note.rb +1 -1
- data/lib/samagotchi/context_status.rb +255 -0
- data/lib/samagotchi/desktop/macos/App.swift +5 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +7 -63
- data/lib/samagotchi/desktop/macos/Images.swift +38 -3
- data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +233 -55
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
- data/lib/samagotchi/desktop/macos.rb +25 -11
- data/lib/samagotchi/desktop_command.rb +1 -1
- data/lib/samagotchi/engine.rb +6 -7
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/guardrails/approvals.rb +2 -7
- data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
- data/lib/samagotchi/hooks/loader.rb +2 -1
- data/lib/samagotchi/hooks/registry.rb +21 -0
- data/lib/samagotchi/image_store.rb +2 -3
- data/lib/samagotchi/kernel_loop.rb +21 -283
- data/lib/samagotchi/live_versions.rb +9 -13
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/native_backend.rb +8 -8
- data/lib/samagotchi/llm/openai_chat.rb +4 -17
- data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
- data/lib/samagotchi/log_path.rb +2 -3
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
- data/lib/samagotchi/memory_bundle/installer.rb +5 -7
- data/lib/samagotchi/memory_bundle/listing.rb +27 -20
- data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
- data/lib/samagotchi/memory_bundle/profile.rb +145 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
- data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
- data/lib/samagotchi/memory_bundle/source.rb +2 -0
- data/lib/samagotchi/memory_bundle/status.rb +1 -2
- data/lib/samagotchi/memory_bundle.rb +1 -0
- data/lib/samagotchi/note_command.rb +5 -4
- data/lib/samagotchi/owner_lock.rb +0 -6
- data/lib/samagotchi/paths.rb +32 -0
- data/lib/samagotchi/plugin/api.rb +3 -6
- data/lib/samagotchi/plugin/context.rb +2 -3
- data/lib/samagotchi/plugin/loader.rb +1 -1
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/recap_store.rb +2 -2
- data/lib/samagotchi/reply_wait.rb +2 -1
- data/lib/samagotchi/self_report.rb +2 -3
- data/lib/samagotchi/send_command.rb +3 -2
- data/lib/samagotchi/session.rb +41 -143
- data/lib/samagotchi/session_inbox.rb +175 -0
- data/lib/samagotchi/session_manager.rb +62 -363
- data/lib/samagotchi/session_metrics.rb +2 -3
- data/lib/samagotchi/session_retention.rb +259 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
- data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
- data/lib/samagotchi/terminal_ui.rb +10 -20
- data/lib/samagotchi/tools/memory.rb +3 -2
- data/lib/samagotchi/tools/send_note.rb +4 -3
- data/lib/samagotchi/tools/task_runtime.rb +2 -3
- data/lib/samagotchi/turn_note.rb +13 -2
- data/lib/samagotchi/update_command.rb +10 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +44 -76
- data/lib/samagotchi/web/info_probe.rb +33 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +25 -10
- data/lib/samagotchi/web/server.rb +8 -11
- data/lib/samagotchi/web/session_hub.rb +8 -7
- data/lib/samagotchi/web/session_summary.rb +4 -3
- data/lib/samagotchi/web/token.rb +2 -5
- data/lib/samagotchi/worker.rb +34 -20
- data/lib/samagotchi/worker_sidecar.rb +42 -0
- metadata +15 -1
|
@@ -32,17 +32,17 @@ module Samagotchi
|
|
|
32
32
|
pending_input: pending_input
|
|
33
33
|
)
|
|
34
34
|
ModelResult.new(
|
|
35
|
-
text: kernel_result.
|
|
35
|
+
text: kernel_result.output.to_s,
|
|
36
36
|
tool_calls: nil,
|
|
37
37
|
provider: :native,
|
|
38
38
|
usage: usage.usage(prompt_text: Array(messages).sum("") { |message| message[:content].to_s }),
|
|
39
|
-
conversation: kernel_result.
|
|
40
|
-
canceled: kernel_result.
|
|
41
|
-
cancellation_reason: kernel_result.
|
|
42
|
-
exhausted: kernel_result.
|
|
43
|
-
tool_activity: kernel_result.
|
|
44
|
-
context_status: kernel_result.
|
|
45
|
-
pending_tool_calls: kernel_result.
|
|
39
|
+
conversation: kernel_result.conversation,
|
|
40
|
+
canceled: kernel_result.canceled?,
|
|
41
|
+
cancellation_reason: kernel_result.cancellation_reason,
|
|
42
|
+
exhausted: kernel_result.exhausted,
|
|
43
|
+
tool_activity: kernel_result.tool_activity,
|
|
44
|
+
context_status: kernel_result.context_status,
|
|
45
|
+
pending_tool_calls: kernel_result.pending_tool_calls
|
|
46
46
|
)
|
|
47
47
|
end
|
|
48
48
|
end
|
|
@@ -7,6 +7,7 @@ require_relative "../sampling_settings"
|
|
|
7
7
|
require_relative "errors"
|
|
8
8
|
require_relative "http"
|
|
9
9
|
require_relative "usage"
|
|
10
|
+
require_relative "utf8_scrub"
|
|
10
11
|
|
|
11
12
|
module Samagotchi
|
|
12
13
|
module LLM
|
|
@@ -220,8 +221,8 @@ module Samagotchi
|
|
|
220
221
|
SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
|
|
221
222
|
end
|
|
222
223
|
|
|
223
|
-
# A String stays a String
|
|
224
|
-
#
|
|
224
|
+
# A String stays a String, an Array of parts an Array; both scrubbed
|
|
225
|
+
# of invalid UTF-8 (Utf8Scrub).
|
|
225
226
|
def wire_message(message)
|
|
226
227
|
wire = message.to_h.transform_keys(&:to_sym)
|
|
227
228
|
wire[:content] = format_content(wire[:content]) if wire.key?(:content)
|
|
@@ -230,22 +231,8 @@ module Samagotchi
|
|
|
230
231
|
|
|
231
232
|
def format_content(content)
|
|
232
233
|
return content if content.nil?
|
|
233
|
-
return content.map { |part| scrub_part(part) } if content.is_a?(Array)
|
|
234
234
|
|
|
235
|
-
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def scrub_part(part)
|
|
239
|
-
return part unless part.is_a?(Hash)
|
|
240
|
-
|
|
241
|
-
part.to_h do |key, value|
|
|
242
|
-
[key, key.to_s == "text" && value.is_a?(String) ? scrub(value) : value]
|
|
243
|
-
end
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
# Invalid UTF-8 (a tool's garbled output) would make to_json raise.
|
|
247
|
-
def scrub(text)
|
|
248
|
-
text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
|
|
235
|
+
Utf8Scrub.call(content.is_a?(Array) ? content : content.to_s)
|
|
249
236
|
end
|
|
250
237
|
|
|
251
238
|
def chat_once(request, cancel_controller, log_fields)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module LLM
|
|
5
|
+
# Conversation content (system prompt + tool responses + model output)
|
|
6
|
+
# can contain invalid UTF-8, e.g. a shell/file op writes a garbled
|
|
7
|
+
# multibyte sequence (a truncated em-dash, a stray replacement byte).
|
|
8
|
+
# `JSON#to_json` raises `JSON::GeneratorError` on such input, which would
|
|
9
|
+
# abort the whole turn, so a request body is scrubbed first: only
|
|
10
|
+
# offending bytes are replaced with "?", every valid UTF-8 string passes
|
|
11
|
+
# through untouched. Non-UTF-8 encodings are left alone because
|
|
12
|
+
# `#to_json` already handles them without raising.
|
|
13
|
+
module Utf8Scrub
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# +obj+ with every String in it (Array elements, Hash keys and
|
|
17
|
+
# values, at any depth) scrubbed; anything else as is.
|
|
18
|
+
def call(obj)
|
|
19
|
+
case obj
|
|
20
|
+
when String
|
|
21
|
+
obj.encoding == Encoding::UTF_8 && !obj.valid_encoding? ? obj.scrub("?") : obj
|
|
22
|
+
when Array
|
|
23
|
+
obj.map { |element| call(element) }
|
|
24
|
+
when Hash
|
|
25
|
+
obj.each_with_object({}) { |(key, value), memo| memo[call(key)] = call(value) }
|
|
26
|
+
else
|
|
27
|
+
obj
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/samagotchi/log_path.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "config"
|
|
4
|
+
require_relative "paths"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
# Where the debug log (DebugLog) goes, for the REPL, the attached terminal
|
|
@@ -23,9 +24,7 @@ module Samagotchi
|
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
def default_path(env: ENV)
|
|
26
|
-
|
|
27
|
-
base = xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
|
|
28
|
-
File.join(base, "samagotchi", FILENAME)
|
|
27
|
+
File.join(Paths.state_dir(env: env), FILENAME)
|
|
29
28
|
end
|
|
30
29
|
end
|
|
31
30
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "fileutils"
|
|
3
3
|
require "digest"
|
|
4
|
+
require_relative "../atomic_file"
|
|
4
5
|
require_relative "../memory_paths"
|
|
5
6
|
module Samagotchi
|
|
6
7
|
module MemoryBundle
|
|
@@ -66,19 +67,9 @@ module Samagotchi
|
|
|
66
67
|
lock.flock(File::LOCK_EX)
|
|
67
68
|
content = File.exist?(index_path) ? File.read(index_path) : nil
|
|
68
69
|
updated = yield content
|
|
69
|
-
|
|
70
|
+
Samagotchi::AtomicFile.write(index_path, updated) unless updated.nil?
|
|
70
71
|
end
|
|
71
72
|
end
|
|
72
|
-
# Write aside and rename, so a reader never sees a half-written file. The
|
|
73
|
-
# temp name must not end in .md, or it would be listed as a memory. A
|
|
74
|
-
# symlinked target is replaced by a regular file (accepted).
|
|
75
|
-
def self.atomic_write(path, body)
|
|
76
|
-
tmp_path = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
77
|
-
File.write(tmp_path, body)
|
|
78
|
-
File.rename(tmp_path, path)
|
|
79
|
-
ensure
|
|
80
|
-
FileUtils.rm_f(tmp_path) if tmp_path
|
|
81
|
-
end
|
|
82
73
|
def self.managed_line(name, scope, byte_count, description)
|
|
83
74
|
line = "- **#{name}** · #{scope} · #{date_str} · #{byte_count}"
|
|
84
75
|
desc = description&.to_s&.strip
|
|
@@ -180,10 +180,9 @@ module Samagotchi
|
|
|
180
180
|
if @upgrade && existing_provenance && File.exist?(dest) && !@force
|
|
181
181
|
prev_meta = existing_provenance[:hooks] ? (existing_provenance[:hooks][basename.to_sym] || existing_provenance[:hooks][basename]) : nil
|
|
182
182
|
if prev_meta
|
|
183
|
-
prev_sha =
|
|
183
|
+
prev_sha = prev_meta[:sha256] || prev_meta["sha256"]
|
|
184
184
|
if File.exist?(dest)
|
|
185
|
-
|
|
186
|
-
if cur_sha != prev_sha && !prev_sha.empty?
|
|
185
|
+
if !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
|
|
187
186
|
@warnings << "Hook #{basename} was locally modified; overwriting"
|
|
188
187
|
end
|
|
189
188
|
end
|
|
@@ -441,10 +440,9 @@ module Samagotchi
|
|
|
441
440
|
# Whether the file on disk is what the previous install wrote: its
|
|
442
441
|
# recorded checksum, else the base snapshot. Unknown counts as edited.
|
|
443
442
|
def installed_unchanged?(target, meta, base_path)
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
File.exist?(base_path) && current == Digest::SHA256.hexdigest(File.read(base_path))
|
|
443
|
+
recorded = meta.is_a?(Hash) ? meta[:checksum] || meta["checksum"] : nil
|
|
444
|
+
return Provenance.sha_matches?(target, recorded) unless Provenance.recorded_sha(recorded).empty?
|
|
445
|
+
File.exist?(base_path) && Provenance.file_sha(target) == Provenance.file_sha(base_path)
|
|
448
446
|
end
|
|
449
447
|
|
|
450
448
|
def remove_target_index(scope, file_key)
|
|
@@ -11,11 +11,15 @@ module Samagotchi
|
|
|
11
11
|
# each with the shipped version when chi ships a newer one, and the
|
|
12
12
|
# bundles shipped with chi that aren't installed yet. The system bundle
|
|
13
13
|
# is left out of the shipped ones: chi installs and upgrades it itself.
|
|
14
|
+
# A profile (a meta bundle) that isn't installed lists its members on
|
|
15
|
+
# its own line, and they aren't listed again.
|
|
14
16
|
module Listing
|
|
15
|
-
# error is set (and the rest nil) when the provenance manifest.json
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
# error is set (and the rest nil) when the provenance manifest.json
|
|
18
|
+
# can't be read; includes is a profile's recorded members, else nil.
|
|
19
|
+
Installed = Struct.new(:name, :version, :scope, :files, :installed_at, :upgrade, :error, :includes, keyword_init: true)
|
|
20
|
+
# source is the name `chi bundle install` takes (the shipped dir's
|
|
21
|
+
# name); includes is a profile's members ([] for a plain bundle).
|
|
22
|
+
Shipped = Struct.new(:name, :source, :version, :description, :includes, keyword_init: true)
|
|
19
23
|
|
|
20
24
|
module_function
|
|
21
25
|
|
|
@@ -28,7 +32,7 @@ module Samagotchi
|
|
|
28
32
|
next unless File.file?(File.join(dir, source, "manifest.yml"))
|
|
29
33
|
m = Manifest.read(dir: File.join(dir, source))
|
|
30
34
|
next if m.name == SystemBundle::BUNDLE_NAME
|
|
31
|
-
Shipped.new(name: m.name, source: source, version: m.version, description: m.description)
|
|
35
|
+
Shipped.new(name: m.name, source: source, version: m.version, description: m.description, includes: m.includes)
|
|
32
36
|
rescue Manifest::ValidationError, Psych::Exception
|
|
33
37
|
nil
|
|
34
38
|
end.sort_by(&:name)
|
|
@@ -36,30 +40,33 @@ module Samagotchi
|
|
|
36
40
|
|
|
37
41
|
# @return [Array<Installed>] by name; upgrade is the Shipped when it is newer
|
|
38
42
|
def installed(shipped: self.shipped)
|
|
39
|
-
dir = Provenance.bundles_dir
|
|
40
|
-
return [] unless File.directory?(dir)
|
|
41
|
-
|
|
42
43
|
by_name = shipped.to_h { |s| [s.name, s] }
|
|
43
|
-
|
|
44
|
-
|
|
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)
|
|
44
|
+
Provenance.each_installed.map do |name, data|
|
|
45
|
+
next Installed.new(name: name, error: "manifest.json unreadable") if data[:error]
|
|
51
46
|
|
|
52
47
|
ship = by_name[name]
|
|
53
48
|
Installed.new(name: name, version: data[:version], scope: data[:scope],
|
|
54
49
|
files: (data[:files] || {}).size, installed_at: data[:installed_at],
|
|
55
|
-
upgrade: ship && newer?(ship.version, data[:version]) ? ship : nil
|
|
50
|
+
upgrade: ship && newer?(ship.version, data[:version]) ? ship : nil,
|
|
51
|
+
includes: data[:includes].is_a?(Array) ? data[:includes].map(&:to_s) : nil)
|
|
56
52
|
end
|
|
57
53
|
end
|
|
58
54
|
|
|
59
|
-
# The shipped bundles not installed under their manifest name
|
|
55
|
+
# The shipped bundles not installed under their manifest name, a
|
|
56
|
+
# profile's members folded into it (grouped).
|
|
60
57
|
def available(shipped: self.shipped, installed: self.installed(shipped: shipped))
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
grouped(shipped, installed.map(&:name))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The shipped bundles not in +installed_names+; a profile among them
|
|
62
|
+
# keeps only its members not installed, and those aren't listed
|
|
63
|
+
# again on their own.
|
|
64
|
+
def grouped(shipped, installed_names)
|
|
65
|
+
left = shipped.reject { |s| installed_names.include?(s.name) }
|
|
66
|
+
folded = left.flat_map(&:includes)
|
|
67
|
+
left.reject { |s| folded.include?(s.name) }.map do |s|
|
|
68
|
+
s.includes.empty? ? s : s.dup.tap { |d| d.includes = s.includes - installed_names }
|
|
69
|
+
end
|
|
63
70
|
end
|
|
64
71
|
|
|
65
72
|
def newer?(candidate, current)
|
|
@@ -25,6 +25,9 @@ module Samagotchi
|
|
|
25
25
|
# why: reads PRs # optional
|
|
26
26
|
# hint: brew install gh # optional
|
|
27
27
|
# - jq # short form: just the command
|
|
28
|
+
# includes: [a, b] # optional — a profile (meta bundle): the
|
|
29
|
+
# # shipped bundles it installs; its dir holds
|
|
30
|
+
# # only manifest.yml (MemoryBundle::Profile)
|
|
28
31
|
class Manifest
|
|
29
32
|
class ValidationError < StandardError; end
|
|
30
33
|
|
|
@@ -32,8 +35,10 @@ module Samagotchi
|
|
|
32
35
|
PLUGIN_FILE = /\A[A-Za-z0-9_][A-Za-z0-9_.-]*\.rb\z/
|
|
33
36
|
# A need is a plain executable name: no path, no spaces.
|
|
34
37
|
NEED_COMMAND = /\A[A-Za-z0-9][A-Za-z0-9._+-]*\z/
|
|
38
|
+
# A bundle name, as `chi bundle install <name>` takes a shipped one.
|
|
39
|
+
BUNDLE_NAME = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
35
40
|
|
|
36
|
-
attr_reader :name, :version, :scope, :description, :files, :hooks, :trust_level, :plugin, :requires_chi, :needs
|
|
41
|
+
attr_reader :name, :version, :scope, :description, :files, :hooks, :trust_level, :plugin, :requires_chi, :needs, :includes
|
|
37
42
|
|
|
38
43
|
def initialize(path:)
|
|
39
44
|
@path = path
|
|
@@ -48,6 +53,12 @@ module Samagotchi
|
|
|
48
53
|
@plugin = parse_plugin(raw["plugin"])
|
|
49
54
|
@requires_chi = parse_requires_chi(raw["requires_chi"])
|
|
50
55
|
@needs = self.class.parse_needs(raw["needs"])
|
|
56
|
+
@includes = parse_includes(raw["includes"])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A profile: it installs the bundles in includes instead of files.
|
|
60
|
+
def meta?
|
|
61
|
+
!@includes.empty?
|
|
51
62
|
end
|
|
52
63
|
|
|
53
64
|
def self.read(dir:)
|
|
@@ -194,6 +205,26 @@ module Samagotchi
|
|
|
194
205
|
{ file: file, sha256: sha }
|
|
195
206
|
end
|
|
196
207
|
|
|
208
|
+
# includes: → the names in order, duplicates dropped; [] when absent.
|
|
209
|
+
# A meta ships nothing of its own, so files, hooks or a plugin next
|
|
210
|
+
# to it is a ValidationError.
|
|
211
|
+
def parse_includes(raw)
|
|
212
|
+
return [] if raw.nil?
|
|
213
|
+
raise ValidationError, "includes: must be a list of bundle names" unless raw.is_a?(Array)
|
|
214
|
+
|
|
215
|
+
names = raw.map do |item|
|
|
216
|
+
name = item.to_s.strip
|
|
217
|
+
raise ValidationError, "includes: #{item.inspect} is not a bundle name" unless name.match?(BUNDLE_NAME)
|
|
218
|
+
|
|
219
|
+
name
|
|
220
|
+
end.uniq
|
|
221
|
+
if !names.empty? && (!@files.empty? || !@hooks.empty? || @plugin)
|
|
222
|
+
raise ValidationError, "a bundle with includes: holds only its includes (no files, hooks or plugin)"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
names
|
|
226
|
+
end
|
|
227
|
+
|
|
197
228
|
def parse_requires_chi(raw)
|
|
198
229
|
return nil if raw.nil?
|
|
199
230
|
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "installer"
|
|
4
|
+
require_relative "manifest"
|
|
5
|
+
require_relative "provenance"
|
|
6
|
+
require_relative "source"
|
|
7
|
+
require_relative "uninstaller"
|
|
8
|
+
require_relative "../version"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
module MemoryBundle
|
|
12
|
+
# Profiles: a shipped meta bundle (a dir holding only manifest.yml, with
|
|
13
|
+
# includes:) installs a set of shipped bundles. Every entry point (chi
|
|
14
|
+
# bundle install/upgrade/uninstall, chi update, chi bootstrap) goes
|
|
15
|
+
# through here, and all of them follow one rule:
|
|
16
|
+
#
|
|
17
|
+
# to_install = shipped includes - recorded includes - installed bundles
|
|
18
|
+
#
|
|
19
|
+
# The meta's provenance records the members it installed or found
|
|
20
|
+
# installed, so a member the user uninstalls stays out, re-running is a
|
|
21
|
+
# no-op, and a bundle new to the profile is the only one installed. A
|
|
22
|
+
# member this chi is too old for, or one that fails, isn't recorded and
|
|
23
|
+
# is tried again next time. Members come from the shipped dir, never
|
|
24
|
+
# from a name lookup, so they stay updatable by chi update. Only a
|
|
25
|
+
# shipped meta expands: includes: in any other bundle is ignored.
|
|
26
|
+
module Profile
|
|
27
|
+
# installed: members installed now; already: members found installed
|
|
28
|
+
# (recorded, not reinstalled); skipped and failed: name => why.
|
|
29
|
+
InstallResult = Struct.new(:name, :version, :installed, :already, :skipped, :failed, keyword_init: true) do
|
|
30
|
+
def failed? = !failed.empty?
|
|
31
|
+
end
|
|
32
|
+
# removed: members uninstalled; blocked: name => why; gone: the meta
|
|
33
|
+
# itself was removed (only once every member is).
|
|
34
|
+
UninstallResult = Struct.new(:name, :removed, :blocked, :gone, keyword_init: true)
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Whether +dir+ is a meta bundle in the shipped dir.
|
|
39
|
+
def shipped_meta?(dir, shipped_dir: SourceNormalizer::SHIPPED_DIR)
|
|
40
|
+
return false unless File.expand_path(File.dirname(dir.to_s)) == File.expand_path(shipped_dir)
|
|
41
|
+
|
|
42
|
+
Manifest.read(dir: dir).meta?
|
|
43
|
+
rescue Manifest::ValidationError, Psych::Exception, SystemCallError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether the installed bundle +name+ (its provenance +data+) is a
|
|
48
|
+
# meta: it records includes, or it came from chi and chi ships it as
|
|
49
|
+
# a meta.
|
|
50
|
+
def installed_meta?(name, data, shipped_dir: SourceNormalizer::SHIPPED_DIR)
|
|
51
|
+
return false unless data.is_a?(Hash)
|
|
52
|
+
return true if data[:includes].is_a?(Array)
|
|
53
|
+
|
|
54
|
+
data[:source].to_s.match?(SourceNormalizer::SHIPPED_SOURCE) && shipped_meta?(File.join(shipped_dir, name), shipped_dir: shipped_dir)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The members the meta has recorded: none when it isn't installed,
|
|
58
|
+
# all of +manifest+'s (the shipped meta, nil when chi ships none) when
|
|
59
|
+
# its provenance has no includes: an unknown record is taken as
|
|
60
|
+
# everything.
|
|
61
|
+
def recorded(data, manifest)
|
|
62
|
+
return [] unless data
|
|
63
|
+
return manifest&.includes || [] unless data[:includes].is_a?(Array)
|
|
64
|
+
|
|
65
|
+
data[:includes].map(&:to_s)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def installed_names = Provenance.each_installed.map { |name, _| name }
|
|
69
|
+
|
|
70
|
+
# The one rule.
|
|
71
|
+
def to_install(manifest, data = Provenance.new(name: manifest.name).read, installed: installed_names)
|
|
72
|
+
manifest.includes - recorded(data, manifest) - installed
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Installs the shipped meta at +dir+: its to_install members from the
|
|
76
|
+
# shipped dir, then its provenance (the recorded members plus the
|
|
77
|
+
# ones installed now or found installed). Never raises for a member.
|
|
78
|
+
# @return [InstallResult]
|
|
79
|
+
def install(dir, shipped_dir: SourceNormalizer::SHIPPED_DIR, chi_version: Samagotchi::VERSION, dry_run: false, force: false)
|
|
80
|
+
manifest = Manifest.read(dir: dir)
|
|
81
|
+
data = Provenance.new(name: manifest.name).read
|
|
82
|
+
was = recorded(data, manifest)
|
|
83
|
+
installed = installed_names
|
|
84
|
+
result = InstallResult.new(name: manifest.name, version: manifest.version, installed: [],
|
|
85
|
+
already: (manifest.includes - was) & installed, skipped: {}, failed: {})
|
|
86
|
+
|
|
87
|
+
to_install(manifest, data, installed: installed).each do |member|
|
|
88
|
+
install_member(member, shipped_dir, chi_version, dry_run, force, result)
|
|
89
|
+
end
|
|
90
|
+
return result if dry_run
|
|
91
|
+
|
|
92
|
+
now = was | result.installed | result.already
|
|
93
|
+
includes = (manifest.includes & now) + (now - manifest.includes)
|
|
94
|
+
# Nothing new: the record stays as it is (installed_at too).
|
|
95
|
+
return result if data && data[:version] == manifest.version && data[:source] == dir && data[:includes] == includes
|
|
96
|
+
|
|
97
|
+
Provenance.new(name: manifest.name).write(
|
|
98
|
+
files: {}, scope: "system", version: manifest.version, source_path: dir,
|
|
99
|
+
trust_level: manifest.trust_level, requires_chi: manifest.requires_chi, includes: includes
|
|
100
|
+
)
|
|
101
|
+
result
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def install_member(member, shipped_dir, chi_version, dry_run, force, result)
|
|
105
|
+
member_dir = File.join(shipped_dir, member)
|
|
106
|
+
failure = Manifest.requires_chi_failure(Manifest.read(dir: member_dir).requires_chi, chi_version)
|
|
107
|
+
return result.skipped[member] = failure if failure
|
|
108
|
+
return result.installed << member if dry_run
|
|
109
|
+
|
|
110
|
+
Installer.new(source: member_dir, name: member, scope: "system", force: force, strict: true).run
|
|
111
|
+
result.installed << member
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
result.failed[member] = e.message.lines.first.to_s.strip
|
|
114
|
+
end
|
|
115
|
+
private_class_method :install_member
|
|
116
|
+
|
|
117
|
+
# Uninstalls each recorded member still installed, then the meta. A
|
|
118
|
+
# member that won't go (an edited .md file, without +force+) is
|
|
119
|
+
# reported and the rest carry on; the meta then stays, still
|
|
120
|
+
# recording it.
|
|
121
|
+
# @return [UninstallResult]
|
|
122
|
+
# @raise [Uninstaller::UninstallError] when +name+ isn't installed
|
|
123
|
+
def uninstall(name, shipped_dir: SourceNormalizer::SHIPPED_DIR, force: false)
|
|
124
|
+
data = Provenance.new(name: name).read
|
|
125
|
+
raise Uninstaller::UninstallError, "Bundle '#{name}' is not installed" unless data
|
|
126
|
+
|
|
127
|
+
shipped = File.join(shipped_dir, name)
|
|
128
|
+
manifest = File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil
|
|
129
|
+
members = recorded(data, manifest)
|
|
130
|
+
result = UninstallResult.new(name: name, removed: [], blocked: {}, gone: false)
|
|
131
|
+
(members & installed_names).each do |member|
|
|
132
|
+
Uninstaller.new(name: member, force: force).run
|
|
133
|
+
result.removed << member
|
|
134
|
+
rescue Uninstaller::UninstallError => e
|
|
135
|
+
result.blocked[member] = e.message
|
|
136
|
+
end
|
|
137
|
+
return result unless result.blocked.empty?
|
|
138
|
+
|
|
139
|
+
Uninstaller.new(name: name, force: force).run
|
|
140
|
+
result.gone = true
|
|
141
|
+
result
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "date"
|
|
6
6
|
require "fileutils"
|
|
7
|
+
require_relative "../atomic_file"
|
|
7
8
|
require_relative "../memory_paths"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
@@ -53,65 +54,54 @@ module Samagotchi
|
|
|
53
54
|
File.join(@bundle_dir, "bases", "plugin", file.to_s)
|
|
54
55
|
end
|
|
55
56
|
|
|
56
|
-
# Yields [name, data] for each installed bundle
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
# Yields [name, data] for each installed bundle (a dir under
|
|
58
|
+
# bundles_dir holding a manifest.json, dot dirs aside), by name. data
|
|
59
|
+
# is the manifest with symbol keys, or {error:} when it doesn't parse
|
|
60
|
+
# or isn't an object; the rest still come.
|
|
61
|
+
#
|
|
62
|
+
# With holding: (:hooks, :guardrails or :plugin) only the bundles
|
|
63
|
+
# whose manifest has a non-empty mapping under that key come, and a
|
|
64
|
+
# manifest that doesn't parse comes as {error:} only when its bundle
|
|
65
|
+
# has that dir (hooks/, guardrails/, plugin/): what it would load
|
|
66
|
+
# can't be checked. One that isn't an object is skipped there.
|
|
67
|
+
# @param dir [String] the bundles dir (bundles_dir by default)
|
|
68
|
+
def self.each_installed(holding: nil, dir: bundles_dir)
|
|
69
|
+
return enum_for(:each_installed, holding: holding, dir: dir) unless block_given?
|
|
62
70
|
return unless Dir.exist?(dir)
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
|
|
72
|
+
Dir.children(dir).reject { |e| e.start_with?(".") }.sort.each do |name|
|
|
73
|
+
mjson = File.join(dir, name, "manifest.json")
|
|
74
|
+
next unless File.exist?(mjson)
|
|
75
|
+
|
|
65
76
|
begin
|
|
66
77
|
data = JSON.parse(File.read(mjson), symbolize_names: true)
|
|
67
78
|
rescue JSON::ParserError, SystemCallError => e
|
|
68
|
-
yield name, { error: "manifest.json is unreadable: #{e.message}" } if Dir.exist?(File.join(
|
|
79
|
+
yield name, { error: "manifest.json is unreadable: #{e.message}" } if holding.nil? || Dir.exist?(File.join(dir, name, holding.to_s))
|
|
69
80
|
next
|
|
70
81
|
end
|
|
71
|
-
|
|
82
|
+
if holding
|
|
83
|
+
next unless data.is_a?(Hash) && data[holding].is_a?(Hash) && !data[holding].empty?
|
|
84
|
+
elsif !data.is_a?(Hash)
|
|
85
|
+
data = { error: "manifest.json is not an object" }
|
|
86
|
+
end
|
|
72
87
|
yield name, data
|
|
73
88
|
end
|
|
74
89
|
end
|
|
75
90
|
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return enum_for(:each_installed_with_guardrails) unless block_given?
|
|
82
|
-
dir = bundles_dir
|
|
83
|
-
return unless Dir.exist?(dir)
|
|
84
|
-
Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
|
|
85
|
-
name = File.basename(File.dirname(mjson))
|
|
86
|
-
begin
|
|
87
|
-
data = JSON.parse(File.read(mjson), symbolize_names: true)
|
|
88
|
-
rescue JSON::ParserError, SystemCallError => e
|
|
89
|
-
yield name, { error: "manifest.json is unreadable: #{e.message}" } if Dir.exist?(File.join(File.dirname(mjson), "guardrails"))
|
|
90
|
-
next
|
|
91
|
-
end
|
|
92
|
-
next unless data.is_a?(Hash) && data[:guardrails].is_a?(Hash) && !data[:guardrails].empty?
|
|
93
|
-
yield name, data
|
|
94
|
-
end
|
|
91
|
+
# The hex digest a manifest records, in either of its formats:
|
|
92
|
+
# "sha256:<hex>" (hooks, rules, the plugin) or bare <hex> (a memory
|
|
93
|
+
# file's checksum:). "" when none is recorded.
|
|
94
|
+
def self.recorded_sha(value)
|
|
95
|
+
value.to_s.delete_prefix("sha256:")
|
|
95
96
|
end
|
|
96
97
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
|
|
105
|
-
name = File.basename(File.dirname(mjson))
|
|
106
|
-
begin
|
|
107
|
-
data = JSON.parse(File.read(mjson), symbolize_names: true)
|
|
108
|
-
rescue JSON::ParserError, SystemCallError => e
|
|
109
|
-
yield name, { error: "manifest.json is unreadable: #{e.message}" } if Dir.exist?(File.join(File.dirname(mjson), "hooks"))
|
|
110
|
-
next
|
|
111
|
-
end
|
|
112
|
-
next unless data.is_a?(Hash) && data[:hooks].is_a?(Hash) && !data[:hooks].empty?
|
|
113
|
-
yield name, data
|
|
114
|
-
end
|
|
98
|
+
def self.file_sha(path)
|
|
99
|
+
Digest::SHA256.hexdigest(File.binread(path))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Whether the file's content is the one recorded (either format).
|
|
103
|
+
def self.sha_matches?(path, recorded)
|
|
104
|
+
file_sha(path) == recorded_sha(recorded)
|
|
115
105
|
end
|
|
116
106
|
|
|
117
107
|
# Writes provenance for an installation: merges into existing manifest.json
|
|
@@ -127,8 +117,10 @@ module Samagotchi
|
|
|
127
117
|
# @param needs [Array<Hash>] the manifest's needs ({command:, why:, hint:})
|
|
128
118
|
# @param conflicts [Array<String>] files an upgrade kept with local
|
|
129
119
|
# edits that conflict with it; their entries get conflict: true
|
|
120
|
+
# @param includes [Array<String>, nil] a profile's recorded members
|
|
121
|
+
# (MemoryBundle::Profile); nil for a plain bundle
|
|
130
122
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
131
|
-
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [])
|
|
123
|
+
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [], includes: nil)
|
|
132
124
|
FileUtils.mkdir_p(@bundle_dir)
|
|
133
125
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
134
126
|
FileUtils.mkdir_p(bases_dir)
|
|
@@ -237,6 +229,7 @@ module Samagotchi
|
|
|
237
229
|
end
|
|
238
230
|
manifest_data["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
|
|
239
231
|
manifest_data["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } if needs.is_a?(Array) && !needs.empty?
|
|
232
|
+
manifest_data["includes"] = includes.map(&:to_s) if includes.is_a?(Array)
|
|
240
233
|
|
|
241
234
|
write_manifest(manifest_data)
|
|
242
235
|
end
|
|
@@ -274,11 +267,7 @@ module Samagotchi
|
|
|
274
267
|
# chi start) never parses a truncated manifest.json.
|
|
275
268
|
def write_manifest(manifest_data)
|
|
276
269
|
manifest_path = File.join(@bundle_dir, "manifest.json")
|
|
277
|
-
|
|
278
|
-
File.write(tmp_path, JSON.pretty_generate(manifest_data))
|
|
279
|
-
File.rename(tmp_path, manifest_path)
|
|
280
|
-
ensure
|
|
281
|
-
FileUtils.rm_f(tmp_path) if tmp_path
|
|
270
|
+
AtomicFile.write(manifest_path, JSON.pretty_generate(manifest_data))
|
|
282
271
|
end
|
|
283
272
|
private :write_manifest
|
|
284
273
|
|