samagotchi 0.7.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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +7 -3
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +19 -4
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +2 -2
  8. data/docs/internals/context-telemetry.md +3 -1
  9. data/docs/memory.md +48 -1
  10. data/docs/plugins.md +7 -5
  11. data/docs/releasing.md +5 -1
  12. data/docs/sessions.md +2 -2
  13. data/lib/samagotchi/archive_store.rb +3 -3
  14. data/lib/samagotchi/atomic_file.rb +34 -0
  15. data/lib/samagotchi/bootstrap/bundles.rb +37 -0
  16. data/lib/samagotchi/bootstrap/config_writer.rb +4 -6
  17. data/lib/samagotchi/bootstrap/probe.rb +9 -5
  18. data/lib/samagotchi/bootstrap_command.rb +78 -8
  19. data/lib/samagotchi/bridge/pending_card.rb +2 -3
  20. data/lib/samagotchi/bridge.rb +56 -116
  21. data/lib/samagotchi/bridge_client.rb +7 -11
  22. data/lib/samagotchi/bundle_command.rb +81 -21
  23. data/lib/samagotchi/bundle_needs.rb +2 -11
  24. data/lib/samagotchi/bundles/core/manifest.yml +11 -0
  25. data/lib/samagotchi/bundles/dev/manifest.yml +13 -0
  26. data/lib/samagotchi/bundles/system/identity.md +1 -0
  27. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/system/memory_guide.md +3 -2
  29. data/lib/samagotchi/client.rb +3 -23
  30. data/lib/samagotchi/config.rb +6 -11
  31. data/lib/samagotchi/context_note.rb +1 -1
  32. data/lib/samagotchi/context_status.rb +255 -0
  33. data/lib/samagotchi/desktop/macos.rb +2 -3
  34. data/lib/samagotchi/engine.rb +6 -7
  35. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  36. data/lib/samagotchi/guardrails/approvals.rb +2 -7
  37. data/lib/samagotchi/hooks/bundle_loader.rb +6 -3
  38. data/lib/samagotchi/hooks/loader.rb +2 -1
  39. data/lib/samagotchi/hooks/registry.rb +21 -0
  40. data/lib/samagotchi/image_store.rb +2 -3
  41. data/lib/samagotchi/kernel_loop.rb +21 -283
  42. data/lib/samagotchi/live_versions.rb +9 -13
  43. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  44. data/lib/samagotchi/llm/native_backend.rb +8 -8
  45. data/lib/samagotchi/llm/openai_chat.rb +4 -17
  46. data/lib/samagotchi/llm/utf8_scrub.rb +32 -0
  47. data/lib/samagotchi/log_path.rb +2 -3
  48. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -11
  49. data/lib/samagotchi/memory_bundle/installer.rb +5 -7
  50. data/lib/samagotchi/memory_bundle/listing.rb +27 -20
  51. data/lib/samagotchi/memory_bundle/manifest.rb +32 -1
  52. data/lib/samagotchi/memory_bundle/profile.rb +145 -0
  53. data/lib/samagotchi/memory_bundle/provenance.rb +42 -53
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -27
  55. data/lib/samagotchi/memory_bundle/source.rb +2 -0
  56. data/lib/samagotchi/memory_bundle/status.rb +1 -2
  57. data/lib/samagotchi/memory_bundle.rb +1 -0
  58. data/lib/samagotchi/note_command.rb +5 -4
  59. data/lib/samagotchi/owner_lock.rb +0 -6
  60. data/lib/samagotchi/paths.rb +32 -0
  61. data/lib/samagotchi/plugin/api.rb +3 -6
  62. data/lib/samagotchi/plugin/context.rb +2 -3
  63. data/lib/samagotchi/plugin/loader.rb +1 -1
  64. data/lib/samagotchi/question_desk.rb +3 -2
  65. data/lib/samagotchi/recap_store.rb +2 -2
  66. data/lib/samagotchi/reply_wait.rb +2 -1
  67. data/lib/samagotchi/self_report.rb +2 -3
  68. data/lib/samagotchi/send_command.rb +3 -2
  69. data/lib/samagotchi/session.rb +41 -143
  70. data/lib/samagotchi/session_inbox.rb +175 -0
  71. data/lib/samagotchi/session_manager.rb +62 -363
  72. data/lib/samagotchi/session_metrics.rb +2 -3
  73. data/lib/samagotchi/session_retention.rb +259 -0
  74. data/lib/samagotchi/sessions_command.rb +3 -2
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -2
  76. data/lib/samagotchi/terminal_ui/input_support.rb +2 -9
  77. data/lib/samagotchi/terminal_ui.rb +10 -20
  78. data/lib/samagotchi/tools/memory.rb +3 -2
  79. data/lib/samagotchi/tools/send_note.rb +4 -3
  80. data/lib/samagotchi/tools/task_runtime.rb +2 -3
  81. data/lib/samagotchi/turn_note.rb +13 -2
  82. data/lib/samagotchi/update_command.rb +9 -2
  83. data/lib/samagotchi/version.rb +1 -1
  84. data/lib/samagotchi/web/app.rb +44 -76
  85. data/lib/samagotchi/web/info_probe.rb +33 -0
  86. data/lib/samagotchi/web/public/app.js +1 -1
  87. data/lib/samagotchi/web/public/index.html +1 -0
  88. data/lib/samagotchi/web/public/stage_model.js +22 -3
  89. data/lib/samagotchi/web/public/stage_view.js +25 -10
  90. data/lib/samagotchi/web/server.rb +8 -11
  91. data/lib/samagotchi/web/session_hub.rb +8 -7
  92. data/lib/samagotchi/web/session_summary.rb +4 -3
  93. data/lib/samagotchi/web/token.rb +2 -5
  94. data/lib/samagotchi/worker.rb +34 -20
  95. data/lib/samagotchi/worker_sidecar.rb +42 -0
  96. metadata +13 -1
