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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -3
- data/README.md +32 -18
- data/app/controllers/proctolog/application_controller.rb +2 -2
- data/app/controllers/proctolog/bursts_controller.rb +1 -1
- data/app/controllers/proctolog/choices_controller.rb +3 -1
- data/app/controllers/proctolog/decisions_controller.rb +1 -1
- data/app/controllers/proctolog/notes_controller.rb +2 -2
- data/app/controllers/proctolog/prompts_controller.rb +1 -1
- data/app/controllers/proctolog/pull_requests_controller.rb +2 -4
- data/app/controllers/proctolog/review_data_controller.rb +3 -1
- data/app/controllers/proctolog/shares_controller.rb +27 -0
- data/app/helpers/proctolog/decisions_helper.rb +6 -2
- data/app/helpers/proctolog/desk_helper.rb +48 -8
- data/app/helpers/proctolog/rounds_helper.rb +4 -2
- data/app/helpers/proctolog/shares_helper.rb +11 -0
- data/app/helpers/proctolog/standings_helper.rb +5 -4
- data/app/models/proctolog/burst.rb +17 -8
- data/app/models/proctolog/choices.rb +8 -0
- data/app/models/proctolog/doctor.rb +23 -12
- data/app/models/proctolog/github.rb +1 -1
- data/app/models/proctolog/localhost.rb +17 -0
- data/app/models/proctolog/marks.rb +1 -1
- data/app/models/proctolog/note.rb +4 -0
- data/app/models/proctolog/owner.rb +3 -0
- data/app/models/proctolog/pick.rb +12 -0
- data/app/models/proctolog/rounds.rb +37 -0
- data/app/models/proctolog/server.rb +1 -7
- data/app/models/proctolog/setup.rb +190 -0
- data/app/models/proctolog/share/ngrok.rb +120 -0
- data/app/models/proctolog/share/settings.rb +23 -0
- data/app/models/proctolog/share/tailscale.rb +103 -0
- data/app/models/proctolog/share.rb +101 -0
- data/app/models/proctolog/standing.rb +24 -7
- data/app/views/proctolog/bursts/_line.html.erb +2 -2
- data/app/views/proctolog/bursts/_side.html.erb +6 -5
- data/app/views/proctolog/bursts/_side_stop.html.erb +4 -4
- data/app/views/proctolog/notes/_card.html.erb +7 -3
- data/app/views/proctolog/open_pull_requests/_side.html.erb +4 -3
- data/app/views/proctolog/open_pull_requests/_start.html.erb +8 -0
- data/app/views/proctolog/open_pull_requests/index.html.erb +4 -5
- data/app/views/proctolog/open_pull_requests/start/_first_time.html.erb +3 -0
- data/app/views/proctolog/open_pull_requests/start/_nothing_open.html.erb +3 -0
- data/app/views/proctolog/open_pull_requests/start/_nothing_written.html.erb +3 -0
- data/app/views/proctolog/shares/_line.html.erb +5 -0
- data/app/views/proctolog/shares/show.html.erb +48 -0
- data/app/views/proctolog/shares/ways/_ngrok.html.erb +6 -0
- data/app/views/proctolog/shares/ways/_tailscale.html.erb +2 -0
- data/bin/proctolog +154 -27
- data/completions/_proctolog +31 -6
- data/config/application.rb +23 -4
- data/config/initializers/grouse.rb +1 -1
- data/config/routes.rb +3 -0
- data/docs/manual/bursts.md +7 -2
- data/docs/manual/deciding.md +4 -1
- data/docs/manual/handover.md +6 -0
- data/docs/manual/pr-format.md +1 -1
- data/docs/manual/reading.md +54 -27
- data/docs/manual/setup.md +59 -0
- data/docs/manual/sharing.md +77 -0
- data/docs/manual/updates.md +11 -4
- data/lib/proctolog/changelog/news.rb +67 -0
- data/lib/proctolog/changelog.rb +75 -0
- data/lib/proctolog/installation.rb +19 -0
- data/lib/proctolog/version.rb +1 -1
- data/public/asking.js +31 -4
- data/public/decisions.js +4 -4
- data/public/desk.js +10 -6
- data/public/keys.js +6 -0
- data/public/notes.js +164 -58
- data/public/pick.css +21 -0
- data/public/respond.css +6 -0
- data/public/respond.js +51 -7
- data/public/review-drafts.js +47 -9
- data/public/review-rounds.js +27 -3
- data/public/share.css +58 -0
- data/public/side.js +11 -20
- data/public/terminal.css +295 -34
- data/public/viewer.js +700 -173
- 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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
(
|
|
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? =
|
|
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? {
|
|
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
|