samagotchi 0.123.0 → 0.124.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.
@@ -0,0 +1,272 @@
1
+ // The panel's chi side without the panel: what a send to chi sessions runs
2
+ // (SendPlan, built from the panel's state, run by PanelController), and the
3
+ // build spec's seams: `ChiHelper --sessions` prints the session rows the
4
+ // panel lists, `ChiHelper --send-plan` the chi calls a send would make.
5
+ import Foundation
6
+
7
+ /// A send to chi sessions as the chi calls it makes: `chi send` (a message,
8
+ /// or `--new` on a new row, this Mac's or a remote's) or `chi note`, one per
9
+ /// machine. Pure: nothing runs here.
10
+ struct SendPlan {
11
+ /// One chi call: its args after the launch file's argv, its stdin, its
12
+ /// name in the panel's lines ("chi send"), the remote it goes to and its
13
+ /// time limit.
14
+ struct Call: Equatable {
15
+ let command: String
16
+ let args: [String]
17
+ let stdin: Data?
18
+ var remote: String?
19
+ var timeout: TimeInterval = 10
20
+ }
21
+
22
+ /// A session on a machine: nil is this Mac.
23
+ struct Target: Equatable {
24
+ let remote: String?
25
+ let id: String
26
+ }
27
+
28
+ let kind: SendKind
29
+ /// UUID-shaped session ids and their machines, in list order; not used
30
+ /// with +new+.
31
+ let targets: [Target]
32
+ let new: Bool
33
+ /// The remote a new session starts on; nil: this Mac.
34
+ let newRemote: String?
35
+ /// Where a new session starts (PanelModel.newSessionDir, or
36
+ /// newSessionDir(remote:) on a remote); nil: nowhere known, no call.
37
+ let newDir: String?
38
+ /// `--model` for `chi send --new` on this Mac, verbatim; nil for the
39
+ /// default. Never on a remote (D4: the list is this Mac's).
40
+ let model: String?
41
+ /// The message line, trimmed; `-m` of `chi send`.
42
+ let prompt: String
43
+ /// A message's stdin: the quoted context; nil when there is none.
44
+ let context: String?
45
+ /// `chi note`'s stdin: the message line, then the context.
46
+ let note: String
47
+ /// `chi note --source`, trimmed; "" for none.
48
+ let source: String
49
+ /// `--image` paths; a note's are dropped before (text only).
50
+ let images: [String]
51
+
52
+ /// The panel's state for this send.
53
+ static func from(_ model: PanelModel, kind: SendKind, sessions: [LiveSession], new: Bool, images: [PanelImage]) -> SendPlan {
54
+ let newRemote = new ? model.newRemote : nil
55
+ let newDir = newRemote.map(model.newSessionDir(remote:)) ?? model.newSessionDir
56
+ return SendPlan(kind: kind, targets: sessions.map { Target(remote: $0.remote, id: $0.id) }, new: new, newRemote: newRemote,
57
+ newDir: newDir, model: newRemote == nil ? model.modelForSend : nil,
58
+ prompt: model.trimmedPrompt, context: model.hasContext ? model.text : nil, note: model.noteText,
59
+ source: model.source.trimmingCharacters(in: .whitespacesAndNewlines), images: images.map(\.url.path))
60
+ }
61
+
62
+ /// This Mac's call first, then one per remote in list order. A session
63
+ /// goes once, to the first machine it is under; a remote whose name chi
64
+ /// wouldn't take is left out (the listing never makes one).
65
+ var calls: [Call] {
66
+ if new {
67
+ guard newDir != nil, newRemote.map(Remotes.valid) ?? true else { return [] }
68
+ return [call(ids: [], remote: newRemote)]
69
+ }
70
+ var machines: [String?] = []
71
+ var ids: [String?: [String]] = [:]
72
+ var seen = Set<String>()
73
+ let ordered = targets.filter { $0.remote == nil } + targets.filter { $0.remote != nil }
74
+ for target in ordered where target.remote.map(Remotes.valid) ?? true && seen.insert(target.id).inserted {
75
+ if ids[target.remote] == nil { machines.append(target.remote) }
76
+ ids[target.remote, default: []].append(target.id)
77
+ }
78
+ return machines.map { call(ids: ids[$0] ?? [], remote: $0) }
79
+ }
80
+
81
+ /// Reading, converting and a new worker's start take longer than text;
82
+ /// a remote's text, waking a stopped worker there, too.
83
+ private func call(ids: [String], remote: String?) -> Call {
84
+ var args: [String]
85
+ let command: String
86
+ let stdin: Data?
87
+ switch kind {
88
+ case .message, .paste:
89
+ // stdin is the quoted context; without -m, chi takes it as the message.
90
+ command = "chi send"
91
+ args = new ? ["send", "--new", "--dir", newDir ?? ""] : ["send"]
92
+ // The row's name verbatim; none for the default, nor on a remote.
93
+ if new, remote == nil, let model { args += ["--model", model] }
94
+ if !prompt.isEmpty { args += ["-m", prompt] }
95
+ args += images.flatMap { ["--image", $0] }
96
+ stdin = context.map { Data($0.utf8) }
97
+ case .note:
98
+ command = "chi note"
99
+ args = ["note"]
100
+ if !source.isEmpty { args += ["--source", source] }
101
+ stdin = Data(note.utf8)
102
+ }
103
+ if !new { args += ids }
104
+ if let remote { args = ["--remote", remote] + args }
105
+ let timeout: TimeInterval = !images.isEmpty && kind != .note ? 30 : remote == nil ? 10 : 15
106
+ return Call(command: command, args: args, stdin: stdin, remote: remote, timeout: timeout)
107
+ }
108
+ }
109
+
110
+ private func printJSON(_ object: Any) {
111
+ let data = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
112
+ print(String(decoding: data, as: UTF8.self))
113
+ }
114
+
115
+ /// `ChiHelper --sessions [--launch PATH] [--last KEY]... [--order
116
+ /// local-first|remotes-first] [--click KEY]...`: the panel's session rows in
117
+ /// its order (live, then recent), the new row's folder, a section per remote
118
+ /// (rows not listed before, or its line, and its new row's folder) and what
119
+ /// the panel preselects with KEYs as the remembered choice (none by default:
120
+ /// never the user's) when the answers come in that order (this Mac's first
121
+ /// by default), then after the clicks (a row's key, "new", "new:REMOTE" for
122
+ /// a remote's new row, "model" for ⌘M), as JSON ({"rows": [{"id", "desc",
123
+ /// "cwd", "busy", "recent"}], "new_dir", "remotes": [{"name", "rows" (each
124
+ /// with "remote"), "error", "new_dir" (null: no new row)}], "selected",
125
+ /// "new_selected", "new_remote", "picker_open", "message", "hint"}); a
126
+ /// failed listing of this Mac's is its message on stderr, exit 1.
127
+ func sessionsCommand(_ args: [String]) -> Int32 {
128
+ var args = args
129
+ var launchPath = ChiRunner.launchPath
130
+ var last: [String] = []
131
+ var remotesFirst = false
132
+ var clicks: [String] = []
133
+ let usage = "usage: ChiHelper --sessions [--launch PATH] [--last KEY]... [--order local-first|remotes-first] [--click KEY]...\n"
134
+ while !args.isEmpty {
135
+ let arg = args.removeFirst()
136
+ switch arg {
137
+ case "--launch" where !args.isEmpty: launchPath = args.removeFirst()
138
+ case "--last" where !args.isEmpty: last.append(args.removeFirst())
139
+ case "--click" where !args.isEmpty: clicks.append(args.removeFirst())
140
+ case "--order" where !args.isEmpty:
141
+ let order = args.removeFirst()
142
+ guard ["local-first", "remotes-first"].contains(order) else {
143
+ FileHandle.standardError.write(usage.data(using: .utf8)!)
144
+ return 2
145
+ }
146
+ remotesFirst = order == "remotes-first"
147
+ default:
148
+ FileHandle.standardError.write(usage.data(using: .utf8)!)
149
+ return 2
150
+ }
151
+ }
152
+ let runner = ChiRunner(launchPath: launchPath)
153
+ // As the panel: this Mac's lists beside `remote ls`, then each remote's
154
+ // at once. All are awaited, then fed to the panel's model in the order
155
+ // --order says (the panel takes them as they come).
156
+ let queue = DispatchQueue(label: "sessions-command")
157
+ let group = DispatchGroup()
158
+ var local: Result<[LiveSession], ChiError>?
159
+ var names: [String] = []
160
+ var answers: [String: Result<[LiveSession], ChiError>] = [:]
161
+ group.enter()
162
+ runner.sessions(queue: queue) { local = $0; group.leave() }
163
+ group.enter()
164
+ runner.remotes(queue: queue) { found in
165
+ names = found
166
+ for name in found {
167
+ group.enter()
168
+ runner.sessions(remote: name, queue: queue) { answers[name] = $0; group.leave() }
169
+ }
170
+ group.leave()
171
+ }
172
+ group.wait()
173
+ let model = PanelModel()
174
+ model.lastChoice = last
175
+ model.startRemotes(names)
176
+ let showRemotes = { for name in names { model.showRemote(name, answers[name]!) } }
177
+ if remotesFirst { showRemotes() }
178
+ model.showSessions(local!)
179
+ if !remotesFirst { showRemotes() }
180
+ for key in clicks {
181
+ switch key {
182
+ case "new": model.toggleNew()
183
+ case "model": _ = model.pressModel()
184
+ case let key where key.hasPrefix("new:"): model.toggleNew(remote: String(key.dropFirst(4)))
185
+ default: model.toggle(key)
186
+ }
187
+ }
188
+ switch local! {
189
+ case .failure(let error):
190
+ FileHandle.standardError.write("\(error.description)\n".data(using: .utf8)!)
191
+ return 1
192
+ case .success:
193
+ let row = { (s: LiveSession) -> [String: Any] in
194
+ var row: [String: Any] = ["id": s.id, "desc": s.desc ?? NSNull(), "cwd": s.cwd ?? NSNull(), "busy": s.busy ?? NSNull(),
195
+ "recent": s.recent]
196
+ if let remote = s.remote { row["remote"] = remote }
197
+ return row
198
+ }
199
+ let remotes: [[String: Any]] = model.remoteGroups.map { group in
200
+ ["name": group.section.name, "rows": group.rows.map(row), "error": group.section.error ?? NSNull(),
201
+ "new_dir": model.newSessionDir(remote: group.section.name) ?? NSNull()]
202
+ }
203
+ printJSON(["rows": model.sessions.map(row), "new_dir": model.newSessionDir, "remotes": remotes,
204
+ "selected": model.selected.sorted(), "new_selected": model.newSelected, "new_remote": model.newRemote ?? NSNull(),
205
+ "picker_open": model.pickerOpen, "message": model.message, "hint": model.machineHint])
206
+ return 0
207
+ }
208
+ }
209
+
210
+ /// `ChiHelper --send-plan message|note|paste [--id [REMOTE:]ID]... [--new [REMOTE:]DIR]
211
+ /// [--default MODEL] [--model MODEL] [--prompt P] [--source S] [--image PATH]...`:
212
+ /// the panel's text on stdin; prints the calls SendPlan makes as JSON
213
+ /// ([{"command", "args", "stdin", "timeout"}], stdin null when none). An id
214
+ /// as a row's key: "studio:<id>" on a remote. --new is the new row, DIR the
215
+ /// folder of its only (live) session; with REMOTE: (DIR absolute), that
216
+ /// remote's new row; --default is the configured model, --model the pick.
217
+ func sendPlanCommand(_ args: [String]) -> Int32 {
218
+ let usage = "usage: ChiHelper --send-plan message|note|paste [--id [REMOTE:]ID]... [--new [REMOTE:]DIR] [--default MODEL] "
219
+ + "[--model MODEL] "
220
+ + "[--prompt P] [--source S] [--image PATH]...\n"
221
+ var args = args
222
+ let kinds: [String: SendKind] = ["message": .message, "note": .note, "paste": .paste]
223
+ guard let first = args.first, let kind = kinds[first] else {
224
+ FileHandle.standardError.write(usage.data(using: .utf8)!)
225
+ return 2
226
+ }
227
+ args.removeFirst()
228
+ let model = PanelModel()
229
+ var sessions: [LiveSession] = [], newDir: String?, images: [PanelImage] = []
230
+ while !args.isEmpty {
231
+ let arg = args.removeFirst()
232
+ guard !args.isEmpty else { FileHandle.standardError.write(usage.data(using: .utf8)!); return 2 }
233
+ let value = args.removeFirst()
234
+ switch arg {
235
+ case "--id":
236
+ // A UUID has no ":", so the last one splits off the remote.
237
+ let colon = value.lastIndex(of: ":")
238
+ var session = LiveSession(id: colon.map { String(value[value.index(after: $0)...]) } ?? value, desc: nil, cwd: nil,
239
+ busy: nil, owner: nil, updatedAt: nil)
240
+ session.remote = colon.map { String(value[..<$0]) }
241
+ sessions.append(session)
242
+ case "--new" where value.hasPrefix("/"): newDir = value
243
+ case "--new":
244
+ guard let colon = value.firstIndex(of: ":") else { FileHandle.standardError.write(usage.data(using: .utf8)!); return 2 }
245
+ let name = String(value[..<colon])
246
+ var session = LiveSession(id: UUID().uuidString, desc: nil, cwd: String(value[value.index(after: colon)...]), busy: nil,
247
+ owner: nil, updatedAt: nil)
248
+ session.remote = name
249
+ model.startRemotes([name])
250
+ model.showRemote(name, .success([session]))
251
+ model.newRemote = name
252
+ case "--default": model.newModel = value
253
+ case "--model": model.pickedModel = value
254
+ case "--prompt": model.prompt = value
255
+ case "--source": model.source = value
256
+ case "--image": images.append(PanelImage(url: URL(fileURLWithPath: value), temp: false))
257
+ default:
258
+ FileHandle.standardError.write(usage.data(using: .utf8)!)
259
+ return 2
260
+ }
261
+ }
262
+ model.text = String(decoding: FileHandle.standardInput.readDataToEndOfFile(), as: UTF8.self)
263
+ if let newDir {
264
+ model.sessions = [LiveSession(id: UUID().uuidString, desc: nil, cwd: newDir, busy: nil, owner: nil, updatedAt: nil)]
265
+ }
266
+ let plan = SendPlan.from(model, kind: kind, sessions: sessions, new: newDir != nil || model.newRemote != nil, images: images)
267
+ printJSON(plan.calls.map { call -> [String: Any] in
268
+ ["command": call.command, "args": call.args, "timeout": call.timeout,
269
+ "stdin": call.stdin.map { String(decoding: $0, as: UTF8.self) } ?? NSNull()]
270
+ })
271
+ return 0
272
+ }
@@ -16,6 +16,32 @@ module Samagotchi
16
16
  # with the config registry's.
