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 +7 -0
- data/DESIGN.md +67 -0
- data/LICENSE +21 -0
- data/README.md +146 -0
- data/exe/workspaces +5 -0
- data/lib/workspaces/backend.rb +108 -0
- data/lib/workspaces/cli.rb +189 -0
- data/lib/workspaces/command_runner.rb +49 -0
- data/lib/workspaces/config.rb +116 -0
- data/lib/workspaces/coordinator.rb +203 -0
- data/lib/workspaces/dashboard/app.js +132 -0
- data/lib/workspaces/dashboard/checkout_authentication_error.html.erb +22 -0
- data/lib/workspaces/dashboard/environment.js +120 -0
- data/lib/workspaces/dashboard/pull_request.html.erb +37 -0
- data/lib/workspaces/dashboard/styles.css +461 -0
- data/lib/workspaces/dashboard/template.html.erb +82 -0
- data/lib/workspaces/dashboard_view.rb +96 -0
- data/lib/workspaces/environment_overrides.rb +177 -0
- data/lib/workspaces/front_door.rb +275 -0
- data/lib/workspaces/paths.rb +63 -0
- data/lib/workspaces/port_allocator.rb +35 -0
- data/lib/workspaces/presentation.rb +68 -0
- data/lib/workspaces/recipe.rb +135 -0
- data/lib/workspaces/registry.rb +55 -0
- data/lib/workspaces/state_store.rb +84 -0
- data/lib/workspaces/tee_log.rb +39 -0
- data/lib/workspaces/tls.rb +112 -0
- data/lib/workspaces/version.rb +3 -0
- data/lib/workspaces/workflow.rb +78 -0
- data/lib/workspaces/worktree.rb +218 -0
- data/lib/workspaces.rb +5 -0
- metadata +121 -0
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,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
|