@@ -32,17 +32,17 @@ module Samagotchi
32
32
  pending_input: pending_input
33
33
  )
34
34
  ModelResult.new(
35
- text: kernel_result.respond_to?(:output) ? kernel_result.output.to_s : kernel_result.to_s,
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.respond_to?(:conversation) ? kernel_result.conversation : nil,
40
- canceled: kernel_result.respond_to?(:canceled?) && kernel_result.canceled?,
41
- cancellation_reason: kernel_result.respond_to?(:cancellation_reason) ? kernel_result.cancellation_reason : nil,
42
- exhausted: kernel_result.respond_to?(:exhausted) && kernel_result.exhausted,
43
- tool_activity: kernel_result.respond_to?(:tool_activity) ? kernel_result.tool_activity : [],
44
- context_status: kernel_result.respond_to?(:context_status) ? kernel_result.context_status : nil,
45
- pending_tool_calls: kernel_result.respond_to?(:pending_tool_calls) && kernel_result.pending_tool_calls
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; an Array of parts passes as given, its text
224
- # parts scrubbed like a String.
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
- scrub(content.to_s)
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
@@ -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
- xdg = env.fetch("XDG_STATE_HOME", "").to_s.strip
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
- atomic_write(index_path, updated) unless updated.nil?
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 = (prev_meta[:sha256] || prev_meta["sha256"] || "").to_s.sub(/\Asha256:/, "")
183
+ prev_sha = prev_meta[:sha256] || prev_meta["sha256"]
184
184
  if File.exist?(dest)
185
- cur_sha = Digest::SHA256.hexdigest(File.read(dest))
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
- current = Digest::SHA256.hexdigest(File.read(target))
445
- recorded = meta.is_a?(Hash) ? (meta[:checksum] || meta["checksum"]).to_s.sub(/\Asha256:/, "") : ""
446
- return current == recorded unless recorded.empty?
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 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)
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
- 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)
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
- names = installed.map(&:name)
62
- shipped.reject { |s| names.include?(s.name) }
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 with a plugin, by
57
- # name. A manifest that doesn't parse is yielded as {error:} when its
58
- # bundle has a plugin/ dir.
59
- def self.each_installed_with_plugin
60
- return enum_for(:each_installed_with_plugin) unless block_given?
61
- dir = bundles_dir
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
- Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
64
- name = File.basename(File.dirname(mjson))
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(File.dirname(mjson), "plugin"))
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
- next unless data.is_a?(Hash) && data[:plugin].is_a?(Hash)
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
- # Yields [name, data] for each installed bundle with guardrail rule
77
- # files, by name. A manifest that doesn't parse is yielded as
78
- # {error:} when its bundle has a guardrails/ dir (its rules can't be
79
- # checked), and skipped otherwise.
80
- def self.each_installed_with_guardrails
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
- # Yields [name, data] for each installed bundle with hooks, by name.
98
- # A manifest that doesn't parse is yielded as {error:} when its
99
- # bundle has a hooks/ dir, and skipped otherwise; the rest still load.
100
- def self.each_installed_holding_hooks
101
- return enum_for(:each_installed_holding_hooks) unless block_given?
102
- dir = self.bundles_dir
103
- return unless Dir.exist?(dir)
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
- tmp_path = "#{manifest_path}.#{Process.pid}.tmp"
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