17
17
  KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel --start-dir].freeze
18
18
 
19
+ # An OptionParser that also records each switch it took, as the
20
+ # normalized [flag] or [flag, value] (a --[no-]x as --x or --no-x; an
21
+ # abbreviation never gets this far): chi web's restart argv.
22
+ class RecordingParser < OptionParser
23
+ def initialize(record, *, &)
24
+ @record = record
25
+ super(*, &)
26
+ end
27
+
28
+ def define(*args, &block)
29
+ switch = nil
30
+ switch = super do |value|
31
+ @record&.push(RecordingParser.normalized(switch, value))
32
+ block&.call(value)
33
+ end
34
+ end
35
+
36
+ def self.normalized(switch, value)
37
+ flag = switch.long.first || switch.short.first
38
+ return [flag.sub("[no-]", value ? "" : "no-")] if flag.include?("[no-]")
39
+ return [flag] if switch.is_a?(OptionParser::Switch::NoArgument)
40
+
41
+ [flag, value.to_s]
42
+ end
43
+ end
44
+
19
45
  module_function
20
46
 
21
47
  # Parse +argv+ in place: what the parser leaves is bin/chi's to read
@@ -25,7 +51,7 @@ module Samagotchi
25
51
  # (Config.reload!'s cli_overrides)
