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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -1
- data/bin/chi +13 -2
- data/docs/architecture.md +19 -1
- data/docs/cli.md +20 -5
- data/docs/desktop.md +51 -3
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos/App.swift +9 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
- data/lib/samagotchi/desktop/macos/Images.swift +4 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
- data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
- data/lib/samagotchi/run_options.rb +43 -2
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/update_command.rb +40 -13
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +198 -2
- data/lib/samagotchi/web/public/app.js +40 -1
- data/lib/samagotchi/web/public/data.js +17 -0
- data/lib/samagotchi/web/public/index.html +21 -2
- data/lib/samagotchi/web/public/toasts.js +13 -5
- data/lib/samagotchi/web/public/update_notice.js +6 -6
- data/lib/samagotchi/web/public/update_sheet.js +158 -0
- data/lib/samagotchi/web/server.rb +116 -7
- data/lib/samagotchi/web/session_hub.rb +28 -0
- data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
- data/lib/samagotchi/web/update_check.rb +14 -7
- metadata +3 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|