samagotchi 0.132.0 → 0.133.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/bin/chi +16 -0
  4. data/docs/architecture.md +28 -1
  5. data/docs/cli.md +1 -1
  6. data/docs/configuration.md +55 -12
  7. data/docs/desktop.md +2 -1
  8. data/docs/guardrails.md +7 -1
  9. data/docs/sessions.md +60 -6
  10. data/docs/testing.md +1 -1
  11. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  12. data/lib/samagotchi/bundles/mcp/plugin.rb +10 -3
  13. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  14. data/lib/samagotchi/chi_shim.rb +77 -0
  15. data/lib/samagotchi/child_env.rb +5 -3
  16. data/lib/samagotchi/config.rb +2 -0
  17. data/lib/samagotchi/context_fetch.rb +4 -2
  18. data/lib/samagotchi/desktop/macos.rb +26 -0
  19. data/lib/samagotchi/guardrails/pair_boundary.rb +21 -2
  20. data/lib/samagotchi/guardrails/pair_command.rb +57 -1
  21. data/lib/samagotchi/guardrails/review_paths.rb +7 -4
  22. data/lib/samagotchi/home_command.rb +15 -19
  23. data/lib/samagotchi/home_doctor.rb +177 -0
  24. data/lib/samagotchi/home_notice.rb +80 -0
  25. data/lib/samagotchi/project_env.rb +360 -0
  26. data/lib/samagotchi/self_report.rb +33 -0
  27. data/lib/samagotchi/session_commands.rb +4 -2
  28. data/lib/samagotchi/session_manager.rb +18 -0
  29. data/lib/samagotchi/testing/boundary.rb +3 -1
  30. data/lib/samagotchi/tools/builtins.rb +13 -2
  31. data/lib/samagotchi/version.rb +1 -1
  32. data/lib/samagotchi/web/app.rb +50 -0
  33. data/lib/samagotchi/web/public/app.js +4 -1
  34. data/lib/samagotchi/web/public/chip_popover.js +5 -2
  35. data/lib/samagotchi/web/public/composer.css +11 -1
  36. data/lib/samagotchi/web/public/data.js +8 -4
  37. data/lib/samagotchi/web/public/index.html +51 -13
  38. data/lib/samagotchi/web/public/level_chip.js +22 -38
  39. data/lib/samagotchi/web/public/llm_context_chip.js +26 -70
  40. data/lib/samagotchi/web/public/review_start.js +13 -57
  41. data/lib/samagotchi/web/public/send_turn.js +3 -2
  42. data/lib/samagotchi/web/public/start_page.js +117 -97
  43. data/lib/samagotchi/web/public/start_settings.js +298 -0
  44. data/lib/samagotchi/web/public/start_toolkit.js +157 -0
  45. data/lib/samagotchi/web/public/thinking_chip.js +9 -22
  46. metadata +7 -1
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "shellwords"
5
+ require_relative "atomic_file"
6
+ require_relative "chi_home"
7
+ require_relative "log"
8
+ require_relative "paths"
9
+
10
+ module Samagotchi
11
+ # The `chi` a worker's tools run: $XDG_STATE_HOME/samagotchi/bin/chi, a
12
+ # shell script that execs the chi home's Ruby with its boot
13
+ # (ChiHome::Record#boot_argv, CHI_BOOT). ProjectEnv puts its dir first on
14
+ # the worker's PATH, so after the project env (whose PATH would find the
15
+ # project Ruby's chi, an old one or none) an agent's `chi send`, `chi self`
16
+ # or `chi answer` still runs the home's chi. The dir holds `chi` only: the
17
+ # project's ruby, gem and bundle stay the project's; bin/chi, a checkout
18
+ # path or `bundle exec chi` stay themselves.
19
+ module ChiShim
20
+ NAME = "chi"
21
+ HEADER = "# chi's own chi for its tools (ChiShim): rewritten at each worker start from the chi home.\n"
22
+ # A project's RUBYOPT/RUBYLIB/RUBYGEMS_GEMDEPS/GEMRC/BUNDLE_* (a `bundle
23
+ # exec` the tool runs under, a mise [env]) would load the project's gems
24
+ # or settings into the home's chi, its LD_*/DYLD_* libraries into its
25
+ # ruby: unset before the exec.
26
+ UNSET = "unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS GEMRC LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES " \
27
+ "DYLD_LIBRARY_PATH\n" \
28
+ "for v in $(/usr/bin/env | /usr/bin/sed -n 's/^\\(BUNDLE[A-Za-z0-9_]*\\)=.*/\\1/p'); do unset \"$v\"; done\n"
29
+
30
+ module_function
31
+
32
+ # @return [String] $XDG_STATE_HOME/samagotchi/bin
33
+ def dir(env: ENV) = File.join(Paths.state_dir(env: env), "bin")
34
+
35
+ def path(dir: self.dir) = File.join(dir, NAME)
36
+
37
+ # The script for +home+.
38
+ # @param home [ChiHome::Record]
39
+ def content(home)
40
+ argv = home.boot_argv(run: ChiHome::CHI_BOOT) + ["--"]
41
+ "#!/bin/sh\n#{HEADER}#{UNSET}exec #{argv.map { |arg| Shellwords.escape(arg) }.join(" ")} \"$@\"\n"
42
+ end
43
+
44
+ # Writes the shim for +home+ unless it is already that (atomically: a
45
+ # tool running `chi` meanwhile gets the old one or the new one).
46
+ # @return [Boolean] whether the shim is there and current
47
+ def ensure!(home, dir: self.dir)
48
+ file = path(dir: dir)
49
+ want = content(home)
50
+ return true if current?(file, want)
51
+
52
+ FileUtils.mkdir_p(dir)
53
+ AtomicFile.write(file, want, perm: 0o755)
54
+ true
55
+ rescue SystemCallError => e
56
+ Log.warn(:worker, "chi_shim_failed", path: file, error: e.class.name, msg: e.message)
57
+ false
58
+ end
59
+
60
+ # Why the shim isn't +home+'s (chi home doctor), or nil: nil too
61
+ # without one (the first worker start writes it).
62
+ # @return [String, nil]
63
+ def problem(home, dir: self.dir)
64
+ file = path(dir: dir)
65
+ return nil if !File.exist?(file) || current?(file, content(home))
66
+
67
+ "the chi script tools run (#{Paths.tilde(file)}) boots another chi than the home: " \
68
+ "the next worker start rewrites it (chi sessions restart ID for a running session)"
69
+ end
70
+
71
+ def current?(file, want)
72
+ File.file?(file) && File.executable?(file) && File.read(file) == want
73
+ rescue SystemCallError
74
+ false
75
+ end
76
+ end
77
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "installed_gem"
4
4
  require_relative "installed_versions"