26
52
  def parse(argv)
27
53
  # no_interrupt / no_default_input start from config.yml or the env; the flags below turn them on.
28
- options = { verbose: false, no_interrupt: Samagotchi::Config.get("no_interrupt"), non_interactive: false, no_default_input: Samagotchi::Config.get("no_default_input"), web: false, web_port: nil, web_open: false, web_markdown: nil }
54
+ options = { verbose: false, no_interrupt: Samagotchi::Config.get("no_interrupt"), non_interactive: false, no_default_input: Samagotchi::Config.get("no_default_input"), web: false, web_port: nil, web_open: false, web_markdown: nil, flags: [] }
29
55
  cli_overrides = {}
30
56
 
31
57
  # Strict kebab enforcement: --recap_base_url is unknown (OptionParser would otherwise accept _ as -)
@@ -100,11 +126,13 @@ module Samagotchi
100
126
  value = argv[index + 1]
101
127
  missing_no_tool.call if value.nil? || value.start_with?("-")
102
128
  (options[:no_tools] ||= []) << argv.delete_at(index + 1)
129
+ options[:flags] << ["--no-tool", value]
103
130
  argv.delete_at(index)
104
131
  elsif arg.start_with?("--no-tool=")
105
132
  value = arg.delete_prefix("--no-tool=")
