foruiman 0.1.2

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0de0b215283d2c3dc89422aa9e6f6da235acf10f5ede3907afcf8e7c88dd9290
4
+ data.tar.gz: 81dfcdcfbc8d7ab445242fd5c07419192b6445e9caa3107fcdce5a2a027d914e
5
+ SHA512:
6
+ metadata.gz: bb3cbdd6d6c38335bd5d6d4b467bbedec757cb2e534896b24d471c8b4275203d22cb22ec642fafe63410df21c1616322ae2e4616b0b26dce277959308f0092c5
7
+ data.tar.gz: 03db66ce62f3e21723aac3808791b2a027bb2ab497d415de11730661ff90b5150b82a0d7351ea2c156abc93be670e93a5b575933d06bf6a0fdcdcece35f7b517
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Test Ruby 3.2, 3.3, 3.4, and 4.0 in a reusable CI workflow and keep the
6
+ development dependency set compatible with Ruby 3.2.
7
+ - Add a tag-driven RubyGems trusted-publishing workflow gated by the full CI suite.
8
+ - Make renderer row-width checks independent of the runner's terminal settings.
9
+
10
+ ## 0.1.2
11
+
12
+ - Fix lowercase `r` on the `all` tab to restart every process; keep `R` available
13
+ from any tab and make the footer describe the selected restart scope.
14
+ - Show the active Procfile in the header, including custom paths and working
15
+ directories, with filename priority in narrow terminals.
16
+ - Follow the terminal's global theme through its ANSI palette and default
17
+ background. Use reverse-video selection for light, dark, and monochrome themes.
18
+ - Verify group restarts with real keyboard input and process cleanup, and cover
19
+ Procfile titles, theme inheritance, and preserved child colors.
20
+
21
+ ## 0.1.1
22
+
23
+ - Redesign the TUI with a forest palette, framed process navigation, selected-tab
24
+ backgrounds, status colors, aligned log columns, a scrollbar, and grouped help.
25
+ - Support truecolor, 256 colors, and `NO_COLOR`; preserve child ANSI styles without
26
+ letting resets overwrite the interface background.
27
+ - Adapt chrome and log metadata to terminal size, keep selected tabs visible,
28
+ and use the actual visible log height for scrolling and paging.
29
+ - Include a static preview fixture and expand color, layout, and PTY coverage.
30
+
31
+ ## 0.1.0
32
+
33
+ - Fork-derived MVP based on Foreman `f65ddba83932bd4670e014389d6e27ea1e20b469`.
34
+ - Strict Procfiles, layered environments, and validated port allocation.
35
+ - Independent process supervision, bounded log rings, and asynchronous restarts.
36
+ - Terminal tabs, scrolling, Unicode-aware rows, and exception-safe restoration.
37
+ - Automatic plain mode and process-group cleanup with TERM/KILL escalation.
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2012 David Dollar
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # Foruiman
2
+
3
+ [![CI](https://github.com/Nuzair46/foruiman/actions/workflows/ci.yml/badge.svg)](https://github.com/Nuzair46/foruiman/actions/workflows/ci.yml)
4
+
5
+ ![Foruiman terminal interface](docs/terminal-preview.png)
6
+
7
+ A modern Procfile runner with process tabs, bounded logs, scrolling, and isolated
8
+ restarts. Foruiman is a fork of [Foreman](https://github.com/ddollar/foreman) and
9
+ retains its MIT license.
10
+
11
+ Requires Ruby 3.2+ and a POSIX system. CI covers Ruby 3.2, 3.3, 3.4, and 4.0 on Linux.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ gem install foruiman
17
+ ```
18
+
19
+ ## Run
20
+
21
+ Create a `Procfile`:
22
+
23
+ ```procfile
24
+ web: bundle exec rails server -p "$PORT"
25
+ worker: bundle exec sidekiq
26
+ css: yarn build:css --watch
27
+ ```
28
+
29
+ Then start Foruiman:
30
+
31
+ ```sh
32
+ foruiman
33
+ ```
34
+
35
+ Useful commands:
36
+
37
+ ```sh
38
+ foruiman start -f Procfile.dev # use another Procfile
39
+ foruiman start web # run one process
40
+ foruiman start --no-tui # stream plain logs
41
+ foruiman check # validate without starting
42
+ foruiman --version
43
+ ```
44
+
45
+ The TUI opens when stdin and stdout are terminals. Otherwise, Foruiman streams
46
+ plain logs and exits when the processes finish. Processes run independently, and
47
+ restarting one process does not interrupt the others.
48
+
49
+ ## Options
50
+
51
+ | Option | Default | Purpose |
52
+ | --- | --- | --- |
53
+ | `-f`, `--procfile FILE` | `Procfile` | Procfile to run |
54
+ | `-d`, `--root DIR` | Current directory | Working directory |
55
+ | `-e`, `--env FILE` | None | Additional environment file |
56
+ | `--no-dotenv` | `.env` enabled | Skip `.env` |
57
+ | `-p`, `--port PORT` | `5000` | Base port; entries increment by 100 |
58
+ | `--log-lines N` | `10000` | Retained records per process and in `all` |
59
+ | `--no-tui` | TUI when interactive | Force plain output |
60
+
61
+ ## Keyboard
62
+
63
+ | Key | Action |
64
+ | --- | --- |
65
+ | Tab / Shift-Tab, Left / Right, `h` / `l` | Change tab |
66
+ | `1`–`9`, `0` | Select a process or `all` |
67
+ | Up / Down, `k` / `j` | Scroll |
68
+ | PageUp / PageDown, Ctrl-U / Ctrl-D | Scroll a page |
69
+ | Home / `g` | Jump to oldest retained log |
70
+ | End / `G` / `f` | Follow new output |
71
+ | Space | Pause or resume following |
72
+ | `r` | Restart the selected process, or all from `all` |
73
+ | `R` | Restart all processes |
74
+ | `s` / `S` | Stop selected / stop all |
75
+ | `?` / Escape | Toggle help / close help |
76
+ | `q` / Ctrl-C | Stop processes and quit |
77
+
78
+ ## Documentation
79
+
80
+ - [Rails usage](docs/RAILS.md)
81
+ - [Architecture and supervisor API](docs/ARCHITECTURE.md)
82
+ - [Differences from Foreman](docs/COMPATIBILITY.md)
83
+
84
+ ## Development
85
+
86
+ ```sh
87
+ bundle install
88
+ bundle exec rspec
89
+ bundle exec rubocop
90
+ gem build foruiman.gemspec
91
+ bundle exec ruby script/smoke_gem.rb
92
+ ```
93
+
94
+ ## Release
95
+
96
+ Releases use RubyGems trusted publishing. Configure the publisher once with
97
+ repository `Nuzair46/foruiman`, workflow `release.yml`, and environment `release`.
98
+
99
+ To publish, update `lib/foruiman/version.rb` on `main`, open the
100
+ [Release workflow](https://github.com/Nuzair46/foruiman/actions/workflows/release.yml),
101
+ choose **Run workflow**, and select `main`. GitHub Actions runs the full CI suite,
102
+ creates the version tag, and publishes the gem to RubyGems.org.
data/bin/foruiman ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "foruiman/cli"
6
+ Foruiman::CLI.start(ARGV)
@@ -0,0 +1,84 @@
1
+ # Architecture and embedding
2
+
3
+ `Procfile` retains Foreman's ordered parser/writer API with strict file validation.
4
+ `Env` retains its quoting rules and adds a non-mutating precedence merge. `Process`
5
+ wraps `/bin/sh -c` with a new process group, two output streams, and null stdin.
6
+ `CLI < Thor` validates the full configuration before starting the engine.
7
+
8
+ `Engine` owns registration, PID/group tracking, nonblocking pipes, child reaping,
9
+ and lifecycle transitions. A single caller thread drives all mutations. Signal
10
+ handlers only set a flag and wake a self-pipe. The loop reaps only its own direct
11
+ children, services bounded reads in round-robin order, checks TERM deadlines, and
12
+ starts pending replacements once old groups and pipes are finished. Group existence
13
+ is tracked independently of leader status; Linux `/proc` distinguishes running
14
+ members from orphan zombies awaiting the system reaper.
15
+
16
+ ```ruby
17
+ require "foruiman"
18
+
19
+ env = Foruiman::Env.load(root: Dir.pwd)
20
+ engine = Foruiman::Engine.new(procfile: "Procfile", env: env, log_lines: 2_000)
21
+ engine.on_event do |event|
22
+ # Types: output, started, restarting, stopping, exited, killed, failed.
23
+ # Fields: type, name, pid, status, record, message.
24
+ # Output records add sequence, stream, time, text, complete.
25
+ end
26
+
27
+ engine.run # install handlers, start all, loop, clean up, restore handlers; returns 0/1
28
+ ```
29
+
30
+ For an embedded event loop:
31
+
32
+ ```ruby
33
+ begin
34
+ engine.start_all # or engine.start("web")
35
+ engine.tick(timeout: 0.03)
36
+ engine.restart("web") # asynchronous; peers keep running
37
+ engine.stop("worker") # asynchronous
38
+ engine.shutdown # cancels replacements and requests group cleanup
39
+ engine.tick until engine.finished?
40
+ ensure
41
+ engine.close # finishes cleanup, closes pipes; safe to repeat
42
+ end
43
+ ```
44
+
45
+ Call engine methods and event listeners on the driving thread; this is not a
46
+ cross-thread messaging API. `run` handles INT/TERM/HUP and restores existing
47
+ handlers. An embedding loop that drives `tick` directly owns its signal handling.
48
+ `close` disconnects observers so a failed renderer/output consumer cannot prevent
49
+ process cleanup. `term_timeout:` exists for deterministic embedded tests; CLI
50
+ shutdown always uses five seconds. `state(name)` exposes lifecycle state for
51
+ rendering; callers should not mutate it.
52
+
53
+ `LogStore` uses per-process `Ring` instances and an aggregate `Ring`, each with O(1)
54
+ append, eviction, record replacement, and identity lookup. Immutable `Data` records
55
+ are shared. A partial record has one sequence identity; completing it replaces
56
+ retained references without re-inserting an already evicted aggregate record.
57
+ Sequence order represents first observation, not line completion time. Actual
58
+ memory use depends on line length and ring capacity; retained text is bounded by
59
+ approximately `(process_count + 1) * capacity * 16 KiB`, plus fixed per-stream and
60
+ per-record overhead, with overlap shared between rings.
61
+
62
+ `Output` bounds each stream, handles partial records, and takes style snapshots.
63
+ `ANSI::Decoder` incrementally decodes UTF-8 and suppresses terminal controls;
64
+ `ANSI::Styles` keeps bounded independent style properties across rows. A long line
65
+ is split into completed records at the size limit.
66
+
67
+ The TUI loads only in interactive mode. `Terminal` owns raw input and alternate
68
+ screen restoration. `Keyboard` decodes fragmented key sequences. `State` stores
69
+ selection/help/feedback, and each tab owns a `Viewport` anchored by record identity.
70
+ `Renderer` composes framed process navigation, a log pane with a scrollbar, and a
71
+ shortcut bar. Its shared log-height calculation keeps paging consistent with the
72
+ responsive layout. `Text` measures graphemes through `unicode-display_width` and
73
+ clips rows safely. `Theme` uses the terminal's ANSI palette, default foreground and
74
+ background, and reverse-video selection, with a monochrome fallback. It does not
75
+ read OS theme files or override palette entries; child SGR resets restore terminal
76
+ defaults. The header uses the engine's `procfile_path` to identify the loaded file.
77
+ `LogFormatter` aligns timestamps,
78
+ process names, stream markers, and content. Complete frames reset styles at each row. `Application` connects input and lifecycle
79
+ commands to the engine and limits drawing to 30 FPS. Resize handling reads current
80
+ terminal dimensions each loop; it does not replace an application's WINCH handler.
81
+
82
+ `Plain` prints completed records and runs to natural completion. `Diagnostics`
83
+ keeps internal exceptions separate from process logs, using a private temporary
84
+ file in interactive mode and stderr otherwise.
@@ -0,0 +1,23 @@
1
+ # Inputs and implementation defaults
2
+
3
+ The implementation workspace was empty. The RFC referenced by the supplied plan,
4
+ its requested file list, and earlier preference selections were not present. The
5
+ supplied Foreman-based MVP plan is the implementation contract available here.
6
+ The missing RFC was requested during implementation; these choices fill the gaps:
7
+
8
+ - Default command `start`; also `start PROCESS`, `check`, `version`, `help`.
9
+ - Foreman's `-f`, `-d`, `-e`, `-p` aliases; `--log-lines`, `--no-tui`, `--no-dotenv`.
10
+ - Default capacity 10,000 records per ring.
11
+ - Process tabs in entry order, then `all`; select `all` initially.
12
+ - `all` reserved as a process name to avoid an ambiguous aggregate tab.
13
+ - Vim-style and arrow navigation, digits, `f`, Space, `r`/`R`, `s`/`S`, `?`, `q`.
14
+ - Generated `PS=name.1`, as in Foreman; no other generated variables.
15
+ - Relative file paths resolved against invocation directory or explicit `-d`.
16
+ - Header whitespace after a Procfile colon is a separator; command-internal and
17
+ trailing whitespace is preserved.
18
+ - 30 FPS maximum; 16 KiB records; 64 KiB total read budget per event-loop iteration.
19
+ - No public repository URL is invented for the unpublished Foruiman project.
20
+
21
+ See README for the actual public interface. These defaults can be reconciled with
22
+ the original RFC when it becomes available; missing requirements are not claimed
23
+ as verified.
@@ -0,0 +1,27 @@
1
+ # Intentional differences from Foreman
2
+
3
+ Foruiman is a fork-derived MVP, not a drop-in replacement for every Foreman feature.
4
+
5
+ | Area | Foruiman behavior |
6
+ | --- | --- |
7
+ | Identity | `foruiman` gem/executable, `Foruiman` namespace, version 0.1.2 |
8
+ | Runtime | Ruby 3.2+, POSIX process groups; Linux CI |
9
+ | CLI | Thor-based `start [PROCESS]`, `check`, `version`, and `help` |
10
+ | Excluded features | No export, scaling/formation, `run`, `.foreman` YAML, custom shutdown timeout, forced color, or timestamp toggle |
11
+ | Procfile validation | Reject malformed lines, duplicates, empty commands, and reserved `all`; report line numbers |
12
+ | Working directory | Invocation directory unless `-d`; moving a Procfile does not change child cwd |
13
+ | Environment | Optional `.env` then explicit env file; parent `ENV` stays untouched |
14
+ | Ports | 5000 or `-p`, plus 100 per original entry; `PORT` in the environment is overridden |
15
+ | Expansion | `/bin/sh -c` performs shell expansion; no Ruby string substitution |
16
+ | Instances | Exactly one instance per entry; `PS=name.1` |
17
+ | Failures | Independent process failures; peers keep running |
18
+ | Restart | Stop only the affected group, wait for descendants and output, then replace |
19
+ | Shutdown | TERM, five-second grace, KILL; track groups after leader exit; restore prior signal handlers |
20
+ | Output | Separate stdout/stderr metadata; bounded logs and live partial records |
21
+ | Terminal | Tabs, independent scroll/follow, restart and stop controls; no child stdin |
22
+ | Plain exit | Wait for all processes; status 0/1; explicit orderly shutdown returns 0 |
23
+ | Interactive exit | Remain open after all processes exit; quit explicitly |
24
+
25
+ Commands needing a PTY, interactive child input, or background daemonization are
26
+ outside this release. Remote process control, persistence across Foruiman sessions,
27
+ search, horizontal scrolling, and log export are not provided.
data/docs/RAILS.md ADDED
@@ -0,0 +1,54 @@
1
+ # Using Foruiman with Rails
2
+
3
+ Install the locally built Foruiman gem as a development tool using the README's
4
+ instructions. Keep it outside the Rails application's Gemfile when using a global
5
+ development-tool installation. The Rails processes can still run under `bundle exec`.
6
+
7
+ A typical `Procfile.dev`:
8
+
9
+ ```procfile
10
+ web: bin/rails server -p "$PORT"
11
+ worker: bundle exec sidekiq
12
+ css: yarn build:css --watch
13
+ ```
14
+
15
+ Include only entries used by your app. For a Vite application, replace the CSS entry
16
+ with your project's foreground Vite command, for example `vite: bin/vite dev`.
17
+ Use your app's existing port configuration for tools that do not read `PORT`.
18
+
19
+ An optional `.env` in the Rails project root:
20
+
21
+ ```dotenv
22
+ RAILS_ENV=development
23
+ REDIS_URL=redis://localhost:6379/0
24
+ ```
25
+
26
+ Keep secrets out of version control. Foruiman reads this file as assignments; it
27
+ does not source it as a shell script.
28
+
29
+ From the Rails root:
30
+
31
+ ```sh
32
+ foruiman start -f Procfile.dev
33
+ foruiman start -f Procfile.dev -p 3000
34
+ ```
35
+
36
+ The second example assigns 3000 to `web`, 3100 to `worker`, and 3200 to `css`.
37
+ Only programs that consume their generated `PORT` use those values. The base port
38
+ is set with `-p`; a `PORT` assignment in `.env` does not set it.
39
+
40
+ An optional `bin/dev`:
41
+
42
+ ```sh
43
+ #!/bin/sh
44
+ exec foruiman start -f Procfile.dev "$@"
45
+ ```
46
+
47
+ Select the web tab and press `r` to restart Rails without interrupting the worker
48
+ or asset watcher. On the `all` tab, `r` restarts all entries; `R` does so from any
49
+ tab. The header shows the active Procfile. `q` or Ctrl-C stops all owned process
50
+ groups, including grandchildren, before returning to the shell.
51
+
52
+ Redirect output with `foruiman start -f Procfile.dev > development.log` to use plain
53
+ mode. A failed worker will not stop Rails; plain mode finishes only after every
54
+ entry exits or Foruiman receives a shutdown signal.
data/docs/UPSTREAM.md ADDED
@@ -0,0 +1,25 @@
1
+ # Foreman provenance
2
+
3
+ - Repository: https://github.com/ddollar/foreman
4
+ - Revision: `f65ddba83932bd4670e014389d6e27ea1e20b469` (`update docs`)
5
+ - Source archive: https://codeload.github.com/ddollar/foreman/tar.gz/f65ddba83932bd4670e014389d6e27ea1e20b469
6
+ - License: MIT, Copyright (c) 2012 David Dollar; preserved verbatim in `LICENSE`.
7
+
8
+ The initial local import copied `lib/foreman/{procfile,env,process,engine,cli}.rb`
9
+ into the renamed namespace before applying the MVP changes. The Procfile
10
+ reader/writer, environment assignment/quoting parser, process execution wrapper,
11
+ ordered engine registration and lookup, pipe/signal approach, and Thor command
12
+ foundation derive from that source. The supervisor loop and terminal components
13
+ were refactored or written for Foruiman's independent process and log semantics.
14
+
15
+ Relevant upstream test files are retained verbatim as review references in
16
+ `docs/upstream/*_spec.rb.txt` (not part of the packaged gem). Active tests in
17
+ `spec/foruiman/procfile_spec.rb`, `env_spec.rb`, and `process_spec.rb` adapt the
18
+ applicable upstream examples to real temporary files and managed Ruby fixtures.
19
+ The CLI and engine tests cover the new contract rather than upstream concurrency,
20
+ export, command substitution, or stop-on-first-exit behavior.
21
+
22
+ `docs/upstream/SHA256SUMS` records the original imported source/test checksums.
23
+ Exporters, distribution code, scaling, and obsolete runtime compatibility code are
24
+ not included in the distributed implementation. There is no runtime dependency
25
+ on the Foreman gem and no claim of complete Foreman compatibility.
Binary file
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foruiman::ANSI
4
+ SGR = /\e\[[0-9;:]*m/
5
+ RESET = "\e[0m"
6
+
7
+ # A bounded style snapshot makes each retained row independently renderable.
8
+ class Styles
9
+ def initialize
10
+ @values = {}
11
+ end
12
+
13
+ def apply(sequence)
14
+ values = sequence.delete_prefix("\e[").delete_suffix("m").split(";")
15
+ values = ["0"] if values.empty?
16
+ until values.empty?
17
+ value = values.shift
18
+ code = value.to_i
19
+ case code
20
+ when 0 then @values.clear
21
+ when 1, 2 then @values[:intensity] = value
22
+ when 3 then @values[:italic] = value
23
+ when 4, 21 then @values[:underline] = value
24
+ when 5, 6 then @values[:blink] = value
25
+ when 7 then @values[:inverse] = value
26
+ when 8 then @values[:conceal] = value
27
+ when 9 then @values[:strike] = value
28
+ when 22 then @values.delete(:intensity)
29
+ when 23 then @values.delete(:italic)
30
+ when 24 then @values.delete(:underline)
31
+ when 25 then @values.delete(:blink)
32
+ when 27 then @values.delete(:inverse)
33
+ when 28 then @values.delete(:conceal)
34
+ when 29 then @values.delete(:strike)
35
+ when 30..37, 90..97 then @values[:foreground] = value
36
+ when 40..47, 100..107 then @values[:background] = value
37
+ when 38, 48 then color(code, value, values)
38
+ when 39 then @values.delete(:foreground)
39
+ when 49 then @values.delete(:background)
40
+ end
41
+ end
42
+ end
43
+
44
+ def prefix
45
+ @values.empty? ? "" : "\e[#{@values.values.join(';')}m"
46
+ end
47
+
48
+ private
49
+
50
+ def color(code, value, values)
51
+ if value.include?(":")
52
+ color = value
53
+ else
54
+ mode = values.shift
55
+ return unless %w[2 5].include?(mode)
56
+
57
+ count = mode == "2" ? 3 : 1
58
+ return if values.size < count
59
+
60
+ color = [value, mode, *values.shift(count)].join(";")
61
+ end
62
+ @values[code == 38 ? :foreground : :background] = color
63
+ end
64
+ end
65
+
66
+ # Incremental byte parser: only text, newlines, and SGR can leave this class.
67
+ # OSC (including clipboard), DCS, cursor controls, and other escapes are dropped.
68
+ class Decoder
69
+ def initialize
70
+ @state = :text
71
+ @escape = +""
72
+ @pending = +"".b
73
+ end
74
+
75
+ def feed(bytes, eof: false)
76
+ output = +"".b
77
+ bytes.each_byte { |byte| consume(byte, output) }
78
+ output = @pending + output
79
+ @pending = +"".b
80
+ unless eof
81
+ length = incomplete_suffix(output)
82
+ @pending = output.slice!(-length, length) if length.positive?
83
+ end
84
+ output.force_encoding(Encoding::UTF_8).scrub.delete("\u0080-\u009f")
85
+ end
86
+
87
+ private
88
+
89
+ def consume(byte, output)
90
+ case @state
91
+ when :text
92
+ case byte
93
+ when 27 then @state = :escape
94
+ when 9 then output << " "
95
+ when 10, 32..126, 128..255 then output << byte
96
+ end
97
+ when :escape
98
+ @state = case byte
99
+ when 91 then :csi
100
+ when 93 then :osc
101
+ when 80, 88, 94, 95 then :string
102
+ when 32..47 then :intermediate
103
+ else :text
104
+ end
105
+ @escape.clear
106
+ when :intermediate
107
+ @state = :text if byte >= 48
108
+ when :csi
109
+ if byte.between?(64, 126)
110
+ output << "\e[#{@escape}m" if byte == 109 && @escape.match?(/\A[0-9;:]*\z/)
111
+ @state = :text
112
+ elsif @escape.bytesize < 96
113
+ @escape << byte
114
+ else
115
+ @state = :discard_csi
116
+ end
117
+ when :discard_csi
118
+ @state = :text if byte.between?(64, 126)
119
+ when :osc, :string
120
+ if byte == 27
121
+ @string_state = @state
122
+ @state = :string_escape
123
+ elsif byte == 7 && @state == :osc
124
+ @state = :text
125
+ end
126
+ when :string_escape
127
+ @state = byte == 92 ? :text : @string_state
128
+ end
129
+ end
130
+
131
+ def incomplete_suffix(bytes)
132
+ index = bytes.bytesize - 1
133
+ index -= 1 while index >= 0 && bytes.getbyte(index).between?(128, 191)
134
+ return 0 if index.negative?
135
+
136
+ lead = bytes.getbyte(index)
137
+ expected = case lead
138
+ when 194..223 then 2
139
+ when 224..239 then 3
140
+ when 240..244 then 4
141
+ else 1
142
+ end
143
+ actual = bytes.bytesize - index
144
+ actual < expected ? actual : 0
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "foruiman"
5
+ require_relative "plain"
6
+ require_relative "diagnostics"
7
+
8
+ # Foreman's Thor command structure, reduced to the MVP surface.
9
+ class Foruiman::CLI < Thor
10
+ map ["-v", "--version"] => :version
11
+ default_task :start
12
+ check_unknown_options!
13
+ remove_command :tree
14
+
15
+ class_option :procfile, type: :string, aliases: "-f", default: "Procfile", desc: "Procfile to read"
16
+ class_option :root, type: :string, aliases: "-d", desc: "Working directory (default: invocation directory)"
17
+ class_option :env, type: :string, aliases: "-e", desc: "Environment file layered after .env"
18
+ class_option :dotenv, type: :boolean, default: true, desc: "Load optional .env from working directory"
19
+ class_option :port, type: :string, aliases: "-p", default: "5000", desc: "Base port (increments by 100)"
20
+ class_option :log_lines, type: :string, default: "10000", desc: "Maximum records per process and in all"
21
+
22
+ def self.exit_on_failure?
23
+ true
24
+ end
25
+
26
+ desc "start [PROCESS]", "Run the Procfile with process tabs, or stream logs without a TTY"
27
+ method_option :tui, type: :boolean, default: true, desc: "Use the terminal interface when stdin and stdout are TTYs"
28
+ def start(process = nil)
29
+ engine = build_engine
30
+ engine.select(process) if process
31
+ interactive = options[:tui] && $stdin.tty? && $stdout.tty?
32
+ diagnostics = Foruiman::Diagnostics.new(interactive: interactive)
33
+ code = if interactive
34
+ require_relative "tui/application"
35
+ Foruiman::TUI::Application.new(engine).run
36
+ else
37
+ Foruiman::Plain.new(engine).run
38
+ end
39
+ exit(code)
40
+ rescue Foruiman::Error, SystemCallError => e
41
+ raise Thor::Error, e.message
42
+ rescue StandardError => e
43
+ diagnostics&.error(e)
44
+ warn "foruiman: #{e.message}#{" (details: #{diagnostics.path})" if diagnostics&.path}"
45
+ exit(1)
46
+ ensure
47
+ engine&.close
48
+ diagnostics&.close
49
+ end
50
+
51
+ desc "check", "Validate the Procfile, environment files, ports, and log capacity without starting processes"
52
+ def check
53
+ engine = build_engine
54
+ puts "valid Procfile (#{engine.process_names.join(', ')})"
55
+ rescue Foruiman::Error, SystemCallError => e
56
+ raise Thor::Error, e.message
57
+ ensure
58
+ engine&.close
59
+ end
60
+
61
+ desc "version", "Display Foruiman gem version"
62
+ def version
63
+ puts Foruiman::VERSION
64
+ end
65
+
66
+ private
67
+
68
+ def integer_option(name)
69
+ text = options.fetch(name).to_s
70
+ raise Foruiman::Error, "#{name.to_s.tr('_', '-')} must be a positive integer" unless text.match?(/\A[0-9]+\z/)
71
+
72
+ text.to_i
73
+ end
74
+
75
+ def build_engine
76
+ root = File.expand_path(options[:root] || Dir.pwd)
77
+ procfile = File.expand_path(options[:procfile], root)
78
+ file = File.expand_path(options[:env], root) if options[:env]
79
+ env = Foruiman::Env.load(root: root, file: file, dotenv: options[:dotenv])
80
+ Foruiman::Engine.new(procfile: procfile, root: root, env: env,
81
+ port: integer_option(:port), log_lines: integer_option(:log_lines))
82
+ end
83
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Foruiman::Diagnostics
4
+ attr_reader :path
5
+
6
+ def initialize(interactive:, stderr: $stderr)
7
+ @interactive = interactive
8
+ @temporary = nil
9
+ @output = stderr
10
+ end
11
+
12
+ def error(exception)
13
+ if @interactive && !@temporary
14
+ require "tempfile"
15
+ @temporary = Tempfile.create(["foruiman-", ".log"])
16
+ @temporary.chmod(0o600)
17
+ @output = @temporary
18
+ @path = @temporary.path
19
+ end
20
+ @output.puts(exception.full_message(highlight: false))
21
+ @output.flush
22
+ end
23
+
24
+ def close
25
+ @temporary&.close
26
+ end
27
+ end