5
+ require_relative "project_env"
5
6
 
6
7
  module Samagotchi
7
8
  # What a chi process another chi starts must not take from its spawner's
@@ -23,11 +24,12 @@ module Samagotchi
23
24
  module_function
24
25
 
25
26
  # The keys of +env+ a child chi must not inherit: the guardrails
26
- # settings, the session markers and a smoke's fake "newest installed"
27
- # (InstalledVersions::ENV_KEY, for the spawner's notices only).
27
+ # settings, the session markers, a smoke's fake "newest installed"
28
+ # (InstalledVersions::ENV_KEY, for the spawner's notices only) and the
29
+ # worker's project env marker and record (a child worker applies its own).
28
30
  # @return [Array<String>]
29
31
  def spawner_keys(env = ENV)
30
- env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS + [InstalledVersions::ENV_KEY]
32
+ env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS + [InstalledVersions::ENV_KEY, ProjectEnv::MARKER_ENV, ProjectEnv::ORIG_ENV]
31
33
  end
32
34
 
33
35
  # #spawner_keys mapped to nil: Process.spawn unsets a key mapped to nil.
@@ -138,6 +138,8 @@ module Samagotchi
138
138
  Entry.new(key: "session.keep_status", yaml_path: %w[session keep_status], type: :string, default: "", expose: %i[env config cli], lifecycle: :next_retention_sweep),
139
139
  Entry.new(key: "session.sweep_interval_hours", yaml_path: %w[session sweep_interval_hours], type: :integer, default: 24, expose: %i[env config cli], lifecycle: :next_retention_sweep),
140
140
  Entry.new(key: "session.idle_exit_minutes", yaml_path: %w[session idle_exit_minutes], type: :float, default: 30.0, expose: %i[env config cli], lifecycle: :worker_start),
141
+ # Tools follow the project: a worker applies its folder's `mise env` at start (ProjectEnv); off keeps the spawner's env.
142
+ Entry.new(key: "session.project_env", yaml_path: %w[session project_env], type: :enum, default: "mise", expose: %i[env config], enum_values: %w[mise off], lifecycle: :worker_start),
141
143
  # Plain `chi` runs like `chi --shared` (bin/chi, LaunchMode); false, or --no-shared per run, keeps the plain REPL. No CLI flag: that would duplicate --shared.
