proctolog 0.1.1 → 0.3.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -3
  3. data/README.md +32 -18
  4. data/app/controllers/proctolog/application_controller.rb +2 -2
  5. data/app/controllers/proctolog/bursts_controller.rb +1 -1
  6. data/app/controllers/proctolog/choices_controller.rb +3 -1
  7. data/app/controllers/proctolog/decisions_controller.rb +1 -1
  8. data/app/controllers/proctolog/notes_controller.rb +2 -2
  9. data/app/controllers/proctolog/prompts_controller.rb +1 -1
  10. data/app/controllers/proctolog/pull_requests_controller.rb +2 -4
  11. data/app/controllers/proctolog/review_data_controller.rb +3 -1
  12. data/app/controllers/proctolog/shares_controller.rb +27 -0
  13. data/app/helpers/proctolog/decisions_helper.rb +6 -2
  14. data/app/helpers/proctolog/desk_helper.rb +48 -8
  15. data/app/helpers/proctolog/rounds_helper.rb +4 -2
  16. data/app/helpers/proctolog/shares_helper.rb +11 -0
  17. data/app/helpers/proctolog/standings_helper.rb +5 -4
  18. data/app/models/proctolog/burst.rb +17 -8
  19. data/app/models/proctolog/choices.rb +8 -0
  20. data/app/models/proctolog/doctor.rb +23 -12
  21. data/app/models/proctolog/github.rb +1 -1
  22. data/app/models/proctolog/localhost.rb +17 -0
  23. data/app/models/proctolog/marks.rb +1 -1
  24. data/app/models/proctolog/note.rb +4 -0
  25. data/app/models/proctolog/owner.rb +3 -0
  26. data/app/models/proctolog/pick.rb +12 -0
  27. data/app/models/proctolog/rounds.rb +37 -0
  28. data/app/models/proctolog/server.rb +1 -7
  29. data/app/models/proctolog/setup.rb +190 -0
  30. data/app/models/proctolog/share/ngrok.rb +120 -0
  31. data/app/models/proctolog/share/settings.rb +23 -0
  32. data/app/models/proctolog/share/tailscale.rb +103 -0
  33. data/app/models/proctolog/share.rb +101 -0
  34. data/app/models/proctolog/standing.rb +24 -7
  35. data/app/views/proctolog/bursts/_line.html.erb +2 -2
  36. data/app/views/proctolog/bursts/_side.html.erb +6 -5
  37. data/app/views/proctolog/bursts/_side_stop.html.erb +4 -4
  38. data/app/views/proctolog/notes/_card.html.erb +7 -3
  39. data/app/views/proctolog/open_pull_requests/_side.html.erb +4 -3
  40. data/app/views/proctolog/open_pull_requests/_start.html.erb +8 -0
  41. data/app/views/proctolog/open_pull_requests/index.html.erb +4 -5
  42. data/app/views/proctolog/open_pull_requests/start/_first_time.html.erb +3 -0
  43. data/app/views/proctolog/open_pull_requests/start/_nothing_open.html.erb +3 -0
  44. data/app/views/proctolog/open_pull_requests/start/_nothing_written.html.erb +3 -0
  45. data/app/views/proctolog/shares/_line.html.erb +5 -0
  46. data/app/views/proctolog/shares/show.html.erb +48 -0
  47. data/app/views/proctolog/shares/ways/_ngrok.html.erb +6 -0
  48. data/app/views/proctolog/shares/ways/_tailscale.html.erb +2 -0
  49. data/bin/proctolog +154 -27
  50. data/completions/_proctolog +31 -6
  51. data/config/application.rb +23 -4
  52. data/config/initializers/grouse.rb +1 -1
  53. data/config/routes.rb +3 -0
  54. data/docs/manual/bursts.md +7 -2
  55. data/docs/manual/deciding.md +4 -1
  56. data/docs/manual/handover.md +6 -0
  57. data/docs/manual/pr-format.md +1 -1
  58. data/docs/manual/reading.md +54 -27
  59. data/docs/manual/setup.md +59 -0
  60. data/docs/manual/sharing.md +77 -0
  61. data/docs/manual/updates.md +11 -4
  62. data/lib/proctolog/changelog/news.rb +67 -0
  63. data/lib/proctolog/changelog.rb +75 -0
  64. data/lib/proctolog/installation.rb +19 -0
  65. data/lib/proctolog/version.rb +1 -1
  66. data/public/asking.js +31 -4
  67. data/public/decisions.js +4 -4
  68. data/public/desk.js +10 -6
  69. data/public/keys.js +6 -0
  70. data/public/notes.js +164 -58
  71. data/public/pick.css +21 -0
  72. data/public/respond.css +6 -0
  73. data/public/respond.js +51 -7
  74. data/public/review-drafts.js +47 -9
  75. data/public/review-rounds.js +27 -3
  76. data/public/share.css +58 -0
  77. data/public/side.js +11 -20
  78. data/public/terminal.css +295 -34
  79. data/public/viewer.js +700 -173
  80. metadata +22 -1
