stream_weaver 0.3.0 → 0.3.1
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 +10 -1
- data/README.md +9 -0
- data/docs/canvas-panel-workflow.md +8 -0
- data/docs/university/send-to-coworker.md +28 -3
- data/lib/stream_weaver/canvas/bridge_server.rb +7 -3
- data/lib/stream_weaver/canvas/client.rb +56 -6
- data/lib/stream_weaver/canvas/code_stamp.rb +74 -0
- data/lib/stream_weaver/canvas/reader.rb +5 -1
- data/lib/stream_weaver/canvas/staleness_guard.rb +154 -0
- data/lib/stream_weaver/cli.rb +448 -5
- data/lib/stream_weaver/iterm.rb +90 -9
- data/lib/stream_weaver/org/writer.rb +1 -0
- data/lib/stream_weaver/university/artifacts.rb +250 -0
- data/lib/stream_weaver/university/canvas.rb +88 -0
- data/lib/stream_weaver/university/cleanup.rb +430 -0
- data/lib/stream_weaver/university/course.rb +7 -0
- data/lib/stream_weaver/university/listener.rb +77 -3
- data/lib/stream_weaver/university/scripts/growing_doc.rb +19 -0
- data/lib/stream_weaver/version.rb +1 -1
- metadata +6 -2
data/lib/stream_weaver/cli.rb
CHANGED
|
@@ -115,6 +115,12 @@ module StreamWeaver
|
|
|
115
115
|
university_demo(args)
|
|
116
116
|
when 'university-done'
|
|
117
117
|
university_done(args)
|
|
118
|
+
when 'university-artifact'
|
|
119
|
+
university_artifact(args)
|
|
120
|
+
when 'university-cleanup'
|
|
121
|
+
university_cleanup(args)
|
|
122
|
+
when 'university-stop'
|
|
123
|
+
university_stop(args)
|
|
118
124
|
when 'focus-me'
|
|
119
125
|
focus_me
|
|
120
126
|
when 'get-started'
|
|
@@ -720,6 +726,25 @@ module StreamWeaver
|
|
|
720
726
|
streamweaver university-done <N> Mark step N done (same as clicking Mark done) and bring
|
|
721
727
|
the University window forward. What every step's own
|
|
722
728
|
closing ritual runs -- no click required from you.
|
|
729
|
+
streamweaver university-artifact The course's record of what it created -- saved docs,
|
|
730
|
+
[list | add <ref>] exported .org files, gists, demo canvas sessions.
|
|
731
|
+
[--step N] [--type T] Docs and sessions record themselves; `add` is for a
|
|
732
|
+
gist URL, which only your agent ever sees.
|
|
733
|
+
streamweaver university-cleanup Offer to delete everything the course created, group by
|
|
734
|
+
[--scan] [--dry-run] group (gists one at a time, showing each URL). Only
|
|
735
|
+
ever touches what the manifest above records; --scan
|
|
736
|
+
first adopts artifacts from a run that predates it,
|
|
737
|
+
found by the course's own file, gist and session
|
|
738
|
+
names (and adoption sticks, even if you then decline
|
|
739
|
+
every delete -- `university-artifact remove` undoes
|
|
740
|
+
one).
|
|
741
|
+
Composes with reset: cleanup takes back what the
|
|
742
|
+
course made, reset starts it over -- both, in either
|
|
743
|
+
order, leave nothing behind.
|
|
744
|
+
streamweaver university-stop Put the course down: stop the listener, close its demo
|
|
745
|
+
canvas sessions and the two iTerm2 windows
|
|
746
|
+
get-started opened. Keeps your progress and the
|
|
747
|
+
artifact manifest.
|
|
723
748
|
streamweaver focus-me Bring the calling terminal's own iTerm2 pane to the
|
|
724
749
|
front. Silent no-op outside iTerm2/darwin.
|
|
725
750
|
HELP
|
|
@@ -1537,6 +1562,7 @@ module StreamWeaver
|
|
|
1537
1562
|
# Stop the canvas bridge
|
|
1538
1563
|
def self.canvas_stop
|
|
1539
1564
|
require_relative 'canvas/client'
|
|
1565
|
+
Canvas::StalenessGuard.disable!
|
|
1540
1566
|
|
|
1541
1567
|
if Canvas::Client.stop_bridge
|
|
1542
1568
|
puts "Canvas bridge stopped"
|
|
@@ -1814,6 +1840,7 @@ module StreamWeaver
|
|
|
1814
1840
|
# to blow away), asks for confirmation before stopping unless --yes.
|
|
1815
1841
|
def self.canvas_restart(args = [])
|
|
1816
1842
|
require_relative 'canvas/client'
|
|
1843
|
+
Canvas::StalenessGuard.disable!
|
|
1817
1844
|
|
|
1818
1845
|
auto_yes = args.include?('--yes') || args.include?('-y')
|
|
1819
1846
|
|
|
@@ -1865,6 +1892,36 @@ module StreamWeaver
|
|
|
1865
1892
|
exit 1 unless ok
|
|
1866
1893
|
end
|
|
1867
1894
|
|
|
1895
|
+
# The programmatic core of canvas-restart: snapshot -> stop -> start ->
|
|
1896
|
+
# wait -> restore, with none of the command's narration, prompting or exit
|
|
1897
|
+
# codes. Returns { ok:, dir:, unconfirmed:, old_port:, port: } -- `ok` is
|
|
1898
|
+
# true iff every session that needed restoring came back, `unconfirmed`
|
|
1899
|
+
# names the sessions the snapshot could not read at all, and the two ports
|
|
1900
|
+
# let the caller say so when the bridge moved. All three are things the
|
|
1901
|
+
# caller owes the operator: it cannot honestly claim to have preserved a
|
|
1902
|
+
# session it never read, or to have healed anything if every open tab is
|
|
1903
|
+
# now pointing at a dead port (the ps84 port-squat gotcha, which
|
|
1904
|
+
# canvas-restart itself warns about in stars).
|
|
1905
|
+
#
|
|
1906
|
+
# Used by the staleness auto-heal (disc-171), which runs in the middle of
|
|
1907
|
+
# some other command: it must not exit that command, and must not stop to
|
|
1908
|
+
# ask a question nobody is sitting there to answer.
|
|
1909
|
+
def self.restart_bridge_preserving_sessions
|
|
1910
|
+
require_relative 'canvas/client'
|
|
1911
|
+
Canvas::StalenessGuard.disable!
|
|
1912
|
+
|
|
1913
|
+
old_port = Canvas::Client.bridge_running? ? Canvas::Client.read_bridge_info&.dig(:port) : nil
|
|
1914
|
+
|
|
1915
|
+
dir = default_snapshot_dir
|
|
1916
|
+
snapshot = do_canvas_snapshot(dir)
|
|
1917
|
+
Canvas::Client.stop_bridge
|
|
1918
|
+
new_port = Canvas::Client.ensure_bridge_running[:port]
|
|
1919
|
+
wait_for_bridge_ready
|
|
1920
|
+
|
|
1921
|
+
{ ok: do_canvas_restore(dir, force: false), dir: dir,
|
|
1922
|
+
unconfirmed: snapshot[:unconfirmed] || [], old_port: old_port, port: new_port }
|
|
1923
|
+
end
|
|
1924
|
+
|
|
1868
1925
|
# Bounded-wait retry on the bridge's liveness, called right after
|
|
1869
1926
|
# ensure_bridge_running in canvas-restart before handing off to
|
|
1870
1927
|
# do_canvas_restore (stream_weaver-f568, a canvas-restart fix cycle
|
|
@@ -2293,8 +2350,8 @@ module StreamWeaver
|
|
|
2293
2350
|
# Usage: streamweaver panel [session-name] [--fresh]
|
|
2294
2351
|
#
|
|
2295
2352
|
# Panel never opens an external browser - the point is to have the browser
|
|
2296
|
-
# inline in a split pane. If
|
|
2297
|
-
#
|
|
2353
|
+
# inline in a split pane. If the split itself couldn't be made, we just
|
|
2354
|
+
# print the URL for the user to open manually.
|
|
2298
2355
|
def self.panel(args)
|
|
2299
2356
|
# See canvas_session for why this require comes before the guard.
|
|
2300
2357
|
require_relative 'iterm'
|
|
@@ -2764,6 +2821,28 @@ module StreamWeaver
|
|
|
2764
2821
|
ITerm.python_api_reachable?
|
|
2765
2822
|
end
|
|
2766
2823
|
|
|
2824
|
+
# Advisory, not blocking: a missing Browser Plugin still lets get-started
|
|
2825
|
+
# run, it just falls back to opening the canvas in a regular browser tab
|
|
2826
|
+
# instead of an iTerm2 window (get_started_premier does that fallback
|
|
2827
|
+
# itself). Kept out of get_started_premier_ok?'s gate on purpose -- see
|
|
2828
|
+
# there. Field report: a fresh macOS tester who'd installed the plugin
|
|
2829
|
+
# but still lacked a saved "Web Browser" profile is no longer relevant
|
|
2830
|
+
# to whether panes render (StreamWeaver never needs that saved profile
|
|
2831
|
+
# -- see BROWSER_TYPE_PROPERTY in iterm.rb) but the plugin itself is a
|
|
2832
|
+
# real, separate download this cannot substitute for.
|
|
2833
|
+
def self.get_started_premier_browser_plugin?
|
|
2834
|
+
require_relative 'iterm'
|
|
2835
|
+
ITerm.browser_plugin_available?
|
|
2836
|
+
end
|
|
2837
|
+
|
|
2838
|
+
# Advisory, same reasoning as browser_plugin above. Ships enabled, so
|
|
2839
|
+
# this only ever fails for someone who went looking in Settings →
|
|
2840
|
+
# Advanced and switched it off.
|
|
2841
|
+
def self.get_started_premier_browser_style_enabled?
|
|
2842
|
+
require_relative 'iterm'
|
|
2843
|
+
ITerm.browser_style_profiles_enabled?
|
|
2844
|
+
end
|
|
2845
|
+
|
|
2767
2846
|
# `gh` is only needed by course step 5 (pushing a doc to a gist), long
|
|
2768
2847
|
# after get-started has already opened the door -- advisory, never a
|
|
2769
2848
|
# blocker, same spirit as the "no agent CLI" warning above.
|
|
@@ -2810,16 +2889,23 @@ module StreamWeaver
|
|
|
2810
2889
|
darwin: get_started_premier_darwin?,
|
|
2811
2890
|
in_iterm: get_started_premier_in_iterm?,
|
|
2812
2891
|
gem_loadable: get_started_premier_gem_loadable?,
|
|
2813
|
-
python_api: get_started_premier_python_api
|
|
2892
|
+
python_api: get_started_premier_python_api?,
|
|
2893
|
+
browser_plugin: get_started_premier_browser_plugin?,
|
|
2894
|
+
browser_style_enabled: get_started_premier_browser_style_enabled?
|
|
2814
2895
|
}
|
|
2815
2896
|
else
|
|
2816
|
-
{ darwin: nil, in_iterm: nil, gem_loadable: nil, python_api: nil
|
|
2897
|
+
{ darwin: nil, in_iterm: nil, gem_loadable: nil, python_api: nil,
|
|
2898
|
+
browser_plugin: nil, browser_style_enabled: nil }
|
|
2817
2899
|
end
|
|
2818
2900
|
}
|
|
2819
2901
|
end
|
|
2820
2902
|
|
|
2903
|
+
# browser_plugin / browser_style_enabled are deliberately excluded: both
|
|
2904
|
+
# are advisory (see get_started_premier_browser_plugin? and its sibling),
|
|
2905
|
+
# never a reason to fall back to the fully degraded (non-iTerm2)
|
|
2906
|
+
# experience.
|
|
2821
2907
|
def self.get_started_premier_ok?(report)
|
|
2822
|
-
report[:premier]
|
|
2908
|
+
%i[darwin in_iterm gem_loadable python_api].all? { |k| report[:premier][k] }
|
|
2823
2909
|
end
|
|
2824
2910
|
|
|
2825
2911
|
# --- Reporting ---
|
|
@@ -2849,6 +2935,21 @@ module StreamWeaver
|
|
|
2849
2935
|
puts " #{get_started_check_mark(report[:premier][:in_iterm])} running inside iTerm2"
|
|
2850
2936
|
puts " #{get_started_check_mark(report[:premier][:gem_loadable])} iterm2_ruby gem installed"
|
|
2851
2937
|
puts " #{get_started_check_mark(report[:premier][:python_api])} iTerm2 Python API reachable"
|
|
2938
|
+
if report[:premier][:browser_plugin] == false
|
|
2939
|
+
puts " ❌ iTerm2 Browser Plugin"
|
|
2940
|
+
puts " Not installed — download from https://iterm2.com/browser-plugin.html and unzip " \
|
|
2941
|
+
"into /Applications (no need to run it). Never blocks: get-started falls back to opening " \
|
|
2942
|
+
"the canvas in your regular browser tab."
|
|
2943
|
+
else
|
|
2944
|
+
puts " #{get_started_check_mark(report[:premier][:browser_plugin])} iTerm2 Browser Plugin"
|
|
2945
|
+
end
|
|
2946
|
+
if report[:premier][:browser_style_enabled] == false
|
|
2947
|
+
puts " ❌ iTerm2 \"Enable browser-style profiles\" (Settings → Advanced, Experimental Features)"
|
|
2948
|
+
puts " Off — turn it on and restart iTerm2. Never blocks: same browser-tab fallback as above."
|
|
2949
|
+
else
|
|
2950
|
+
puts " #{get_started_check_mark(report[:premier][:browser_style_enabled])} " \
|
|
2951
|
+
"iTerm2 \"Enable browser-style profiles\" (Advanced setting)"
|
|
2952
|
+
end
|
|
2852
2953
|
# `.dig` -- :course is absent from a hand-built report (a caller that
|
|
2853
2954
|
# only wants the premier-tier printing, e.g. an old/partial report),
|
|
2854
2955
|
# and neither entry here ever blocks get-started. Distinguished from
|
|
@@ -2953,6 +3054,332 @@ module StreamWeaver
|
|
|
2953
3054
|
end
|
|
2954
3055
|
end
|
|
2955
3056
|
|
|
3057
|
+
# `streamweaver university-artifact add <ref>` / `list`: the manual door
|
|
3058
|
+
# onto the course's artifact manifest, for the one artifact nothing can
|
|
3059
|
+
# record on its own. A saved doc records itself (growing_doc) and a demo
|
|
3060
|
+
# canvas session records itself (Listener.warm_up!), but a gist exists
|
|
3061
|
+
# only inside the worker's own `gh gist create` output -- step 5's
|
|
3062
|
+
# prompt has the worker run this the moment it has the URL.
|
|
3063
|
+
#
|
|
3064
|
+
# The manifest is what `university-cleanup` is allowed to delete, so
|
|
3065
|
+
# `add` is deliberately narrow: a ref whose type can't be determined is
|
|
3066
|
+
# refused rather than recorded under a guess.
|
|
3067
|
+
def self.university_artifact(args)
|
|
3068
|
+
require_relative 'university/artifacts'
|
|
3069
|
+
case (args.first || 'list')
|
|
3070
|
+
when 'list' then university_artifact_list
|
|
3071
|
+
when 'add' then university_artifact_add(args.drop(1))
|
|
3072
|
+
when 'remove' then university_artifact_remove(args.drop(1))
|
|
3073
|
+
else
|
|
3074
|
+
$stderr.puts "Usage: streamweaver university-artifact " \
|
|
3075
|
+
"[list | add <ref> [--step N] [--type doc|org|gist|session] | remove <ref> [--type T]]"
|
|
3076
|
+
exit 1
|
|
3077
|
+
end
|
|
3078
|
+
end
|
|
3079
|
+
|
|
3080
|
+
def self.university_artifact_list
|
|
3081
|
+
grouped = University::Artifacts.grouped
|
|
3082
|
+
if grouped.empty?
|
|
3083
|
+
puts "No University artifacts recorded yet."
|
|
3084
|
+
return
|
|
3085
|
+
end
|
|
3086
|
+
|
|
3087
|
+
grouped.each do |type, entries|
|
|
3088
|
+
puts "#{University::Artifacts.label(type)} (#{entries.size}):"
|
|
3089
|
+
entries.each do |entry|
|
|
3090
|
+
step = entry['step'] ? " [step #{entry['step']}]" : ''
|
|
3091
|
+
puts " #{entry['ref']}#{step}"
|
|
3092
|
+
end
|
|
3093
|
+
end
|
|
3094
|
+
end
|
|
3095
|
+
|
|
3096
|
+
def self.university_artifact_add(args)
|
|
3097
|
+
step = flag_value(args, '--step')
|
|
3098
|
+
type = flag_value(args, '--type')
|
|
3099
|
+
ref = positional(args, valued_flags: %w[--step --type])
|
|
3100
|
+
|
|
3101
|
+
unless ref
|
|
3102
|
+
$stderr.puts "Usage: streamweaver university-artifact add <ref> [--step N] [--type doc|org|gist|session]"
|
|
3103
|
+
exit 1
|
|
3104
|
+
end
|
|
3105
|
+
|
|
3106
|
+
entry = University::Artifacts.record!(ref, type: type, step: step)
|
|
3107
|
+
unless entry
|
|
3108
|
+
$stderr.puts "Not recorded: could not tell what kind of artifact #{ref} is."
|
|
3109
|
+
$stderr.puts "Pass one explicitly: --type #{University::Artifacts::TYPES.join('|')}"
|
|
3110
|
+
exit 1
|
|
3111
|
+
end
|
|
3112
|
+
|
|
3113
|
+
puts "Recorded #{entry['type']}: #{entry['ref']}#{entry['step'] ? " (step #{entry['step']})" : ''}"
|
|
3114
|
+
end
|
|
3115
|
+
|
|
3116
|
+
# Drops one entry from the manifest WITHOUT deleting the thing it names
|
|
3117
|
+
# -- the counterweight to `add` and to `--scan`'s adoption. Recording is
|
|
3118
|
+
# what makes an artifact destroyable, so "I did not mean to record that"
|
|
3119
|
+
# needs an answer that isn't hand-editing artifacts.yml; without one, a
|
|
3120
|
+
# mistaken adoption is offered by every cleanup run forever.
|
|
3121
|
+
def self.university_artifact_remove(args)
|
|
3122
|
+
type = flag_value(args, '--type')
|
|
3123
|
+
ref = positional(args, valued_flags: %w[--type])
|
|
3124
|
+
|
|
3125
|
+
unless ref
|
|
3126
|
+
$stderr.puts "Usage: streamweaver university-artifact remove <ref> [--type doc|org|gist|session]"
|
|
3127
|
+
exit 1
|
|
3128
|
+
end
|
|
3129
|
+
|
|
3130
|
+
# A doc/org ref is stored expanded, so match what the user typed AND
|
|
3131
|
+
# what it resolves to from here -- otherwise a relative path they can
|
|
3132
|
+
# see in `list` is one they cannot remove. File.identical? is the last
|
|
3133
|
+
# of the three because it is the only one that sees through a symlink
|
|
3134
|
+
# (on macOS `Dir.pwd` reports /private/var where the recorded path
|
|
3135
|
+
# says /var, and string comparison alone misses that).
|
|
3136
|
+
candidates = [ref, File.expand_path(ref)].uniq
|
|
3137
|
+
matches = University::Artifacts.all.select do |entry|
|
|
3138
|
+
next false unless type.nil? || entry['type'] == type
|
|
3139
|
+
|
|
3140
|
+
candidates.include?(entry['ref']) ||
|
|
3141
|
+
(File.exist?(entry['ref']) && File.exist?(ref) && File.identical?(entry['ref'], ref))
|
|
3142
|
+
end
|
|
3143
|
+
|
|
3144
|
+
if matches.empty?
|
|
3145
|
+
$stderr.puts "Not in the manifest: #{ref}"
|
|
3146
|
+
exit 1
|
|
3147
|
+
end
|
|
3148
|
+
|
|
3149
|
+
matches.each do |entry|
|
|
3150
|
+
University::Artifacts.forget!(entry['type'], entry['ref'])
|
|
3151
|
+
puts "Removed #{entry['type']} from the manifest: #{entry['ref']}"
|
|
3152
|
+
end
|
|
3153
|
+
puts "(the #{matches.size == 1 ? 'artifact itself is' : 'artifacts themselves are'} untouched on disk)"
|
|
3154
|
+
end
|
|
3155
|
+
|
|
3156
|
+
# `--flag value` or `--flag=value`, or nil. Local to the university
|
|
3157
|
+
# artifact commands, which are the only ones here taking optional
|
|
3158
|
+
# valued flags.
|
|
3159
|
+
def self.flag_value(args, flag)
|
|
3160
|
+
inline = args.grep(/\A#{Regexp.escape(flag)}=/) { |a| a.split('=', 2).last }.first
|
|
3161
|
+
return inline if inline
|
|
3162
|
+
|
|
3163
|
+
idx = args.index(flag)
|
|
3164
|
+
idx ? args[idx + 1] : nil
|
|
3165
|
+
end
|
|
3166
|
+
|
|
3167
|
+
# The first real argument, with flags AND the values of the separated
|
|
3168
|
+
# `--flag value` forms skipped. Not just "the first thing not starting
|
|
3169
|
+
# with a dash": `university-artifact add --type doc /tmp/a.rb` would
|
|
3170
|
+
# otherwise record the literal ref "doc", and a relative ref like that
|
|
3171
|
+
# is precisely what cleanup would later resolve against some other
|
|
3172
|
+
# process's working directory. Flags-before-positional is exactly the
|
|
3173
|
+
# shape an agent produces, and step 5's prompt hands this command to one.
|
|
3174
|
+
def self.positional(args, valued_flags: [])
|
|
3175
|
+
skip_next = false
|
|
3176
|
+
args.find do |arg|
|
|
3177
|
+
if skip_next
|
|
3178
|
+
skip_next = false
|
|
3179
|
+
next false
|
|
3180
|
+
end
|
|
3181
|
+
skip_next = valued_flags.include?(arg)
|
|
3182
|
+
!arg.start_with?('-')
|
|
3183
|
+
end
|
|
3184
|
+
end
|
|
3185
|
+
|
|
3186
|
+
# `streamweaver university-cleanup`: the course offers to take back
|
|
3187
|
+
# everything it left on the machine -- the docs it saved, the .org files
|
|
3188
|
+
# exported from them, the gists published, the demo canvas sessions it
|
|
3189
|
+
# opened, and its own state files.
|
|
3190
|
+
#
|
|
3191
|
+
# Confirmed per GROUP, except gists, which are confirmed one at a time
|
|
3192
|
+
# showing the URL: a gist is the only artifact here that left the
|
|
3193
|
+
# machine and cannot be undeleted. Every delete goes through
|
|
3194
|
+
# University::Cleanup, the same module the canvas's own delete buttons
|
|
3195
|
+
# use, so "what may be deleted" is answered in exactly one place.
|
|
3196
|
+
def self.university_cleanup(args)
|
|
3197
|
+
require_relative 'university/cleanup'
|
|
3198
|
+
dry_run = args.include?('--dry-run')
|
|
3199
|
+
# A dry-run scan previews without adopting, so what it found is NOT in
|
|
3200
|
+
# the inventory below. Carried here so the two can't contradict each
|
|
3201
|
+
# other -- listing a found artifact and then saying there is nothing to
|
|
3202
|
+
# clean up is the one transcript this command must never print.
|
|
3203
|
+
scanned = args.include?('--scan') ? print_cleanup_scan(dry_run) : []
|
|
3204
|
+
inventory = University::Cleanup.inventory
|
|
3205
|
+
|
|
3206
|
+
if University::Cleanup.empty?(inventory)
|
|
3207
|
+
return puts("Nothing to clean up -- StreamWeaver University has not recorded any artifacts.") if
|
|
3208
|
+
scanned.empty?
|
|
3209
|
+
else
|
|
3210
|
+
print_cleanup_inventory(inventory)
|
|
3211
|
+
end
|
|
3212
|
+
|
|
3213
|
+
if dry_run
|
|
3214
|
+
puts ""
|
|
3215
|
+
puts "--dry-run: nothing was deleted."
|
|
3216
|
+
return
|
|
3217
|
+
end
|
|
3218
|
+
|
|
3219
|
+
puts ""
|
|
3220
|
+
# get_started_confirm? answers "no" for every question when there is
|
|
3221
|
+
# no tty to ask at, which is the right default for a destructive
|
|
3222
|
+
# command and a baffling transcript without this line -- every group
|
|
3223
|
+
# comes back "Kept" for no visible reason.
|
|
3224
|
+
puts "(no terminal to confirm at -- everything will be kept)" unless $stdin.tty?
|
|
3225
|
+
cleanup_group('doc', inventory['doc'], "Delete #{inventory['doc'].size} saved doc file(s)?")
|
|
3226
|
+
cleanup_group('org', inventory['org'], "Delete #{inventory['org'].size} exported .org file(s)?")
|
|
3227
|
+
cleanup_gists(inventory['gist'])
|
|
3228
|
+
cleanup_group('session', inventory['session'],
|
|
3229
|
+
"Close #{inventory['session'].size} course canvas session(s)?")
|
|
3230
|
+
cleanup_state_files(inventory[University::Cleanup::STATE])
|
|
3231
|
+
puts ""
|
|
3232
|
+
puts "Cleanup done."
|
|
3233
|
+
end
|
|
3234
|
+
|
|
3235
|
+
# `--scan`: find artifacts from a run that predates the manifest, by the
|
|
3236
|
+
# course's own deterministic names, and adopt them into it so the
|
|
3237
|
+
# ordinary grouped confirm can offer them. Adoption is a write, so
|
|
3238
|
+
# `--dry-run` reports the same findings and records nothing -- a flag
|
|
3239
|
+
# that promises to change nothing must not quietly change the manifest.
|
|
3240
|
+
def self.print_cleanup_scan(dry_run)
|
|
3241
|
+
refs = if dry_run
|
|
3242
|
+
University::Cleanup.scan_unrecorded.values.flatten
|
|
3243
|
+
else
|
|
3244
|
+
University::Cleanup.adopt_scan!.map { |entry| entry['ref'] }
|
|
3245
|
+
end
|
|
3246
|
+
|
|
3247
|
+
if refs.empty?
|
|
3248
|
+
puts "--scan: nothing on this machine matches the course's own file, session or gist names."
|
|
3249
|
+
else
|
|
3250
|
+
puts "--scan #{dry_run ? 'found' : 'adopted'} #{refs.size} artifact(s) the manifest did not record:"
|
|
3251
|
+
refs.each { |ref| puts " #{ref}" }
|
|
3252
|
+
end
|
|
3253
|
+
puts ""
|
|
3254
|
+
refs
|
|
3255
|
+
end
|
|
3256
|
+
|
|
3257
|
+
def self.print_cleanup_inventory(inventory)
|
|
3258
|
+
puts "StreamWeaver University created these:"
|
|
3259
|
+
print_cleanup_files(University::Artifacts.label('doc'), inventory['doc'])
|
|
3260
|
+
print_cleanup_files(University::Artifacts.label('org'), inventory['org'])
|
|
3261
|
+
print_cleanup_refs(University::Artifacts.label('gist'), inventory['gist'])
|
|
3262
|
+
print_cleanup_refs(University::Artifacts.label('session'), inventory['session'])
|
|
3263
|
+
print_cleanup_files("University state files", inventory[University::Cleanup::STATE])
|
|
3264
|
+
end
|
|
3265
|
+
|
|
3266
|
+
def self.print_cleanup_files(label, entries)
|
|
3267
|
+
return if entries.empty?
|
|
3268
|
+
|
|
3269
|
+
puts ""
|
|
3270
|
+
puts "#{label} (#{entries.size}):"
|
|
3271
|
+
entries.each do |entry|
|
|
3272
|
+
detail = entry[:exists] ? "#{entry[:size]} bytes" : "missing (already deleted)"
|
|
3273
|
+
puts " #{entry[:ref]} -- #{detail}"
|
|
3274
|
+
end
|
|
3275
|
+
end
|
|
3276
|
+
|
|
3277
|
+
def self.print_cleanup_refs(label, entries)
|
|
3278
|
+
return if entries.empty?
|
|
3279
|
+
|
|
3280
|
+
puts ""
|
|
3281
|
+
puts "#{label} (#{entries.size}):"
|
|
3282
|
+
entries.each { |entry| puts " #{entry[:ref]}" }
|
|
3283
|
+
end
|
|
3284
|
+
|
|
3285
|
+
# One y/N for a whole group. Declining leaves every entry in the
|
|
3286
|
+
# manifest, so a later run can still offer them. Deletes through
|
|
3287
|
+
# `delete_refs!` rather than looping `delete_entry!` here: that is where
|
|
3288
|
+
# a refusal becomes a reported outcome instead of an exception, and a
|
|
3289
|
+
# backtrace halfway through a destructive command -- with some files
|
|
3290
|
+
# already gone and the remaining groups never offered -- is the one
|
|
3291
|
+
# failure mode this command must not have.
|
|
3292
|
+
def self.cleanup_group(type, entries, question)
|
|
3293
|
+
return if entries.empty?
|
|
3294
|
+
return puts("Kept: #{University::Artifacts.label(type).downcase}.") unless
|
|
3295
|
+
get_started_confirm?(question, default: false)
|
|
3296
|
+
|
|
3297
|
+
University::Cleanup.delete_refs!(type, entries.map { |entry| entry[:ref] })
|
|
3298
|
+
.each { |outcome| puts " #{outcome.message}" }
|
|
3299
|
+
end
|
|
3300
|
+
|
|
3301
|
+
# Per ITEM, showing the URL. A gist is public, remote, and gone for
|
|
3302
|
+
# good -- the one thing here worth asking about individually.
|
|
3303
|
+
def self.cleanup_gists(entries)
|
|
3304
|
+
return if entries.empty?
|
|
3305
|
+
|
|
3306
|
+
unless University::Cleanup.gh_available?
|
|
3307
|
+
puts ""
|
|
3308
|
+
puts "gh is not installed, so gists cannot be deleted from here. Delete these yourself:"
|
|
3309
|
+
entries.each { |entry| puts " #{entry[:ref]}" }
|
|
3310
|
+
return
|
|
3311
|
+
end
|
|
3312
|
+
|
|
3313
|
+
entries.each do |entry|
|
|
3314
|
+
if get_started_confirm?("Delete gist #{entry[:ref]}?", default: false)
|
|
3315
|
+
University::Cleanup.delete_refs!('gist', [entry[:ref]])
|
|
3316
|
+
.each { |outcome| puts " #{outcome.message}" }
|
|
3317
|
+
else
|
|
3318
|
+
puts " Kept #{entry[:ref]}"
|
|
3319
|
+
end
|
|
3320
|
+
end
|
|
3321
|
+
end
|
|
3322
|
+
|
|
3323
|
+
def self.cleanup_state_files(entries)
|
|
3324
|
+
return if entries.empty?
|
|
3325
|
+
|
|
3326
|
+
question = "Delete #{entries.size} University state file(s) " \
|
|
3327
|
+
"(progress, artifact manifest, worker/listener records)?"
|
|
3328
|
+
return puts("Kept: University state files.") unless get_started_confirm?(question, default: false)
|
|
3329
|
+
|
|
3330
|
+
University::Cleanup.delete_state_files!.each { |outcome| puts " #{outcome.message}" }
|
|
3331
|
+
end
|
|
3332
|
+
|
|
3333
|
+
# `streamweaver university-stop`: put the course down without throwing
|
|
3334
|
+
# any of it away. Stops the listener, closes the demo canvas sessions and
|
|
3335
|
+
# the two iTerm2 surfaces `get-started` opened (the agent's worker tab
|
|
3336
|
+
# and the controller window), and keeps every file -- progress and the
|
|
3337
|
+
# artifact manifest both. Deleting is `university-cleanup`'s job;
|
|
3338
|
+
# starting over is `university-reset`'s. This is neither.
|
|
3339
|
+
#
|
|
3340
|
+
# Never touches a session or window the course did not open: the canvas
|
|
3341
|
+
# sessions come from the same allowlist `university-reset` closes by, and
|
|
3342
|
+
# the two iTerm2 sessions are the exact ids `get-started` recorded in
|
|
3343
|
+
# worker.json.
|
|
3344
|
+
def self.university_stop(_args = [])
|
|
3345
|
+
require_relative 'canvas/client'
|
|
3346
|
+
require_relative 'university/artifacts'
|
|
3347
|
+
|
|
3348
|
+
puts(University::Listener.stop! ? "Stopped the University listener." : "University listener was not running.")
|
|
3349
|
+
|
|
3350
|
+
if Canvas::Client.bridge_running?
|
|
3351
|
+
# clear_state: false -- stopping is not starting over, so step 4's
|
|
3352
|
+
# growing doc must still remember what it had when the user returns.
|
|
3353
|
+
University::Listener.close_demo_sessions!(clear_state: false)
|
|
3354
|
+
puts "Closed the course demo canvas sessions: #{University::Listener::DEMO_SESSION_NAMES.join(', ')}"
|
|
3355
|
+
else
|
|
3356
|
+
puts "(canvas bridge not running -- no demo sessions to close)"
|
|
3357
|
+
end
|
|
3358
|
+
|
|
3359
|
+
close_university_iterm_sessions
|
|
3360
|
+
puts "Kept your progress (#{University::Progress.path})"
|
|
3361
|
+
puts "Kept the artifact manifest (#{University::Artifacts.path}) -- `streamweaver university-cleanup` removes what the course created."
|
|
3362
|
+
end
|
|
3363
|
+
|
|
3364
|
+
# Closes the worker tab and the controller window `get-started` recorded,
|
|
3365
|
+
# and nothing else. A session that is already gone (or was never
|
|
3366
|
+
# recorded, on the degraded path) is skipped silently -- both are the
|
|
3367
|
+
# ordinary case, not a failure worth reporting.
|
|
3368
|
+
def self.close_university_iterm_sessions
|
|
3369
|
+
require_relative 'iterm'
|
|
3370
|
+
recorded = University::Runner.worker or return
|
|
3371
|
+
|
|
3372
|
+
{
|
|
3373
|
+
'session_id' => 'worker tab',
|
|
3374
|
+
'controller_session_id' => 'University canvas window'
|
|
3375
|
+
}.each do |key, label|
|
|
3376
|
+
id = recorded[key]
|
|
3377
|
+
next unless id && ITerm.session_alive?(id)
|
|
3378
|
+
|
|
3379
|
+
puts "Closed the #{label}." if ITerm.close_pane(id)
|
|
3380
|
+
end
|
|
3381
|
+
end
|
|
3382
|
+
|
|
2956
3383
|
# Print the absolute path of a canned course demo inside the installed
|
|
2957
3384
|
# gem. Every Getting Started prompt runs its demo through this rather
|
|
2958
3385
|
# than naming a path, so the course works from a plain `gem install`
|
|
@@ -3099,6 +3526,22 @@ module StreamWeaver
|
|
|
3099
3526
|
# Forrest's Law: don't hand the user a URL and a chore. The degraded
|
|
3100
3527
|
# path already opens the browser itself; do the same here.
|
|
3101
3528
|
$stderr.puts "Could not open the canvas window in iTerm2 — opening it in your browser instead."
|
|
3529
|
+
# Print WHY, honestly -- ITerm.open_browser_window used to swallow
|
|
3530
|
+
# its exception entirely (rescue StandardError; nil), which left a
|
|
3531
|
+
# tester with no way to tell what went wrong. last_error surfaces
|
|
3532
|
+
# the underlying exception; browser_plugin_available? gives the one
|
|
3533
|
+
# targeted hint worth naming explicitly, since a missing Browser
|
|
3534
|
+
# Plugin is the failure this path is most likely to hit and has a
|
|
3535
|
+
# known fix -- guarded on `available?` so a broader connectivity
|
|
3536
|
+
# failure (already explained by last_error above) doesn't get
|
|
3537
|
+
# misreported as a missing plugin.
|
|
3538
|
+
if (err = ITerm.last_error)
|
|
3539
|
+
$stderr.puts " (#{err.class}: #{err.message})"
|
|
3540
|
+
end
|
|
3541
|
+
if ITerm.available? && !ITerm.browser_plugin_available?
|
|
3542
|
+
$stderr.puts " iTerm2's Browser Plugin isn't installed — download it from " \
|
|
3543
|
+
"https://iterm2.com/browser-plugin.html and unzip into /Applications."
|
|
3544
|
+
end
|
|
3102
3545
|
open_browser(canvas_url)
|
|
3103
3546
|
end
|
|
3104
3547
|
puts "Recorded: #{path}"
|
data/lib/stream_weaver/iterm.rb
CHANGED
|
@@ -8,8 +8,40 @@ module StreamWeaver
|
|
|
8
8
|
# (`gem install iterm2_ruby` — https://rubygems.org/gems/iterm2_ruby).
|
|
9
9
|
# Falls back to system browser when iTerm2 API is unavailable.
|
|
10
10
|
class ITerm
|
|
11
|
-
#
|
|
12
|
-
|
|
11
|
+
# A pane renders a web view instead of a shell when its profile's
|
|
12
|
+
# "Custom Command" property is the literal string "Browser" -- this is
|
|
13
|
+
# exactly how iTerm2's own built-in "Web Browser" profile is defined
|
|
14
|
+
# (confirmed by reading it out of `defaults read com.googlecode.iterm2
|
|
15
|
+
# "New Bookmarks"`). Passing it as a profile_customizations override on
|
|
16
|
+
# split_pane/create_tab produces a genuine browser pane from ANY base
|
|
17
|
+
# profile -- verified live (title came back as the loaded page's own
|
|
18
|
+
# <title>, not a shell prompt) -- so nothing here depends on a
|
|
19
|
+
# "Web Browser" profile actually being installed. A fresh macOS/iTerm2
|
|
20
|
+
# install with only "Default" in its profile list works identically.
|
|
21
|
+
#
|
|
22
|
+
# This still needs iTerm2's own browser feature to be usable, which
|
|
23
|
+
# (per iterm2.com/documentation-preferences-profiles-general.html) is
|
|
24
|
+
# gated on TWO things this override cannot bypass: the separately
|
|
25
|
+
# downloaded Browser Plugin (browser_plugin_available? below), and the
|
|
26
|
+
# "Enable browser-style profiles" Advanced setting (enabled by default;
|
|
27
|
+
# browser_style_profiles_enabled? below). The live verification above
|
|
28
|
+
# ran on a machine with both already satisfied -- a fresh install
|
|
29
|
+
# lacking the plugin needs it installed regardless of this override.
|
|
30
|
+
BROWSER_TYPE_PROPERTY = { "Custom Command" => "Browser" }.freeze
|
|
31
|
+
|
|
32
|
+
# iTerm2's bundle identifier for its own separately-downloaded Browser
|
|
33
|
+
# Plugin (iterm2.com/browser-plugin.html: unzip into /Applications,
|
|
34
|
+
# "you don't need to run it; iTerm2 will locate it automatically").
|
|
35
|
+
# Matches the plugin's own Info.plist CFBundleIdentifier, confirmed on
|
|
36
|
+
# a machine that has it installed.
|
|
37
|
+
BROWSER_PLUGIN_BUNDLE_ID = "com.googlecode.iterm2.iTermBrowserPlugin"
|
|
38
|
+
|
|
39
|
+
# The Advanced setting's persisted preference key (found in the app
|
|
40
|
+
# binary's strings: "advancedSettingsModelDictionary_browserProfiles" /
|
|
41
|
+
# "browserProfilesUserDefaultsKey", labeled in Settings → Advanced as
|
|
42
|
+
# "Experimental Features: Enable browser-style profiles?"). Unset on a
|
|
43
|
+
# machine that has never touched it -- iTerm2 defaults it to enabled.
|
|
44
|
+
BROWSER_STYLE_PROFILES_PREF_KEY = "browserProfiles"
|
|
13
45
|
|
|
14
46
|
# Default window frames (points) for the two windows this class ever
|
|
15
47
|
# creates from scratch -- the University controller (narrow, tall) and a
|
|
@@ -56,6 +88,41 @@ module StreamWeaver
|
|
|
56
88
|
!ENV["ITERM_SESSION_ID"].to_s.empty?
|
|
57
89
|
end
|
|
58
90
|
|
|
91
|
+
# True when iTerm2's Browser Plugin is installed -- checked the same
|
|
92
|
+
# way iTerm2 itself locates it (by bundle identifier via Launch
|
|
93
|
+
# Services/Spotlight, not a fixed path), so this is accurate
|
|
94
|
+
# regardless of which folder the user unzipped it into. Needs no
|
|
95
|
+
# Python API connection at all -- darwin-only, since mdfind is a
|
|
96
|
+
# macOS tool. False on any doubt (not darwin, mdfind missing/erroring,
|
|
97
|
+
# timeout), same spirit as every other probe in this file.
|
|
98
|
+
def browser_plugin_available?
|
|
99
|
+
return false unless RbConfig::CONFIG["host_os"].match?(/darwin/)
|
|
100
|
+
|
|
101
|
+
with_timeout(5, default: false) do
|
|
102
|
+
out = `mdfind "kMDItemCFBundleIdentifier == '#{BROWSER_PLUGIN_BUNDLE_ID}'" 2>/dev/null`
|
|
103
|
+
!out.strip.empty?
|
|
104
|
+
end
|
|
105
|
+
rescue StandardError
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# True unless the user has explicitly turned off iTerm2's "Enable
|
|
110
|
+
# browser-style profiles" Advanced setting -- an "Experimental
|
|
111
|
+
# Features" toggle that ships enabled, so an unset preference (the
|
|
112
|
+
# common case) reads as enabled rather than disabled. Reads the
|
|
113
|
+
# plist directly via `defaults`, not the Python API -- this is a
|
|
114
|
+
# plain macOS preference, no RPC involved.
|
|
115
|
+
def browser_style_profiles_enabled?
|
|
116
|
+
return false unless RbConfig::CONFIG["host_os"].match?(/darwin/)
|
|
117
|
+
|
|
118
|
+
with_timeout(5, default: true) do
|
|
119
|
+
out = `defaults read com.googlecode.iterm2 #{BROWSER_STYLE_PROFILES_PREF_KEY} 2>/dev/null`.strip
|
|
120
|
+
out != "0"
|
|
121
|
+
end
|
|
122
|
+
rescue StandardError
|
|
123
|
+
true
|
|
124
|
+
end
|
|
125
|
+
|
|
59
126
|
# Split a browser pane with the URL into `target_session` (any session
|
|
60
127
|
# id, e.g. a just-created worker tab's session -- not necessarily the
|
|
61
128
|
# calling pane). Defaults to the calling session (current_session_guid)
|
|
@@ -99,6 +166,7 @@ module StreamWeaver
|
|
|
99
166
|
def open_browser_window(url)
|
|
100
167
|
return nil unless available?
|
|
101
168
|
|
|
169
|
+
@last_error = nil
|
|
102
170
|
with_timeout(10, default: nil) do
|
|
103
171
|
connect do |c|
|
|
104
172
|
created = c.create_tab
|
|
@@ -119,10 +187,22 @@ module StreamWeaver
|
|
|
119
187
|
pane
|
|
120
188
|
end
|
|
121
189
|
end
|
|
122
|
-
rescue StandardError
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
@last_error = e
|
|
123
192
|
nil
|
|
124
193
|
end
|
|
125
194
|
|
|
195
|
+
# The exception that made the most recent open_browser_window call
|
|
196
|
+
# fall back to nil, or nil if that call succeeded (or hasn't run
|
|
197
|
+
# yet). Reset at the start of every open_browser_window call, so a
|
|
198
|
+
# caller committed to the honest-fallback message (get-started's
|
|
199
|
+
# premier path, cli.rb) can report WHY the canvas window couldn't
|
|
200
|
+
# open instead of the silent nil this class returned before --
|
|
201
|
+
# rather than parse it out of a swallowed rescue.
|
|
202
|
+
def last_error
|
|
203
|
+
@last_error
|
|
204
|
+
end
|
|
205
|
+
|
|
126
206
|
def close_pane(pane_id)
|
|
127
207
|
return false unless available? && pane_id
|
|
128
208
|
connect { |c| c.close_session(pane_id, force: true) }
|
|
@@ -298,15 +378,17 @@ module StreamWeaver
|
|
|
298
378
|
# The browser pane for `url`, or nil. The real client raises on a
|
|
299
379
|
# failed split (and returns nil only for an OK response with no
|
|
300
380
|
# session), so both spellings of failure are caught here rather than
|
|
301
|
-
# unwinding past the shell cleanup above.
|
|
381
|
+
# unwinding past the shell cleanup above. No profile_name: the split
|
|
382
|
+
# inherits whatever profile `shell` already has, and BROWSER_TYPE_PROPERTY
|
|
383
|
+
# overrides it into a browser pane regardless (see that constant).
|
|
302
384
|
def browser_pane_in(client, shell, url)
|
|
303
385
|
client.split_pane(
|
|
304
386
|
shell,
|
|
305
387
|
vertical: true,
|
|
306
|
-
|
|
307
|
-
profile_customizations: { "Initial URL" => url }
|
|
388
|
+
profile_customizations: BROWSER_TYPE_PROPERTY.merge("Initial URL" => url)
|
|
308
389
|
)
|
|
309
|
-
rescue StandardError
|
|
390
|
+
rescue StandardError => e
|
|
391
|
+
@last_error = e
|
|
310
392
|
nil
|
|
311
393
|
end
|
|
312
394
|
|
|
@@ -441,8 +523,7 @@ module StreamWeaver
|
|
|
441
523
|
c.split_pane(
|
|
442
524
|
guid,
|
|
443
525
|
vertical: !horizontal,
|
|
444
|
-
|
|
445
|
-
profile_customizations: { "Initial URL" => url }
|
|
526
|
+
profile_customizations: BROWSER_TYPE_PROPERTY.merge("Initial URL" => url)
|
|
446
527
|
)
|
|
447
528
|
end
|
|
448
529
|
end
|