workspaces 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 431b59c0d2a5bef3dc7a2456416d865992ac05664b371bbf6245bb7d5ae3a3b4
4
+ data.tar.gz: 88526e06c47837b47e6b507df9ecaf48cd4656365427cf9a7922bcf3fefcff52
5
+ SHA512:
6
+ metadata.gz: 3ce730d1d8d81bfd3e2f997c867623b3f8936b6861a441abec837e38b3ab6098371c0a1e800e8934eccfa651af0f33c81d93e7d0764543bacadb48df3f5cd6b2
7
+ data.tar.gz: 489216342c60ff24c9455c41e43cc05753f396c18ac690f051cada947c01b06d97af40123b75bf639cb51d394499e90d20cc37a69884f70bedbbe1ecc0baa9bb
data/DESIGN.md ADDED
@@ -0,0 +1,67 @@
1
+ # Managed Workspace Dashboard Design Tokens
2
+
3
+ ## Scope
4
+
5
+ Workspace list/create screen and per-workspace setup/status screen. Workspace identity is independent of PR provenance.
6
+
7
+ GET is read-only. All creation and lifecycle controls submit same-origin POST forms. No source-reset or cleanup button exists. Workspace IDs, branches and filesystem paths wrap on narrow screens. Native labelled inputs and buttons are keyboard accessible; polling announces status without replacing form focus or making the entire log an ARIA live region.
8
+
9
+ ## Visual direction
10
+
11
+ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear status affordances, and no decorative animation.
12
+
13
+ ## Tokens used
14
+
15
+ ### Color
16
+
17
+ - `--bg`: shell background
18
+ - `--bg-elevated`: panel surface
19
+ - `--border`: panel/control borders
20
+ - `--text`: primary text
21
+ - `--muted`: secondary/supporting text
22
+ - `--accent`: active status and primary action
23
+ - `--success`: completed/ready states
24
+ - `--danger`: failed/error states
25
+ - `--warning`: retry/connection warning text
26
+
27
+ ### Radius and depth
28
+
29
+ - `--radius`: 12px panel corners
30
+ - `--shadow`: single elevated panel shadow
31
+
32
+ ### Spacing scale
33
+
34
+ - `--space-1` to `--space-8` (4px base multiples)
35
+
36
+ ### Typography
37
+
38
+ - System sans stack for body/content
39
+ - System mono stack for logs/error text
40
+
41
+ ## Component primitives
42
+
43
+ - **Creation form**: labelled branch, PR, and new-branch inputs with a primary create action
44
+ - **Status pill**: current workspace status as text
45
+ - **Setup progress**: names and ordered states come from repository run steps; failures are highlighted. Restart shows the background launch and later readiness commands.
46
+ - **Actions by state**: idle/stopped offers Start; active operations show progress without lifecycle buttons; failure offers Retry; ready offers Open workspace plus rebuild/restart/stop.
47
+ - **Failure panel**: prominently above the log, with escaped error details and a matching retry action.
48
+ - **PR page**: existing workspaces are primary; creating another is a collapsed secondary disclosure. Creation is primary only when none exist.
49
+ - **Action buttons**: start, prepare/rebuild, restart server, stop server, and refresh
50
+ - **Open workspace**: success-styled link available when the server is ready
51
+ - **Lifecycle controls**: disabled while busy; helper text distinguishes rebuilding from server-only restart
52
+ - **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
53
+ - **Log source switcher**: two native links inside one log panel, with current selection announced through `aria-current`; only the selected bounded log is loaded and refreshed. Setup is the default.
54
+ - **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
55
+
56
+ ## Accessibility constraints
57
+
58
+ - Semantic sections/headings for all regions
59
+ - Focus-visible outlines on actionable controls
60
+ - `aria-live` limited to phase-change and connection warnings (not full log stream)
61
+ - No-JS refresh path always available
62
+
63
+ ## Responsive behavior
64
+
65
+ - Single-column layout with intrinsic grid sections
66
+ - Panel padding compresses at <=640px
67
+ - No horizontal scrolling expected at 375px viewport
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 G2 Engineering
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # Workspaces
2
+
3
+ Workspaces is a Ruby gem for managed Git worktrees, lifecycle commands, and browser previews.
4
+ It runs independently of Rails. Each project supplies its own `.workspaces.yml` recipe and
5
+ any setup or readiness scripts that recipe calls. Workspace state and logs are stored outside
6
+ the project under `WORKSPACES_HOME`.
7
+
8
+ ## Usage
9
+
10
+ Install the gem in your project's bundle:
11
+
12
+ ```ruby
13
+ # Gemfile
14
+ gem 'workspaces'
15
+ ```
16
+
17
+ ```sh
18
+ bundle install
19
+ bundle exec workspaces help
20
+ bundle exec workspaces create --branch main --json
21
+ bundle exec workspaces start <workspace-id> --json
22
+ ```
23
+
24
+ Or install it into your Ruby environment without Bundler:
25
+
26
+ ```sh
27
+ gem install workspaces
28
+ workspaces help
29
+ ```
30
+
31
+ Run the commands from the project containing `.workspaces.yml`, or set
32
+ `WORKSPACES_REPO_ROOT` to its absolute path. The gem executable does not load Rails.
33
+ Gem installation requires a published release; until then, a project may reference a
34
+ tagged Git revision in its Gemfile instead of the RubyGems release.
35
+
36
+ `create` makes a managed worktree but does not start the application. `start` runs the
37
+ project's `.workspaces.yml` steps, starts its background server, and waits for readiness.
38
+ The returned JSON includes the workspace ID, dashboard URL, and preview URL. You can also
39
+ create from a pull request with `create --pr NUMBER`, or create a new branch with
40
+ `create --new-branch NAME --from REF`. Run `help` for lifecycle and other commands.
41
+
42
+ ## Serve the dashboard and previews
43
+
44
+ Run `serve` in a separate terminal using the **same project root and `WORKSPACES_HOME`**
45
+ as `create` and `start`:
46
+
47
+ ```sh
48
+ bundle exec workspaces serve
49
+ ```
50
+
51
+ For a global installation, omit `bundle exec` in every example.
52
+
53
+ By default, the front door listens on `127.0.0.1:4747`. Open
54
+ `http://localhost:4747/workspaces` for the dashboard. Preview URLs use
55
+ `http://ws-<workspace-id>.localhost:4747/`. Make sure the wildcard preview hosts
56
+ resolve to the machine running the front door; a DNS entry or local resolver may be
57
+ needed on your system. Set `WORKSPACES_BASE_DOMAIN` and
58
+ `WORKSPACES_SUBDOMAIN_PREFIX` to choose a different preview hostname pattern.
59
+
60
+ The front door is a reverse proxy. It accepts the dashboard host for workspace status
61
+ and lifecycle actions, recognizes `ws-<workspace-id>` preview hosts, checks that the
62
+ workspace is ready, and forwards requests (including cookies) to that workspace's
63
+ server on a reserved `127.0.0.1` port in the `30000–30999` range. The recipe's
64
+ `background: true` step must start the application on `127.0.0.1:$WORKSPACE_PORT`;
65
+ the following foreground readiness step must verify it is *this* boot (the runner
66
+ provides `WORKSPACE_RUN_ID`). The runner also supplies `WORKSPACE_URL` and
67
+ `WORKSPACE_HOST` for applications that need browser-facing URLs and host settings.
68
+ `serve` does **not** start workspace backends; use `start` or `restart` for those.
69
+
70
+ The front door is not an authentication boundary. Its host and same-origin checks
71
+ protect routing and POST actions, but a publicly reachable instance still needs
72
+ access control such as VPN or SSO. Do not expose workspace backend ports directly.
73
+
74
+ ### Local HTTPS
75
+
76
+ To serve TLS directly, install/trust a local CA and create a certificate covering
77
+ `workspaces.localhost` and `*.workspaces.localhost`. For example, with `mkcert`:
78
+
79
+ ```sh
80
+ mkcert -install
81
+ mkdir -p ~/.workspaces/certs
82
+ mkcert -cert-file ~/.workspaces/certs/workspaces.pem \
83
+ -key-file ~/.workspaces/certs/workspaces-key.pem \
84
+ workspaces.localhost '*.workspaces.localhost'
85
+ chmod 600 ~/.workspaces/certs/workspaces-key.pem
86
+ ```
87
+
88
+ Keep the private key out of Git and readable only by the service user. Then run:
89
+
90
+ ```sh
91
+ bundle exec workspaces serve --secure
92
+ ```
93
+
94
+ Without explicit settings, `--secure` serves the dashboard at
95
+ `https://workspaces.localhost:4747/workspaces` and previews at
96
+ `https://ws-<workspace-id>.workspaces.localhost:4747/`. The shortcut sets these
97
+ host/origin defaults **only in the `serve` process**. For correct URLs in separate
98
+ `create`, `show`, `start`, or `restart` invocations, export the same settings there:
99
+
100
+ ```sh
101
+ export WORKSPACES_PUBLIC_ORIGIN=https://workspaces.localhost:4747
102
+ export WORKSPACES_BASE_DOMAIN=workspaces.localhost
103
+ ```
104
+
105
+ Alternatively, pass both `--tls-cert PATH` and `--tls-key PATH` to `serve`, and set
106
+ `WORKSPACES_PUBLIC_ORIGIN` to an HTTPS origin and `WORKSPACES_BASE_DOMAIN` to the
107
+ matching preview domain. TLS startup fails for missing, unreadable, invalid, or
108
+ mismatched files; it does not fall back to HTTP. TLS flags apply to `serve` only.
109
+
110
+ ### HTTPS behind a reverse proxy
111
+
112
+ When an ingress terminates HTTPS, keep the front door on HTTP internally. Set
113
+ `WORKSPACES_BIND=0.0.0.0` if it must listen outside the container, set
114
+ `WORKSPACES_PUBLIC_ORIGIN` to the external HTTPS dashboard origin, and set
115
+ `WORKSPACES_BASE_DOMAIN` to the domain used for preview hosts. Route both the
116
+ dashboard hostname and the wildcard preview hostnames to the front door, **preserving
117
+ the original Host header**. The configured public origin determines generated URLs
118
+ and forwarded scheme/host headers; it does not change the internal listener to TLS.
119
+
120
+ | Setting | Default | Purpose |
121
+ | --- | --- | --- |
122
+ | `WORKSPACES_REPO_ROOT` | Nearest ancestor Git checkout with `.workspaces.yml` | Trusted recipe and source repository |
123
+ | `WORKSPACES_HOME` | `~/.workspaces/<repository-path-hash>` | Per-project worktrees, state, locks, and logs |
124
+ | `WORKSPACES_BIND` | `127.0.0.1` | Front-door bind address |
125
+ | `WORKSPACES_PORT` | `4747` | Front-door listener port |
126
+ | `WORKSPACES_PUBLIC_ORIGIN` | `http://localhost:4747` | Browser-facing dashboard origin and scheme |
127
+ | `WORKSPACES_BASE_DOMAIN` | `localhost` | Preview hostname suffix |
128
+ | `WORKSPACES_SUBDOMAIN_PREFIX` | `ws-` | Prefix before the workspace ID on preview hosts |
129
+
130
+ Keep the project root, public origin, preview domain, and storage settings consistent
131
+ across CLI and server processes. Changing the browser-facing origin requires restarting
132
+ workspace backends that need updated `WORKSPACE_URL` or `WORKSPACE_HOST` values.
133
+
134
+ ## Development
135
+
136
+ Use Ruby 3.3.10. To run the standalone test suite from a source checkout, install and
137
+ use its test bundle:
138
+
139
+ ```sh
140
+ BUNDLE_GEMFILE=Gemfile.test bundle install
141
+ BUNDLE_GEMFILE=Gemfile.test bundle exec rspec spec/workspaces
142
+ ```
143
+
144
+ ## License
145
+
146
+ MIT. See [LICENSE](LICENSE).
data/exe/workspaces ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'workspaces'
4
+
5
+ exit Workspaces::Cli.run(ARGV)
@@ -0,0 +1,108 @@
1
+ require 'timeout'
2
+ require 'securerandom'
3
+ require 'time'
4
+ require_relative 'config'
5
+ require_relative 'state_store'
6
+ require_relative 'port_allocator'
7
+
8
+ module Workspaces
9
+ class Backend
10
+ class BootFailedError < StandardError; end
11
+ attr_reader :workspace_id
12
+
13
+ def initialize(workspace_id)
14
+ @workspace_id = Config.validate_id!(workspace_id.to_s)
15
+ @state = StateStore.new(Config.state_file)
16
+ end
17
+
18
+ def running?
19
+ entry = @state.get(workspace_id) || {}
20
+ entry['pid'] && entry['port'] && !process_exited?(entry['pid'])
21
+ end
22
+
23
+ def port
24
+ @state.get(workspace_id)&.fetch('port', nil)
25
+ end
26
+
27
+ def reserve!
28
+ @state.transaction do |data|
29
+ taken = data.values.filter_map { |entry| entry['port'] }
30
+ assigned = PortAllocator.allocate(Config::BACKEND_PORT_RANGE, taken)
31
+ data[workspace_id] = (data[workspace_id] || {}).merge('port' => assigned)
32
+ assigned
33
+ end
34
+ end
35
+
36
+ def start!(workspace, command, env:)
37
+ assigned = port
38
+ raise BootFailedError, 'Reserved port became occupied before server launch' unless PortAllocator.free?(assigned)
39
+
40
+ pid = spawn_command(workspace, command, env)
41
+ @state.set(workspace_id, 'pid' => pid, 'process_group' => pid, 'started_at' => Time.now.utc.iso8601)
42
+ pid
43
+ rescue StandardError
44
+ terminate(pid) if pid
45
+ raise
46
+ end
47
+
48
+ def ensure_alive!
49
+ raise BootFailedError, 'Application exited before readiness completed; inspect the server log' unless running?
50
+ end
51
+
52
+ def stop!
53
+ entry = @state.get(workspace_id) || {}
54
+ terminate(entry['pid'], group: entry['process_group']) if entry['pid']
55
+ @state.transaction do |data|
56
+ %w(pid port process_group).each { |key| data[workspace_id]&.delete(key) }
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def spawn_command(workspace, command, env)
63
+ File.open(Config.backend_log_path(workspace_id), 'a') do |file|
64
+ Process.spawn(env, [command.first, command.first], *command.drop(1),
65
+ chdir: workspace.path.to_s, in: File::NULL, out: file, err: file, pgroup: true)
66
+ end
67
+ end
68
+
69
+ def terminate(pid, group: nil)
70
+ return if stopped?(pid, group)
71
+
72
+ target = group ? -group : pid
73
+ Process.kill('TERM', target)
74
+ Timeout.timeout(10) { sleep 0.05 until stopped?(pid, group) }
75
+ rescue Timeout::Error
76
+ Process.kill('KILL', target)
77
+ Timeout.timeout(5) { sleep 0.05 until process_exited?(pid) }
78
+ rescue Errno::ESRCH
79
+ nil
80
+ end
81
+
82
+ def stopped?(pid, group)
83
+ process_exited?(pid) && !group_alive?(group)
84
+ end
85
+
86
+ def group_alive?(group)
87
+ return false unless group
88
+
89
+ Process.kill(0, -group)
90
+ true
91
+ rescue Errno::ESRCH
92
+ false
93
+ end
94
+
95
+ def process_exited?(pid)
96
+ !!Process.waitpid(pid, Process::WNOHANG)
97
+ rescue Errno::ECHILD
98
+ !pid_alive?(pid)
99
+ end
100
+
101
+ def pid_alive?(pid)
102
+ Process.kill(0, pid)
103
+ true
104
+ rescue Errno::ESRCH, TypeError
105
+ false
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,189 @@
1
+ require 'optparse'
2
+ require 'rack'
3
+ require 'rackup'
4
+ require 'rack/handler/puma'
5
+ require_relative 'registry'
6
+ require_relative 'coordinator'
7
+ require_relative 'front_door'
8
+ require_relative 'tls'
9
+
10
+ module Workspaces
11
+ class Cli
12
+ COMMANDS = {
13
+ 'serve' => :serve, 'create' => :create, 'list' => :list, 'show' => :show,
14
+ 'start' => :lifecycle, 'prepare' => :lifecycle, 'restart' => :lifecycle,
15
+ 'stop' => :stop, 'remove' => :remove, 'help' => :help
16
+ }.freeze
17
+ def self.run(argv)
18
+ new.run(argv.dup)
19
+ end
20
+
21
+ def run(argv)
22
+ command = argv.shift || 'serve'
23
+ return execute(argv) if command == 'exec'
24
+
25
+ options = parse(command, argv)
26
+ id = argv.shift
27
+ raise ArgumentError, 'Unexpected extra arguments' unless argv.empty?
28
+
29
+ handler = COMMANDS.fetch(command) { raise ArgumentError, usage }
30
+ send(handler, id, options.merge(command: command))
31
+ 0
32
+ rescue ArgumentError, OptionParser::ParseError, Worktree::CommandFailedError => e
33
+ warn e.message
34
+ 1
35
+ end
36
+
37
+ private
38
+
39
+ def registry
40
+ @registry ||= Registry.new
41
+ end
42
+
43
+ def coordinator
44
+ @coordinator ||= Coordinator.new(registry: registry)
45
+ end
46
+
47
+ def parse(command, argv)
48
+ options = {}
49
+ OptionParser.new do |parser|
50
+ parse_common_options(parser, options)
51
+ parse_tls_options(parser, options) if command == 'serve'
52
+ end.parse!(argv)
53
+ options
54
+ end
55
+
56
+ def parse_common_options(parser, options)
57
+ parser.on('--branch REF') { |value| options[:branch] = value }
58
+ parser.on('--pr NUMBER') { |value| options[:pr] = value }
59
+ parser.on('--new-branch NAME') { |value| options[:new_branch] = value }
60
+ parser.on('--from REF') { |value| options[:from] = value }
61
+ parser.on('--json') { options[:json] = true }
62
+ parser.on('--force') { options[:force] = true }
63
+ end
64
+
65
+ def parse_tls_options(parser, options)
66
+ parser.on('--secure') { options[:secure] = true }
67
+ parser.on('--tls-cert PATH') { |value| options[:tls_cert] = value }
68
+ parser.on('--tls-key PATH') { |value| options[:tls_key] = value }
69
+ end
70
+
71
+ def serve(_id, options)
72
+ tls = Tls.from_serve_options(options)&.validate!
73
+
74
+ if tls
75
+ tls.apply_env_defaults { serve_with_tls(tls) }
76
+ else
77
+ serve_with_tls(nil)
78
+ end
79
+ end
80
+
81
+ def serve_with_tls(tls)
82
+ puts "Workspaces listening on #{Config.bind_address}:#{Config.front_door_port}"
83
+ puts "Dashboard: #{Config.public_origin}/workspaces"
84
+ puts "Managed directory: #{Config.worktrees_dir}"
85
+ print_tls_exports(tls)
86
+ Rackup::Handler::Puma.run(FrontDoor.new, **puma_options(tls))
87
+ end
88
+
89
+ def print_tls_exports(tls)
90
+ return unless tls&.shortcut
91
+
92
+ puts 'When using separate commands, export the workspace public origin settings first:'
93
+ tls.env_exports.each { |command| puts command }
94
+ end
95
+
96
+ def puma_options(tls)
97
+ options = { config_files: ['-'], workers: 0 }
98
+ return options.merge(Host: tls.bind_uri(host: Config.bind_address, port: Config.front_door_port)) if tls
99
+
100
+ options.merge(Host: Config.bind_address, Port: Config.front_door_port)
101
+ end
102
+
103
+ def create(_id, options)
104
+ emit(registry.create(**options.slice(:branch, :pr, :new_branch, :from)).describe, options)
105
+ end
106
+
107
+ def list(_id, options)
108
+ emit(registry.list(pr: options[:pr]), options)
109
+ end
110
+
111
+ def show(id, options)
112
+ emit(coordinator.snapshot(id), options)
113
+ end
114
+
115
+ def lifecycle(id, options)
116
+ action = proc { run_lifecycle(options.fetch(:command), id) }
117
+ result = options[:json] ? with_stdout_on_stderr(&action) : action.call
118
+ emit(result, options)
119
+ raise ArgumentError, result['last_error'] if result['status'] == 'error'
120
+ end
121
+
122
+ def run_lifecycle(command, id)
123
+ started = command == 'restart' ? coordinator.restart(id) : coordinator.start(id, force: command == 'prepare')
124
+ if !started && coordinator.snapshot(id)['active']
125
+ raise ArgumentError, 'Workspace is busy; inspect it with show --json'
126
+ end
127
+
128
+ coordinator.wait(id)
129
+ end
130
+
131
+ def stop(id, options)
132
+ registry.with_lock(id) do
133
+ Backend.new(id).stop!
134
+ StateStore.new(Config.state_file).set(id, 'status' => 'stopped', 'message' => 'Server stopped')
135
+ end
136
+ emit(coordinator.snapshot(id), options)
137
+ end
138
+
139
+ def remove(id, options)
140
+ registry.with_lock(id) do |workspace|
141
+ if !options[:force] && workspace.dirty?
142
+ raise ArgumentError,
143
+ 'Workspace has uncommitted/untracked work; use --force to discard it'
144
+ end
145
+
146
+ Backend.new(id).stop!
147
+ workspace.remove!(force: options[:force])
148
+ StateStore.new(Config.state_file).delete(id)
149
+ end
150
+ emit({ 'id' => id, 'removed' => true, 'branch_preserved' => true }, options)
151
+ end
152
+
153
+ def execute(argv)
154
+ id = argv.shift
155
+ unless argv.shift == '--' && !argv.empty?
156
+ raise ArgumentError,
157
+ 'Usage: workspaces exec ID -- COMMAND [ARGUMENTS...]'
158
+ end
159
+
160
+ registry.with_lock(id) do |workspace|
161
+ pid = Process.spawn([argv.first, argv.first], *argv.drop(1), chdir: workspace.path.to_s)
162
+ _, status = Process.wait2(pid)
163
+ status.exitstatus || (128 + status.termsig)
164
+ end
165
+ end
166
+
167
+ def with_stdout_on_stderr
168
+ previous = $stdout
169
+ $stdout = $stderr
170
+ yield
171
+ ensure
172
+ $stdout = previous
173
+ end
174
+
175
+ def emit(data, options)
176
+ puts(options[:json] ? JSON.generate(data) : JSON.pretty_generate(data))
177
+ end
178
+
179
+ def usage
180
+ 'Usage: bin/workspaces serve | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--json] | ' \
181
+ 'list [--pr NUMBER] [--json] | show/start/prepare/restart/stop ID [--json] | ' \
182
+ 'exec ID -- COMMAND... | remove ID [--force]'
183
+ end
184
+
185
+ def help(*)
186
+ puts usage
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,49 @@
1
+ require 'open3'
2
+ require 'timeout'
3
+
4
+ module Workspaces
5
+ class CommandRunner
6
+ class Failed < StandardError; end
7
+
8
+ def initialize(path, log)
9
+ @path = path
10
+ @log = log
11
+ end
12
+
13
+ def run!(command, env:, timeout:)
14
+ executable = [command.first, command.first]
15
+ Open3.popen2e(env, executable, *command.drop(1), chdir: @path.to_s, pgroup: true) do |input, output, child|
16
+ input.close
17
+ output.set_encoding(Encoding::UTF_8, invalid: :replace, undef: :replace)
18
+ run_child(output, child, timeout)
19
+ end
20
+ rescue Errno::ENOENT
21
+ raise Failed, "Executable not found: #{command.first}. Install it or update the repository's run step."
22
+ end
23
+
24
+ private
25
+
26
+ def run_child(output, child, timeout)
27
+ Timeout.timeout(timeout) do
28
+ output.each_line { |line| @log.print(line) }
29
+ result = child.value
30
+ unless result.success?
31
+ raise Failed,
32
+ "Command exited with status #{result.exitstatus || "signal #{result.termsig}"}; see setup log"
33
+ end
34
+ end
35
+ rescue Timeout::Error
36
+ terminate(child)
37
+ raise Failed, "Command exceeded its #{timeout}s timeout"
38
+ ensure
39
+ terminate(child) if child.alive?
40
+ end
41
+
42
+ def terminate(child)
43
+ Process.kill('KILL', -child.pid)
44
+ child.join
45
+ rescue Errno::ESRCH
46
+ child.join
47
+ end
48
+ end
49
+ end