@@ -11,19 +11,29 @@ module Proctolog
11
11
  MARKS = {fine: "ok", missing: "!!", off: "--"}.freeze
12
12
 
13
13
  Finding = Data.define(:state, :line) do
14
+ def fine? = state == :fine
14
15
  def missing? = state == :missing
15
16
  def to_s = "#{MARKS.fetch(state)} #{line}"
16
17
  end
17
18
 
18
19
  NEEDS = %i[ruby gh launcher checkout repository bursts].freeze
19
- NICETIES = %i[release skills chrome diffle imagemagick ffmpeg app port].freeze
20
+ NICETIES = %i[release skills chrome diffle imagemagick ffmpeg app share port].freeze
20
21
 
21
22
  # project is nil when the directory is no Git checkout.
22
23
  def initialize(project, home: Dir.home, path: ENV.fetch("PATH", ""))
23
24
  @project, @home, @path = project, Pathname(home), path.split(File::PATH_SEPARATOR)
24
25
  end
25
26
 
26
- def findings = @findings ||= (NEEDS + NICETIES).filter_map { send(it) }
27
+ def findings = (NEEDS + NICETIES).filter_map { self[it] }
28
+
29
+ # One check's finding, by its name, asked once: setup asks a step's
30
+ # checks alone. Nil for one not asked here, as the repository is not
31
+ # without gh.
32
+ def [](check)
33
+ raise ArgumentError, "Doctor checks no #{check}" unless (NEEDS + NICETIES).include?(check)
34
+
35
+ (@checked ||= {}).fetch(check) { @checked[check] = send(check) }
36
+ end
27
37
 
28
38
  def fine? = findings.none?(&:missing?)
29
39
 
@@ -72,7 +82,7 @@ module Proctolog
72
82
  def skills
73
83
  folders = %w[.claude/skills .agents/skills].select { |folder| SKILLS.keys.all? { @home.join(folder, it).exist? } }
74
84
  missing = SKILLS.reject { |skill, _| %w[.claude/skills .agents/skills].any? { @home.join(it, skill).exist? } }.values
75
- if missing.any? then off("The #{missing.to_sentence} #{"skill".pluralize(missing.size)} #{missing.one? ? "is" : "are"} not linked: proctolog install")
85
+ if missing.any? then off("The #{missing.to_sentence} #{"skill".pluralize(missing.size)} #{missing.one? ? "is" : "are"} not linked: proctolog setup")
76
86
  else fine("The #{SKILLS.values.to_sentence} skills, in ~/#{folders.join(" and ~/")}")
77
87
  end
78
88
  end
@@ -82,7 +92,7 @@ module Proctolog
82
92
 
83
93
  def chrome
84
94
  require "ferrum"
85
- Ferrum::Browser::Options::Chrome.options.detect_path ? fine("Chrome, for browser tests and scenarios") : off("No Chrome: browser tests skip and scenarios cannot open")
95
+ Ferrum::Browser::Options::Chrome.options.detect_path ? fine("Chrome, for browser tests and scenarios") : off("No Chrome, so browser tests skip and scenarios cannot open: brew install --cask google-chrome")
86
96
  end
87
97
 
88
98
  def diffle = command?("diffle") ? fine("Diffle, for d on a pull request") : off("No Diffle, so d opens no code: brew install diffle")
@@ -104,7 +114,15 @@ module Proctolog
104
114
  fine("app.yml names #{app.parts.to_sentence}")
105
115
  end
106
116
 