106
133
  missing_no_tool.call if value.empty? || value.start_with?("-")
107
134
  options[:no_tools] = (options[:no_tools] || []) << value
135
+ options[:flags] << ["--no-tool", value]
108
136
  argv.delete_at(index)
109
137
  else
110
138
  reject_abbreviation.call(arg)
@@ -139,6 +167,19 @@ module Samagotchi
139
167
  .push("--no-tool").uniq
140
168
  end
141
169
 
170
+ # The flags not repeated by chi web's restart: one-shot actions of the
171
+ # first start (open a browser, replace the LAN token).
172
+ WEB_ONE_SHOT = %w[--open --new-token].freeze
173
+
174
+ # chi web's own command line again, for its in-place restart: every flag
175
+ # the parse took (options[:flags], in its normalized spelling) but the
176
+ # one-shot ones, so the restarted chi web parses to the same options and
177
+ # config overrides.
178
+ # @return [Array<String>]
179
+ def web_restart_argv(options)
180
+ ["web", *options.fetch(:flags).reject { |flag, _| WEB_ONE_SHOT.include?(flag) }.flatten(1)]
181
+ end
182
+
142
183
  # The parser's switches (OptionParser::Switch), short and long.
143
184
  def parser_switches(parser)
144
185
  parser.instance_variable_get(:@stack).flat_map { |list| list.list }