142
144
  Entry.new(key: "session.shared", yaml_path: %w[session shared], type: :bool, default: true, expose: %i[env config], lifecycle: :next_invocation),
143
145
  # false: a session nothing happened in is deleted when it is left (SessionManager.discardable?).
@@ -5,6 +5,7 @@ require_relative "context_sources"
5
5
  require_relative "context_providers"
6
6
  require_relative "log"
7
7
  require_relative "process_group"
8
+ require_relative "project_env"
8
9
 
9
10
  module Samagotchi
10
11
  # Runs an attached context source's command once and records the result
@@ -108,8 +109,9 @@ module Samagotchi
108
109
 
109
110
  def env_for(location, name)
110
111
  previous = location.snapshot_path(name)
111
- { "SAMAGOTCHI_CONTEXT_NAME" => name,
112
- "SAMAGOTCHI_CONTEXT_PREVIOUS" => File.exist?(previous) ? previous : nil }
112
+ # The session's project env (ProjectEnv) under the source's own keys.
113
+ ProjectEnv.tools_env.merge("SAMAGOTCHI_CONTEXT_NAME" => name,
114
+ "SAMAGOTCHI_CONTEXT_PREVIOUS" => File.exist?(previous) ? previous : nil)
113
115
  end
114
116
 
115
117
  # @return [Run]
@@ -233,6 +233,32 @@ module Samagotchi
233
233
  argv && argv_problem(argv)
234
234
  end
235
235
 
236
+ # Why the launch file doesn't boot +home+ (chi home doctor), or nil:
237
+ # it runs [ruby, chi] (installed before the home), boots another Ruby
238
+ # or gem path (the home was set since), or bakes GEM_HOME/GEM_PATH
239
+ # under the home's boot. Each fixed by chi desktop upgrade. nil without
240
+ # a launch file.
241
+ # @param home [ChiHome::Record]
242
+ # @return [String, nil]
243
+ def home_problem(home)
244
+ launch = read_launch
245
+ argv = usable_argv(launch)
246
+ return nil unless argv
247
+
248
+ unless home_boot?(argv)
249
+ return "the desktop helper runs #{argv.first(2).join(" ")}, not the chi home's boot: chi desktop upgrade"
250
+ end
251
+ unless ChiHome.real(argv[0]) == ChiHome.real(home.ruby) && argv[2].to_s.start_with?("#{home.prelude};")
252
+ return "the desktop helper boots the chi home as it was (#{argv[0]}), not as it is now: chi desktop upgrade"
253
+ end
254
+
255
+ env = launch["env"]
256
+ baked = env.is_a?(Hash) ? GEM_ENV.select { |key| env.key?(key) } : []
257
+ return nil if baked.empty?
258
+
259
+ "the desktop helper's launch file sets #{baked.join(", ")} for its sessions' tools: chi desktop upgrade"
260
+ end
261
+
236
262
  # The chi version the launch file names, nil without one.
237
263
  def launch_version = read_launch["version"]
238
264
 
@@ -79,6 +79,10 @@ module Samagotchi
79
79
  "approves it"
80
80
  GIT_CONFIG = "a participant's turn can't change git's global config or set what makes git run a command " \
81
81
  "(core.hooksPath, core.fsmonitor, core.sshCommand, core.pager, core.editor, ! aliases)"
82
+ ENV_LOADER = "a participant's turn runs mise or direnv: they load the project's env files (a mise.local.toml, " \
83
+ "an .envrc), which may hold the owner's secrets, so the owner approves it"
84
+ MISE_CONFIG = "a participant's turn changes mise's config: the owner's next worker start runs it (its [env] " \
85
+ "can run commands), so the owner approves it"
82
86
  GIT_HOOKS = "a participant's turn can't write git's hooks (.git/hooks): git runs them in the owner's name"
83
87
  # chi web on loopback asks the owner's token too (Web::App#token_gate,
84
88
  # LAN mode); this text check stays as defense in depth: a loopback-only
@@ -127,7 +131,8 @@ module Samagotchi
127
131
  ParentApprovals::CHI_TEXT,
128
132
  %r{\.local/state/samagotchi|web-grants|grants\.json|web-token},
