samagotchi 0.88.0 → 0.89.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 +20 -1
- data/docs/bundle-authoring.md +19 -0
- data/docs/cli.md +41 -2
- data/docs/internals/bundles.md +10 -0
- data/lib/samagotchi/bundle_command/test_run.rb +2 -2
- data/lib/samagotchi/bundle_command/try/run.rb +162 -0
- data/lib/samagotchi/bundle_command/try/sandbox.rb +159 -0
- data/lib/samagotchi/bundle_command/try/trace.rb +152 -0
- data/lib/samagotchi/bundle_command.rb +216 -10
- data/lib/samagotchi/bundle_session_settings.rb +2 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/context_providers.rb +1 -2
- data/lib/samagotchi/hooks/bundle_loader.rb +2 -0
- data/lib/samagotchi/installed_gem.rb +11 -0
- data/lib/samagotchi/memory_bundle/asset_installer.rb +25 -13
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
- data/lib/samagotchi/memory_bundle/installer.rb +22 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +21 -6
- data/lib/samagotchi/session_manager.rb +2 -10
- data/lib/samagotchi/version.rb +1 -1
- metadata +24 -1
|
@@ -10,7 +10,7 @@ module Samagotchi
|
|
|
10
10
|
# subcommands have their own flags).
|
|
11
11
|
class BundleCommand
|
|
12
12
|
USAGE = <<~TEXT
|
|
13
|
-
Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check> [options]
|
|
13
|
+
Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try> [options]
|
|
14
14
|
|
|
15
15
|
install <source> [--scope system|project] [--force]
|
|
16
16
|
Install a memory bundle from a directory, zip, tar archive, or git URL.
|
|
@@ -52,17 +52,26 @@ module Samagotchi
|
|
|
52
52
|
check [PATH] [--fix] [--set KEY=VALUE]
|
|
53
53
|
Check a bundle you're writing (default: the current dir): manifest, files, sha256 lines,
|
|
54
54
|
requires_chi, and an install + load in a sandbox. --fix rewrites stale sha256 lines.
|
|
55
|
+
|
|
56
|
+
try PATH -p TEXT [options]
|
|
57
|
+
Run one non-interactive model turn with a bundle installed in an isolated sandbox.
|
|
55
58
|
TEXT
|
|
56
59
|
|
|
57
60
|
# Each subcommand's --help (and only --help: -h and help are taken as
|
|
58
61
|
# the source or name).
|
|
59
62
|
INSTALL_HELP = <<~TEXT
|
|
60
63
|
Usage: chi bundle install <source> [--scope system|project] [--force]
|
|
64
|
+
chi bundle install --dev PATH
|
|
61
65
|
|
|
62
66
|
install <source> [--scope system|project] [--force]
|
|
63
67
|
Install a memory bundle from a directory, zip, tar archive, or git URL,
|
|
64
68
|
or a bundle shipped with chi by name (see: chi bundle list).
|
|
65
69
|
--force overwrites existing entries; default skips them.
|
|
70
|
+
install --dev PATH
|
|
71
|
+
Install the bundle in the local directory PATH as a link to it: its plugin, hooks, rules and scripts
|
|
72
|
+
are PATH's own files (an edit applies at the next worker or chi web restart, with no re-install) and
|
|
73
|
+
their sha256 isn't checked, for this bundle only. Its memories are copied; re-install with --force to
|
|
74
|
+
pick up memory edits. Upgrade refuses it; uninstall removes the link, never PATH's files.
|
|
66
75
|
TEXT
|
|
67
76
|
UPGRADE_HELP = "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]"
|
|
68
77
|
UNINSTALL_HELP = "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
|
|
@@ -108,6 +117,19 @@ module Samagotchi
|
|
|
108
117
|
--set KEY=VALUE a setting for the load (config.yml bundles: <name>: KEY), repeatable
|
|
109
118
|
Exit status: 0 no errors, 1 errors, 2 usage.
|
|
110
119
|
TEXT
|
|
120
|
+
TRY_HELP = <<~TEXT
|
|
121
|
+
Usage: chi bundle try PATH -p TEXT [--model NAME] [--memory NAME] [--mute NAME] [--toolkit NAME]
|
|
122
|
+
[--thinking LEVEL] [--set KEY=VALUE] [--json] [--keep] [--timeout SECONDS]
|
|
123
|
+
|
|
124
|
+
Install PATH and run one non-interactive turn in an isolated temporary config, state and work directory.
|
|
125
|
+
The user's hosts, keys and ordinary runtime settings are retained; their bundles, hooks, guardrails,
|
|
126
|
+
memories, prompt additions, sessions and attached context sources are not. Shipped guardrails and a
|
|
127
|
+
$1 cost cap are installed for the run. --set KEY=VALUE sets the tried bundle's config.yml bundles entry;
|
|
128
|
+
--set cost-cap.cap=N overrides the cap (unlike chi --set BUNDLE.KEY=VALUE, these are config settings).
|
|
129
|
+
--memory and --mute accept comma-separated names and repeat. --keep retains the sandbox and debug log.
|
|
130
|
+
--json prints the experimental machine-readable trace.
|
|
131
|
+
Exit status: child turn's status; 1 on install failure or timeout; 2 for usage; 130 on Ctrl-C.
|
|
132
|
+
TEXT
|
|
111
133
|
|
|
112
134
|
# The subcommands' flags: --help only; a word not starting with -- is
|
|
113
135
|
# a positional (install's source, -h included). --scope takes whatever
|
|
@@ -115,8 +137,12 @@ module Samagotchi
|
|
|
115
137
|
INSTALL_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
|
|
116
138
|
f.value "--scope"
|
|
117
139
|
f.switch "--force"
|
|
140
|
+
f.switch "--dev"
|
|
141
|
+
end
|
|
142
|
+
UNINSTALL_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
|
|
143
|
+
f.value "--scope"
|
|
144
|
+
f.switch "--force"
|
|
118
145
|
end
|
|
119
|
-
UNINSTALL_FLAGS = INSTALL_FLAGS
|
|
120
146
|
UPGRADE_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
|
|
121
147
|
f.value "--scope"
|
|
122
148
|
f.switch "--force"
|
|
@@ -135,6 +161,19 @@ module Samagotchi
|
|
|
135
161
|
f.switch "--fix"
|
|
136
162
|
f.value "--set", key: :set, repeat: true
|
|
137
163
|
end
|
|
164
|
+
TRY_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A-/) do |f|
|
|
165
|
+
f.value "-p", "--prompt", key: :prompt
|
|
166
|
+
f.value "--model"
|
|
167
|
+
f.value "--memory", repeat: true
|
|
168
|
+
f.value "--mute", repeat: true
|
|
169
|
+
f.value "--toolkit"
|
|
170
|
+
f.value "--thinking"
|
|
171
|
+
f.value "--set", repeat: true
|
|
172
|
+
f.switch "--json"
|
|
173
|
+
f.switch "--keep"
|
|
174
|
+
f.value "--timeout"
|
|
175
|
+
f.refuse "--attach", "--shared", "chi bundle try can't use --attach or --shared"
|
|
176
|
+
end
|
|
138
177
|
TRASH_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
|
|
139
178
|
f.switch "--empty"
|
|
140
179
|
f.switch "--dry-run"
|
|
@@ -169,8 +208,9 @@ module Samagotchi
|
|
|
169
208
|
when "new" then new_bundle(@argv[1..])
|
|
170
209
|
when "test" then test_bundle(@argv[1..])
|
|
171
210
|
when "check" then check(@argv[1..])
|
|
211
|
+
when "try" then try_bundle(@argv[1..])
|
|
172
212
|
else
|
|
173
|
-
@stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash, new, test, check"
|
|
213
|
+
@stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash, new, test, check, try"
|
|
174
214
|
CLI::Exit::USAGE
|
|
175
215
|
end
|
|
176
216
|
end
|
|
@@ -185,11 +225,21 @@ module Samagotchi
|
|
|
185
225
|
source = parsed.args.last
|
|
186
226
|
scope = opts[:scope]
|
|
187
227
|
force = opts.fetch(:force, false)
|
|
228
|
+
dev = opts.fetch(:dev, false)
|
|
188
229
|
if source.nil? || source.empty?
|
|
189
230
|
@stderr.puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
|
|
190
231
|
return CLI::Exit::USAGE
|
|
191
232
|
end
|
|
192
|
-
|
|
233
|
+
if dev && (Samagotchi::MemoryBundle::SourceNormalizer.git_url?(source) || !File.directory?(source))
|
|
234
|
+
@stderr.puts "chi bundle install --dev takes a local directory (the bundle's source), not #{source}"
|
|
235
|
+
return CLI::Exit::USAGE
|
|
236
|
+
end
|
|
237
|
+
expanded_source = dev ? File.realpath(source) : expand_source(source)
|
|
238
|
+
if dev && File.dirname(expanded_source) == File.realpath(Samagotchi::MemoryBundle::SourceNormalizer::SHIPPED_DIR)
|
|
239
|
+
@stderr.puts "chi bundle install --dev cannot be used with chi-shipped bundles or profiles; " \
|
|
240
|
+
"use chi bundle install #{File.basename(expanded_source)} instead"
|
|
241
|
+
return CLI::Exit::USAGE
|
|
242
|
+
end
|
|
193
243
|
if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
|
|
194
244
|
return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
|
|
195
245
|
end
|
|
@@ -198,7 +248,7 @@ module Samagotchi
|
|
|
198
248
|
return 1 unless prepared
|
|
199
249
|
return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Install")
|
|
200
250
|
|
|
201
|
-
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force)
|
|
251
|
+
installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, dev: dev)
|
|
202
252
|
run_installer(installer, failed: "Install") do |manifest|
|
|
203
253
|
print_hooks_and_plugin(manifest)
|
|
204
254
|
print_provenance(bundle_name, manifest)
|
|
@@ -457,7 +507,7 @@ module Samagotchi
|
|
|
457
507
|
@stdout.puts "Bundle '#{name}' not installed."
|
|
458
508
|
return 0
|
|
459
509
|
end
|
|
460
|
-
@stdout.puts "Bundle: #{name} v#{st[:provenance].version} scope=#{status_scope(st)} installed=#{st[:provenance].installed_at}"
|
|
510
|
+
@stdout.puts "Bundle: #{name} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} installed=#{st[:provenance].installed_at}"
|
|
461
511
|
if st[:scope_error]
|
|
462
512
|
@stdout.puts "Target: (unknown scope: this chi can't resolve it; upgrade chi or reinstall the bundle)"
|
|
463
513
|
else
|
|
@@ -519,12 +569,19 @@ module Samagotchi
|
|
|
519
569
|
plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
|
|
520
570
|
includes = st[:provenance].includes
|
|
521
571
|
includes_info = includes ? " includes=#{includes.join(",")}" : ""
|
|
522
|
-
@stdout.puts " #{bname} v#{st[:provenance].version} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
|
|
572
|
+
@stdout.puts " #{bname} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
|
|
523
573
|
end
|
|
524
574
|
end
|
|
525
575
|
0
|
|
526
576
|
end
|
|
527
577
|
|
|
578
|
+
# " dev → /path (missing)" when a dev install's source isn't there, else "".
|
|
579
|
+
def dev_label(record)
|
|
580
|
+
return "" unless record.dev
|
|
581
|
+
|
|
582
|
+
" dev → #{record.source}#{" (missing)" unless File.directory?(record.source)}"
|
|
583
|
+
end
|
|
584
|
+
|
|
528
585
|
# "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
|
|
529
586
|
def status_scope(st) = st[:scope_error] ? "#{st[:scope]} (unknown)" : st[:scope]
|
|
530
587
|
|
|
@@ -844,7 +901,7 @@ module Samagotchi
|
|
|
844
901
|
parsed = parse_flags(CHECK_FLAGS, rest, "check", help: CHECK_HELP)
|
|
845
902
|
return parsed if parsed.is_a?(Integer)
|
|
846
903
|
|
|
847
|
-
settings = check_settings(parsed.options.fetch(:set, [])) or return CLI::Exit::USAGE
|
|
904
|
+
settings = check_settings(parsed.options.fetch(:set, []), label: "check") or return CLI::Exit::USAGE
|
|
848
905
|
if parsed.args.size > 1
|
|
849
906
|
@stderr.puts "chi bundle check takes one PATH (#{parsed.args.join(" ")})"
|
|
850
907
|
return CLI::Exit::USAGE
|
|
@@ -858,14 +915,163 @@ module Samagotchi
|
|
|
858
915
|
Check.new(dir, fix: parsed.options.fetch(:fix, false), settings: settings, stdout: @stdout).run
|
|
859
916
|
end
|
|
860
917
|
|
|
918
|
+
def try_bundle(rest)
|
|
919
|
+
parsed = parse_flags(TRY_FLAGS, rest, "try", help: TRY_HELP)
|
|
920
|
+
return parsed if parsed.is_a?(Integer)
|
|
921
|
+
|
|
922
|
+
options = parsed.options
|
|
923
|
+
if options[:prompt].nil? || parsed.args.size != 1
|
|
924
|
+
@stderr.puts "Usage: chi bundle try PATH -p TEXT [options]"
|
|
925
|
+
return CLI::Exit::USAGE
|
|
926
|
+
end
|
|
927
|
+
path = parsed.args.first
|
|
928
|
+
unless File.directory?(path)
|
|
929
|
+
@stderr.puts "chi bundle try: #{path} isn't a folder"
|
|
930
|
+
return CLI::Exit::USAGE
|
|
931
|
+
end
|
|
932
|
+
if options.values_at(:model, :toolkit, :thinking, :memory, :mute).flatten.compact.any?(&:empty?)
|
|
933
|
+
@stderr.puts "chi bundle try: model, toolkit, thinking, memory and mute values cannot be empty"
|
|
934
|
+
return CLI::Exit::USAGE
|
|
935
|
+
end
|
|
936
|
+
require_relative "thinking"
|
|
937
|
+
if options[:thinking] && !Thinking::LEVELS.map(&:to_s).include?(options[:thinking])
|
|
938
|
+
@stderr.puts "chi bundle try: --thinking takes #{Thinking::LEVELS.join(", ")}, not #{options[:thinking]}"
|
|
939
|
+
return CLI::Exit::USAGE
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
timeout = parse_try_timeout(options[:timeout])
|
|
943
|
+
return CLI::Exit::USAGE unless timeout
|
|
944
|
+
|
|
945
|
+
set_pairs = []
|
|
946
|
+
cost_cap = 1
|
|
947
|
+
cost_cap_override = false
|
|
948
|
+
require_relative "bundle_session_settings"
|
|
949
|
+
Array(options[:set]).each do |pair|
|
|
950
|
+
key, sep, value = pair.partition("=")
|
|
951
|
+
if sep.empty?
|
|
952
|
+
@stderr.puts "chi bundle try: --set takes KEY=VALUE, not #{pair}"
|
|
953
|
+
return CLI::Exit::USAGE
|
|
954
|
+
end
|
|
955
|
+
if key == "cost-cap.cap"
|
|
956
|
+
cost_cap = BundleSessionSettings.typed(value)
|
|
957
|
+
cost_cap_override = true
|
|
958
|
+
unless (cost_cap.is_a?(Numeric) && cost_cap >= 0) || cost_cap == false
|
|
959
|
+
@stderr.puts "chi bundle try: --set cost-cap.cap takes a non-negative number or off"
|
|
960
|
+
return CLI::Exit::USAGE
|
|
961
|
+
end
|
|
962
|
+
else
|
|
963
|
+
set_pairs << pair
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
settings = check_settings(set_pairs, label: "try")
|
|
967
|
+
return CLI::Exit::USAGE unless settings
|
|
968
|
+
|
|
969
|
+
require_relative "bundle_command/try/sandbox"
|
|
970
|
+
require_relative "bundle_command/try/run"
|
|
971
|
+
require_relative "bundle_command/try/trace"
|
|
972
|
+
sandbox = nil
|
|
973
|
+
begin
|
|
974
|
+
sandbox = Try::Sandbox.new(path, bundle_settings: settings, cost_cap: cost_cap,
|
|
975
|
+
cost_cap_override: cost_cap_override)
|
|
976
|
+
sandbox.install
|
|
977
|
+
result = Try::Run.new(sandbox).run(prompt: options[:prompt], options: options, timeout: timeout)
|
|
978
|
+
trace = Try::TraceReader.new(state_home: sandbox.state_home, session_id: result.session_id)
|
|
979
|
+
.read(exit_code: result.exit_status, answer: result.stdout, stderr: result.stderr,
|
|
980
|
+
sandbox: options[:keep] ? sandbox.root : nil)
|
|
981
|
+
render_try_trace(trace, json: options[:json], child_stderr: result.stderr)
|
|
982
|
+
@stderr.puts "chi bundle try: timed out after #{timeout} seconds" if result.timed_out
|
|
983
|
+
result.exit_status
|
|
984
|
+
rescue Try::Sandbox::Error => e
|
|
985
|
+
@stderr.puts e.message
|
|
986
|
+
e.status
|
|
987
|
+
ensure
|
|
988
|
+
report_try_sandbox(sandbox) if options[:keep] && sandbox
|
|
989
|
+
sandbox&.cleanup unless options[:keep]
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
def render_try_trace(trace, json:, child_stderr:)
|
|
994
|
+
error_lines = child_stderr.lines.reject { |line| line.start_with?("Session: ") }
|
|
995
|
+
@stderr.write(error_lines.join)
|
|
996
|
+
if json
|
|
997
|
+
render_trace_lines(trace, @stderr, include_answer: false)
|
|
998
|
+
@stdout.puts JSON.generate(trace.to_h.transform_keys(&:to_s))
|
|
999
|
+
else
|
|
1000
|
+
render_trace_lines(trace, @stdout, include_answer: true)
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def report_try_sandbox(sandbox)
|
|
1005
|
+
@stderr.puts "sandbox: #{sandbox.root}"
|
|
1006
|
+
@stderr.puts "debug log: #{File.join(sandbox.state_home, "samagotchi", "samagotchi.log")}"
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
def render_trace_lines(trace, output, include_answer:)
|
|
1010
|
+
output.puts "tools:"
|
|
1011
|
+
trace.tools.each do |tool|
|
|
1012
|
+
output.puts format(" %d %-16s %-7s %dms %s", tool[:index], tool[:name], tool[:status],
|
|
1013
|
+
tool[:duration_ms], JSON.generate(tool[:args].to_s[0, 60]))
|
|
1014
|
+
end
|
|
1015
|
+
trace.notices.each { |notice| output.puts "notices: [#{notice["hook"] || "hook"}] #{notice["text"]}" }
|
|
1016
|
+
trace.cards.each do |card|
|
|
1017
|
+
output.puts "card: #{[card["title"], card["body"]].compact.join(" — ")}"
|
|
1018
|
+
end
|
|
1019
|
+
trace.questions.each do |entry|
|
|
1020
|
+
question = entry.dig("pending_question", "question").to_s
|
|
1021
|
+
outcome = if entry["cancelled"] && entry["reason"] == "non_interactive"
|
|
1022
|
+
"unanswerable (non_interactive)"
|
|
1023
|
+
elsif entry["cancelled"]
|
|
1024
|
+
"cancelled (#{entry["reason"]})"
|
|
1025
|
+
elsif entry["answer"]
|
|
1026
|
+
"answered"
|
|
1027
|
+
else
|
|
1028
|
+
"unanswered"
|
|
1029
|
+
end
|
|
1030
|
+
output.puts "questions: #{JSON.generate(question)} -> #{outcome}"
|
|
1031
|
+
end
|
|
1032
|
+
if trace.interactive_blocked
|
|
1033
|
+
count = trace.denied.size + trace.questions.count { |question| question["cancelled"] && question["reason"] == "non_interactive" }
|
|
1034
|
+
output.puts "warn: #{count} question(s)/call(s) couldn't be answered"
|
|
1035
|
+
end
|
|
1036
|
+
if include_answer
|
|
1037
|
+
answer_lines = trace.answer.lines
|
|
1038
|
+
output.puts "answer:"
|
|
1039
|
+
answer_lines.first(10).each { |line| output.puts " #{line.chomp}" }
|
|
1040
|
+
output.puts " … (truncated)" if answer_lines.size > 10
|
|
1041
|
+
end
|
|
1042
|
+
tokens = trace.tokens
|
|
1043
|
+
prompt = tokens[:prompt] || "unknown"
|
|
1044
|
+
completion = tokens[:completion] || "unknown"
|
|
1045
|
+
cost = if trace.cost
|
|
1046
|
+
"#{"~" if trace.cost_is_estimate}$#{format("%.4f", trace.cost)}"
|
|
1047
|
+
else
|
|
1048
|
+
"unknown"
|
|
1049
|
+
end
|
|
1050
|
+
output.puts "tokens: #{prompt} in / #{completion} out cost #{cost} exit #{trace.exit_code}" \
|
|
1051
|
+
"#{" (#{trace.session_id})" if trace.session_id}"
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
def parse_try_timeout(value)
|
|
1055
|
+
return 300 unless value
|
|
1056
|
+
|
|
1057
|
+
timeout = Float(value)
|
|
1058
|
+
return timeout if timeout.positive? && timeout.finite?
|
|
1059
|
+
|
|
1060
|
+
@stderr.puts "chi bundle try: --timeout must be a positive number"
|
|
1061
|
+
nil
|
|
1062
|
+
rescue ArgumentError
|
|
1063
|
+
@stderr.puts "chi bundle try: --timeout must be a positive number"
|
|
1064
|
+
nil
|
|
1065
|
+
end
|
|
1066
|
+
|
|
861
1067
|
# --set KEY=VALUE values as typed (BundleSessionSettings.typed), or nil
|
|
862
1068
|
# after saying what's wrong.
|
|
863
|
-
def check_settings(pairs)
|
|
1069
|
+
def check_settings(pairs, label:)
|
|
864
1070
|
require_relative "bundle_session_settings"
|
|
865
1071
|
pairs.each_with_object({}) do |pair, out|
|
|
866
1072
|
key, sep, value = pair.partition("=")
|
|
867
1073
|
unless !sep.empty? && key.match?(BundleSessionSettings::KEY)
|
|
868
|
-
@stderr.puts "chi bundle
|
|
1074
|
+
@stderr.puts "chi bundle #{label}: --set takes KEY=VALUE, not #{pair}"
|
|
869
1075
|
return nil
|
|
870
1076
|
end
|
|
871
1077
|
out[key] = BundleSessionSettings.typed(value)
|
|
@@ -176,6 +176,8 @@ module Samagotchi
|
|
|
176
176
|
file = File.join(dir, name, "plugin", bundle.plugin.file.to_s)
|
|
177
177
|
return "the file is missing" unless File.file?(file)
|
|
178
178
|
|
|
179
|
+
return nil if MemoryBundle::Provenance.unchecked?(bundle.plugin.sha256)
|
|
180
|
+
|
|
179
181
|
expected = MemoryBundle::Provenance.recorded_sha(bundle.plugin.sha256)
|
|
180
182
|
return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
|
|
181
183
|
|
|
@@ -134,8 +134,7 @@ module Samagotchi
|
|
|
134
134
|
|
|
135
135
|
record.scripts.each do |file, sha|
|
|
136
136
|
path = File.join(provenance.scripts_dir, file)
|
|
137
|
-
|
|
138
|
-
next if File.file?(path) && MemoryBundle::Provenance.file_sha(path) == recorded
|
|
137
|
+
next if File.file?(path) && MemoryBundle::Provenance.sha_matches?(path, sha)
|
|
139
138
|
|
|
140
139
|
raise Invalid, "bundle #{source.provider}'s scripts/#{file} differs from the installed one (reinstall the bundle)"
|
|
141
140
|
end
|
|
@@ -126,6 +126,8 @@ module Samagotchi
|
|
|
126
126
|
# Why the file doesn't match the sha256 recorded at install, or nil.
|
|
127
127
|
# With none recorded, only a required hook counts as a mismatch.
|
|
128
128
|
def sha_mismatch(file, recorded, required:)
|
|
129
|
+
return nil if MemoryBundle::Provenance.unchecked?(recorded)
|
|
130
|
+
|
|
129
131
|
expected = MemoryBundle::Provenance.recorded_sha(recorded)
|
|
130
132
|
return (required ? "no sha256 recorded for it (reinstall the bundle)" : nil) if expected.empty?
|
|
131
133
|
|
|
@@ -29,6 +29,17 @@ module Samagotchi
|
|
|
29
29
|
File.file?(path) ? path : nil
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# What Bundler changed in ENV, mapped back to the values from before it
|
|
33
|
+
# (nil: unset); {} outside Bundler. Child chi commands use this in gem
|
|
34
|
+
# mode so they load the same installed gem without inheriting this app's
|
|
35
|
+
# bundle.
|
|
36
|
+
def unbundled_env_changes(env = ENV.to_h)
|
|
37
|
+
return {} unless defined?(::Bundler) && ::Bundler.respond_to?(:unbundled_env)
|
|
38
|
+
|
|
39
|
+
clean = ::Bundler.unbundled_env
|
|
40
|
+
(env.keys | clean.keys).each_with_object({}) { |key, changes| changes[key] = clean[key] unless clean[key] == env[key] }
|
|
41
|
+
end
|
|
42
|
+
|
|
32
43
|
def real_path(path)
|
|
33
44
|
File.realpath(path)
|
|
34
45
|
rescue SystemCallError
|
|
@@ -17,6 +17,8 @@ module Samagotchi
|
|
|
17
17
|
# scripts/*. Each replaces what an earlier version installed. Installer
|
|
18
18
|
# runs it after the memories; it adds to the installer's results and
|
|
19
19
|
# warnings, and #run returns what the record (Provenance#write) keeps.
|
|
20
|
+
# A dev install (+dev+, `chi bundle install --dev`) links each file to
|
|
21
|
+
# the source instead of copying it, and warns of no checksum mismatch.
|
|
20
22
|
class AssetInstaller
|
|
21
23
|
# What was installed: the paths Provenance#write records (each
|
|
22
24
|
# empty / nil on a dry run).
|
|
@@ -32,7 +34,7 @@ module Samagotchi
|
|
|
32
34
|
# @param results, warnings, conflicts the Installer's (results and
|
|
33
35
|
# warnings are added to)
|
|
34
36
|
def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:,
|
|
35
|
-
force: false, strict: true, upgrade: false, dry_run: false)
|
|
37
|
+
force: false, strict: true, upgrade: false, dry_run: false, dev: false)
|
|
36
38
|
@name = name
|
|
37
39
|
@bundle_dir = source_dir
|
|
38
40
|
@manifest = manifest
|
|
@@ -45,6 +47,7 @@ module Samagotchi
|
|
|
45
47
|
@strict = strict
|
|
46
48
|
@upgrade = upgrade
|
|
47
49
|
@dry_run = dry_run
|
|
50
|
+
@dev = dev
|
|
48
51
|
end
|
|
49
52
|
|
|
50
53
|
# @return [Installed]
|
|
@@ -52,8 +55,8 @@ module Samagotchi
|
|
|
52
55
|
# script the bundle doesn't have
|
|
53
56
|
def run
|
|
54
57
|
install_hooks
|
|
55
|
-
prune_dropped_hooks if @upgrade && @existing && !@dry_run
|
|
56
|
-
verify_hook_checksums if @manifest && @strict
|
|
58
|
+
prune_dropped_hooks if (@upgrade || (@dev && @existing&.dev)) && @existing && !@dry_run
|
|
59
|
+
verify_hook_checksums if @manifest && @strict && !@dev
|
|
57
60
|
incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
|
|
58
61
|
.reject { |src| linked_out?(src, "guardrails/#{File.basename(src)}") }
|
|
59
62
|
guardrail_files = install_guardrails(incoming_rules)
|
|
@@ -109,7 +112,7 @@ module Samagotchi
|
|
|
109
112
|
|
|
110
113
|
FileUtils.mkdir_p(@provenance.hooks_dir)
|
|
111
114
|
unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
|
|
112
|
-
|
|
115
|
+
place(src, dest)
|
|
113
116
|
@results[basename] = if unchanged
|
|
114
117
|
{ status: "skipped", reason: "already up to date" }
|
|
115
118
|
elsif @upgrade && @existing
|
|
@@ -132,7 +135,7 @@ module Samagotchi
|
|
|
132
135
|
else
|
|
133
136
|
FileUtils.mkdir_p(@provenance.hooks_dir)
|
|
134
137
|
dest = File.join(@provenance.hooks_dir, basename)
|
|
135
|
-
|
|
138
|
+
place(src, dest)
|
|
136
139
|
@results[basename] = { status: "installed" }
|
|
137
140
|
@hook_files[basename] = dest
|
|
138
141
|
end
|
|
@@ -140,13 +143,13 @@ module Samagotchi
|
|
|
140
143
|
end
|
|
141
144
|
|
|
142
145
|
# An upgrade removes the hooks the previous version shipped and this
|
|
143
|
-
# one doesn't (a local file too, with a warning).
|
|
146
|
+
# one doesn't (a local file too, with a warning; a dev link silently).
|
|
144
147
|
def prune_dropped_hooks
|
|
145
148
|
@existing.hooks.each_key do |old_key_str|
|
|
146
149
|
next if @bundle_hooks.include?(old_key_str)
|
|
147
150
|
|
|
148
151
|
old_dest = File.join(@provenance.hooks_dir, old_key_str)
|
|
149
|
-
if File.exist?(old_dest) && !@force
|
|
152
|
+
if File.exist?(old_dest) && !@force && !File.symlink?(old_dest)
|
|
150
153
|
@warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
|
|
151
154
|
end
|
|
152
155
|
FileUtils.rm_f(old_dest)
|
|
@@ -193,7 +196,7 @@ module Samagotchi
|
|
|
193
196
|
FileUtils.mkdir_p(target)
|
|
194
197
|
incoming_rules.to_h do |src|
|
|
195
198
|
dest = File.join(target, File.basename(src))
|
|
196
|
-
|
|
199
|
+
place(src, dest)
|
|
197
200
|
@results["guardrails/#{File.basename(src)}"] =
|
|
198
201
|
unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
|
|
199
202
|
[File.basename(src), dest]
|
|
@@ -227,7 +230,7 @@ module Samagotchi
|
|
|
227
230
|
unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
|
|
228
231
|
FileUtils.rm_rf(@provenance.plugin_dir)
|
|
229
232
|
FileUtils.mkdir_p(@provenance.plugin_dir)
|
|
230
|
-
|
|
233
|
+
place(src, dest)
|
|
231
234
|
@results[plugin[:file]] = if unchanged
|
|
232
235
|
{ status: "skipped", reason: "already up to date" }
|
|
233
236
|
elsif @upgrade && @provenance.installed?
|
|
@@ -237,7 +240,7 @@ module Samagotchi
|
|
|
237
240
|
end
|
|
238
241
|
expected = @manifest.checksum_for_plugin
|
|
239
242
|
actual = Digest::SHA256.hexdigest(File.binread(dest))
|
|
240
|
-
if @strict && expected && actual != expected
|
|
243
|
+
if @strict && !@dev && expected && actual != expected
|
|
241
244
|
@warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
242
245
|
end
|
|
243
246
|
dest
|
|
@@ -253,6 +256,8 @@ module Samagotchi
|
|
|
253
256
|
src = File.join(@bundle_dir, "scripts", file)
|
|
254
257
|
raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have" unless File.file?(src)
|
|
255
258
|
raise Installer::InstallError, "scripts/#{file} is a symlink (not installed)" if linked?(src)
|
|
259
|
+
|
|
260
|
+
@warnings << "scripts/#{file} is not executable; chmod +x #{src}" if @dev && !File.executable?(src)
|
|
256
261
|
end
|
|
257
262
|
return {} if @dry_run
|
|
258
263
|
|
|
@@ -266,21 +271,28 @@ module Samagotchi
|
|
|
266
271
|
FileUtils.mkdir_p(@provenance.scripts_dir)
|
|
267
272
|
scripts.to_h do |file, declared|
|
|
268
273
|
dest = File.join(@provenance.scripts_dir, file)
|
|
269
|
-
|
|
274
|
+
place(File.join(@bundle_dir, "scripts", file), dest)
|
|
270
275
|
@results["scripts/#{file}"] = if unchanged.include?(file) then { status: "skipped", reason: "already up to date" }
|
|
271
276
|
elsif @upgrade && @provenance.installed? then { status: "updated" }
|
|
272
277
|
else { status: "installed" }
|
|
273
278
|
end
|
|
274
|
-
FileUtils.chmod(0o755, dest)
|
|
279
|
+
FileUtils.chmod(0o755, dest) unless @dev
|
|
275
280
|
actual = Digest::SHA256.hexdigest(File.binread(dest))
|
|
276
281
|
expected = declared.to_s.delete_prefix("sha256:")
|
|
277
|
-
if @strict && !expected.empty? && actual != expected
|
|
282
|
+
if @strict && !@dev && !expected.empty? && actual != expected
|
|
278
283
|
@warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
|
|
279
284
|
end
|
|
280
285
|
[file, dest]
|
|
281
286
|
end
|
|
282
287
|
end
|
|
283
288
|
|
|
289
|
+
# Copies +src+ to +dest+, or (a dev install) links +dest+ to it. An
|
|
290
|
+
# existing link goes first: a copy over it would write into the source.
|
|
291
|
+
def place(src, dest)
|
|
292
|
+
FileUtils.rm_f(dest) if File.symlink?(dest)
|
|
293
|
+
@dev ? File.symlink(File.realpath(src), dest) : FileUtils.cp(src, dest)
|
|
294
|
+
end
|
|
295
|
+
|
|
284
296
|
# Whether the bundle file +src+ reads through a symlink: it is one, or
|
|
285
297
|
# it (or its dir, hooks/ say) resolves outside the bundle dir. A tar,
|
|
286
298
|
# zip or git repo keeps links; copying through one would install any
|
|
@@ -22,13 +22,15 @@ module Samagotchi
|
|
|
22
22
|
# includes a profile's recorded members, nil for a plain bundle
|
|
23
23
|
# session_settings
|
|
24
24
|
# the keys a session may set (chi --set), [] when none
|
|
25
|
+
# dev a dev install (`chi bundle install --dev`): its source is
|
|
26
|
+
# the folder it links to and its files carry Provenance::DEV_SHA
|
|
25
27
|
# error why manifest.json couldn't be read (the rest empty), else nil
|
|
26
28
|
class InstalledBundle < Data.define(:name, :version, :scope, :source, :installed_at, :trust_level, :source_commit,
|
|
27
29
|
:requires_chi, :files, :hooks, :guardrails, :scripts, :plugin, :needs,
|
|
28
|
-
:includes, :context_providers, :session_settings, :error)
|
|
30
|
+
:includes, :context_providers, :session_settings, :dev, :error)
|
|
29
31
|
OPTIONAL = { version: nil, scope: nil, source: nil, installed_at: nil, trust_level: nil, source_commit: nil,
|
|
30
32
|
requires_chi: nil, files: {}, hooks: {}, guardrails: {}, scripts: {}, plugin: nil, needs: nil,
|
|
31
|
-
includes: nil, context_providers: nil, session_settings: [], error: nil }.freeze
|
|
33
|
+
includes: nil, context_providers: nil, session_settings: [], dev: false, error: nil }.freeze
|
|
32
34
|
|
|
33
35
|
def initialize(name:, **fields)
|
|
34
36
|
super(name: name, **OPTIONAL, **fields)
|
|
@@ -60,7 +62,8 @@ module Samagotchi
|
|
|
60
62
|
scripts: mapping(h["scripts"]) { |meta| sha(meta) },
|
|
61
63
|
plugin: PluginRef.parse(h["plugin"]), needs: h["needs"], context_providers: h["context_providers"],
|
|
62
64
|
includes: h["includes"].is_a?(Array) ? h["includes"].map(&:to_s) : nil,
|
|
63
|
-
session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : []
|
|
65
|
+
session_settings: h["session_settings"].is_a?(Array) ? h["session_settings"].map(&:to_s) : [],
|
|
66
|
+
dev: h["dev"] == true)
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
def self.string(value) = value&.to_s
|
|
@@ -40,7 +40,7 @@ module Samagotchi
|
|
|
40
40
|
# installs from a dir in it only, unless force); ShippedUpdate,
|
|
41
41
|
# Profile and SystemBundle pass the one they install from.
|
|
42
42
|
def initialize(source:, name:, scope: nil, force: false, strict: true, upgrade: false, dry_run: false, prepared: nil,
|
|
43
|
-
shipped_dir: SourceNormalizer::SHIPPED_DIR)
|
|
43
|
+
shipped_dir: SourceNormalizer::SHIPPED_DIR, dev: false)
|
|
44
44
|
@source = source
|
|
45
45
|
@shipped_dir = shipped_dir
|
|
46
46
|
@prepared = prepared
|
|
@@ -50,6 +50,7 @@ module Samagotchi
|
|
|
50
50
|
@strict = strict
|
|
51
51
|
@upgrade = upgrade
|
|
52
52
|
@dry_run = dry_run
|
|
53
|
+
@dev = dev
|
|
53
54
|
@results = {}
|
|
54
55
|
@warnings = []
|
|
55
56
|
@placeholder_warnings = []
|
|
@@ -98,7 +99,7 @@ module Samagotchi
|
|
|
98
99
|
installed = AssetInstaller.new(
|
|
99
100
|
name: @name, source_dir: @bundle_dir, manifest: @manifest, provenance: @provenance, existing: @existing,
|
|
100
101
|
results: @results, warnings: @warnings, conflicts: @conflicts,
|
|
101
|
-
force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run
|
|
102
|
+
force: @force, strict: @strict, upgrade: @upgrade, dry_run: @dry_run, dev: @dev
|
|
102
103
|
).run
|
|
103
104
|
|
|
104
105
|
# Files the previous version shipped and this one doesn't.
|
|
@@ -172,10 +173,14 @@ module Samagotchi
|
|
|
172
173
|
def check_source
|
|
173
174
|
refuse_case_twin
|
|
174
175
|
refuse_shipped_name
|
|
176
|
+
refuse_dev_mismatch
|
|
175
177
|
now = incoming_identity
|
|
176
178
|
return if now.nil?
|
|
177
179
|
|
|
178
180
|
was = @existing && SourceIdentity.of(@existing.source)
|
|
181
|
+
if @dev && @existing&.dev && was != now
|
|
182
|
+
raise InstallError, "#{@name} is a dev install of #{@existing.source}; uninstall it before installing from #{@source}"
|
|
183
|
+
end
|
|
179
184
|
return if was.nil? || was == now
|
|
180
185
|
return @source_change = [@existing.source, @source.to_s] if @upgrade
|
|
181
186
|
|
|
@@ -191,6 +196,19 @@ module Samagotchi
|
|
|
191
196
|
raise InstallError, "#{@name} differs only in case from the installed #{twin}" if twin
|
|
192
197
|
end
|
|
193
198
|
|
|
199
|
+
# A dev install (--dev, linked to its source) and a normal one don't
|
|
200
|
+
# replace each other: neither an upgrade nor a plain install over a
|
|
201
|
+
# dev bundle, nor --dev over a normal one.
|
|
202
|
+
def refuse_dev_mismatch
|
|
203
|
+
return if @existing.nil? || @existing.dev == @dev
|
|
204
|
+
|
|
205
|
+
if @existing.dev
|
|
206
|
+
raise InstallError, "#{@name} is a dev install of #{@existing.source}: it follows its source already " \
|
|
207
|
+
"(to leave dev mode: chi bundle uninstall #{@name}, then chi bundle install without --dev)"
|
|
208
|
+
end
|
|
209
|
+
raise InstallError, "#{@name} is installed normally; `chi bundle uninstall #{@name}` first, then chi bundle install --dev"
|
|
210
|
+
end
|
|
211
|
+
|
|
194
212
|
# A shipped name (in any case) from anything but this chi's own
|
|
195
213
|
# shipped dir. A recorded source may be an older gem's (SourceIdentity
|
|
196
214
|
# matches the path); an incoming one must be a dir in SHIPPED_DIR, so
|
|
@@ -421,7 +439,8 @@ module Samagotchi
|
|
|
421
439
|
scripts_files: installed.script_files,
|
|
422
440
|
context_providers: @manifest.context_providers,
|
|
423
441
|
file_descriptions: @manifest.file_descriptions,
|
|
424
|
-
session_settings: @manifest.session_settings
|
|
442
|
+
session_settings: @manifest.session_settings,
|
|
443
|
+
dev: @dev
|
|
425
444
|
)
|
|
426
445
|
end
|
|
427
446
|
|