markymark 0.1.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/CHANGELOG.md +29 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +255 -0
  6. data/Rakefile +8 -0
  7. data/assets/.gitkeep +0 -0
  8. data/assets/Markymark.icns +0 -0
  9. data/assets/Markymark.iconset/icon_128x128.png +0 -0
  10. data/assets/Markymark.iconset/icon_128x128@2x.png +0 -0
  11. data/assets/Markymark.iconset/icon_16x16.png +0 -0
  12. data/assets/Markymark.iconset/icon_16x16@2x.png +0 -0
  13. data/assets/Markymark.iconset/icon_256x256.png +0 -0
  14. data/assets/Markymark.iconset/icon_256x256@2x.png +0 -0
  15. data/assets/Markymark.iconset/icon_32x32.png +0 -0
  16. data/assets/Markymark.iconset/icon_32x32@2x.png +0 -0
  17. data/assets/Markymark.iconset/icon_512x512.png +0 -0
  18. data/assets/Markymark.iconset/icon_512x512@2x.png +0 -0
  19. data/assets/README.md +3 -0
  20. data/assets/marky-mark-dj.jpg +0 -0
  21. data/assets/marky-mark-icon.png +0 -0
  22. data/assets/marky-mark-icon2.png +0 -0
  23. data/config.ru +19 -0
  24. data/docs/for_llms.md +141 -0
  25. data/docs/plans/2025-12-18-macos-app-installer-design.md +149 -0
  26. data/exe/markymark +5 -0
  27. data/lib/markymark/app_installer.rb +437 -0
  28. data/lib/markymark/cli.rb +497 -0
  29. data/lib/markymark/init_wizard.rb +186 -0
  30. data/lib/markymark/pumadev_manager.rb +194 -0
  31. data/lib/markymark/server_simple.rb +452 -0
  32. data/lib/markymark/version.rb +5 -0
  33. data/lib/markymark.rb +12 -0
  34. data/lib/public/css/style.css +350 -0
  35. data/lib/public/js/app.js +186 -0
  36. data/lib/public/js/theme.js +79 -0
  37. data/lib/public/js/tree.js +124 -0
  38. data/lib/views/browse.erb +225 -0
  39. data/lib/views/index.erb +37 -0
  40. data/lib/views/simple.erb +806 -0
  41. data/sig/markymark.rbs +4 -0
  42. metadata +242 -0