@@ -148,7 +189,7 @@ module Samagotchi
148
189
  # @param options [Hash] filled in by the parse
149
190
  # @param cli_overrides [Hash] the config keys the flags set
150
191
  def build_parser(options, cli_overrides)
151
- OptionParser.new do |opts|
192
+ RecordingParser.new(options[:flags]) do |opts|
152
193
  opts.banner = <<~BANNER
153
194
  Usage: chi [options]
154
195
  chi bootstrap [HOST[:PORT]|URL] first setup: find the model server, write config.yml
@@ -620,6 +620,17 @@ module Samagotchi
620
620
  @spawns_mutex.synchronize { @spawns.clear }
621
621
  end
622
622
 
623
+ # Starter processes are children of chi web, unlike the detached workers
624
+ # they launch; the web restart adopts these pids before replacing itself.
625
+ def self.live_spawn_pids
626
+ @spawns_mutex.synchronize do
627
+ @spawns.delete_if { |_, (_, waiter)| !(waiter.respond_to?(:alive?) && waiter.alive?) }
628
+ @spawns.values.filter_map do |_, waiter|
629
+ waiter.pid if waiter.respond_to?(:pid) && waiter.alive?
630
+ end.uniq.sort
631
+ end
632
+ end
633
+
623
634
  # Read any new output files from a session directory.
