samagotchi 0.52.0 → 0.54.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 +58 -1
- data/README.md +1 -1
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +1 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +8 -5
- data/docs/internals/engine.md +16 -7
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -3
- data/docs/sessions.md +1 -1
- data/docs/testing.md +5 -3
- data/lib/samagotchi/analytics_file.rb +35 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundle_command.rb +35 -44
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +0 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +132 -135
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_registry.rb +13 -0
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +184 -190
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +4 -28
- data/lib/samagotchi/session_commands.rb +32 -4
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +45 -257
- data/lib/samagotchi/session_metrics.rb +18 -21
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +25 -3
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -8
- data/lib/samagotchi/web/public/activity.js +2 -0
- data/lib/samagotchi/web/public/app.js +6 -8
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/index.html +4 -2
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +5 -3
- data/lib/samagotchi/web/public/thinking_chip.js +31 -21
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_html.js +10 -6
- metadata +9 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "tmpdir"
|
|
6
|
+
require_relative "bundle_hook"
|
|
6
7
|
require_relative "manifest"
|
|
7
8
|
require_relative "placeholder"
|
|
8
9
|
require_relative "../context_providers"
|
|
@@ -22,6 +23,16 @@ module Samagotchi
|
|
|
22
23
|
DEFAULT_VERSION = "1.0.0"
|
|
23
24
|
DEFAULT_SYSTEM_NAME = "chi_system_memories"
|
|
24
25
|
|
|
26
|
+
# What a build takes from the installed bundle of the same name (its
|
|
27
|
+
# record and its dir), else from the scope dir:
|
|
28
|
+
# record the InstalledBundle, nil when none is installed (or
|
|
29
|
+
# it doesn't read)
|
|
30
|
+
# hooks basename => BundleHook; hook_files basename => path
|
|
31
|
+
# rules guardrails/*.yml paths
|
|
32
|
+
# plugin the installed plugin file, nil without one
|
|
33
|
+
# scripts file name => path; context_providers the parsed list
|
|
34
|
+
InstalledParts = Data.define(:record, :hooks, :hook_files, :rules, :plugin, :scripts, :context_providers)
|
|
35
|
+
|
|
25
36
|
attr_reader :built_files, :warnings, :placeholder_warnings, :out_path, :staging_dir
|
|
26
37
|
|
|
27
38
|
def initialize(scope: nil, name: nil, version: nil, description: "", out: nil, files: nil, trust_level: nil)
|
|
@@ -50,242 +61,225 @@ module Samagotchi
|
|
|
50
61
|
validate_version!(resolved_version)
|
|
51
62
|
raise BuildError, "invalid scope: #{@scope}" unless %w[system project].include?(@scope)
|
|
52
63
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
candidates =
|
|
56
|
-
|
|
64
|
+
refuse_profile!(resolved_name)
|
|
65
|
+
|
|
66
|
+
candidates = select_memories(target_dir, resolved_name)
|
|
67
|
+
files_map = checksum_memories(candidates)
|
|
68
|
+
|
|
69
|
+
parts = installed_parts(resolved_name, target_dir)
|
|
70
|
+
resolved_out, format = resolve_out_path(@out, resolved_name)
|
|
71
|
+
|
|
72
|
+
staging = Dir.mktmpdir("samagotchi-build-")
|
|
73
|
+
@staging_dir = staging
|
|
74
|
+
begin
|
|
75
|
+
stage_files(staging, candidates, parts)
|
|
76
|
+
write_manifest(staging, resolved_name, resolved_version, files_map, parts)
|
|
77
|
+
write_out(staging, resolved_out, format)
|
|
78
|
+
@out_path = resolved_out
|
|
79
|
+
ensure
|
|
80
|
+
FileUtils.rm_rf(staging) if staging && File.directory?(staging)
|
|
81
|
+
@staging_dir = nil
|
|
82
|
+
end
|
|
57
83
|
|
|
84
|
+
{
|
|
85
|
+
out_path: @out_path,
|
|
86
|
+
format: format,
|
|
87
|
+
name: resolved_name,
|
|
88
|
+
version: resolved_version,
|
|
89
|
+
scope: @scope,
|
|
90
|
+
files: @built_files.dup,
|
|
91
|
+
placeholder_warnings: @placeholder_warnings.dup
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def summary
|
|
96
|
+
lines = []
|
|
97
|
+
lines << "Built #{@built_files.size} file(s) to #{@out_path}"
|
|
98
|
+
lines << "Files: #{@built_files.join(", ")}" unless @built_files.empty?
|
|
99
|
+
lines.concat(@placeholder_warnings.map { |w| "Placeholder: #{w}" }) unless @placeholder_warnings.empty?
|
|
100
|
+
lines.concat(@warnings) unless @warnings.empty?
|
|
101
|
+
lines.join("\n")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# The scope's memories to build: every *.md but index.md and hidden
|
|
107
|
+
# ones; with an allowlist the named ones (each must exist) with their
|
|
108
|
+
# model overlays, else all but those another installed bundle owns.
|
|
109
|
+
# @return [Array<String>] paths
|
|
110
|
+
def select_memories(target_dir, name)
|
|
111
|
+
candidates = Dir.glob(File.join(target_dir, "*.md")).sort
|
|
112
|
+
.reject { |p| File.basename(p) == "index.md" || File.basename(p).start_with?(".") }
|
|
58
113
|
if candidates.empty?
|
|
59
114
|
raise BuildError, "No memories to build in #{@scope} scope (#{target_dir}): no .md files found"
|
|
60
115
|
end
|
|
116
|
+
return leave_out_owned(candidates, name) unless @filter_files && !@filter_files.empty?
|
|
61
117
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
candidates_by_basename = candidates.map { |p| [File.basename(p), p] }.to_h
|
|
67
|
-
missing = allow.reject { |k| candidates_by_basename.key?(k) }
|
|
68
|
-
unless missing.empty?
|
|
69
|
-
raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(", ")}"
|
|
70
|
-
end
|
|
118
|
+
allow = @filter_files.map { |f| normalize_filter_entry(f) }
|
|
119
|
+
candidates_by_basename = candidates.to_h { |p| [File.basename(p), p] }
|
|
120
|
+
missing = allow.reject { |k| candidates_by_basename.key?(k) }
|
|
121
|
+
raise BuildError, "Requested file(s) not found in #{@scope} scope: #{missing.join(", ")}" unless missing.empty?
|
|
71
122
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
candidates = leave_out_owned(candidates, resolved_name)
|
|
75
|
-
end
|
|
123
|
+
with_overlays(allow, candidates_by_basename).map { |k| candidates_by_basename[k] }
|
|
124
|
+
end
|
|
76
125
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
126
|
+
# file key => "sha256:<hex>" for the manifest; fills @built_files and
|
|
127
|
+
# a placeholder warning per memory with {{placeholders}}.
|
|
128
|
+
def checksum_memories(paths)
|
|
129
|
+
paths.to_h do |path|
|
|
80
130
|
key = File.basename(path)
|
|
81
131
|
content = File.read(path)
|
|
82
|
-
hex = Digest::SHA256.hexdigest(content)
|
|
83
|
-
files_map[key] = "sha256:#{hex}"
|
|
84
132
|
@built_files << key
|
|
85
|
-
|
|
86
133
|
names = Placeholder.new(content: content).placeholders
|
|
87
|
-
unless names.empty?
|
|
88
|
-
|
|
89
|
-
@placeholder_warnings << "#{key}: {{#{uniq.join("}}, {{")}}}"
|
|
90
|
-
end
|
|
134
|
+
@placeholder_warnings << "#{key}: {{#{names.map(&:strip).uniq.sort.join("}}, {{")}}}" unless names.empty?
|
|
135
|
+
[key, "sha256:#{Digest::SHA256.hexdigest(content)}"]
|
|
91
136
|
end
|
|
137
|
+
end
|
|
92
138
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
139
|
+
# The installed bundle's hooks (a recorded one missing on disk left
|
|
140
|
+
# out; one with no recorded sha gets its file's), guardrail rules,
|
|
141
|
+
# plugin, scripts and context providers. Without hooks or rules in a
|
|
142
|
+
# record, the scope dir's hooks/*.rb and guardrails/*.yml (building
|
|
143
|
+
# from a raw bundle dir that is also the memories dir).
|
|
144
|
+
# @return [InstalledParts]
|
|
145
|
+
def installed_parts(name, target_dir)
|
|
97
146
|
prov = nil
|
|
98
|
-
|
|
147
|
+
record = nil
|
|
99
148
|
begin
|
|
100
149
|
require_relative "provenance"
|
|
101
|
-
prov = Provenance.new(name:
|
|
102
|
-
|
|
150
|
+
prov = Provenance.new(name: name)
|
|
151
|
+
record = prov.record
|
|
103
152
|
rescue StandardError
|
|
104
|
-
|
|
153
|
+
record = nil
|
|
105
154
|
end
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
155
|
+
|
|
156
|
+
hooks = {}
|
|
157
|
+
hook_files = {}
|
|
158
|
+
if record&.hooks&.any?
|
|
159
|
+
record.hooks.each do |basename, hook|
|
|
109
160
|
src = File.join(prov.hooks_dir, basename)
|
|
110
|
-
# Skip if hook file missing on disk
|
|
111
161
|
next unless File.exist?(src)
|
|
112
162
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
sha = (m["sha256"] || m["checksum"] || "").to_s
|
|
116
|
-
if sha.empty?
|
|
117
|
-
sha = "sha256:#{Digest::SHA256.hexdigest(File.read(src))}"
|
|
118
|
-
elsif !sha.start_with?("sha256:")
|
|
119
|
-
sha = "sha256:#{sha}"
|
|
120
|
-
end
|
|
121
|
-
hooks_map[basename] = {
|
|
122
|
-
"sha256" => sha,
|
|
123
|
-
"event" => (m["event"] || "").to_s,
|
|
124
|
-
"on_error" => (m["on_error"] || "skip").to_s,
|
|
125
|
-
"priority" => (m["priority"] || 100).to_i
|
|
126
|
-
}
|
|
127
|
-
hooks_to_copy << [src, basename]
|
|
163
|
+
hooks[basename] = hook.sha256.empty? ? hook.with(sha256: BundleHook.of_file(src).sha256) : hook
|
|
164
|
+
hook_files[basename] = src
|
|
128
165
|
end
|
|
129
166
|
else
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
Dir.glob(File.join(local_hooks_dir, "*.rb")).sort.each do |src|
|
|
134
|
-
basename = File.basename(src)
|
|
135
|
-
sha = Digest::SHA256.hexdigest(File.read(src))
|
|
136
|
-
hooks_map[basename] = {
|
|
137
|
-
"sha256" => "sha256:#{sha}",
|
|
138
|
-
"event" => "",
|
|
139
|
-
"on_error" => "skip",
|
|
140
|
-
"priority" => 100
|
|
141
|
-
}
|
|
142
|
-
hooks_to_copy << [src, basename]
|
|
143
|
-
end
|
|
167
|
+
Dir.glob(File.join(target_dir, "hooks", "*.rb")).sort.each do |src|
|
|
168
|
+
hooks[File.basename(src)] = BundleHook.of_file(src)
|
|
169
|
+
hook_files[File.basename(src)] = src
|
|
144
170
|
end
|
|
145
171
|
end
|
|
146
|
-
# ── Guardrail rules: the installed bundle's, else target_dir's ──
|
|
147
|
-
rules_dir = prov && prov_data && prov_data[:guardrails].is_a?(Hash) ? prov.guardrails_dir : File.join(target_dir, "guardrails")
|
|
148
|
-
rules_to_copy = Dir.exist?(rules_dir) ? Dir.glob(File.join(rules_dir, "*.yml")).sort : []
|
|
149
|
-
|
|
150
|
-
# ── Plugin: the installed bundle's, with its requires_chi ──
|
|
151
|
-
plugin_src = prov && prov_data ? prov.plugin_path(prov_data) : nil
|
|
152
|
-
plugin_src = nil unless plugin_src && File.file?(plugin_src)
|
|
153
|
-
requires_chi = prov_data && prov_data[:requires_chi]
|
|
154
|
-
needs = prov_data && prov_data[:needs]
|
|
155
|
-
|
|
156
|
-
# ── Scripts and context providers: the installed bundle's ──
|
|
157
|
-
scripts_to_copy = installed_scripts(prov, prov_data)
|
|
158
|
-
context_providers = prov_data ? ContextProviders.parse_list(prov_data[:context_providers]) : []
|
|
159
|
-
|
|
160
|
-
# Determine trust_level for the built bundle
|
|
161
|
-
build_trust_level = @trust_level
|
|
162
|
-
if (build_trust_level.nil? || build_trust_level.empty?) && prov_data && prov_data[:trust_level]
|
|
163
|
-
build_trust_level = prov_data[:trust_level].to_s
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
# Resolve output path and format
|
|
167
|
-
resolved_out, format = resolve_out_path(@out, resolved_name)
|
|
168
172
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
FileUtils.mkdir_p(hooks_staging)
|
|
180
|
-
hooks_to_copy.each do |src, basename|
|
|
181
|
-
FileUtils.cp(src, File.join(hooks_staging, basename))
|
|
182
|
-
end
|
|
183
|
-
end
|
|
173
|
+
rules_dir = record&.guardrails&.any? ? prov.guardrails_dir : File.join(target_dir, "guardrails")
|
|
174
|
+
plugin = record ? prov.plugin_path(record) : nil
|
|
175
|
+
InstalledParts.new(
|
|
176
|
+
record: record, hooks: hooks, hook_files: hook_files,
|
|
177
|
+
rules: Dir.exist?(rules_dir) ? Dir.glob(File.join(rules_dir, "*.yml")).sort : [],
|
|
178
|
+
plugin: plugin && File.file?(plugin) ? plugin : nil,
|
|
179
|
+
scripts: installed_scripts(prov, record),
|
|
180
|
+
context_providers: record ? ContextProviders.parse_list(record.context_providers) : []
|
|
181
|
+
)
|
|
182
|
+
end
|
|
184
183
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
184
|
+
# The memories, hooks/, guardrails/, the plugin and scripts/ into the
|
|
185
|
+
# staging dir, laid out as Installer reads a bundle.
|
|
186
|
+
def stage_files(staging, candidates, parts)
|
|
187
|
+
candidates.each { |src| FileUtils.cp(src, File.join(staging, File.basename(src))) }
|
|
188
|
+
unless parts.hook_files.empty?
|
|
189
|
+
FileUtils.mkdir_p(File.join(staging, "hooks"))
|
|
190
|
+
parts.hook_files.each { |basename, src| FileUtils.cp(src, File.join(staging, "hooks", basename)) }
|
|
191
|
+
end
|
|
192
|
+
unless parts.rules.empty?
|
|
193
|
+
FileUtils.mkdir_p(File.join(staging, "guardrails"))
|
|
194
|
+
parts.rules.each { |src| FileUtils.cp(src, File.join(staging, "guardrails", File.basename(src))) }
|
|
195
|
+
end
|
|
196
|
+
FileUtils.cp(parts.plugin, File.join(staging, File.basename(parts.plugin))) if parts.plugin
|
|
197
|
+
return if parts.scripts.empty?
|
|
190
198
|
|
|
191
|
-
|
|
199
|
+
FileUtils.mkdir_p(File.join(staging, "scripts"))
|
|
200
|
+
parts.scripts.each_value { |src| FileUtils.cp(src, File.join(staging, "scripts", File.basename(src))) }
|
|
201
|
+
end
|
|
192
202
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
203
|
+
# manifest.yml for the staged bundle: requires_chi, needs and the
|
|
204
|
+
# file descriptions come from the installed bundle's record, the
|
|
205
|
+
# trust_level from the CLI else the record.
|
|
206
|
+
def write_manifest(staging, name, version, files_map, parts)
|
|
207
|
+
record = parts.record
|
|
208
|
+
trust_level = @trust_level
|
|
209
|
+
trust_level = record.trust_level if (trust_level.nil? || trust_level.empty?) && record&.trust_level
|
|
210
|
+
Manifest.write(
|
|
211
|
+
dir: staging,
|
|
212
|
+
name: name,
|
|
213
|
+
version: version,
|
|
214
|
+
scope: @scope,
|
|
215
|
+
description: @description,
|
|
216
|
+
files: files_map,
|
|
217
|
+
hooks: parts.hooks.empty? ? nil : parts.hooks,
|
|
218
|
+
trust_level: trust_level,
|
|
219
|
+
plugin: parts.plugin && { file: File.basename(parts.plugin), sha256: Digest::SHA256.hexdigest(File.binread(parts.plugin)) },
|
|
220
|
+
requires_chi: record&.requires_chi,
|
|
221
|
+
needs: record&.needs,
|
|
222
|
+
scripts: parts.scripts.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
|
|
223
|
+
context_providers: parts.context_providers,
|
|
224
|
+
file_descriptions: file_descriptions(record, files_map)
|
|
225
|
+
)
|
|
226
|
+
end
|
|
197
227
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
needs: needs,
|
|
210
|
-
scripts: scripts_to_copy.transform_values { |src| "sha256:#{Digest::SHA256.hexdigest(File.binread(src))}" },
|
|
211
|
-
context_providers: context_providers,
|
|
212
|
-
file_descriptions: file_descriptions(prov_data, files_map)
|
|
213
|
-
)
|
|
214
|
-
|
|
215
|
-
case format
|
|
216
|
-
when :zip
|
|
217
|
-
zip_staging(staging, resolved_out)
|
|
218
|
-
@out_path = resolved_out
|
|
219
|
-
when :tar_gz, :tar, :tgz
|
|
220
|
-
tar_staging(staging, resolved_out, format)
|
|
221
|
-
@out_path = resolved_out
|
|
222
|
-
when :dir
|
|
223
|
-
# Copy staging contents to out dir
|
|
224
|
-
FileUtils.mkdir_p(resolved_out)
|
|
225
|
-
# Guard: refuse to overwrite non-empty dir without force (error)
|
|
226
|
-
existing = Dir.entries(resolved_out).reject { |e| e.start_with?(".") }
|
|
227
|
-
unless existing.empty?
|
|
228
|
-
# Allow if out is the staging itself? no, staging is temp
|
|
229
|
-
# Check if out dir already has manifest.yml — treat as existing bundle
|
|
230
|
-
if File.exist?(File.join(resolved_out, "manifest.yml"))
|
|
231
|
-
raise BuildError, "Output directory already contains a bundle (#{resolved_out}/manifest.yml) — choose different --out or remove it"
|
|
232
|
-
end
|
|
233
|
-
# If dir has any files, still require explicit handling — error
|
|
234
|
-
unless existing.empty?
|
|
235
|
-
raise BuildError, "Output directory already exists and is not empty: #{resolved_out}"
|
|
236
|
-
end
|
|
228
|
+
# The staged bundle to +out+: a zip or tar archive, or a directory,
|
|
229
|
+
# which must be new or empty (one holding a bundle says so).
|
|
230
|
+
def write_out(staging, out, format)
|
|
231
|
+
case format
|
|
232
|
+
when :zip then zip_staging(staging, out)
|
|
233
|
+
when :tar_gz, :tar, :tgz then tar_staging(staging, out, format)
|
|
234
|
+
when :dir
|
|
235
|
+
FileUtils.mkdir_p(out)
|
|
236
|
+
unless Dir.entries(out).reject { |e| e.start_with?(".") }.empty?
|
|
237
|
+
if File.exist?(File.join(out, "manifest.yml"))
|
|
238
|
+
raise BuildError, "Output directory already contains a bundle (#{out}/manifest.yml) — choose different --out or remove it"
|
|
237
239
|
end
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
else
|
|
241
|
-
raise BuildError, "unknown format: #{format}"
|
|
240
|
+
|
|
241
|
+
raise BuildError, "Output directory already exists and is not empty: #{out}"
|
|
242
242
|
end
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
@staging_dir = nil
|
|
243
|
+
FileUtils.cp_r("#{staging}/.", out)
|
|
244
|
+
else
|
|
245
|
+
raise BuildError, "unknown format: #{format}"
|
|
247
246
|
end
|
|
248
|
-
|
|
249
|
-
{
|
|
250
|
-
out_path: @out_path,
|
|
251
|
-
format: format,
|
|
252
|
-
name: resolved_name,
|
|
253
|
-
version: resolved_version,
|
|
254
|
-
scope: @scope,
|
|
255
|
-
files: @built_files.dup,
|
|
256
|
-
placeholder_warnings: @placeholder_warnings.dup
|
|
257
|
-
}
|
|
258
247
|
end
|
|
259
248
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
249
|
+
# An installed profile (meta bundle) ships only its includes: a build
|
|
250
|
+
# under its name would make a plain bundle of the scope's memories,
|
|
251
|
+
# without the includes, carrying the profile's trust_level.
|
|
252
|
+
def refuse_profile!(name)
|
|
253
|
+
require_relative "profile"
|
|
254
|
+
record = Provenance.new(name: name).record
|
|
255
|
+
return unless Profile.installed_meta?(name, record)
|
|
256
|
+
|
|
257
|
+
shipped = File.join(SourceNormalizer::SHIPPED_DIR, name)
|
|
258
|
+
members = Profile.recorded(record, File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil)
|
|
259
|
+
raise BuildError, "#{name} is an installed profile (includes: #{members.join(", ")}): it ships only its includes, " \
|
|
260
|
+
"so a build can't remake it; pick another --name for these memories"
|
|
261
|
+
rescue JSON::ParserError, SystemCallError, Manifest::ValidationError
|
|
262
|
+
nil
|
|
267
263
|
end
|
|
268
264
|
|
|
269
|
-
private
|
|
270
|
-
|
|
271
265
|
# The installed bundle's scripts its record lists, file name => path
|
|
272
266
|
# in its scripts/ (a missing one is left out); {} without a record.
|
|
273
|
-
def installed_scripts(prov,
|
|
274
|
-
return {} unless
|
|
267
|
+
def installed_scripts(prov, record)
|
|
268
|
+
return {} unless record
|
|
275
269
|
|
|
276
|
-
|
|
277
|
-
|
|
270
|
+
record.scripts.keys.sort.to_h { |file| [file, File.join(prov.scripts_dir, file)] }
|
|
271
|
+
.select { |_, path| File.file?(path) }
|
|
278
272
|
end
|
|
279
273
|
|
|
280
274
|
# The descriptions the installed bundle's record keeps for the files
|
|
281
275
|
# being built (its manifest's files: mappings), so a rebuild writes
|
|
282
276
|
# them back; {} without a record.
|
|
283
|
-
def file_descriptions(
|
|
284
|
-
return {} unless
|
|
277
|
+
def file_descriptions(record, files_map)
|
|
278
|
+
return {} unless record
|
|
285
279
|
|
|
286
|
-
|
|
287
|
-
text = entry
|
|
288
|
-
acc[key
|
|
280
|
+
record.files.each_with_object({}) do |(key, entry), acc|
|
|
281
|
+
text = entry[:description].to_s
|
|
282
|
+
acc[key] = text if files_map.key?(key) && !text.empty?
|
|
289
283
|
end
|
|
290
284
|
end
|
|
291
285
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
module MemoryBundle
|
|
7
|
+
# One hook's metadata, as a bundle's manifest.yml declares it and its
|
|
8
|
+
# installed record (manifest.json) keeps it: the file's sha256
|
|
9
|
+
# ("sha256:<hex>", "" when none is recorded), the event it registers
|
|
10
|
+
# for ("" when none: it isn't loaded), on_error (skip, log or
|
|
11
|
+
# fail_closed) and priority. The one place the defaults live.
|
|
12
|
+
class BundleHook < Data.define(:sha256, :event, :on_error, :priority)
|
|
13
|
+
DEFAULT_ON_ERROR = "skip"
|
|
14
|
+
DEFAULT_PRIORITY = 100
|
|
15
|
+
|
|
16
|
+
def initialize(sha256: "", event: "", on_error: DEFAULT_ON_ERROR, priority: DEFAULT_PRIORITY)
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param raw [Hash, BundleHook, nil] string keys (manifest.yml) or
|
|
21
|
+
# symbol keys (a read record); missing or blank values get the
|
|
22
|
+
# defaults
|
|
23
|
+
def self.parse(raw)
|
|
24
|
+
return raw if raw.is_a?(BundleHook)
|
|
25
|
+
|
|
26
|
+
h = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
|
|
27
|
+
on_error = h["on_error"].to_s.strip
|
|
28
|
+
new(sha256: prefixed(h["sha256"]), event: h["event"].to_s.strip,
|
|
29
|
+
on_error: on_error.empty? ? DEFAULT_ON_ERROR : on_error,
|
|
30
|
+
priority: (h["priority"] || DEFAULT_PRIORITY).to_i)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A hook known only by its file (no declared event): its sha, the defaults.
|
|
34
|
+
def self.of_file(path)
|
|
35
|
+
new(sha256: "sha256:#{Digest::SHA256.hexdigest(File.binread(path))}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# "sha256:<hex>" from either form, "" for none.
|
|
39
|
+
def self.prefixed(sha)
|
|
40
|
+
sha = sha.to_s.strip
|
|
41
|
+
sha.empty? || sha.start_with?("sha256:") ? sha : "sha256:#{sha}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The sha's hex, "" when none is recorded.
|
|
45
|
+
def hex = sha256.delete_prefix("sha256:")
|
|
46
|
+
|
|
47
|
+
# The string-keyed form manifest.yml and manifest.json hold.
|
|
48
|
+
def to_record = to_h.transform_keys(&:to_s)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "bundle_hook"
|
|
5
|
+
require_relative "plugin_ref"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
module MemoryBundle
|
|
9
|
+
# An installed bundle's record, as Provenance#write keeps it in
|
|
10
|
+
# <bundles>/<name>/manifest.json. Reading is tolerant: an older record
|
|
11
|
+
# lacks keys a newer chi writes, and a key that is missing or of the
|
|
12
|
+
# wrong shape reads as empty (nil for the optional strings and lists).
|
|
13
|
+
#
|
|
14
|
+
# files file key ("x.md") => { checksum:, conflict:, description: } (symbol keys)
|
|
15
|
+
# hooks basename => BundleHook
|
|
16
|
+
# guardrails basename => recorded sha256 ("" when none)
|
|
17
|
+
# scripts file name => recorded sha256 ("" when none)
|
|
18
|
+
# plugin PluginRef, nil without one
|
|
19
|
+
# needs, context_providers
|
|
20
|
+
# the lists as recorded (nil when absent): Manifest.parse_needs
|
|
21
|
+
# and ContextProviders.parse_list check them
|
|
22
|
+
# includes a profile's recorded members, nil for a plain bundle
|
|
23
|
+
# error why manifest.json couldn't be read (the rest empty), else nil
|
|
24
|
+
class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
|
|
25
|
+
:requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
|
|
26
|
+
:includes, :context_providers, :error)
|
|
27
|
+
OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
|
|
28
|
+
requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
|
|
29
|
+
includes: nil, context_providers: nil, error: nil }.freeze
|
|
30
|
+
|
|
31
|
+
def initialize(name:, **fields)
|
|
32
|
+
super(name: name, **OPTIONAL, **fields)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The record at +path+, nil when there is none.
|
|
36
|
+
# @raise [JSON::ParserError, SystemCallError] it doesn't read
|
|
37
|
+
def self.read(name, path)
|
|
38
|
+
return nil unless File.exist?(path)
|
|
39
|
+
|
|
40
|
+
parse(name, JSON.parse(File.read(path)))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# One that couldn't be read: +message+ says why.
|
|
44
|
+
def self.unreadable(name, message) = new(name: name, error: message)
|
|
45
|
+
|
|
46
|
+
# @param raw [Hash] the parsed manifest.json (string or symbol keys);
|
|
47
|
+
# anything else reads as unreadable
|
|
48
|
+
def self.parse(name, raw)
|
|
49
|
+
return unreadable(name, "manifest.json is not an object") unless raw.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
h = raw.transform_keys(&:to_s)
|
|
52
|
+
new(name: name, version: string(h["version"]), scope: string(h["scope"]), source: string(h["source"]),
|
|
53
|
+
installed_at: string(h["installed_at"]), trust_level: string(h["trust_level"]),
|
|
54
|
+
source_commit: string(h["source_commit"]), requires_chi: string(h["requires_chi"]),
|
|
55
|
+
files: mapping(h["files"]) { |entry| entry.is_a?(Hash) ? entry.transform_keys(&:to_sym) : {} },
|
|
56
|
+
hooks: mapping(h["hooks"]) { |meta| BundleHook.parse(meta) },
|
|
57
|
+
guardrails: mapping(h["guardrails"]) { |meta| sha(meta) },
|
|
58
|
+
scripts: mapping(h["scripts"]) { |meta| sha(meta) },
|
|
59
|
+
plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
|
|
60
|
+
includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.string(value) = value&.to_s
|
|
64
|
+
|
|
65
|
+
def self.mapping(value, &)
|
|
66
|
+
value.is_a?(Hash) ? value.to_h { |k, v| [k.to_s, yield(v)] } : {}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.sha(meta) = meta.is_a?(Hash) ? meta.transform_keys(&:to_s)["sha256"].to_s : ""
|
|
70
|
+
private_class_method :string, :mapping, :sha
|
|
71
|
+
|
|
72
|
+
def error? = !error.nil?
|
|
73
|
+
|
|
74
|
+
# The scope its files went to: "system" when none is recorded.
|
|
75
|
+
def effective_scope = scope.to_s.strip.empty? ? "system" : scope
|
|
76
|
+
|
|
77
|
+
# A bundle with no trust_level counts as experimental.
|
|
78
|
+
def experimental? = (trust_level || "experimental") == "experimental"
|
|
79
|
+
|
|
80
|
+
# A profile (meta bundle): it records its members.
|
|
81
|
+
def profile? = !includes.nil?
|
|
82
|
+
|
|
83
|
+
def owns?(file_key) = files.key?(file_key.to_s)
|
|
84
|
+
|
|
85
|
+
def needs? = needs.is_a?(Array) && !needs.empty?
|
|
86
|
+
|
|
87
|
+
def context_providers? = context_providers.is_a?(Array) && !context_providers.empty?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|