@@ -0,0 +1,497 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'socket'
7
+
8
+ module Markymark
9
+ # Command-line interface handler
10
+ class CLI
11
+ DEFAULT_PORT = 4545
12
+
13
+ attr_reader :root_path, :port, :open_browser, :initial_file
14
+
15
+ def initialize(args)
16
+ @root_path = Dir.pwd
17
+ @port = DEFAULT_PORT
18
+ @open_browser = true
19
+ @initial_file = nil
20
+
21
+ parse_options(args)
22
+ validate!
23
+ end
24
+
25
+ def self.run(args)
26
+ cli = new(args)
27
+
28
+ # Check if pumadev mode is active
29
+ if PumadevManager.active?
30
+ puts "Pumadev mode is active"
31
+ # In pumadev mode - switch directory via pumadev manager
32
+ if PumadevManager.switch_directory(cli.root_path)
33
+ puts "Switched to #{cli.root_path}"
34
+ puts "Access markymark at: http://markymark.test"
35
+
36
+ # Open browser if requested
37
+ if cli.open_browser
38
+ require 'launchy'
39
+ url = if cli.initial_file
40
+ "http://markymark.test/?file=#{CGI.escape(cli.initial_file)}"
41
+ else
42
+ 'http://markymark.test'
43
+ end
44
+ Launchy.open(url)
45
+ end
46
+ else
47
+ warn "Failed to switch directory in pumadev mode"
48
+ exit 1
49
+ end
50
+ return
51
+ end
52
+
53
+ # Check if a standalone server is already running
54
+ existing_server = cli.send(:detect_existing_server)
55
+
56
+ if existing_server
57
+ # Server exists - switch it to the new directory
58
+ cli.send(:handle_existing_server, existing_server)
59
+ else
60
+ # No server running - start a new one (check for port conflicts)
61
+ if cli.send(:port_available?, cli.port)
62
+ ServerSimple.launch(cli)
63
+ else
64
+ # Port is busy - prompt user
65
+ cli.send(:handle_port_conflict)
66
+ end
67
+ end
68
+ rescue => e
69
+ warn "Error: #{e.message}"
70
+ warn e.backtrace.join("\n")
71
+ exit 1
72
+ end
73
+
74
+ def self.stop_server
75
+ # Check if pumadev mode is active first
76
+ if PumadevManager.active?
77
+ PumadevManager.teardown
78
+ exit 0
79
+ end
80
+
81
+ # Check for standalone server
82
+ pid_file = File.expand_path('~/.markymark/server.pid')
83
+
84
+ unless File.exist?(pid_file)
85
+ puts "No markymark server is running"
86
+ exit 0
87
+ end
88
+
89
+ # Read PID file
90
+ content = File.read(pid_file)
91
+ port = content[/port=(\d+)/, 1]&.to_i
92
+ pid = content[/pid=(\d+)/, 1]&.to_i
93
+
94
+ unless pid
95
+ puts "Invalid PID file"
96
+ File.delete(pid_file)
97
+ exit 1
98
+ end
99
+
100
+ # Check if process is still running
101
+ begin
102
+ Process.kill(0, pid)
103
+ rescue Errno::ESRCH
104
+ puts "Server process not found (PID: #{pid})"
105
+ File.delete(pid_file)
106
+ exit 0
107
+ end
108
+
109
+ puts "Stopping markymark server (PID: #{pid}, port: #{port})..."
110
+
111
+ # Try graceful shutdown first (SIGTERM)
112
+ begin
113
+ Process.kill('TERM', pid)
114
+
115
+ # Wait up to 3 seconds for graceful shutdown
116
+ 3.times do
117
+ sleep 1
118
+ begin
119
+ Process.kill(0, pid)
120
+ rescue Errno::ESRCH
121
+ # Process has exited
122
+ puts "Server stopped successfully"
123
+ File.delete(pid_file) if File.exist?(pid_file)
124
+ exit 0
125
+ end
126
+ end
127
+
128
+ # If still running, force kill
129
+ puts "Server didn't stop gracefully, forcing shutdown..."
130
+ Process.kill('KILL', pid)
131
+ sleep 1
132
+
133
+ puts "Server stopped (forced)"
134
+ File.delete(pid_file) if File.exist?(pid_file)
135
+ rescue Errno::ESRCH
136
+ puts "Server stopped successfully"
137
+ File.delete(pid_file) if File.exist?(pid_file)
138
+ rescue => e
139
+ warn "Error stopping server: #{e.message}"
140
+ exit 1
141
+ end
142
+ end
143
+
144
+ def self.show_status
145
+ puts "Markymark Status"
146
+ puts "=" * 40
147
+
148
+ # Check macOS app status
149
+ if RUBY_PLATFORM.include?('darwin')
150
+ app_status = AppInstaller.status
151
+ if app_status
152
+ puts ""
153
+ puts "macOS App:"
154
+ puts " #{app_status[:message]}"
155
+ end
156
+ end
157
+
158
+ # Check pumadev
159
+ pumadev_status = PumadevManager.status
160
+ if pumadev_status
161
+ puts ""
162
+ puts "Server Mode: Pumadev"
163
+ puts " #{pumadev_status[:message]}"
164
+ return
165
+ end
166
+
167
+ # Check standalone server
168
+ pid_file = File.expand_path('~/.markymark/server.pid')
169
+ unless File.exist?(pid_file)
170
+ puts ""
171
+ puts "Server: Not running"
172
+ return
173
+ end
174
+
175
+ content = File.read(pid_file)
176
+ port = content[/port=(\d+)/, 1]&.to_i
177
+ pid = content[/pid=(\d+)/, 1]&.to_i
178
+
179
+ # Check if process is still running
180
+ begin
181
+ Process.kill(0, pid)
182
+ puts ""
183
+ puts "Server Mode: Standalone"
184
+ puts " Running (PID: #{pid}, port: #{port})"
185
+ puts " Access at: http://localhost:#{port}"
186
+ rescue Errno::ESRCH
187
+ puts ""
188
+ puts "Server: Not running (stale PID file removed)"
189
+ File.delete(pid_file)
190
+ end
191
+ end
192
+
193
+ def self.show_pumadev_instructions
194
+ puts <<~INSTRUCTIONS
195
+ Pumadev Setup for markymark
196
+ ============================
197
+
198
+ Pumadev allows you to access markymark via a .test domain instead of remembering ports.
199
+
200
+ Quick Setup:
201
+ markymark --setup-pumadev [PATH]
202
+
203
+ Manual Setup Instructions:
204
+
205
+ 1. Install pumadev (if not already installed):
206
+ gem install puma-dev
207
+
208
+ 2. Set up pumadev:
209
+ sudo puma-dev -setup
210
+ puma-dev -install
211
+
212
+ 3. Find your markymark gem installation:
213
+ gem which markymark
214
+
215
+ 4. Create a symlink in ~/.puma-dev/:
216
+ cd ~/.puma-dev
217
+ ln -s /path/to/markymark/gem markymark
218
+
219
+ 5. Access markymark at:
220
+ http://markymark.test
221
+
222
+ Notes for Ruby Version Manager Users:
223
+ - Install markymark in your global gemset of your default Ruby
224
+ - This ensures the command is available across all Ruby versions
225
+ - The symlink points to the gem location, which pumadev will use
226
+
227
+ Smart Directory Switching:
228
+ - The smart directory switching feature works with pumadev
229
+ - Run 'markymark [PATH]' from any directory to switch the server
230
+ - The directory setting persists across requests
231
+
232
+ For more information: https://github.com/puma/puma-dev
233
+ INSTRUCTIONS
234
+ end
235
+
236
+ private
237
+
238
+ def parse_options(args)
239
+ parser = OptionParser.new do |opts|
240
+ opts.banner = <<~BANNER
241
+ markymark - Browse markdown documentation with live reload
242
+
243
+ Usage: markymark [PATH] [OPTIONS]
244
+ markymark init [-y]
245
+
246
+ Arguments:
247
+ PATH Directory or markdown file to browse (default: current directory)
248
+ If a file is specified, opens that directory with the file displayed
249
+
250
+ Commands:
251
+ init Interactive setup wizard (use -y to accept defaults)
252
+
253
+ Options:
254
+ BANNER
255
+
256
+ opts.on('-p', '--port PORT', Integer, "Port to run server on (default: #{DEFAULT_PORT})") do |p|
257
+ @port = p
258
+ end
259
+
260
+ opts.on('--no-browser', 'Do not auto-open browser on startup') do
261
+ @open_browser = false
262
+ end
263
+
264
+ opts.on('--stop', 'Stop the running markymark server') do
265
+ CLI.stop_server
266
+ end
267
+
268
+ opts.on('-h', '--help', 'Show this help message') do
269
+ puts opts
270
+ exit
271
+ end
272
+
273
+ opts.on('-v', '--version', 'Show version') do
274
+ puts "markymark #{Markymark::VERSION}"
275
+ exit
276
+ end
277
+
278
+ opts.on('--setup-pumadev [PATH]', 'Setup pumadev integration with optional path') do |path|
279
+ if PumadevManager.setup(path)
280
+ require 'launchy'
281
+ Launchy.open('http://markymark.test')
282
+ else
283
+ exit 1
284
+ end
285
+ exit 0
286
+ end
287
+
288
+ opts.on('--status', 'Show current server status') do
289
+ CLI.show_status
290
+ exit
291
+ end
292
+
293
+ opts.on('--pumadev-info', 'Show pumadev setup instructions') do
294
+ CLI.show_pumadev_instructions
295
+ exit
296
+ end
297
+
298
+ opts.on('--install-app', 'Install macOS app bundle to ~/Applications') do
299
+ if AppInstaller.install
300
+ exit 0
301
+ else
302
+ exit 1
303
+ end
304
+ end
305
+
306
+ opts.on('--uninstall-app', 'Remove macOS app bundle') do
307
+ if AppInstaller.uninstall
308
+ exit 0
309
+ else
310
+ exit 1
311
+ end
312
+ end
313
+
314
+ opts.on('--set-default', 'Set Markymark as default handler for .md files') do
315
+ if AppInstaller.set_default_handler
316
+ exit 0
317
+ else
318
+ exit 1
319
+ end
320
+ end
321
+ end
322
+
323
+ parser.parse!(args)
324
+
325
+ # Handle 'init' subcommand
326
+ if args.first == 'init'
327
+ args.shift
328
+ accept_defaults = args.include?('-y') || args.include?('--yes')
329
+ wizard = InitWizard.new(accept_defaults: accept_defaults)
330
+ wizard.run
331
+ exit 0
332
+ end
333
+
334
+ # First non-option argument is the path
335
+ @root_path = args.first if args.any?
336
+ end
337
+
338
+ def validate!
339
+ expanded_path = File.expand_path(@root_path)
340
+
341
+ unless File.exist?(expanded_path)
342
+ raise ArgumentError, "Path does not exist: #{@root_path}"
343
+ end
344
+
345
+ # If path is a file, extract directory and filename
346
+ if File.file?(expanded_path)
347
+ unless expanded_path =~ /\.(md|markdown)$/i
348
+ raise ArgumentError, "File must be a markdown file (.md or .markdown): #{@root_path}"
349
+ end
350
+ @initial_file = File.basename(expanded_path)
351
+ @root_path = File.realpath(File.dirname(expanded_path))
352
+ else
353
+ @root_path = File.realpath(expanded_path)
354
+ end
355
+
356
+ unless @port.between?(1, 65535)
357
+ raise ArgumentError, "Port must be between 1 and 65535"
358
+ end
359
+ end
360
+
361
+ # Detect if a markymark server is already running
362
+ def detect_existing_server
363
+ pid_file = File.expand_path('~/.markymark/server.pid')
364
+ return nil unless File.exist?(pid_file)
365
+
366
+ # Parse PID file
367
+ content = File.read(pid_file)
368
+ port = content[/port=(\d+)/, 1]&.to_i
369
+ pid = content[/pid=(\d+)/, 1]&.to_i
370
+
371
+ return nil unless port && pid
372
+
373
+ # Check if process is still running
374
+ begin
375
+ Process.kill(0, pid)
376
+ rescue Errno::ESRCH
377
+ # Process not found - clean up stale PID file
378
+ File.delete(pid_file)
379
+ return nil
380
+ end
381
+
382
+ # Verify it's actually a markymark server by checking /api/status
383
+ begin
384
+ uri = URI("http://localhost:#{port}/api/status")
385
+ response = Net::HTTP.get_response(uri)
386
+ if response.is_a?(Net::HTTPSuccess)
387
+ data = JSON.parse(response.body)
388
+ return { port: port, pid: pid } if data['app'] == 'markymark'
389
+ end
390
+ rescue => e
391
+ # Server not responding or not markymark - ignore
392
+ end
393
+
394
+ nil
395
+ end
396
+
397
+ # Handle existing server - switch directory and open browser
398
+ def handle_existing_server(server_info)
399
+ puts "Found existing markymark server on port #{server_info[:port]}"
400
+ puts "Switching to #{@root_path}..."
401
+
402
+ if switch_server_directory(server_info[:port], @root_path)
403
+ puts "Successfully switched to #{@root_path}"
404
+
405
+ # Open browser if requested
406
+ if @open_browser
407
+ base_url = "http://localhost:#{server_info[:port]}"
408
+ url = if @initial_file
409
+ "#{base_url}/?file=#{CGI.escape(@initial_file)}"
410
+ else
411
+ base_url
412
+ end
413
+ require 'launchy'
414
+ Launchy.open(url)
415
+ end
416
+ else
417
+ warn "Failed to switch directory. Starting new server instead..."
418
+ # Port might be available now if switch failed
419
+ if port_available?(@port)
420
+ ServerSimple.launch(self)
421
+ else
422
+ handle_port_conflict
423
+ end
424
+ end
425
+ end
426
+
427
+ # Handle port conflict with non-markymark app
428
+ def handle_port_conflict
429
+ puts "Port #{@port} is already in use by another application."
430
+ puts "What would you like to do?"
431
+ puts " 1) Start markymark on a different port"
432
+ puts " 2) Cancel"
433
+ print "Choice (1-2): "
434
+
435
+ choice = $stdin.gets&.strip
436
+
437
+ case choice
438
+ when "1"
439
+ new_port = find_available_port(@port + 1)
440
+ if new_port
441
+ puts "Starting markymark on port #{new_port}..."
442
+ @port = new_port
443
+ ServerSimple.launch(self)
444
+ else
445
+ warn "Could not find an available port"
446
+ exit 1
447
+ end
448
+ else
449
+ puts "Cancelled"
450
+ exit 0
451
+ end
452
+ end
453
+
454
+ # Switch directory on existing server
455
+ def switch_server_directory(port, new_path)
456
+ uri = URI("http://localhost:#{port}/change-dir")
457
+ request = Net::HTTP::Post.new(uri)
458
+ request.set_form_data('path' => new_path)
459
+ request['Accept'] = 'application/json'
460
+
461
+ begin
462
+ response = Net::HTTP.start(uri.hostname, uri.port, read_timeout: 5) do |http|
463
+ http.request(request)
464
+ end
465
+
466
+ # Server returns 303 redirect on successful directory change
467
+ if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
468
+ return true
469
+ else
470
+ warn "Server returned error: #{response.code}"
471
+ return false
472
+ end
473
+ rescue => e
474
+ warn "Failed to communicate with server: #{e.message}"
475
+ return false
476
+ end
477
+ end
478
+
479
+ # Check if a port is available
480
+ def port_available?(port)
481
+ TCPServer.open('127.0.0.1', port) do |server|
482
+ server.close
483
+ true
484
+ end
485
+ rescue Errno::EADDRINUSE, Errno::EACCES
486
+ false
487
+ end
488
+
489
+ # Find next available port starting from given port
490
+ def find_available_port(start_port)
491
+ (start_port..65535).each do |port|
492
+ return port if port_available?(port)
493
+ end
494
+ nil
495
+ end
496
+ end
497
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Markymark
4
+ # Interactive setup wizard for Markymark
5
+ class InitWizard
6
+ def initialize(accept_defaults: false)
7
+ @accept_defaults = accept_defaults
8
+ end
9
+
10
+ def run
11
+ puts "Markymark Setup Wizard"
12
+ puts "=" * 40
13
+ puts ""
14
+
15
+ results = {
16
+ app_installed: false,
17
+ default_set: false,
18
+ server_mode: nil
19
+ }
20
+
21
+ # Step 1: Install macOS app (macOS only)
22
+ if macos?
23
+ results[:app_installed] = step_install_app
24
+
25
+ # Step 2: Set as default handler (only if app was installed)
26
+ if results[:app_installed]
27
+ results[:default_set] = step_set_default
28
+ end
29
+ else
30
+ puts "Skipping macOS app installation (not on macOS)"
31
+ puts ""
32
+ end
33
+
34
+ # Step 3: Server mode setup
35
+ results[:server_mode] = step_server_mode
36
+
37
+ # Summary
38
+ print_summary(results)
39
+
40
+ results
41
+ end
42
+
43
+ private
44
+
45
+ def macos?
46
+ RUBY_PLATFORM.include?('darwin')
47
+ end
48
+
49
+ def step_install_app
50
+ if AppInstaller.installed?
51
+ status = AppInstaller.status
52
+ if status[:valid]
53
+ puts "Markymark.app is already installed and valid."
54
+ return prompt_yes_no("Reinstall anyway?", default: false)
55
+ else
56
+ puts "Warning: #{status[:message]}"
57
+ reinstall = prompt_yes_no("Reinstall Markymark.app?", default: true)
58
+ if reinstall
59
+ return AppInstaller.install(force: true)
60
+ end
61
+ return false
62
+ end
63
+ end
64
+
65
+ install = prompt_yes_no("Install Markymark.app for macOS file handling?", default: true)
66
+ return false unless install
67
+
68
+ AppInstaller.install(force: true)
69
+ end
70
+
71
+ def step_set_default
72
+ set_default = prompt_yes_no("Set Markymark as default app for .md files?", default: true)
73
+ return false unless set_default
74
+
75
+ AppInstaller.set_default_handler
76
+ end
77
+
78
+ def step_server_mode
79
+ puts "Server mode setup:"
80
+ puts " 1) Standalone (port 4545) - run 'markymark' to start"
81
+ puts " 2) Pumadev (.test domain) - always available at markymark.test"
82
+ puts " 3) Skip - configure later"
83
+ puts ""
84
+
85
+ if @accept_defaults
86
+ puts "Using default: Skip (run 'markymark' or 'markymark --setup-pumadev' later)"
87
+ return :skip
88
+ end
89
+
90
+ print "Choice [1/2/3, default: 3]: "
91
+ choice = $stdin.gets&.strip
92
+
93
+ case choice
94
+ when '1'
95
+ puts ""
96
+ puts "Standalone mode selected."
97
+ puts "Run 'markymark' or 'markymark <directory>' to start the server."
98
+ :standalone
99
+ when '2'
100
+ puts ""
101
+ setup_pumadev
102
+ else
103
+ puts ""
104
+ puts "Skipped. Run 'markymark' or 'markymark --setup-pumadev' later."
105
+ :skip
106
+ end
107
+ end
108
+
109
+ def setup_pumadev
110
+ if PumadevManager.active?
111
+ puts "Pumadev is already configured for Markymark."
112
+ puts "Access at: http://markymark.test"
113
+ return :pumadev
114
+ end
115
+
116
+ puts "Setting up pumadev..."
117
+ if PumadevManager.setup
118
+ puts "Pumadev configured successfully!"
119
+ puts "Access at: http://markymark.test"
120
+ :pumadev
121
+ else
122
+ puts "Pumadev setup failed. You can try again with: markymark --setup-pumadev"
123
+ :skip
124
+ end
125
+ end
126
+
127
+ def prompt_yes_no(question, default: true)
128
+ if @accept_defaults
129
+ puts "#{question} [Y/n] #{default ? 'Y' : 'n'} (auto)"
130
+ return default
131
+ end
132
+
133
+ default_hint = default ? "[Y/n]" : "[y/N]"
134
+ print "#{question} #{default_hint} "
135
+ response = $stdin.gets&.strip&.downcase
136
+
137
+ return default if response.nil? || response.empty?
138
+
139
+ case response
140
+ when 'y', 'yes'
141
+ true
142
+ when 'n', 'no'
143
+ false
144
+ else
145
+ default
146
+ end
147
+ end
148
+
149
+ def print_summary(results)
150
+ puts ""
151
+ puts "=" * 40
152
+ puts "Setup Summary"
153
+ puts "=" * 40
154
+
155
+ if macos?
156
+ if results[:app_installed]
157
+ puts " [x] Markymark.app installed to ~/Applications"
158
+ else
159
+ puts " [ ] Markymark.app not installed"
160
+ end
161
+
162
+ if results[:default_set]
163
+ puts " [x] Set as default handler for .md files"
164
+ else
165
+ puts " [ ] Not set as default handler"
166
+ end
167
+ end
168
+
169
+ case results[:server_mode]
170
+ when :standalone
171
+ puts " [x] Standalone mode (run 'markymark' to start)"
172
+ when :pumadev
173
+ puts " [x] Pumadev mode (http://markymark.test)"
174
+ else
175
+ puts " [ ] Server mode not configured"
176
+ end
177
+
178
+ puts ""
179
+ puts "You can always reconfigure with:"
180
+ puts " markymark --install-app # Install/reinstall macOS app"
181
+ puts " markymark --set-default # Set as default .md handler"
182
+ puts " markymark --setup-pumadev # Setup pumadev integration"
183
+ puts ""
184
+ end
185
+ end
186
+ end