624
635
  # Optionally filters to only files newer than `since_time`.
625
636
  def self.read_responses(session_id, since_time: nil, state_dir: nil)
@@ -12,6 +12,7 @@ require_relative "memory_bundle/system_bundle"
12
12
  require_relative "session"
13
13
  require_relative "version"
14
14
  require_relative "web/lan"
15
+ require "json"
15
16
 
16
17
  module Samagotchi
17
18
  # `chi update`: brings an installed chi up to date in one go: the system
@@ -24,13 +25,15 @@ module Samagotchi
24
25
  include CLI::Command
25
26
 
26
27
  USAGE = <<~TEXT
27
- Usage: chi update [--dry-run] [--no-gem] [--no-bundles] [--no-desktop]
28
+ Usage: chi update [--dry-run] [--to VERSION] [--json] [--no-gem] [--no-bundles] [--no-desktop]
28
29
  Updates this chi's install: the gem (from rubygems.org), then the
29
30
  system bundle, the bundles chi ships that you installed (chi bundle
30
31
  list), and the desktop helper (macOS; rebuilt only when its sources
31
32
  changed). Edited memory files are kept and reported; nothing is
32
33
  installed that isn't already, and old gem versions stay.
33
34
  --dry-run show what would change; change nothing
35
+ --to VERSION install exactly this gem version (must be newer than this chi)
36
+ --json print update rows as one JSON document
34
37
  --no-gem don't install a newer gem (config update.gem: false)
35
38
  --no-bundles leave the shipped bundles alone (config update.bundles: false)
36
39
  --no-desktop leave the desktop helper alone (config update.desktop: false)
@@ -44,6 +47,8 @@ module Samagotchi
44
47
  FLAGS = CLI::Flags.new(args: false) do |f|
45
48
  f.switch "-h", "--help", "help"
46
49
  f.switch "--dry-run"
50
+ f.value "--to"
51
+ f.switch "--json"
47
52
  f.switch "--no-gem"
48
53
  f.switch "--no-bundles"
49
54
  f.switch "--no-desktop"
@@ -54,7 +59,7 @@ module Samagotchi
54
59
  end
55
60
  # The switches the newer gem's update gets again (hand_over).
56
61
  HANDED_OVER = { "--no-gem" => :no_gem, "--no-bundles" => :no_bundles, "--no-desktop" => :no_desktop,
57
- "--no-register" => :no_register }.freeze
62
+ "--no-register" => :no_register, "--to" => :to, "--json" => :json }.freeze
58
63
 
59
64
  # A table row. info rows (live workers, chi web) say what runs; they
60
65
  # never fail.
@@ -95,6 +100,13 @@ module Samagotchi
95
100
  @stdout.puts(USAGE)
96
101
  return 0
97
102
  end
103
+ return usage_error("invalid version #{options[:to]}") if options[:to] && !Gem::Version.correct?(options[:to])
104
+
105
+ if options[:to] && !options[:gem_from] && Gem::Version.new(options[:to]) <= Gem::Version.new(VERSION)
106
+ @stderr.puts("chi update: #{options[:to]} is not newer than the running chi (#{VERSION})")
107
+ return 1
108
+ end
109
+
98
110
  unless @gem_spec