129
133
  %r{(?:\A|[\s/'"=])\.(?:ssh|gnupg|aws|kube|docker)(?![\w.-])},
130
- %r{(?:\A|[\s/'"=])(?:\.env(?:\.[\w.-]*)?|[._]netrc|\.npmrc|\.pypirc|\.git-credentials)(?![\w-])},
134
+ %r{(?:\A|[\s/'"=])(?:\.env(?:\.[\w.-]*)?|\.envrc|\.?mise(?:\.[\w-]+)?\.local\.toml|(?:\.config/mise|\.?mise)/config(?:\.[\w-]+)?\.local\.toml|[._]netrc|\.npmrc|\.pypirc|
135
+ \.git-credentials)(?![\w-])}x,
131
136
  /\bid_(?:rsa|dsa|ecdsa|ed25519)\b|credentials|\.(?:pem|p12|pfx|tfstate)\b/
132
137
  )
133
138
 
@@ -187,16 +192,30 @@ module Samagotchi
187
192
  end
188
193
 
189
194
  # Why the owner is asked (fresh, once): a command that makes a link
190
- # (LINK), a write that may land outside the project (OUTSIDE_WRITE, or
195
+ # (LINK), one that runs mise or direnv (ENV_LOADER), a write to mise's
196
+ # config (MISE_CONFIG), a write that may land outside the project (OUTSIDE_WRITE, or
191
197
  # NO_PROJECT without one); nil when nothing asks.
192
198
  def ask_reason(verdict)
193
199
  targets = verdict.targets
194
200
  return LINK if targets&.command && command(targets, nil).links?
201
+ return ENV_LOADER if targets&.command && command(targets, nil).loads_env?
202
+ return MISE_CONFIG if mise_config_write?(verdict)
195
203
  return nil unless outside_write?(verdict)
196
204
 
197
205
  project_root ? OUTSIDE_WRITE : NO_PROJECT
198
206
  end
199
207
 
208
+ # A write or edit of a mise config file (PairCommand.mise_config?), or a
209
+ # command that writes or names one (PairCommand#mise_config_write?).
210
+ def mise_config_write?(verdict)
211
+ targets = verdict.targets
212
+ return false unless targets
213
+ return command(targets, nil).mise_config_write? if targets.command
214
+ return false unless WRITE_TOOLS.include?(verdict.call&.dig(:name).to_s)
215
+
216
+ targets.paths.to_a.compact.any? { |path| PairCommand.mise_config?(File.expand_path(path, targets.cwd || Dir.pwd)) }
217
+ end
218
+
200
219
  # A write or edit whose path may land outside the project root, or a
201
220
  # command that may write there (PairCommand#outside_writes?); a tmp
202
221
  # dir the project isn't in aside (PairCommand.writable?).
@@ -52,6 +52,9 @@ module Samagotchi
52
52
  ENV_READERS = %w[printenv security op].freeze
53
53
  # Commands that print a stored credential (a token, a password, a
54
54
  # key), by the program and the words that say so in its arguments.
55
+ # Programs that load a project's env files (PairBoundary::ENV_LOADER).
56
+ ENV_LOADERS = %w[mise direnv].freeze
57
+ ENV_LOADER_TEXT = %r{(?<![\w./-])(?:mise|direnv)(?![\w./-])}
55
58
  CREDENTIAL_PRINTERS = {
56
59
  "gh" => [%w[auth token]],
57
60
  "git" => [%w[credential], %w[credential-osxkeychain], %w[credential-store], %w[credential-cache],
@@ -139,6 +142,20 @@ module Samagotchi
139
142
  Outside.inside?(path, root: root) || Outside.tmp_write?(path, root: root)
140
143
  end
141
144
 
145
+ # mise's config files (mise.toml, .mise.toml, mise.<env>.toml, .config/mise.toml,
146
+ # anything under .config/mise/, mise/ or .mise/ config*.toml): mise runs
147
+ # their [env] at the next worker start (ProjectEnv).
148
+ MISE_CONFIG_PATH = %r{(?:\A|/)(?:\.?mise(?:\.[^/]+)?\.toml|\.config/mise\.toml|\.config/mise/.+|
149
+ \.?mise/config[^/]*\.toml)\z}x
150
+ MISE_CONFIG_TEXT = %r{(?<![\w-])(?:\.?mise(?:\.[\w.-]+)?\.toml|\.config/mise|\.?mise/config[\w.-]*\.toml)}
151
+
152
+ # Whether +path+ is one of mise's config files (MISE_CONFIG_PATH), as
153
+ # given or as it resolves.
154
+ def self.mise_config?(path)
155
+ path.to_s.match?(MISE_CONFIG_PATH) ||
156
+ (path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(MISE_CONFIG_PATH) })
157
+ end
158
+
142
159
  # Whether +path+ is in a .git/hooks folder, as given or as it resolves
143
160
  # (ShellPaths::HOOKS_PART): git runs what is there.
144
161
  def self.git_hook?(path)
@@ -193,6 +210,7 @@ module Samagotchi
193
210
 
194
211
  @refusal = read
195
212
  rescue StandardError
213
+ @mise_config = true
196
214
  @refusal = PairBoundary::UNREADABLE
197
215
  end
198
216
 
@@ -210,6 +228,28 @@ module Samagotchi
210
228
  @links = true
211
229
  end
212
230
 
231
+ # Whether it runs mise or direnv (ENV_LOADERS): they load the
232
+ # project's env files (a mise.local.toml, an .envrc), which hold the
233
+ # owner's secrets, into what the command prints; the owner approves
234
+ # it. True for one it can't read.
235
+ def loads_env?
236
+ return @loads_env if defined?(@loads_env)
237
+
238
+ @loads_env = @depth > MAX_DEPTH || ShellLex.simple_commands(ShellLex.lex(@text)).any? do |words|
239
+ words.is_a?(Array) && env_loader_command?(words)
240
+ end
241
+ rescue StandardError
242
+ @loads_env = true
243
+ end
244
+
245
+ # Whether it writes (or, not only reading, names) a mise config file
246
+ # (.mise_config?), a script it hands on included: the owner approves
247
+ # it. True for one it can't read.
248
+ def mise_config_write?
249
+ refusal
250
+ @mise_config ||= false
251
+ end
252
+
213
253
  # Whether it may write outside the project (asked about, never
214
254
  # denied); true for one it can't read.
215
255
  def outside_writes?
@@ -313,8 +353,10 @@ module Samagotchi
313
353
  program = File.basename(words[at])
314
354
  scripts(words, at).each do |script|
315
355
  reason = if SHELLS.include?(program) || program == "eval"
316
- nested(script).refusal
356
+ inner = nested(script)
357
+ inner.refusal.tap { @mise_config = true if inner.mise_config_write? }
317
358
  elsif INTERPRETERS.include?(program)
359
+ @mise_config = true if script.match?(MISE_CONFIG_TEXT)
318
360
  script_text_refusal(script)
319
361
  end
320
362
  return reason if reason
@@ -448,6 +490,8 @@ module Samagotchi
448
490
  resolved.each do |word, path|
449
491
  return PairBoundary::GIT_HOOKS if !@read_only && PairCommand.git_hook?(path || word)
450
492
 
493
+ @mise_config = true if !@read_only && PairCommand.mise_config?(path || word)
494
+
451
495
  if path
452
496
  return PairBoundary::SECRET if @paths.secret_refusal([path])
453
497
  return PairBoundary::HOME_SWEEP if SWEEPERS.include?(program_word) && above_denied?(path)
@@ -512,6 +556,18 @@ module Samagotchi
512
556
  end
513
557
  end
514
558
 
559
+ def env_loader_command?(words)
560
+ at = program_at(words)
561
+ return true if at && ENV_LOADERS.include?(File.basename(words[at]))
562
+ return true if words.each_cons(2).any? { |option, word| CODE_OPTIONS.include?(option) && ENV_LOADERS.include?(File.basename(word)) }
563
+
564
+ runner = (0..(at || (words.size - 1))).find { |i| runner?(words[i]) } or return false
565
+ name = File.basename(words[runner])
566
+ scripts(words, runner).any? do |script|
567
+ SHELLS.include?(name) || name == "eval" ? nested(script).loads_env? : script.match?(ENV_LOADER_TEXT)
568
+ end
569
+ end
570
+
515
571
  def nested(script) = PairCommand.new(script, cwd: @cwd, paths: @paths, home: @home, root: @root,
516
572
  depth: @depth + 1, web: @web)
517
573
 
@@ -30,13 +30,16 @@ module Samagotchi
30
30
  .netrc .npmrc .pypirc .claude.json .zsh_history .bash_history .history .python_history
31
31
  .node_repl_history .psql_history .mysql_history .irb_history .lesshst
32
32
  .local/share/fish/fish_history].freeze
33
- # Project files that hold secrets, by a path part's name: .env*,
34
- # netrc, keys and certificates, anything named credentials, package
33
+ # Project files that hold secrets, by a path part's name: .env* (.envrc
34
+ # too), mise's local files (mise.local.toml, .mise.dev.local.toml), netrc, keys and certificates, anything named credentials, package
35
35
  # registry tokens (.npmrc, .pypirc), ssh keys, terraform state, a
36
36
  # secrets file; and a git config (.git/config, a submodule's
37
37
  # .git/modules/x/config), checked apart.
38
- SECRET_FILE = /\A(?:\.env.*|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
38
+ SECRET_FILE = /\A(?:\.env.*|\.?mise(?:\.[\w-]+)?\.local\.toml|[._]netrc|.*\.(?:pem|key|p12|pfx|tfstate(?:\.backup)?)|.*credentials.*|
39
39
  \.npmrc|\.pypirc|\.git-credentials|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|secrets?\.(?:ya?ml|json))\z/xi
40
+ # mise's local config in its config dirs (.config/mise/config.local.toml,
41
+ # config.<env>.local.toml; mise/ and .mise/ too): by the path's end.
42
+ SECRET_PATH = %r{(?:\A|/)(?:\.config/mise|\.?mise)/config(?:\.[\w-]+)?\.local\.toml\z}i
40
43
  # The .docker/config.json kind: a dot dir's config holding tokens.
41
44
  SECRET_DIR_FILES = { ".docker" => "config.json", ".kube" => "config" }.freeze
42
45
  GIT_DIR = ".git"
@@ -124,7 +127,7 @@ module Samagotchi
124
127
  return true if denied_roots.any? { |root| PathResolve.under?(path, root) }
125
128
 
126
129
  parts = path.split(File::SEPARATOR)
127
- return true if parts.any? { |part| SECRET_FILE.match?(part) }
130
+ return true if parts.any? { |part| SECRET_FILE.match?(part) } || path.match?(SECRET_PATH)
128
131
  return true if parts.each_cons(2).any? { |dir, file| SECRET_DIR_FILES[dir.downcase] == file.downcase }
129
132
 
130
133
  git_config?(parts)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rbconfig"
4
4
  require_relative "chi_home"
5
+ require_relative "home_doctor"
5
6
  require_relative "cli/command"
6
7
  require_relative "cli/exit"
7
8
  require_relative "cli/flags"
@@ -12,7 +13,7 @@ require_relative "version"
12
13
  module Samagotchi
13
14
  # `chi home`: the chi home (ChiHome), the Ruby every chi launch boots.
14
15
  # Shows it, sets it (`chi home set [--ruby PATH]`), and checks it
15
- # (`chi home doctor`: for now whether its Ruby is there and has chi).
16
+ # (`chi home doctor`, HomeDoctor's rows).
16
17
  class HomeCommand
17
18
  include CLI::Command
18
19
 
@@ -23,7 +24,9 @@ module Samagotchi
23
24
  GEM_HOME/GEM_PATH are part of it)
24
25
  set --ruby PATH record that Ruby (its own gem dirs) as the home; it must have samagotchi
25
26
  (gem install samagotchi with it first)
26
- doctor check the home: its Ruby is there and has samagotchi
27
+ doctor check the home (its Ruby is there and has samagotchi) and what should
28
+ follow it: this chi, the desktop helper, sessions, the tools' chi script,
29
+ this project's Gemfile.lock, mise and the last worker's project env
27
30
  Every chi launch (workers, chi web's restart and Update, the macOS helper's next
28
31
  chi desktop install or upgrade) boots the home's Ruby with the home's gems. Saved in
29
32
  #{Paths.tilde(ChiHome.path)}.
@@ -38,8 +41,9 @@ module Samagotchi
38
41
 
39
42
  # @param path [String] home.json
40
43
  # @param probe [#call] (ruby) → ChiHome::Record or nil (ChiHome.probe)
44
+ # @param doctor [#call] (home) → HomeDoctor
41
45
  def initialize(argv, stdout: $stdout, stderr: $stderr, path: ChiHome.path, gem_spec: InstalledGem.spec,
42
- env: ENV, probe: nil)
46
+ env: ENV, probe: nil, doctor: nil)
43
47
  @argv = argv.dup
44
48
  @stdout = stdout
45
49
  @stderr = stderr
@@ -47,6 +51,9 @@ module Samagotchi
47
51
  @gem_spec = gem_spec
48
52
  @env = env
49
53
  @probe = probe || ->(ruby) { ChiHome.probe(ruby, env: env) }
54
+ @doctor = doctor || lambda { |home|
55
+ HomeDoctor.new(home: home, env: env, gem_spec: gem_spec, helper: HomeDoctor.default_helper(env: env))
56
+ }
50
57
  end
51
58
 
52
59
  # @return [Integer] 0, 1 (no usable home, a refusal), 2 usage
@@ -127,23 +134,12 @@ module Samagotchi
127
134
  home
128
135
  end
129
136
 
130
- # P2c adds rows (running chi vs the home, the helper's argv, sessions,
131
- # the project env); for now the home's own.
137
+ # The home's rows and what should follow it (HomeDoctor); exit 1 when
138
+ # one fails.
132
139
  def run_doctor
133
- home = ChiHome.read(path: @path)
134
- unless home
135
- @stdout.puts("ok no chi home: each launch uses the Ruby that starts it (chi home set to record one)")
136
- return CLI::Exit::OK
137
- end
138
-
139
- if (problem = ChiHome.problem(home))
140
- @stdout.puts("fail #{problem}: after a Ruby upgrade, gem install samagotchi with the new Ruby, then " \
141
- "chi home set from it (chi home set --ruby PATH); until then launches use the Ruby that starts them")
142
- return CLI::Exit::FAILED
143
- end
144
-
145
- @stdout.puts("ok chi home #{home.ruby} runs chi #{home.newest_version}")
146
- CLI::Exit::OK
140
+ rows = @doctor.call(ChiHome.read(path: @path)).rows
141
+ rows.each { |row| @stdout.puts(row.to_s) }
142
+ rows.any? { |row| row.status == :fail } ? CLI::Exit::FAILED : CLI::Exit::OK
147
143
  end
148
144
  end
149
145
  end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "chi_home"
5
+ require_relative "chi_shim"
6
+ require_relative "config"
7
+ require_relative "desktop"
8
+ require_relative "home_notice"
9
+ require_relative "installed_gem"
10
+ require_relative "log_path"
11
+ require_relative "paths"
12
+ require_relative "project_env"
13
+ require_relative "session"
14
+ require_relative "version"
15
+
16
+ module Samagotchi
17
+ # `chi home doctor`'s rows: the chi home (ChiHome) and what should follow
18
+ # it, each one line with a status and what to run. One check each; the
19
+ # text is the condition owner's (ChiHome, HomeNotice, Desktop::MacOS,
20
+ # ChiShim, ProjectEnv) where another surface says the same.
21
+ class HomeDoctor
22
+ # +status+ :ok, :warn (works, not as intended) or :fail (a launch can't
23
+ # boot what it should).
24
+ Row = Data.define(:status, :text) do
25
+ def to_s = "#{status.to_s.ljust(5)} #{text}"
26
+ end
27
+
28
+ # What a Gemfile.lock that bundles chi has (a GEM or PATH spec line).
29
+ LOCKED_CHI = /^ {4}samagotchi \(/
30
+
31
+ # @param home [ChiHome::Record, nil] home.json's record
32
+ # @param helper [Desktop::MacOS, nil] the desktop helper (nil off macOS)
33
+ # @param sessions [#call] () → [Session] this machine's sessions
34
+ def initialize(home:, env: ENV, cwd: Dir.pwd, gem_spec: InstalledGem.spec, helper: nil,
35
+ sessions: -> { Session.list(state_dir: Session.default_state_dir(env: env)) },
36
+ shim_dir: ChiShim.dir(env: env), log_path: LogPath.resolve(env: env),
37
+ ruby: RbConfig.ruby, gem_dir: Gem.dir)
38
+ @home = home
39
+ @env = env
40
+ @cwd = cwd
41
+ @gem_spec = gem_spec
42
+ @helper = helper
43
+ @sessions = sessions
44
+ @shim_dir = shim_dir
45
+ @log_path = log_path
46
+ @ruby = ruby
47
+ @gem_dir = gem_dir
48
+ end
49
+
50
+ # @return [Array<Row>]
51
+ def rows
52
+ list = [home_row]
53
+ if @home && !ChiHome.problem(@home)
54
+ list.concat([running_row, helper_row, sessions_row, shim_row].compact)
55
+ end
56
+ list.concat([gemfile_row, mise_row, project_env_row].compact)
57
+ end
58
+
59
+ # The desktop helper chi home doctor checks: this machine's, when it
60
+ # is installed.
61
+ def self.default_helper(env: ENV)
62
+ return nil unless Desktop.supported?
63
+
64
+ helper = Desktop::MacOS.new(env: env, register: false)
65
+ File.file?(helper.launch_path) ? helper : nil
66
+ end
67
+
68
+ private
69
+
70
+ def ok_row(text) = Row.new(status: :ok, text: text)
71
+ def warn_row(text) = Row.new(status: :warn, text: text)
72
+ def fail_row(text) = Row.new(status: :fail, text: text)
73
+
74
+ def home_row
75
+ return ok_row("no chi home: each launch uses the Ruby that starts it (chi home set to record one)") unless @home
76
+
77
+ if (problem = ChiHome.problem(@home))
78
+ return fail_row("#{problem}: after a Ruby upgrade, gem install samagotchi with the new Ruby, then " \
79
+ "chi home set from it (chi home set --ruby PATH); until then launches use the Ruby that starts them")
80
+ end
81
+
82
+ ok_row("chi home #{@home.ruby} runs chi #{@home.newest_version}")
83
+ end
84
+
85
+ def running_row
86
+ return ok_row("this chi runs from a source checkout: it starts its own code, not the home") unless @gem_spec
87
+
88
+ text = HomeNotice.mismatch(@home, ruby: @ruby, gem_dir: @gem_dir)
89
+ text && warn_row("#{text}: launches boot the home; chi home set makes this chi the home")
90
+ end
91
+
92
+ def helper_row
93
+ return nil unless @helper
94
+
95
+ if (problem = @helper.launch_problem)
96
+ fail_row(problem)
97
+ elsif (problem = @helper.home_problem(@home))
98
+ warn_row(problem)
99
+ end
100
+ end
101
+
102
+ # Sessions last saved by a newer chi than the home's: their workers
103
+ # (the home's chi) run them without what that chi added.
104
+ def sessions_row
105
+ newest = @home.newest_version
106
+ newer = @sessions.call.filter_map do |session|
107
+ version = session.written_by
108
+ Gem::Version.new(version) if version.is_a?(String) && Gem::Version.correct?(version)
109
+ end.select { |version| version > newest }
110
+ return nil if newer.empty?
111
+
112
+ count = newer.size == 1 ? "1 session was" : "#{newer.size} sessions were"
113
+ warn_row("#{count} last saved by a newer chi (#{newer.max}) than the home's #{newest}: " \
114
+ "their workers run without what it added; chi update brings the home up to date")
115
+ end
116
+
117
+ def shim_row
118
+ problem = ChiShim.problem(@home, dir: @shim_dir)
119
+ problem && warn_row(problem)
120
+ end
121
+
122
+ # The cwd's project bundling chi (a Gemfile.lock with samagotchi):
123
+ # `bundle exec chi` there runs the bundle's chi, off the home.
124
+ def gemfile_row
125
+ lock = project_lockfile
126
+ return nil unless lock && File.read(lock).match?(LOCKED_CHI)
127
+
128
+ warn_row("#{Paths.tilde(lock)} bundles samagotchi: chi under the project's bundle (bundle exec chi) isn't " \
129
+ "supported; run chi itself, and bundle remove samagotchi there")
130
+ rescue SystemCallError
131
+ nil
132
+ end
133
+
134
+ # The nearest Gemfile.lock from the cwd up to its git root; nil for
135
+ # chi's own source tree (its gemspec is next to it).
136
+ def project_lockfile
137
+ dir = project_dirs.find { |candidate| File.file?(File.join(candidate, "Gemfile.lock")) }
138
+ return nil if dir.nil? || File.file?(File.join(dir, "samagotchi.gemspec"))
139
+
140
+ File.join(dir, "Gemfile.lock")
141
+ end
142
+
143
+ # The cwd and the folders above it up to its git root (the cwd alone
144
+ # outside git).
145
+ def project_dirs
146
+ @project_dirs ||= begin
147
+ dirs = []
148
+ dir = File.expand_path(@cwd)
149
+ loop do
150
+ dirs << dir
151
+ break if File.exist?(File.join(dir, ".git"))
152
+
153
+ parent = File.dirname(dir)
154
+ break dirs = [dirs.first] if parent == dir
155
+
156
+ dir = parent
157
+ end
158
+ dirs
159
+ end
160
+ end
161
+
162
+ def mise_row
163
+ mode, origin = Config.get_with_origin("session.project_env")
164
+ return ok_row("project env off (session.project_env: off): tools keep the env of whoever starts the worker") if mode.to_s == "off"
165
+
166
+ mise = ProjectEnv.find_mise(env: @env)
167
+ return ok_row("project env: mise (#{mise})") if mise
168
+
169
+ origin == :default ? ok_row(ProjectEnv::NO_MISE) : warn_row(ProjectEnv::NO_MISE)
170
+ end
171
+
172
+ def project_env_row
173
+ warning = ProjectEnv.last_warning(project_dirs, log_path: @log_path)
174
+ warning && warn_row(warning.message)
175
+ end
176
+ end
177
+ end