107
- def port = answers?(4040) ? off("Port 4040 answers already: a desk may be open there, or serve --port 4041") : fine("Port 4040 is free")
117
+ # Tailscale or ngrok, either of which shares the desk with the owner's phone.
118
+ def share
119
+ ready, unready = Share.ways(path: @path.join(File::PATH_SEPARATOR)).partition(&:ready?)
120
+ return fine("Sharing through #{ready.map(&:name).to_sentence}: proctolog share") if ready.any?
121
+
122
+ off("Nothing shares the desk beyond this Mac. #{unready.map { "#{it.unready}." }.join(" ")}")
123
+ end
124
+
125
+ def port = Localhost.answers?(4040, within: 1) ? off("Port 4040 answers already: a desk may be open there, or serve --port 4041") : fine("Port 4040 is free")
108
126
 
109
127
  def fine(line) = Finding.new(:fine, line)
110
128
  def missing(line) = Finding.new(:missing, line)
@@ -116,12 +134,5 @@ module Proctolog
116
134
  def runnable?(program) = program.include?("/") ? @project.root.join(program).executable? : command?(program)
117
135
 
118
136
  def succeeds?(*command) = Open3.capture2e({"PATH" => @path.join(File::PATH_SEPARATOR)}, *command).last.success?
119
-
120
- def answers?(port)
121
- Socket.tcp("localhost", port, connect_timeout: 1, &:close)
122
- true
123
- rescue SystemCallError
124
- false
125
- end
126
137
  end
127
138
  end
@@ -240,7 +240,7 @@ module Proctolog
240
240
  # when the owner presses the button that shows this command first. An
241
241
  # alternate marks store remains a trial, even when a data directory moves.
242
242
  def rewrite(number, text)
243
- (ENV.key?("PROCTOLOG_MARKS") || ENV.key?("PROCTOLOG_READ_ONLY")) and raise Unreachable, "A trial or read-only desk does not write to GitHub."
243
+ (Desk.trial? || ENV.key?("PROCTOLOG_READ_ONLY")) and raise Unreachable, "A trial or read-only desk does not write to GitHub."
244
244
 
245
245
  Tempfile.create(["description", ".md"]) do |file|
246
246
  file.write(text)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Proctolog
6
+ # This Mac, as a program on it is reached: by a port something answers on.
7
+ module Localhost
8
+ # TCPSocket, not Socket.tcp: on macOS 27, Socket.tcp with a connect
9
+ # timeout takes a port nothing listens on for one that answers.
10
+ def self.answers?(port, within: 0.5)
11
+ TCPSocket.new("localhost", port, connect_timeout: within).close
12
+ true
13
+ rescue SystemCallError, IO::TimeoutError
14
+ false
15
+ end
16
+ end
17
+ end
@@ -20,7 +20,7 @@ module Proctolog
20
20
  def self.root = Pathname(ENV.fetch("PROCTOLOG_MARKS") { Project.current.marks })
21
21
 
22
22
  # A desk with marks of its own is a trial, whose marks are not the owner's.
23
- def self.trial? = ENV.key?("PROCTOLOG_MARKS")
23
+ def self.trial? = Desk.trial?
24
24
 
25
25
  def self.directory_of(burst) = root.join(burst.slug)
26
26
 
@@ -82,6 +82,10 @@ module Proctolog
82
82
  # Still needs someone: the agent while it is open, the owner once addressed.
83
83
  def live? = open? || addressed?
84
84
 
85
+ # Answered by an agent in a handover: the owner judges the answer under
86
+ # Returned work, which moves the note (Rounds#move_notes).
87
+ def answered_in_a_handover? = reply&.key?("response")
88
+
85
89
  # The decision of a description that this note answers otherwise: a change
86
90
  # asked for on words of a box. A question or a remark leaves the box open.
87
91
  def answers_in(description) = (description.decision_quoted_by(place) if change? && (place in Quote[section: Description::DECISIONS]))
@@ -11,6 +11,9 @@ module Proctolog
11
11
 
12
12
  def self.git_name = Open3.capture2("git", "config", "--get", "user.name").first.strip
13
13
 
14
+ # The address ngrok lets in when the owner named none (Share::Settings).
15
+ def self.git_email = Open3.capture2("git", "config", "--get", "user.email").first.strip
16
+
14
17
  def first_name = name.split.first
15
18
  end
16
19
  end
@@ -32,6 +32,18 @@ module Proctolog
32
32
 
33
33
  def empty? = @pull_requests.empty? && unopened.empty?
34
34
 
35
+ # Those of every kind with decisions for the reviewer, open or not.
36
+ def written = [*@pull_requests, *unopened].select { written?(it) }
37
+
38
+ # What the page first tells someone who has not reviewed here yet, or
39
+ # who would otherwise find nothing to do: nil once neither is true.
40
+ def start(first_time:)
41
+ if empty? then :nothing_open
42
+ elsif written.none? then :nothing_written
43
+ elsif first_time then :first_time
44
+ end
45
+ end
46
+
35
47
  # Whether a pull request nobody has opened yet sits on another pull
36
48
  # request's branch, so that it tops a chain.
37
49
  def chained?(pull_request) = [*@pull_requests, *unopened].any? { it.head == pull_request.base }
@@ -46,6 +46,18 @@ module Proctolog
46
46
  def newness = [order, Rounds.moment(response[:createdAt])]
47
47
  end
48
48
 
49
+ # What came back from an agent on one pull request besides its notes,
50
+ # which say so themselves once addressed (move_notes): the answers the
51
+ # owner is to confirm and the questions they are to answer.
52
+ Returned = Data.define(:to_confirm, :questions) do
53
+ def initialize(to_confirm: 0, questions: 0) = super
54
+ end
55
+
56
+ # Where a note an agent answered stands in its marks, by where its
57
+ # request stands: addressed while the answer waits on the owner,
58
+ # confirmed once they confirmed it, open again once they replied.
59
+ NOTE_STATES = {owner: "addressed", question: "addressed", closed: "confirmed", agent: "open", out: "open"}.freeze
60
+
49
61
  # A time as a page or an agent wrote it; one that cannot be read is
50
62
  # taken for the oldest.
51
63
  def self.moment(text)
@@ -93,6 +105,31 @@ module Proctolog
93
105
  end
94
106
  end
95
107
 
108
+ # What came back to the owner, by pull request.
109
+ def returned
110
+ everything.reject { it.dig(:source, :noteId) }.group_by { it[:pr] }.transform_values do |requests|
111
+ states = requests.group_by { state_of(it) }.tap { it.default = [] }
112
+ Returned.new(to_confirm: states[:owner].size, questions: states[:question].size)
113
+ end
114
+ end
115
+
116
+ # Moves each note an agent answered in a handover to where its answer
117
+ # stands (NOTE_STATES), with the answer as its reply, so the marks say
118
+ # what Returned work says: to the pages, and to an agent reading them.
119
+ # A note the owner withdrew stays withdrawn.
120
+ def move_notes
121
+ everything.each do |item|
122
+ id = item.dig(:source, :noteId) or next
123
+ latest = latest(item) or next
124
+ to = NOTE_STATES[state_of(item)] or next
125
+ marks = @burst.marks(item[:pr] || "sitting")
126
+ note = marks.note(id)
127
+ next if note.nil? || note.withdrawn? || [note.state, note.reply&.dig("response")] == [to, latest.key]
128
+
129
+ marks.move(id, to:, reply: {"text" => latest.result[:summary], "commit" => latest.result[:head]&.first(8), "response" => latest.key}.compact)
130
+ end
131
+ end
132
+
96
133
  # What an agent took and has not answered, with the handover it went in.
97
134
  def with_agent
98
135
  current.select { in_focus?(it[:pr]) && state_of(it) == :out }.map do |item|
@@ -35,7 +35,7 @@ module Proctolog
35
35
  end
36
36
 
37
37
  # Every port the app listens on answers.
38
- def up? = app.listens(port).all? { listening?(it) }
38
+ def up? = app.listens(port).all? { Localhost.answers?(it) }
39
39
 
40
40
  def state
41
41
  return :up if up?
@@ -179,12 +179,6 @@ module Proctolog
179
179
 
180
180
  def logs = Project.current.cache.join("servers")
181
181
 
182
- def listening?(port)
183
- Socket.tcp("localhost", port, connect_timeout: 0.5) { true }
184
- rescue SystemCallError
185
- false
186
- end
187
-
188
182
  def git(*) = Open3.capture2e("git", "-C", checkout.to_s, *)
189
183
 
190
184
  def tip(name)
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proctolog
4
+ # From nothing to an open desk, a step at a time: PRoctolog's own links,
5
+ # then doctor's checks in the order a newcomer meets them, each step saying
6
+ # why it matters. It explains, links to what to install and checks again
7
+ # once the owner says a step is done; of anyone else's, it installs
8
+ # nothing. `proctolog setup` walks it, and `install` is another name for it.
9
+ #
10
+ # A step is done when every one of its checks is fine. A nicety that is
11
+ # off never holds it up: the owner may leave it for later. A need that is
12
+ # missing does, unless the step says what does its work until then, as the
13
+ # desk's first page starts a burst.
14
+ class Setup
15
+ # first names what the setup does itself before checking the step; asks,
16
+ # what the owner may type in place of Enter: the step's folder.
17
+ Step = Data.define(:title, :why, :checks, :advice, :until_then, :first, :asks) do
18
+ def initialize(title:, why:, checks:, advice: [], until_then: nil, first: nil, asks: nil) = super
19
+
20
+ def findings(doctor) = checks.filter_map { doctor[it] }
21
+
22
+ # Whether the owner may go on without it: nothing it needs is missing,
23
+ # or something does that work meanwhile.
24
+ def leavable?(findings) = until_then.present? || findings.none?(&:missing?)
25
+
26
+ def question(findings) = [asks, "Enter checks again", ("l leaves it for later" if leavable?(findings)), "q stops"].compact.join(", ")
27
+ end
28
+
29
+ class << self
30
+ def steps = @steps ||= []
31
+
32
+ private
33
+
34
+ def step(title, **) = steps << Step.new(title:, **)
35
+ end
36
+
37
+ step "PRoctolog on this Mac", checks: %i[ruby launcher skills release], first: :link,
38
+ why: "From wherever this PRoctolog lives, setup links the proctolog command, zsh's completion, and the skills your agents write pull requests and carry out your review with.",
39
+ until_then: "This setup goes on as it is; a new terminal finds proctolog once your PATH has it."
40
+
41
+ step "GitHub, through gh", checks: %i[gh],
42
+ why: "The desk reads pull requests, their stacks and their checks with your own gh, and writes to GitHub only when you say so."
43
+
44
+ step "The checkout you review", checks: %i[checkout repository], asks: "type its folder",
45
+ why: "The desk opens the pull requests of one checkout, from its GitHub origin, which your gh must be able to read."
46
+
47
+ step "A first burst", checks: %i[bursts],
48
+ why: "A burst is a body of pull requests you review together, a part at a time; the desk keeps your marks by it.",
49
+ until_then: "Review on the desk's first page starts one."
50
+
51
+ step "What turns features on", checks: %i[chrome diffle imagemagick ffmpeg app],
52
+ why: "Each of these turns one feature on. The desk opens without any of them.",
53
+ until_then: "Each feature waits for its own."
54
+
55
+ step "Your phone, through a share", checks: %i[share],
56
+ why: "Shared, the desk is yours from your phone or a computer elsewhere, while this Mac keeps it and an agent works on.",
57
+ advice: [
58
+ "Tailscale keeps the desk off the internet: only the devices signed in to your tailnet reach it, your phone among them once it has the Tailscale app. Install it from https://tailscale.com/download/mac.",
59
+ "ngrok puts the desk on the internet, behind its sign-in with Google, for your email alone; its free plan carries 1 GB a month. Install it from https://ngrok.com/download, then copy your authtoken from https://dashboard.ngrok.com/get-started/your-authtoken and run ngrok config add-authtoken TOKEN.",
60
+ "Either one is enough. Once the desk is open, proctolog share shares it."
61
+ ]
62
+
63
+ INDENT = " " * 8
64
+ WIDTH = 78
65
+
66
+ # The folder setup takes the checkout from: where it started, or the one
67
+ # the owner typed at its step.
68
+ attr_reader :directory
69
+
70
+ # installation links PRoctolog's own files. doctor gives a fresh Doctor
71
+ # for a folder each time it is called, so that a step checked again sees
72
+ # what the owner just did.
73
+ def initialize(installation:, directory: Dir.pwd, input: $stdin, output: $stdout, &doctor)
74
+ @installation, @directory, @input, @output, @doctor = installation, Pathname(directory), input, output, doctor
75
+ end
76
+
77
+ def steps = self.class.steps
78
+
79
+ # Asks at each step that is not done yet, and checks it again on Enter.
80
+ # :serve when the owner asked for the desk to open now, :finished when
81
+ # it is left for later or one answers already, :stopped when they
82
+ # stopped on the way.
83
+ def walk
84
+ say "PRoctolog, a step at a time: each says why it matters, and is stamped DONE", "once it is met. Of anyone else's, nothing is installed for you: a step says", "what to do, and Enter checks it again once you have."
85
+ steps.each.with_index(1) { |step, number| taken(step, number) or return :stopped }
86
+ desk
87
+ end
88
+
89
+ # Every step as it stands, asking nothing: for an agent, or a pipe.
90
+ # True when the desk can open: no step waits on a need.
91
+ def show
92
+ shown = steps.each.with_index(1).map { |step, number| shown(step, number) }
93
+ the_desk
94
+ lines [doctor[:port]]
95
+ say(*wrapped("Run proctolog setup in a terminal to take it a step at a time, or proctolog serve to open the desk."))
96
+ shown.all?
97
+ end
98
+
99
+ private
100
+
101
+ def doctor = @doctor.call(directory)
102
+
103
+ def taken(step, number)
104
+ begun(step, number)
105
+ advised = false
106
+ loop do
107
+ findings = step.findings(doctor)
108
+ lines findings
109
+ return stamp("DONE") if findings.all?(&:fine?)
110
+
111
+ advise(step) unless advised
112
+ advised = true
113
+ answer = ask(step.question(findings))
114
+ case answer&.downcase
115
+ in "q" | nil then return stopped(number)
116
+ in "l" then (return stamp("LATER", step.until_then) if step.leavable?(findings))
117
+ in "" then next
118
+ else @directory = Pathname(File.expand_path(answer, directory)) if step.asks
119
+ end
120
+ end
121
+ end
122
+
123
+ def shown(step, number)
124
+ begun(step, number)
125
+ findings = step.findings(doctor)
126
+ lines findings
127
+ return stamp("DONE") if findings.all?(&:fine?)
128
+
129
+ advise(step)
130
+ stamp("TO DO", step.until_then)
131
+ step.leavable?(findings)
132
+ end
133
+
134
+ def begun(step, number)
135
+ heading(step.title, number, why: step.why)
136
+ send(step.first) if step.first
137
+ end
138
+
139
+ # PRoctolog's own links, made fresh before its first step is checked.
140
+ # One in the way that is someone else's stays, and is said.
141
+ def link
142
+ @installation.install.each { say "#{INDENT}#{it.gsub(Dir.home, "~")}" }
143
+ rescue Installation::Refused => e
144
+ say(*wrapped("!! #{e.message}"))
145
+ end
146
+
147
+ # The last step: the desk itself, opened by the owner's Enter.
148
+ def desk
149
+ the_desk
150
+ port = doctor[:port]
151
+ lines [port]
152
+ return :finished unless port.fine?
153
+
154
+ (ask("Enter opens the desk, q leaves it for proctolog serve") == "") ? :serve : :finished
155
+ end
156
+
157
+ def the_desk = heading("The desk", steps.size + 1, why: "It opens on this Mac at #{Desk.address(4040)}; after the first visit, typing procto in the address bar finds it.")
158
+
159
+ def stopped(number)
160
+ say "", "Stopped at step #{number}. proctolog setup starts again from the first, and stamps each step that is done."
161
+ false
162
+ end
163
+
164
+ def heading(title, number, why: nil)
165
+ say "", format("%-8s%s", "#{number} of #{steps.size + 1}", title)
166
+ say(*wrapped(why)) if why
167
+ end
168
+
169
+ def lines(findings) = findings.each { say "#{INDENT}#{it}" }
170
+
171
+ def advise(step) = step.advice.each { say(*wrapped(it)) }
172
+
173
+ def stamp(word, after = nil)
174
+ say "#{INDENT}[ #{word} ]#{" #{after}" if after}"
175
+ true
176
+ end
177
+
178
+ def ask(question)
179
+ @output.print "#{INDENT}#{question.upcase_first} > "
180
+ @output.flush
181
+ @input.gets&.strip
182
+ end
183
+
184
+ # A long line broken at spaces under the step's title; a word too long
185
+ # for the line, an address, stays whole.
186
+ def wrapped(text) = text.scan(/\S.{0,#{WIDTH - INDENT.size - 1}}(?=\s|\z)|\S+/).map { INDENT + it }
187
+
188
+ def say(*lines) = @output.puts(*lines)
189
+ end
190
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "yaml"
6
+
7
+ module Proctolog
8
+ class Share
9
+ # ngrok: the desk on the internet, at the address ngrok gives the account
10
+ # (on the free plan, one that stays the same), behind ngrok's sign-in with
11
+ # Google, which lets in only the addresses the owner allows: their Git
12
+ # email unless they named others. ngrok runs in the background on its own
13
+ # until the share is closed, the desk running or not, and keeps its log
14
+ # beside the share's file. It opens no inspector on this Mac, which would
15
+ # take 4040 or the port after it, a desk's.
16
+ class Ngrok
17
+ DOWNLOAD = "https://ngrok.com/download"
18
+ AUTHTOKEN = "https://dashboard.ngrok.com/get-started/your-authtoken"
19
+ WAIT = 20
20
+
21
+ def initialize(path: ENV.fetch("PATH", ""))
22
+ @program = path.split(File::PATH_SEPARATOR).map { File.join(it, "ngrok") }.find { File.executable?(it) }
23
+ end
24
+
25
+ def name = "ngrok"
26
+
27
+ def to_param = "ngrok"
28
+
29
+ def who = "only #{allowed.to_sentence(two_words_connector: " or ", last_word_connector: ", or ")}, signed in with Google, gets in"
30
+
31
+ def allowed = Settings.current.allowed
32
+
33
+ # What to do before it can share the desk; nil when nothing.
34
+ def unready
35
+ if !@program then "ngrok is not installed: brew install ngrok, or #{DOWNLOAD}"
36
+ elsif !authtoken? then "ngrok has no authtoken: copy yours from #{AUTHTOKEN}, then run ngrok config add-authtoken TOKEN"
37
+ elsif allowed.empty? then "ngrok would let nobody in: name yourself with proctolog share --via ngrok --allow EMAIL"
38
+ end
39
+ end
40
+
41
+ def ready? = unready.nil?
42
+
43
+ def open(port)
44
+ Share.folder.mkpath
45
+ log = Share.folder.join("#{port}.ngrok.log").tap { it.delete if it.exist? }
46
+ process = Process.spawn(Outside.environment, @program, "http", "http://localhost:#{port}", "--config", [config, quiet].join(","),
47
+ "--oauth", "google", *allowed.flat_map { ["--oauth-allow-email", it] }, "--log", log.to_s, "--log-format", "json",
48
+ in: File::NULL, out: File::NULL, err: File::NULL, pgroup: true)
49
+ Process.detach(process)
50
+ Share.new(port:, way: self, address: started(log, process), process:)
51
+ end
52
+
53
+ def close(share) = (stop(share.process) if carrying?(share))
54
+
55
+ # Its process is still this share's ngrok, not another program given
56
+ # the same number since.
57
+ def carrying?(share)
58
+ share.process.present? && Open3.capture3("ps", "-o", "command=", "-p", share.process.to_s).first.include?("ngrok http http://localhost:#{share.port} ")
59
+ end
60
+
61
+ private
62
+
63
+ # What ngrok logged until its address, or its reason for giving none.
64
+ def started(log, process)
65
+ deadline = WAIT.seconds.from_now
66
+ while Time.current < deadline
67
+ lines = log.exist? ? log.each_line.filter_map { logged(it) } : []
68
+ if (tunnel = lines.find { it["msg"] == "started tunnel" }) then return tunnel.fetch("url")
69
+ elsif (failure = lines.find { it["lvl"] == "crit" }) then raise Refused, said(failure.fetch("err", ""))
70
+ elsif !alive?(process) then raise Refused, "ngrok stopped before it gave the desk an address: #{log}."
71
+ end
72
+ sleep 0.1
73
+ end
74
+ stop(process)
75
+ raise Refused, "ngrok gave the desk no address within #{WAIT} seconds: #{log}."
76
+ end
77
+
78
+ # A line of ngrok's log; nil for one it had not finished writing.
79
+ def logged(line)
80
+ JSON.parse(line)
81
+ rescue JSON::ParserError
82
+ nil
83
+ end
84
+
85
+ # ngrok's first sentence, and its error's code, which its docs explain.
86
+ def said(error) = "ngrok said: #{error.lines.first.to_s.strip.chomp(".")}#{" (#{error[/ERR_NGROK_\d+/]})" if error[/ERR_NGROK_\d+/]}."
87
+
88
+ def alive?(process)
89
+ Process.kill(0, process)
90
+ true
91
+ rescue Errno::ESRCH
92
+ false
93
+ end
94
+
95
+ # The whole group: ngrok, and whatever it started.
96
+ def stop(process)
97
+ Process.kill("TERM", -process)
98
+ rescue Errno::ESRCH, Errno::EPERM
99
+ nil
100
+ end
101
+
102
+ def authtoken? = ENV["NGROK_AUTHTOKEN"].present? || own_settings.then { it["authtoken"] || it.dig("agent", "authtoken") }.present?
103
+
104
+ # ngrok's own configuration, where `ngrok config add-authtoken` wrote.
105
+ def config
106
+ @config ||= Open3.capture2e(Outside.environment, @program, "config", "check").first[/configuration file at (.+\.yml)/, 1] ||
107
+ File.expand_path("~/Library/Application Support/ngrok/ngrok.yml")
108
+ end
109
+
110
+ def own_settings = File.file?(config) ? (YAML.safe_load_file(config) || {}) : {}
111
+
112
+ # Read over ngrok's own, in the same version: no inspector.
113
+ def quiet
114
+ Share.folder.join("ngrok.yml").tap do |file|
115
+ file.write((own_settings["version"].to_s == "3") ? {"version" => 3, "agent" => {"web_addr" => false}}.to_yaml : {"version" => "2", "web_addr" => false}.to_yaml)
116
+ end.to_s
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Proctolog
6
+ class Share
7
+ # What the owner chose about sharing, for every checkout on this Mac: the
8
+ # way they last named, and the addresses ngrok lets in. Kept in share.yml
9
+ # beside the desk's secret.
10
+ class Settings < Data.define(:via, :allow)
11
+ def self.file = Desk.data.join("share.yml")
12
+
13
+ def self.current = new(**(file.file? ? (YAML.safe_load_file(file) || {}) : {}).symbolize_keys.slice(:via, :allow))
14
+
15
+ def initialize(via: nil, allow: []) = super(via:, allow: Array(allow).map { it.to_s.strip.downcase }.compact_blank.uniq)
16
+
17
+ def save = self.class.file.tap { it.dirname.mkpath }.write({"via" => via, "allow" => allow}.compact_blank.to_yaml)
18
+
19
+ # Those named, or the owner as Git knows them, as Google spells an address.
20
+ def allowed = allow.presence || [Owner.git_email.downcase].compact_blank
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "uri"
6
+
7
+ module Proctolog
8
+ class Share
9
+ # Tailscale's serve: the desk at this Mac's own name on the owner's
10
+ # tailnet, https://mac.tailnet.ts.net, which only the devices signed in to
11
+ # that tailnet reach. Nothing goes on the internet, and Tailscale is the
12
+ # sign-in. Tailscale keeps serving it, the desk running or not, until the
13
+ # share is closed.
14
+ class Tailscale
15
+ DOWNLOAD = "https://tailscale.com/download/mac"
16
+ CERTIFICATES = "https://login.tailscale.com/admin/dns"
17
+ # The app's own program, for a Mac that never put tailscale on its PATH.
18
+ APP = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
19
+ # The first share of a tailnet can wait on its owner turning Serve on, in a browser.
20
+ WAIT = 20
21
+
22
+ def initialize(path: ENV.fetch("PATH", ""), app: APP)
23
+ @program = path.split(File::PATH_SEPARATOR).map { File.join(it, "tailscale") }.find { File.executable?(it) } ||
24
+ (app if File.executable?(app))
25
+ end
26
+
27
+ def name = "Tailscale"
28
+
29
+ def to_param = "tailscale"
30
+
31
+ # By the tailnet's name: one account can be in several, and a phone
32
+ # signed in to another one never sees this Mac.
33
+ def who = "only devices signed in to your tailnet#{", #{tailnet}," if tailnet} reach it"
34
+
35
+ def tailnet = status.dig("CurrentTailnet", "Name")
36
+
37
+ # What to do before it can share the desk; nil when nothing.
38
+ def unready
39
+ return "Tailscale is not installed: #{DOWNLOAD}" unless @program
40
+
41
+ case status["BackendState"]
42
+ when "Running" then ("Tailscale gives your tailnet no HTTPS certificates: turn them on at #{CERTIFICATES}" if status["CertDomains"].blank?)
43
+ when "NeedsLogin", "NeedsMachineAuth" then "Tailscale is signed out: sign in from its icon in the menu bar"
44
+ else "Tailscale is off: turn it on from its icon in the menu bar"
45
+ end
46
+ end
47
+
48
+ def ready? = unready.nil?
49
+
50
+ # On 443, the tailnet name itself, unless Tailscale serves something
51
+ # else there; then on the desk's own port.
52
+ def open(port)
53
+ https = served.fetch("TCP", {}).key?("443") ? port : 443
54
+ run("serve", "--bg", "--yes", "--https=#{https}", "http://localhost:#{port}")
55
+ Share.new(port:, way: self, address: "https://#{status.dig("Self", "DNSName").to_s.chomp(".")}#{":#{https}" unless https == 443}")
56
+ end
57
+
58
+ def close(share) = run("serve", "--https=#{URI(share.address).port}", "off")
59
+
60
+ def carrying?(share)
61
+ address = URI(share.address)
62
+ served.dig("Web", "#{address.host}:#{address.port}", "Handlers", "/", "Proxy") == "http://localhost:#{share.port}"
63
+ end
64
+
65
+ private
66
+
67
+ def status = @status ||= asked("status", "--json")
68
+
69
+ def served = asked("serve", "status", "--json")
70
+
71
+ # Tailscale's answer as JSON; empty when it gave none, as a stopped one does.
72
+ def asked(*arguments)
73
+ output, = Open3.capture3(Outside.environment, @program, *arguments)
74
+ JSON.parse(output)
75
+ rescue JSON::ParserError, SystemCallError
76
+ {}
77
+ end
78
+
79
+ def run(*arguments)
80
+ Open3.popen3(Outside.environment, @program, *arguments) do |input, output, errors, waiter|
81
+ input.close
82
+ said = [output, errors].map { |stream| Thread.new { stream.read } }
83
+ finished = waiter.join(WAIT)
84
+ Process.kill("TERM", waiter.pid) unless finished
85
+ words = said.map(&:value).join
86
+ raise Refused, refusal(words) unless finished&.value&.success?
87
+
88
+ words
89
+ end
90
+ end
91
+
92
+ # Serve asks a tailnet's owner once to turn it on, at an address it
93
+ # prints, and waits.
94
+ def refusal(words)
95
+ if (address = words[%r{https://login\.tailscale\.com/\S+}])
96
+ "Tailscale waits for Serve to be turned on for your tailnet: #{address}, then share again."
97
+ else
98
+ "Tailscale said: #{words.lines.grep_v(/\AWarning:|TPM/).join(" ").squish.chomp(".").presence || "nothing, and served nothing"}."
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end