99
111
  @stderr.puts("chi update: running from a checkout (#{InstalledGem::ROOT}): chi update updates an installed chi; " \
100
112
  "git pull, or chi bundle upgrade NAME for one bundle")
@@ -104,9 +116,13 @@ module Samagotchi
104
116
  @dry_run = options[:dry_run]
105
117
  gem = gem_row(options)
106
118
  rows = [gem, system_bundle_row, *bundle_rows(options), desktop_row(options), *live_rows].compact
107
- print_table(rows)
108
- footers(options).each { |line| @stdout.puts(line) }
109
- @stdout.puts(summary(rows))
119
+ if options[:json]
120
+ @stdout.puts(JSON.generate(rows.map { |row| row.to_h.merge(failed: !!row.failed, kept: row.kept || []) }))
121
+ else
122
+ print_table(rows)
123
+ footers(options).each { |line| @stdout.puts(line) }
124
+ @stdout.puts(summary(rows))
125
+ end
110
126
  rows.any?(&:failed) ? 1 : 0
111
127
  end
112
128
 
@@ -133,22 +149,32 @@ module Samagotchi
133
149
  row.to = VERSION
134
150
  return done(row, :update)
135
151
  end
136
- if (why = off(options, "gem"))
137
- return skipped(row, why)
138
- end
139
- return skipped(row, "under Bundler: bundle update samagotchi") if @bundler
152
+ why = off(options, "gem") || ("under Bundler: bundle update samagotchi" if @bundler)
153
+ # --to asks for that exact version: not installing it is a failure, not a skip.
154
+ return options[:to] ? failed(row, "skipped: #{why}") : skipped(row, why) if why
140
155
 
141
156
  latest = begin
142
- @gem_update.latest
157
+ if options[:to]
158
+ if ENV[GemUpdate::LOCAL_ENV].to_s.empty?
159
+ GemUpdate::Latest.new(version: options[:to], source: "samagotchi")
160
+ else
161
+ local = @gem_update.latest
162
+ return failed(row, "local gem file is #{local.version}, not #{options[:to]}") unless local.version == options[:to]
163
+
164
+ local
165
+ end
166
+ else
167
+ @gem_update.latest
168
+ end
143
169
  rescue GemUpdate::Error => e
144
170
  return row.tap { |r| r.status = "couldn't check (#{e.message})" }
145
171
  end
146
- return up_to_date(row) unless @gem_update.newer?(latest)
172
+ return up_to_date(row) unless options[:to] || @gem_update.newer?(latest)
147
173
 
148
174
  row.to = latest.version
149
175
  return done(row, :update) if @dry_run
150
176
 
151
- @stdout.puts("installing samagotchi #{latest.version}…")
177
+ (options[:json] ? @stderr : @stdout).puts("installing samagotchi #{latest.version}…")
152
178
  output, ok = @gem_update.install(latest)
153
179
  return failed(row, output.lines.map(&:strip).reject(&:empty?).last.to_s) unless ok
154
180
 
@@ -161,7 +187,8 @@ module Samagotchi
161
187
  wrapper = InstalledGem.wrapper(@gem_spec)
162
188
  return done(row, :update, "run chi update again to sync with it") unless wrapper
163
189
 
164
- passed = HANDED_OVER.select { |_, key| options[key] }.keys
190
+ passed = options[:to] ? ["--to", options[:to]] : []
191
+ passed += HANDED_OVER.except("--to").select { |_, key| options[key] }.keys
165
192
  @stdout.flush
166
193
  @exec.call([RbConfig.ruby, wrapper, "update", "--no-gem", "--gem-from", VERSION, *passed])
167
194
  done(row, :update) # only when exec is a spec's stand-in
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.123.0"
4
+ VERSION = "0.124.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end