├── .env
├── .github
└── workflows
│ ├── Dockerfile
│ └── main.yml
├── .gitignore
├── .rspec
├── Brewfile
├── CODE_OF_CONDUCT.md
├── COMMERCIAL_LICENSE.txt
├── CONTRIBUTING.md
├── Gemfile
├── Gemfile.lock
├── LICENSE.txt
├── README.md
├── Rakefile
├── bin
├── console
├── rake
└── setup
├── containers
├── rails
│ ├── Dockerfile
│ └── app
│ │ ├── models
│ │ └── user.rb
│ │ └── terminal
│ │ └── integration_terminal.rb
├── ubuntu-tebako
│ ├── Dockerfile
│ └── tools
│ │ └── tools.sh
└── ubuntu
│ └── Dockerfile
├── examples
└── exec
│ └── localrails
├── gem
├── terminalwire-client
│ ├── lib
│ │ ├── terminalwire-client.rb
│ │ └── terminalwire
│ │ │ ├── client.rb
│ │ │ └── client
│ │ │ ├── entitlement.rb
│ │ │ ├── entitlement
│ │ │ ├── environment_variables.rb
│ │ │ ├── paths.rb
│ │ │ ├── policy.rb
│ │ │ └── schemes.rb
│ │ │ ├── exec.rb
│ │ │ ├── handler.rb
│ │ │ ├── resource.rb
│ │ │ └── server_license_verification.rb
│ ├── spec
│ │ ├── entitlement
│ │ │ ├── environment_variables_spec.rb
│ │ │ ├── paths_spec.rb
│ │ │ ├── policy_spec.rb
│ │ │ └── schemes_spec.rb
│ │ ├── entitlement_spec.rb
│ │ ├── resource_spec.rb
│ │ └── spec_helper.rb
│ └── terminalwire-client.gemspec
├── terminalwire-core
│ ├── lib
│ │ ├── terminalwire-core.rb
│ │ ├── terminalwire.rb
│ │ └── terminalwire
│ │ │ ├── adapter.rb
│ │ │ ├── binary.rb
│ │ │ ├── cache.rb
│ │ │ ├── logging.rb
│ │ │ ├── shells.rb
│ │ │ ├── transport.rb
│ │ │ └── version.rb
│ ├── spec
│ │ ├── binary_spec.rb
│ │ ├── cache_spec.rb
│ │ ├── shell_spec.rb
│ │ ├── spec_helper.rb
│ │ └── terminalwire_spec.rb
│ └── terminalwire-core.gemspec
├── terminalwire-rails
│ ├── lib
│ │ ├── generators
│ │ │ └── terminalwire
│ │ │ │ └── install
│ │ │ │ ├── USAGE
│ │ │ │ ├── install_generator.rb
│ │ │ │ └── templates
│ │ │ │ ├── application_terminal.rb.tt
│ │ │ │ ├── bin
│ │ │ │ └── terminalwire
│ │ │ │ └── main_terminal.rb
│ │ ├── terminalwire-rails.rb
│ │ └── terminalwire
│ │ │ └── rails.rb
│ └── terminalwire-rails.gemspec
├── terminalwire-server
│ ├── lib
│ │ ├── terminalwire-server.rb
│ │ └── terminalwire
│ │ │ ├── server.rb
│ │ │ └── server
│ │ │ ├── context.rb
│ │ │ ├── resource.rb
│ │ │ ├── thor.rb
│ │ │ └── web_socket.rb
│ ├── spec
│ │ └── spec_helper.rb
│ └── terminalwire-server.gemspec
└── terminalwire
│ ├── Rakefile
│ ├── exe
│ ├── terminalwire
│ └── terminalwire-exec
│ └── terminalwire.gemspec
├── spec
├── integration
│ ├── license_verification_spec.rb
│ └── rails_spec.rb
├── package
│ ├── local_spec.rb
│ └── ubuntu_spec.rb
└── spec_helper.rb
└── support
└── terminalwire.rb
/.env:
--------------------------------------------------------------------------------
1 | PATH="/opt/homebrew/opt/bison/bin:$PATH"
2 |
--------------------------------------------------------------------------------
/.github/workflows/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use an official Ruby runtime as a parent image
2 | FROM ruby:3.3.0
3 |
4 | # Set up environment variables
5 | ENV LANG C.UTF-8
6 |
7 | # Install dependencies
8 | RUN apt-get update -qq && apt-get install -y nodejs yarn
9 |
10 | # Set up working directory
11 | WORKDIR /app
12 |
13 | # Install Bundler
14 | RUN gem install bundler
15 |
16 | # Copy Gemfile and Gemfile.lock from the root context
17 | COPY ../../Gemfile ../../Gemfile.lock ./
18 |
19 | # Install dependencies
20 | RUN bundle install
21 |
22 | # Copy the rest of the application code
23 | COPY ../../ .
24 |
25 | # Expose port 3000 for the Rails server
26 | EXPOSE 3000
27 |
28 | # Command to run tests
29 | CMD ["bundle", "exec", "rspec"]
30 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Ruby
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | pull_request:
9 |
10 | jobs:
11 | test:
12 | runs-on: ubuntu-latest
13 | name: Ruby ${{ matrix.ruby }}
14 | strategy:
15 | matrix:
16 | ruby:
17 | - '3.3.0'
18 |
19 | steps:
20 | - uses: actions/checkout@v4
21 | - name: Set up Ruby
22 | uses: ruby/setup-ruby@v1
23 | with:
24 | ruby-version: ${{ matrix.ruby }}
25 | bundler-cache: false
26 |
27 | - name: Install dependencies
28 | run: bundle install --jobs 4 --retry 3 --verbose
29 |
30 | - name: Run RSpec
31 | run: bundle exec rspec --format documentation
32 |
33 | package:
34 | strategy:
35 | matrix:
36 | os: [ubuntu-latest, macos-latest]
37 | ruby:
38 | - '3.3.0'
39 |
40 | runs-on: ${{ matrix.os }}
41 | name: Package ${{ matrix.os }}
42 | steps:
43 | - uses: actions/checkout@v4
44 | - name: Set up Ruby
45 | uses: ruby/setup-ruby@v1
46 | with:
47 | ruby-version: ${{ matrix.ruby }}
48 | bundler-cache: false
49 |
50 | - name: Install dependencies
51 | run: bundle install --jobs 4 --retry 3 --verbose
52 |
53 | - name: Run Tebako
54 | run: bundle exec rake package:build
55 |
56 | - name: Upload package
57 | uses: actions/upload-artifact@v4
58 | with:
59 | name: package
60 | path: build/*
61 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /_yardoc/
4 | /coverage/
5 | /doc/
6 | /spec/reports/
7 | /tmp/
8 | /build
9 |
10 | # rspec failure tracking
11 | .rspec_status
12 |
13 | /gem/**/*/pkg
14 | /gem/terminalwire/package/*
15 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --format documentation
2 | --color
3 | --require spec_helper
4 | #--pattern spec/**/*_spec.rb,gem/*/spec/**/*_spec.rb
5 |
--------------------------------------------------------------------------------
/Brewfile:
--------------------------------------------------------------------------------
1 | # Used on macOS to install Ruby via Homebrew
2 | brew "ruby-install"
3 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, caste, color, religion, or sexual
10 | identity and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the overall
26 | community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or advances of
31 | any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email address,
35 | without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official email address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | [INSERT CONTACT METHOD].
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series of
86 | actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or permanent
93 | ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within the
113 | community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.1, available at
119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120 |
121 | Community Impact Guidelines were inspired by
122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123 |
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126 | [https://www.contributor-covenant.org/translations][translations].
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130 | [Mozilla CoC]: https://github.com/mozilla/diversity
131 | [FAQ]: https://www.contributor-covenant.org/faq
132 | [translations]: https://www.contributor-covenant.org/translations
133 |
--------------------------------------------------------------------------------
/COMMERCIAL_LICENSE.txt:
--------------------------------------------------------------------------------
1 | # Commercial License
2 |
3 | If you would like to use Terminalwire without complying with the AGPLv3 license (for example, if you do not want to release your modifications or if you're embedding it into a commercial product), you may purchase a commercial license.
4 |
5 | This allows you to:
6 | - Use Terminalwire in closed-source products
7 | - Modify it without open-sourcing your changes
8 | - Avoid AGPL disclosure requirements
9 |
10 | To obtain a commercial license, contact:
11 |
12 | **Brad Gessler**
13 |
14 | 📧 brad@terminalwire.com
15 | 💼 https://terminalwire.com
16 | 💰 Pricing available upon request or at http://terminalwire.com/pricing
17 |
18 | Please include:
19 | - Your company name
20 | - Description of use
21 | - Expected user base or integration details
22 |
23 | Additional information available at https://terminalwire.com/license
24 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributor License Agreement (CLA)
2 |
3 | By submitting code to this project, you agree that your contributions may be relicensed by Rocketship, LLC (D.B.A. "Terminalwire") under different terms in the future.
4 |
5 | This allows us to dual-license the project, maintain commercial options, and ensure long-term sustainability.
6 |
7 | If you do not agree to this, please do not submit contributions.
8 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | # Specify your gem's dependencies in terminalwire.gemspec
6 | gemspec path: "gem/terminalwire-core"
7 | gemspec path: "gem/terminalwire-server"
8 | gemspec path: "gem/terminalwire-client"
9 | gemspec path: "gem/terminalwire"
10 |
11 | gem "tebako", "~> 0.12.4"
12 | gem "tebako-runtime"
13 | gem "rake"
14 | gem "rdoc"
15 |
16 | gem "pity", "~> 0.1.0"
17 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: gem/terminalwire-client
3 | specs:
4 | terminalwire-client (0.3.5.alpha2)
5 | launchy (~> 3.0)
6 | terminalwire-core (= 0.3.5.alpha2)
7 |
8 | PATH
9 | remote: gem/terminalwire-core
10 | specs:
11 | terminalwire-core (0.3.5.alpha2)
12 | async-websocket (~> 0.30)
13 | base64 (~> 0.2.0)
14 | msgpack (~> 1.7)
15 | uri-builder (~> 0.1.9)
16 | zeitwerk (~> 2.0)
17 |
18 | PATH
19 | remote: gem/terminalwire-server
20 | specs:
21 | terminalwire-server (0.3.5.alpha2)
22 | terminalwire-core (= 0.3.5.alpha2)
23 | thor (~> 1.3)
24 |
25 | PATH
26 | remote: gem/terminalwire
27 | specs:
28 | terminalwire (0.3.5.alpha2)
29 | terminalwire-client (= 0.3.5.alpha2)
30 |
31 | GEM
32 | remote: https://rubygems.org/
33 | specs:
34 | addressable (2.8.7)
35 | public_suffix (>= 2.0.2, < 7.0)
36 | async (2.23.0)
37 | console (~> 1.29)
38 | fiber-annotation
39 | io-event (~> 1.9)
40 | metrics (~> 0.12)
41 | traces (~> 0.15)
42 | async-http (0.87.0)
43 | async (>= 2.10.2)
44 | async-pool (~> 0.9)
45 | io-endpoint (~> 0.14)
46 | io-stream (~> 0.6)
47 | metrics (~> 0.12)
48 | protocol-http (~> 0.49)
49 | protocol-http1 (~> 0.30)
50 | protocol-http2 (~> 0.22)
51 | traces (~> 0.10)
52 | async-pool (0.10.3)
53 | async (>= 1.25)
54 | async-websocket (0.30.0)
55 | async-http (~> 0.76)
56 | protocol-http (~> 0.34)
57 | protocol-rack (~> 0.7)
58 | protocol-websocket (~> 0.17)
59 | base64 (0.2.0)
60 | childprocess (5.1.0)
61 | logger (~> 1.5)
62 | console (1.29.3)
63 | fiber-annotation
64 | fiber-local (~> 1.1)
65 | json
66 | date (3.4.1)
67 | diff-lcs (1.6.0)
68 | fiber-annotation (0.2.0)
69 | fiber-local (1.1.0)
70 | fiber-storage
71 | fiber-storage (1.0.0)
72 | io-endpoint (0.15.2)
73 | io-event (1.9.0)
74 | io-stream (0.6.1)
75 | json (2.10.1)
76 | launchy (3.1.1)
77 | addressable (~> 2.8)
78 | childprocess (~> 5.0)
79 | logger (~> 1.6)
80 | logger (1.6.6)
81 | metrics (0.12.1)
82 | msgpack (1.8.0)
83 | pity (0.1.0)
84 | protocol-hpack (1.5.1)
85 | protocol-http (0.49.0)
86 | protocol-http1 (0.30.0)
87 | protocol-http (~> 0.22)
88 | protocol-http2 (0.22.1)
89 | protocol-hpack (~> 1.4)
90 | protocol-http (~> 0.47)
91 | protocol-rack (0.11.1)
92 | protocol-http (~> 0.43)
93 | rack (>= 1.0)
94 | protocol-websocket (0.20.1)
95 | protocol-http (~> 0.2)
96 | psych (5.2.3)
97 | date
98 | stringio
99 | public_suffix (6.0.1)
100 | rack (3.1.10)
101 | rake (13.2.1)
102 | rdoc (6.12.0)
103 | psych (>= 4.0.0)
104 | rspec (3.13.0)
105 | rspec-core (~> 3.13.0)
106 | rspec-expectations (~> 3.13.0)
107 | rspec-mocks (~> 3.13.0)
108 | rspec-core (3.13.3)
109 | rspec-support (~> 3.13.0)
110 | rspec-expectations (3.13.3)
111 | diff-lcs (>= 1.2.0, < 2.0)
112 | rspec-support (~> 3.13.0)
113 | rspec-mocks (3.13.2)
114 | diff-lcs (>= 1.2.0, < 2.0)
115 | rspec-support (~> 3.13.0)
116 | rspec-support (3.13.2)
117 | stringio (3.1.5)
118 | tebako (0.12.15)
119 | bundler
120 | thor (~> 1.2)
121 | yaml (~> 0.2.1)
122 | tebako-runtime (0.5.5)
123 | thor (1.3.2)
124 | traces (0.15.2)
125 | uri-builder (0.1.13)
126 | yaml (0.2.1)
127 | zeitwerk (2.7.2)
128 |
129 | PLATFORMS
130 | arm64-darwin-23
131 | ruby
132 |
133 | DEPENDENCIES
134 | pity (~> 0.1.0)
135 | rake
136 | rdoc
137 | rspec (~> 3.0)
138 | tebako (~> 0.12.4)
139 | tebako-runtime
140 | terminalwire!
141 | terminalwire-client!
142 | terminalwire-core!
143 | terminalwire-server!
144 |
145 | BUNDLED WITH
146 | 2.6.2
147 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) [2024] [Brad Gessler]
5 |
6 | This program is free software: you can redistribute it and/or modify
7 | it under the terms of the GNU Affero General Public License as
8 | published by the Free Software Foundation, either version 3 of the
9 | License, or (at your option) any later version.
10 |
11 | This program is distributed in the hope that it will be useful,
12 | but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | GNU Affero General Public License for more details.
15 |
16 | You should have received a copy of the GNU Affero General Public License
17 | along with this program. If not, see .
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Terminalwire
2 |
3 | Unlike most command-line tools for web services that require an API, Terminalwire streams terminal I/O between a web server and client over WebSockets. This means you can use your preferred command-line parser within your favorite web server framework to deliver a delightful CLI experience to your users.
4 |
5 | ## What's in this repo?
6 |
7 | This is a monolithic repository with several Terminalwire components, including the Terminalwire thin-client and Ruby & Rails servers.
8 |
9 | ### Terminalwire Client
10 |
11 | The Terminalwire thin-client is pacakged using Tebako and is installed on end-user workstations. The thin-client connects to a Terminalwire server and streams stdio, browser, filesystem, and other commands between the server and client via WebSockets through an entitlement-based security layer.
12 |
13 | [Read the Terminalwire-client manual](https://terminalwire.com/docs/client)
14 |
15 | ### Terminalwire Ruby & Rails servers
16 |
17 | Terminalwire servers can run on any platform or framework. This repo has source for the Ruby Terminalwire server, specifically targeting Ruby on Rails.
18 |
19 | [Read the Terminalwire Ruby on Rails manual](https://terminalwire.com/docs/rails)
20 |
21 | ## Installation
22 |
23 | ### Client
24 |
25 | The Terminalwire thin-client may be installed by running:
26 |
27 | $ curl -sSL https://terminalwire.sh/ | bash
28 |
29 | This installs the Tebako packaged version of the Terminalwire client, which is recommended for production use.
30 |
31 | #### RubyGem client
32 |
33 | The Terminalwire thin-client gem may be installed for development purposes by running:
34 |
35 | $ gem install terminalwire
36 |
37 | *This approach is not recommended for production use since developer workstations likely don't have the correct Ruby dependencies installed*
38 |
39 | ## Rails
40 |
41 | Run the intallation command:
42 |
43 | $ rails g terminalwire:install my-app
44 |
45 | This generates the `./bin/my-app` file. Run it to verify that it connects to the server.
46 |
47 | $ bin/my-app
48 | Commands:
49 | my-app help [COMMAND] # Describe available commands or one specific command
50 |
51 | To edit the command-line, open `./app/cli/main_cli.rb` and make changes to the `MainCLI` class.
52 |
53 | ## Development
54 |
55 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
56 |
57 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
58 |
59 | ## Contributing
60 |
61 | Bug reports and pull requests are welcome on GitHub at https://github.com/terminalwire/ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/terminalwire/ruby/blob/main/CODE_OF_CONDUCT.md).
62 |
63 | ## License
64 |
65 | The gem is available as a propietary license. The tl;dr is that it's free for personal use and for commercial use email brad@terminalwire.com to discuss licensing.
66 |
67 | ## Code of Conduct
68 |
69 | Everyone interacting in the Terminalwire project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/terminalwire/ruby/blob/main/CODE_OF_CONDUCT.md).
70 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler/setup"
4 | require "bundler/gem_helper"
5 | require_relative "support/terminalwire"
6 |
7 | Terminalwire::Project.all.each do |project|
8 | namespace project.task_namespace do
9 | project.gem_tasks
10 |
11 | desc "Uninstall #{project.name}"
12 | task :uninstall do
13 | sh "gem uninstall #{project.name} --force --executables"
14 | end
15 |
16 | desc "Clean #{project.name}"
17 | task :clean do
18 | sh "rm -rf #{File.join project.dir, "pkg/*.gem"}"
19 | end
20 |
21 | desc "Test #{project.name}"
22 | task :spec do
23 | project.chdir do
24 | sh "bundle exec rspec spec"
25 | end
26 | end
27 | end
28 | end
29 |
30 | module Tebako
31 | RUBY_VERSION = "3.3.6"
32 |
33 | def self.press(path, exe:, to:, ruby_version: RUBY_VERSION)
34 | "tebako press -r #{path} -e #{exe} -R #{ruby_version} -o #{to}"
35 | end
36 |
37 | def self.host_os
38 | case RbConfig::CONFIG["host_os"]
39 | in /darwin/
40 | "macos"
41 | in /linux/
42 | "ubuntu"
43 | end
44 | end
45 |
46 | def self.host_arch
47 | case RbConfig::CONFIG["host_cpu"]
48 | when /x86_64/
49 | "amd64"
50 | when /arm64/, /aarch64/
51 | "arm64"
52 | else
53 | raise "Unsupported architecture: #{RbConfig::CONFIG["host_cpu"]}"
54 | end
55 | end
56 | end
57 |
58 | def write(path, *, **, &)
59 | puts "Writing file to #{path}"
60 | File.write(path, *, **, &)
61 | end
62 |
63 | namespace :gem do
64 | # Define global tasks for all gems
65 | %i[build clean install install:local uninstall].each do |task|
66 | desc "#{task.capitalize} all gems"
67 | task task do
68 | Terminalwire::Project.all.each do |project|
69 | project.rake_task(task).invoke
70 | end
71 | end
72 | end
73 |
74 | namespace :releasable do
75 | desc "Ensure git working directory is clean"
76 | task :clean do
77 | unless system("git diff-index --quiet HEAD --")
78 | abort "Git working directory is not clean. Please commit or stash changes before release."
79 | end
80 | puts "Git working directory is clean."
81 | end
82 |
83 | desc "Ensure local branch is in sync with its upstream"
84 | task :synced do
85 | system("git fetch") || abort("Failed to fetch updates from origin.")
86 |
87 | local = `git rev-parse @`.strip
88 | remote = `git rev-parse @{u}`.strip rescue nil
89 |
90 | if remote.nil?
91 | abort "No upstream branch configured for the current branch."
92 | end
93 |
94 | if local != remote
95 | abort "Local branch (#{local}) does not match remote (#{remote}). Please commit and push all changes."
96 | end
97 |
98 | puts "Local branch is in sync with the remote."
99 | end
100 | end
101 |
102 | desc "Check if gem is releasable: clean working directory and synced branch"
103 | task releasable: %i[releasable:clean releasable:synced]
104 |
105 | desc "Release all gems"
106 | task release: :releasable do
107 | Terminalwire::Project.all.each do |project|
108 | project.rake_task("release").invoke
109 | end
110 | end
111 | end
112 |
113 | desc "Build gems"
114 | task gem: %i[gem:clean gem:build]
115 |
116 | namespace :spec do
117 | desc "Run isolated specs"
118 | task :isolate do
119 | Terminalwire::Project.all.each do |project|
120 | project.rake_task("spec").invoke
121 | end
122 | end
123 |
124 | desc "Run integration specs"
125 | task integration: :gem do
126 | sh "bundle exec rspec spec"
127 | end
128 | end
129 |
130 | namespace :tebako do
131 | build_path = Pathname.new("build")
132 | stage_path = build_path.join("stage")
133 |
134 | namespace :build do
135 | path = stage_path.join("#{Tebako.host_os}/#{Tebako.host_arch}")
136 | bin_path = path.join("bin/terminalwire-exec")
137 |
138 | task :prepare do
139 | mkdir_p bin_path.dirname
140 | end
141 |
142 | task :press do
143 | Bundler.with_unbundled_env do
144 | sh Tebako.press "gem/terminalwire",
145 | exe: "terminalwire-exec",
146 | to: bin_path
147 | end
148 | end
149 | end
150 | desc "Build terminal-exec binary for #{Tebako.host_os}(#{Tebako.host_arch})"
151 | task build: %w[build:prepare build:press]
152 |
153 | namespace :ubuntu do
154 | docker_image = "terminalwire-ubuntu-tebako-#{Tebako.host_arch}"
155 |
156 | task :prepare do
157 | sh <<~BASH
158 | docker build \
159 | ./containers/ubuntu-tebako \
160 | -t #{docker_image}
161 | BASH
162 | end
163 |
164 | task :press do
165 | sh <<~BASH
166 | docker run -v #{File.expand_path(Dir.pwd)}:/host \
167 | -w /host \
168 | #{docker_image} \
169 | bash -c "bundle && bin/rake tebako:build"
170 | BASH
171 | end
172 |
173 | task build: %i[prepare press]
174 | end
175 |
176 | desc "Build terminal-exec binary for Ubuntu"
177 | task ubuntu: "ubuntu:build"
178 |
179 | task :package do
180 | packages_path = build_path.join("packages")
181 | sh "mkdir -p #{packages_path}"
182 |
183 | Dir.glob(stage_path.join("*/*")).map{ Pathname.new(_1) }.each do |path|
184 | path.each_filename.to_a => *_, os, arch
185 |
186 | write path.join("VERSION"),
187 | Terminalwire::VERSION
188 |
189 | Terminalwire::Binary.new(
190 | url: "wss://terminalwire.com/terminal"
191 | ).write path.join("bin/terminalwire")
192 |
193 | archive_name = packages_path.join("#{os}-#{arch}.tar.gz")
194 | sh "tar -czf #{archive_name} -C #{path} ."
195 | end
196 | end
197 | end
198 | desc "Build #{Tebako.host_os}(#{Tebako.host_arch}) binary"
199 | task tebako: %i[tebako:build tebako:ubuntu:build tebako:package]
200 |
201 | desc "Run specs"
202 | task spec: %i[spec:isolate spec:integration]
203 |
204 | # Run : Lgemntests and build everything.
205 | task default: %i[spec tebako]
206 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "bundler/setup"
5 | require "terminalwire"
6 |
7 | # You can add fixtures and/or initialization code here to make experimenting
8 | # with your gem easier. You can also use a different console, if you like.
9 |
10 | require "irb"
11 | IRB.start(__FILE__)
12 |
--------------------------------------------------------------------------------
/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'rake' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
12 |
13 | bundle_binstub = File.expand_path("bundle", __dir__)
14 |
15 | if File.file?(bundle_binstub)
16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
17 | load(bundle_binstub)
18 | else
19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
21 | end
22 | end
23 |
24 | require "rubygems"
25 | require "bundler/setup"
26 |
27 | load Gem.bin_path("rake", "rake")
28 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle install
7 |
8 | # Do any other automated setup that you need to do here
9 |
--------------------------------------------------------------------------------
/containers/rails/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG RUBY_VERSION=3.3.6
2 | FROM ruby:${RUBY_VERSION} AS base
3 |
4 | WORKDIR /rails
5 |
6 | RUN gem install rails && \
7 | rails new . --minimal --name terminalwire-integration
8 |
9 | FROM base AS local
10 |
11 | # Ensure our local gem executables are on the PATH.
12 | COPY ./containers/rails/app /rails/app/
13 | COPY ./gem/*/pkg/*.gem /gem/
14 | RUN gem install /gem/terminalwire*.gem
15 |
16 | # Remove the remote source from the Gemfile so that nothing is fetched remotely.
17 | RUN sed -i '/^source/d' Gemfile
18 |
19 | # This will add the local terminalwire-raisl gem.
20 | RUN bundle add terminalwire-rails
21 |
22 | RUN bin/rails generate terminalwire:install hello
23 |
24 | # Add IntegrationTerminal as a Thor subcommand to MainTerminal
25 | RUN sed -i '$i\ desc "integration", "Integration tests"\n subcommand "integration", IntegrationTerminal' app/terminal/main_terminal.rb
26 |
27 | CMD ["bin/rails", "server", "--port", "3000", "--binding", "0.0.0.0"]
28 |
--------------------------------------------------------------------------------
/containers/rails/app/models/user.rb:
--------------------------------------------------------------------------------
1 | class User
2 | attr_reader :email
3 |
4 | def initialize(email:)
5 | @email = email
6 | end
7 | alias :id :email
8 |
9 | def valid_password?(password)
10 | true
11 | end
12 |
13 | def self.find_for_authentication(email:)
14 | find email
15 | end
16 |
17 | def self.find(email)
18 | new email: email
19 | end
20 |
21 | def self.find_by(id:)
22 | find id
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/containers/rails/app/terminal/integration_terminal.rb:
--------------------------------------------------------------------------------
1 | class IntegrationTerminal < ApplicationTerminal
2 | desc "exception", "Raise an exception"
3 | def exception
4 | raise "An exception occurred"
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/containers/ubuntu-tebako/Dockerfile:
--------------------------------------------------------------------------------
1 | # Copyright (c) 2024 [Ribose Inc](https://www.ribose.com).
2 | # All rights reserved.
3 | # This file is a part of tamatebako
4 | #
5 | # Redistribution and use in source and binary forms, with or without
6 | # modification, are permitted provided that the following conditions
7 | # are met:
8 | # 1. Redistributions of source code must retain the above copyright
9 | # notice, this list of conditions and the following disclaimer.
10 | # 2. Redistributions in binary form must reproduce the above copyright
11 | # notice, this list of conditions and the following disclaimer in the
12 | # documentation and/or other materials provided with the distribution.
13 | #
14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
15 | # ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
16 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
17 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS
18 | # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
19 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
20 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
21 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
22 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
23 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
24 | # POSSIBILITY OF SUCH DAMAGE.
25 |
26 | FROM ubuntu:focal AS base
27 |
28 | ENV DEBIAN_FRONTEND=noninteractive
29 | ENV TZ=Etc/UTC
30 |
31 | RUN apt-get -y update && \
32 | apt-get -y install sudo wget git make pkg-config clang-12 clang++-12 \
33 | autoconf binutils-dev libevent-dev acl-dev libfmt-dev libjemalloc-dev \
34 | libdouble-conversion-dev libiberty-dev liblz4-dev liblzma-dev libssl-dev \
35 | libboost-filesystem-dev libboost-program-options-dev libboost-system-dev \
36 | libboost-iostreams-dev libboost-date-time-dev libboost-context-dev \
37 | libboost-regex-dev libboost-thread-dev libbrotli-dev libunwind-dev \
38 | libdwarf-dev libelf-dev libgoogle-glog-dev libffi-dev libgdbm-dev \
39 | libyaml-dev libncurses-dev libreadline-dev libutfcpp-dev libstdc++-10-dev
40 |
41 | ENV CC=clang-12
42 | ENV CXX=clang++-12
43 |
44 | COPY tools /opt/tools
45 |
46 | # Default build-time argument
47 | ARG ruby_version=3.3.6
48 | # Persistent environment variable
49 | ENV RUBY_VERSION=$ruby_version
50 |
51 | RUN /opt/tools/tools.sh install_cmake && \
52 | /opt/tools/tools.sh install_ruby
53 |
54 | # This is needed to deal with gems created by `bundler gem` that are
55 | # being packaged by `tebako press` inside of a container. More at
56 | # https://github.com/tamatebako/tebako/issues/233#issuecomment-2593760210.
57 | RUN git config --global --add safe.directory '*'
58 |
59 | # https://github.com/actions/checkout/issues/1014
60 | # RUN adduser --disabled-password --gecos "" --home $HOME tebako && \
61 | # printf "\ntebako\tALL=(ALL)\tNOPASSWD:\tALL" > /etc/sudoers.d/tebako
62 | # USER tebako
63 | # ENV HOME=/home/tebako
64 |
65 | # So we are running as root, HOME=/root, tebako prefix (default) /root/.tebako
66 |
67 | ENV TEBAKO_PREFIX=/root/.tebako
68 |
69 | # Ruby Setup Layer
70 | FROM base AS base-ruby
71 | LABEL stage="base-ruby"
72 |
73 | # Sets the version of the Tebako gem that will be installed.
74 | ARG tebako_version=0.12.4
75 |
76 | RUN gem install tebako -v ${tebako_version} && \
77 | tebako setup -R "$RUBY_VERSION"
78 |
79 | # Test Layer
80 | FROM base-ruby AS test
81 | LABEL stage="test"
82 |
83 | # Copy test files and execute tests
84 | COPY test /root/test
85 | RUN tebako press -R "$RUBY_VERSION" -r /root/test -e tebako-test-run.rb -o "ruby-$RUBY_VERSION-package" && \
86 | rm "ruby-$RUBY_VERSION-package"
87 |
88 | # Final Production Image
89 | FROM base-ruby AS builder
90 | LABEL stage="builder"
91 |
92 | # Skip copying `test` into the final image to reduce size
93 | #ENV PS1="\[\]\[\e]0;\u@\h: \w\a\]${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ \[\]"
94 | CMD ["bash"]
95 |
--------------------------------------------------------------------------------
/containers/ubuntu-tebako/tools/tools.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Copyright (c) 2024 [Ribose Inc](https://www.ribose.com).
4 | # All rights reserved.
5 | # This file is a part of tamatebako
6 | #
7 | # Redistribution and use in source and binary forms, with or without
8 | # modification, are permitted provided that the following conditions
9 | # are met:
10 | # 1. Redistributions of source code must retain the above copyright
11 | # notice, this list of conditions and the following disclaimer.
12 | # 2. Redistributions in binary form must reproduce the above copyright
13 | # notice, this list of conditions and the following disclaimer in the
14 | # documentation and/or other materials provided with the distribution.
15 | #
16 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17 | # ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
18 | # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 | # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS
20 | # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
21 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
22 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
23 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
24 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
25 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26 | # POSSIBILITY OF SUCH DAMAGE.
27 |
28 | set -o errexit -o pipefail -o noclobber -o nounset
29 |
30 | : "${LOCAL_BUILDS:=/tmp/tebako}"
31 | : "${CMAKE_VERSION:=3.24.4-1}"
32 | : "${RUBY_VERSION:=3.3.6}"
33 | : "${RUBY_INSTALL_VERSION:=0.9.3}"
34 |
35 | install_cmake() {
36 | echo "Running install_cmake for CMake version ${CMAKE_VERSION}"
37 |
38 | # Detect system architecture and resolve cmake_arch
39 | case "$(uname -m)" in
40 | x86_64)
41 | cmake_arch="x64"
42 | ;;
43 | arm64 | aarch64)
44 | cmake_arch="arm64"
45 | ;;
46 | *)
47 | echo "Unsupported architecture: $(uname -m)"
48 | exit 1
49 | ;;
50 | esac
51 |
52 | echo "Detected architecture: $(uname -m), resolved cmake_arch: ${cmake_arch}"
53 |
54 | local cmake_install=${LOCAL_BUILDS}/cmake
55 | mkdir -p ${cmake_install}
56 | pushd ${cmake_install}
57 | wget -nv https://github.com/xpack-dev-tools/cmake-xpack/releases/download/v${CMAKE_VERSION}/xpack-cmake-${CMAKE_VERSION}-linux-${cmake_arch}.tar.gz
58 | tar -zxf xpack-cmake-${CMAKE_VERSION}-linux-${cmake_arch}.tar.gz --directory /usr --strip-components=1 --skip-old-files
59 | popd
60 | rm -rf ${cmake_install}
61 | }
62 |
63 | install_ruby() {
64 | echo "Running ruby_install version ${RUBY_INSTALL_VERSION} for Ruby ${RUBY_VERSION}"
65 | local ruby_install=${LOCAL_BUILDS}/ruby_install
66 | mkdir -p ${ruby_install}
67 | pushd ${ruby_install}
68 | wget -nv https://github.com/postmodern/ruby-install/releases/download/v${RUBY_INSTALL_VERSION}/ruby-install-${RUBY_INSTALL_VERSION}.tar.gz && \
69 | tar -xzvf ruby-install-${RUBY_INSTALL_VERSION}.tar.gz
70 | cd ruby-install-${RUBY_INSTALL_VERSION}
71 | make install
72 | ruby-install --system ruby ${RUBY_VERSION} -- --without-gmp --disable-dtrace --disable-debug-env --disable-install-doc CC=${CC}
73 | popd
74 | rm -rf ${ruby_install}
75 | }
76 |
77 | DIR0=$( dirname "$0" )
78 | DIR_TOOLS=$( cd "$DIR0" && pwd )
79 |
80 | echo "Running tools.sh with args: $@, DIR_TOOLS: ${DIR_TOOLS}"
81 |
82 | "$@"
83 |
--------------------------------------------------------------------------------
/containers/ubuntu/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM ubuntu AS base
2 |
3 | ENV PATH=$PATH:/opt/terminalwire/bin
4 |
5 | RUN apt-get update -qq && \
6 | apt-get install --no-install-recommends -y ca-certificates git && \
7 | rm -rf /var/lib/apt/lists /var/cache/apt/archives
8 |
--------------------------------------------------------------------------------
/examples/exec/localrails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env terminalwire-exec
2 | url: "ws://localhost:3000/terminal"
3 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/lib/terminalwire-client.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'terminalwire/client'
4 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/lib/terminalwire/client.rb:
--------------------------------------------------------------------------------
1 | require 'terminalwire'
2 |
3 | require 'launchy'
4 | require 'io/console'
5 | require 'pathname'
6 |
7 | require 'forwardable'
8 | require 'uri'
9 |
10 | require 'async'
11 | require 'async/http/endpoint'
12 | require 'async/websocket/client'
13 | require 'async/websocket/adapters/rack'
14 | require 'uri-builder'
15 |
16 | require "zeitwerk"
17 | Zeitwerk::Loader.for_gem_extension(Terminalwire).tap do |loader|
18 | loader.setup
19 | end
20 |
21 | module Terminalwire
22 | module Client
23 | ROOT_PATH = "~/.terminalwire".freeze
24 | def self.root_path = Pathname.new(ENV.fetch("TERMINALWIRE_HOME", ROOT_PATH))
25 |
26 | def self.websocket(url:, arguments: ARGV, &configuration)
27 | ENV["TERMINALWIRE_HOME"] ||= root_path.to_s
28 |
29 | url = URI(url)
30 |
31 | Async do |task|
32 | endpoint = Async::HTTP::Endpoint.parse(
33 | url,
34 | alpn_protocols: Async::HTTP::Protocol::HTTP11.names
35 | )
36 |
37 | Async::WebSocket::Client.connect(endpoint) do |connection|
38 | transport = Terminalwire::Transport::WebSocket.new(connection)
39 | adapter = Terminalwire::Adapter::Socket.new(transport)
40 | Terminalwire::Client::Handler.new(adapter, arguments:, endpoint:, &configuration).connect
41 | end
42 | end
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/lib/terminalwire/client/entitlement.rb:
--------------------------------------------------------------------------------
1 | require "pathname"
2 |
3 | module Terminalwire::Client
4 | # Entitlements are the security boundary between the server and the client that lives on the client.
5 | # The server might request a file or directory from the client, and the client will check the entitlements
6 | # to see if the server is authorized to access the requested resource.
7 | module Entitlement
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/lib/terminalwire/client/entitlement/environment_variables.rb:
--------------------------------------------------------------------------------
1 | module Terminalwire::Client::Entitlement
2 | # ENV vars that the server can access on the client.
3 | class EnvironmentVariables
4 | include Enumerable
5 |
6 | def initialize
7 | @permitted = Set.new
8 | end
9 |
10 | def each(&)
11 | @permitted.each(&)
12 | end
13 |
14 | def permit(variable)
15 | @permitted << variable.to_s
16 | end
17 |
18 | def permitted?(key)
19 | include? key.to_s
20 | end
21 |
22 | def serialize
23 | map { |name| { name: } }
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/lib/terminalwire/client/entitlement/paths.rb:
--------------------------------------------------------------------------------
1 | module Terminalwire::Client::Entitlement
2 | # A list of paths and permissions that server has to write on the client workstation.
3 | class Paths
4 | class Permit
5 | attr_reader :path, :mode
6 | # Ensure the default file mode is read/write for owner only. This ensures
7 | # that if the server tries uploading an executable file, it won't be when it
8 | # lands on the client.
9 | #
10 | # Eventually we'll move this into entitlements so the client can set maximum
11 | # permissions for files and directories.
12 | MODE = 0o600 # rw-------
13 |
14 | # Constants for permission bit masks
15 | OWNER_PERMISSIONS = 0o700 # rwx------
16 | GROUP_PERMISSIONS = 0o070 # ---rwx---
17 | OTHERS_PERMISSIONS = 0o007 # ------rwx
18 |
19 | # We'll validate that modes are within this range.
20 | MODE_RANGE = 0o000..0o777
21 |
22 | def initialize(path:, mode: MODE)
23 | @path = Pathname.new(path)
24 | @mode = convert(mode)
25 | end
26 |
27 | def permitted_path?(path)
28 | # This MUST be done via File.fnmatch because Pathname#fnmatch does not work. If you
29 | # try changing this 🚨 YOU MAY CIRCUMVENT THE SECURITY MEASURES IN PLACE. 🚨
30 | File.fnmatch @path.to_s, path.to_s, File::FNM_PATHNAME
31 | end
32 |
33 | def permitted_mode?(value)
34 | # Ensure the mode is at least as permissive as the permitted mode.
35 | mode = convert(value)
36 |
37 | # Extract permission bits for owner, group, and others
38 | owner_bits = mode & OWNER_PERMISSIONS
39 | group_bits = mode & GROUP_PERMISSIONS
40 | others_bits = mode & OTHERS_PERMISSIONS
41 |
42 | # Ensure that the mode doesn't grant more permissions than @mode in any class (owner, group, others)
43 | (owner_bits <= @mode & OWNER_PERMISSIONS) &&
44 | (group_bits <= @mode & GROUP_PERMISSIONS) &&
45 | (others_bits <= @mode & OTHERS_PERMISSIONS)
46 | end
47 |
48 | def permitted?(path:, mode: @mode)
49 | permitted_path?(path) && permitted_mode?(mode)
50 | end
51 |
52 | def serialize
53 | {
54 | location: @path.to_s,
55 | mode: @mode
56 | }
57 | end
58 |
59 | protected
60 | def convert(value)
61 | mode = Integer(value)
62 | raise ArgumentError, "The mode #{format_octet value} must be an octet value between #{format_octet MODE_RANGE.first} and #{format_octet MODE_RANGE.last}" unless MODE_RANGE.cover?(mode)
63 | mode
64 | end
65 |
66 | def format_octet(value)
67 | format("0o%03o", value)
68 | end
69 | end
70 |
71 | include Enumerable
72 |
73 | def initialize
74 | @permitted = []
75 | end
76 |
77 | def each(&)
78 | @permitted.each(&)
79 | end
80 |
81 | def permit(path, **)
82 | @permitted.append Permit.new(path:, **)
83 | end
84 |
85 | def permitted?(path, mode: nil)
86 | if mode
87 | find { |it| it.permitted_path?(path) and it.permitted_mode?(mode) }
88 | else
89 | find { |it| it.permitted_path?(path) }
90 | end
91 | end
92 |
93 | def serialize
94 | map(&:serialize)
95 | end
96 | end
97 | end
98 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/lib/terminalwire/client/entitlement/policy.rb:
--------------------------------------------------------------------------------
1 | module Terminalwire::Client::Entitlement
2 | module Policy
3 | # A policy has the authority, paths, and schemes that the server is allowed to access.
4 | class Base
5 | attr_reader :paths, :authority, :schemes, :environment_variables
6 |
7 | def initialize(authority:)
8 | @authority = authority
9 | @paths = Paths.new
10 |
11 | # Permit the domain directory. This is necessary for basic operation of the client.
12 | @paths.permit storage_path
13 | @paths.permit storage_pattern
14 |
15 | @schemes = Schemes.new
16 | # Permit http & https by default.
17 | @schemes.permit "http"
18 | @schemes.permit "https"
19 |
20 | @environment_variables = EnvironmentVariables.new
21 | # Permit the HOME and TERMINALWIRE_HOME environment variables.
22 | @environment_variables.permit "TERMINALWIRE_HOME"
23 | end
24 |
25 | def root_path
26 | # TODO: This needs to be passed into the Policy so that it can be set by the client.
27 | Terminalwire::Client.root_path
28 | end
29 |
30 | def authority_path
31 | root_path.join("authorities/#{authority}")
32 | end
33 |
34 | def storage_path
35 | authority_path.join("storage")
36 | end
37 |
38 | def storage_pattern
39 | storage_path.join("**/*")
40 | end
41 |
42 | def serialize
43 | {
44 | authority: @authority,
45 | schemes: @schemes.serialize,
46 | paths: @paths.serialize,
47 | environment_variables: @environment_variables.serialize
48 | }
49 | end
50 | end
51 |
52 | class Root < Base
53 | AUTHORITY = "terminalwire.com".freeze
54 |
55 | # Ensure the binary stubs are executable. This increases the
56 | # file mode entitlement so that stubs created in ./bin are executable.
57 | BINARY_PATH_FILE_MODE = 0o755
58 |
59 | def initialize(*, **, &)
60 | # Make damn sure the authority is set to Terminalwire.
61 | super(*, authority: AUTHORITY, **, &)
62 |
63 | # Now setup special permitted paths.
64 | @paths.permit root_path
65 | @paths.permit root_pattern
66 | # Permit the dotfiles so terminalwire can install the binary stubs.
67 | Terminalwire::Shells::All.login_files.each do |path|
68 | @paths.permit path
69 | end
70 |
71 | # Permit terminalwire to grant execute permissions to the binary stubs.
72 | @paths.permit binary_pattern, mode: BINARY_PATH_FILE_MODE
73 |
74 | # Used to check if terminalwire is setup in the user's PATH environment variable.
75 | @environment_variables.permit "PATH"
76 |
77 | # Permit the shell environment variable so we can detect the user's shell.
78 | @environment_variables.permit "SHELL"
79 | end
80 |
81 | # Grant access to the `~/.terminalwire/**/*` path so users can install
82 | # terminalwire apps via `terminalwire install svbtle`, etc.
83 | def root_pattern
84 | root_path.join("**/*").freeze
85 | end
86 |
87 | # Path where the terminalwire binary stubs are stored.
88 | def binary_path
89 | root_path.join("bin").freeze
90 | end
91 |
92 | # Pattern for the binary path.
93 | def binary_pattern
94 | binary_path.join("*").freeze
95 | end
96 | end
97 |
98 | def self.resolve(*, authority:, **, &)
99 | case authority
100 | when Policy::Root::AUTHORITY
101 | Root.new(*, **, &)
102 | else
103 | Base.new *, authority:, **, &
104 | end
105 | end
106 | end
107 | end
108 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/lib/terminalwire/client/entitlement/schemes.rb:
--------------------------------------------------------------------------------
1 | module Terminalwire::Client::Entitlement
2 | # URLs the server can open on the client.
3 | class Schemes
4 | include Enumerable
5 |
6 | def initialize
7 | @permitted = Set.new
8 | end
9 |
10 | def each(&)
11 | @permitted.each(&)
12 | end
13 |
14 | def permit(scheme)
15 | @permitted << scheme.to_s
16 | end
17 |
18 | def permitted?(url)
19 | include? URI(url).scheme
20 | end
21 |
22 | def serialize
23 | map { |scheme| { scheme: } }
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/lib/terminalwire/client/exec.rb:
--------------------------------------------------------------------------------
1 | require "pathname"
2 | require "yaml"
3 | require "uri"
4 |
5 | module Terminalwire::Client
6 | # Called by the `terminalwire-exec` shebang in scripts. This makes it easy for people
7 | # to create their own scripts that use Terminalwire that look like this:
8 | #
9 | # ```sh
10 | # #!/usr/bin/env terminalwire-exec
11 | # url: "https://terminalwire.com/terminal"
12 | # ```
13 | #
14 | # These files are saved, then `chmod + x` is run on them and they become executable.
15 | class Exec
16 | attr_reader :arguments, :path, :configuration, :url
17 |
18 | def initialize(path:, arguments:)
19 | @arguments = arguments
20 | @path = Pathname.new(path)
21 | @configuration = YAML.safe_load_file(@path)
22 | @url = URI(@configuration.fetch("url"))
23 | rescue Errno::ENOENT => e
24 | raise Terminalwire::Error, "File not found: #{@path}"
25 | rescue URI::InvalidURIError => e
26 | raise Terminalwire::Error, "Invalid URI: #{@url}"
27 | rescue KeyError => e
28 | raise Terminalwire::Error, "Missing key in configuration: #{e}"
29 | end
30 |
31 | def start
32 | Terminalwire::Client.websocket(url:, arguments:)
33 | end
34 |
35 | def self.start
36 | case ARGV
37 | in path, *arguments
38 | new(path:, arguments:).start
39 | end
40 | rescue NoMatchingPatternError => e
41 | raise Terminalwire::Error, "Launched with incorrect arguments: #{ARGV}"
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/lib/terminalwire/client/handler.rb:
--------------------------------------------------------------------------------
1 | module Terminalwire::Client
2 | # The handler is the main class that connects to the Terminalwire server and
3 | # dispatches messages to the appropriate resources.
4 | class Handler
5 | # The version of the Terminalwire client.
6 | VERSION = Terminalwire::VERSION
7 |
8 | include Terminalwire::Logging
9 |
10 | attr_reader :adapter, :resources, :endpoint
11 | attr_accessor :entitlement
12 |
13 | def initialize(adapter, arguments: ARGV, program_name: $0, endpoint:)
14 | @endpoint = endpoint
15 | @adapter = adapter
16 | @program_arguments = arguments
17 | @program_name = program_name
18 | @entitlement = Entitlement::Policy.resolve(authority: @endpoint.authority)
19 |
20 | yield self if block_given?
21 |
22 | @resources = Resource::Handler.new do |it|
23 | it << Resource::STDOUT.new("stdout", @adapter, entitlement:)
24 | it << Resource::STDIN.new("stdin", @adapter, entitlement:)
25 | it << Resource::STDERR.new("stderr", @adapter, entitlement:)
26 | it << Resource::Browser.new("browser", @adapter, entitlement:)
27 | it << Resource::File.new("file", @adapter, entitlement:)
28 | it << Resource::Directory.new("directory", @adapter, entitlement:)
29 | it << Resource::EnvironmentVariable.new("environment_variable", @adapter, entitlement:)
30 | end
31 | end
32 |
33 | def verify_license
34 | # Connect to the Terminalwire license server to verify the URL endpoint
35 | # and displays a message to the user, if any are present.
36 | $stdout.print ServerLicenseVerification.new(url: @endpoint.to_url).message
37 | rescue
38 | $stderr.puts "Failed to verify server license."
39 | end
40 |
41 | def connect
42 | verify_license
43 |
44 | @adapter.write(
45 | event: "initialization",
46 | protocol: { version: VERSION },
47 | entitlement: @entitlement.serialize,
48 | program: {
49 | name: @program_name,
50 | arguments: @program_arguments
51 | }
52 | )
53 |
54 | loop do
55 | handle @adapter.read
56 | end
57 | end
58 |
59 | def handle(message)
60 | case message
61 | in { event: "resource", action: "command", name:, parameters: }
62 | @resources.dispatch(**message)
63 | in { event: "exit", status: }
64 | exit Integer(status)
65 | end
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/lib/terminalwire/client/resource.rb:
--------------------------------------------------------------------------------
1 | require "fileutils"
2 | require "io/console"
3 |
4 | module Terminalwire::Client::Resource
5 | # Dispatches messages from the Client::Handler to the appropriate resource.
6 | class Handler
7 | include Enumerable
8 |
9 | def initialize
10 | @resources = {}
11 | yield self if block_given?
12 | end
13 |
14 | def each(&block)
15 | @resources.values.each(&block)
16 | end
17 |
18 | def add(resource)
19 | # Detect if the resource is already registered and throw an error
20 | if @resources.key?(resource.name)
21 | raise "Resource #{resource.name} already registered"
22 | else
23 | @resources[resource.name] = resource
24 | end
25 | end
26 | alias :<< :add
27 |
28 | def dispatch(**message)
29 | case message
30 | in { event:, action:, name:, command:, parameters: }
31 | resource = @resources.fetch(name)
32 | resource.command(command, **parameters)
33 | end
34 | end
35 | end
36 |
37 | # Dispatcher, security, and response macros for resources.
38 | class Base < Terminalwire::Resource::Base
39 | def initialize(*, entitlement:, **)
40 | super(*, **)
41 | @entitlement = entitlement
42 | connect
43 | end
44 |
45 | def command(command, **parameters)
46 | begin
47 | if permit(command, **parameters)
48 | succeed self.public_send(command, **parameters)
49 | else
50 | fail "Client denied #{command}", command:, parameters:
51 | end
52 | rescue => e
53 | fail e.message, command:, parameters:
54 | raise
55 | end
56 | end
57 |
58 | protected
59 |
60 | def permit(...)
61 | false
62 | end
63 | end
64 |
65 | class EnvironmentVariable < Base
66 | # Accepts a list of environment variables to permit.
67 | def read(name:)
68 | ENV[name]
69 | end
70 |
71 | # def write(name:, value:)
72 | # ENV[name] = value
73 | # end
74 |
75 | protected
76 |
77 | def permit(command, name:, **)
78 | @entitlement.environment_variables.permitted? name
79 | end
80 | end
81 |
82 | class STDOUT < Base
83 | def connect
84 | @io = $stdout
85 | end
86 |
87 | def print(data:)
88 | @io.print(data)
89 | end
90 |
91 | def print_line(data:)
92 | @io.puts(data)
93 | end
94 |
95 | protected
96 |
97 | def permit(...)
98 | true
99 | end
100 | end
101 |
102 | class STDERR < STDOUT
103 | def connect
104 | @io = $stderr
105 | end
106 | end
107 |
108 | class STDIN < Base
109 | def connect
110 | @io = $stdin
111 | end
112 |
113 | def read_line
114 | @io.gets
115 | end
116 |
117 | def read_password
118 | @io.getpass
119 | end
120 |
121 | protected
122 |
123 | def permit(...)
124 | true
125 | end
126 | end
127 |
128 | class File < Base
129 | File = ::File
130 |
131 | def read(path:)
132 | File.read File.expand_path(path)
133 | end
134 |
135 | def write(path:, content:, mode: nil)
136 | File.open(File.expand_path(path), "w", mode) { |f| f.write(content) }
137 | end
138 |
139 | def append(path:, content:, mode: nil)
140 | File.open(File.expand_path(path), "a", mode) { |f| f.write(content) }
141 | end
142 |
143 | def delete(path:)
144 | File.delete File.expand_path(path)
145 | end
146 |
147 | def exist(path:)
148 | File.exist? File.expand_path(path)
149 | end
150 |
151 | def change_mode(path:, mode:)
152 | File.chmod mode, File.expand_path(path)
153 | end
154 |
155 | protected
156 |
157 | def permit(command, path:, mode: nil, **)
158 | @entitlement.paths.permitted? path, mode:
159 | end
160 | end
161 |
162 | class Directory < Base
163 | File = ::File
164 |
165 | def list(path:)
166 | Dir.glob path
167 | end
168 |
169 | def create(path:)
170 | FileUtils.mkdir_p File.expand_path(path)
171 | rescue Errno::EEXIST
172 | # Do nothing
173 | end
174 |
175 | def exist(path:)
176 | Dir.exist? path
177 | end
178 |
179 | def delete(path:)
180 | Dir.delete path
181 | end
182 |
183 | protected
184 |
185 | def permit(command, path:, **)
186 | @entitlement.paths.permitted? path
187 | end
188 | end
189 |
190 | class Browser < Base
191 | def launch(url:)
192 | Launchy.open(URI(url))
193 | # TODO: This is a hack to get the `respond` method to work.
194 | # Maybe explicitly call a `suceed` and `fail` method?
195 | nil
196 | end
197 |
198 | protected
199 |
200 | def permit(command, url:, **)
201 | @entitlement.schemes.permitted? url
202 | end
203 | end
204 | end
205 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/lib/terminalwire/client/server_license_verification.rb:
--------------------------------------------------------------------------------
1 | require "async/http/internet"
2 | require "base64"
3 | require "uri"
4 | require "fileutils"
5 |
6 | module Terminalwire::Client
7 | # Checkes the server for a license verification at `https://terminalwire.com/licenses/verifications/`
8 | # and displays the message to the user, if necessary.
9 | class ServerLicenseVerification
10 | include Terminalwire::Logging
11 |
12 | def initialize(url:)
13 | @url = URI(url)
14 | @internet = Async::HTTP::Internet.new
15 | @cache_store = Terminalwire::Cache::File::Store.new(path: Terminalwire::Client.root_path.join("cache/licenses/verifications"))
16 | end
17 |
18 | def key
19 | Base64.urlsafe_encode64 @url
20 | end
21 |
22 | def cache = @cache_store.entry key
23 |
24 | def payload
25 | if cache.miss?
26 | logger.debug "Stale verification. Requesting new verification."
27 | request do |response|
28 | # Set the expiry on the file cache for the header.
29 | if max_age = response.headers["cache-control"].max_age
30 | logger.debug "Caching for #{max_age}"
31 | cache.expires = Time.now + max_age
32 | end
33 |
34 | # Process based on the response code.
35 | case response.status
36 | in 200
37 | logger.debug "License for #{@url} found."
38 | data = self.class.unpack response.read
39 | cache.value = data
40 | return data
41 | in 404
42 | logger.debug "License for #{@url} not found."
43 | return self.class.unpack response.read
44 | end
45 | end
46 | else
47 | return cache.value
48 | end
49 | end
50 |
51 | def message
52 | payload.dig(:shell, :output)
53 | end
54 |
55 | protected
56 |
57 | def verification_url
58 | Terminalwire.url
59 | .path("/licenses/verifications", key)
60 | end
61 |
62 | def request(&)
63 | logger.debug "Requesting license verification from #{verification_url}."
64 | response = @internet.get verification_url, {
65 | "Accept" => "application/x-msgpack",
66 | "User-Agent" => "Terminalwire/#{Terminalwire::VERSION} Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})",
67 | }, &
68 | end
69 |
70 | def self.unpack(pack)
71 | MessagePack.unpack(pack, symbolize_keys: true)
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/spec/entitlement/environment_variables_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | RSpec.describe Terminalwire::Client::Entitlement::EnvironmentVariables do
4 | let(:environment_veriables) { described_class.new }
5 | let(:variable) { "my-variable" }
6 | before { subject.permit(variable) }
7 | subject { environment_veriables }
8 |
9 | describe "#permit" do
10 | it "adds and upcases variables to the @permitted list" do
11 | expect(subject).to include(variable)
12 | end
13 | end
14 |
15 | describe "#permitted?" do
16 | it "returns true if the variable matches any permitted variable" do
17 | expect(subject.permitted?("my-variable")).to be_truthy
18 | end
19 |
20 | it "returns false if the variable does not match any permitted variable" do
21 | expect(subject.permitted?("not_my-variable")).to be_falsey
22 | end
23 | end
24 |
25 | describe "#serialize" do
26 | it "returns an array of serialized variables" do
27 | expect(subject.serialize).to eq([
28 | { name: "my-variable" }
29 | ])
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/spec/entitlement/paths_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | RSpec.describe Terminalwire::Client::Entitlement::Paths::Permit do
4 | let(:permit) { described_class.new(path:) }
5 | let(:path) { "/some/path" }
6 | describe "#permitted_path?" do
7 | it "permits /some/path" do
8 | expect(permit.permitted_path?("/some/path")).to be_truthy
9 | end
10 | it "does not permit /some/path/far/far/away" do
11 | expect(permit.permitted_path?("/some/path/far/far/away")).to be_falsey
12 | end
13 | it "does not permit /another/path" do
14 | expect(permit.permitted_path?("/another/path")).to be_falsey
15 | end
16 | end
17 | describe "#permitted_mode?" do
18 | context "default MODE = '0o600'" do
19 | it "permits 0o600" do
20 | expect(permit.permitted_mode?(0o600)).to be_truthy
21 | end
22 | it "permits 0o500" do
23 | expect(permit.permitted_mode?(0o500)).to be_truthy
24 | end
25 | it "does not permit 0o700" do
26 | expect(permit.permitted_mode?(0o700)).to be_falsey
27 | end
28 | it "does not permit 0o601" do
29 | expect(permit.permitted_mode?(0o601)).to be_falsey
30 | end
31 | it "does not permit 0o610" do
32 | expect(permit.permitted_mode?(0o610)).to be_falsey
33 | end
34 | it "does not permit 0o501" do
35 | expect(permit.permitted_mode?(0o501)).to be_falsey
36 | end
37 | end
38 | context "mode: 0o700" do
39 | let(:permit) { described_class.new(path:, mode: 0o700) }
40 | it "permits 0o700" do
41 | expect(permit.permitted_mode?(0o700)).to be_truthy
42 | end
43 | it "permits 0o600" do
44 | expect(permit.permitted_mode?(0o600)).to be_truthy
45 | end
46 | it "does not permit 0o701" do
47 | expect(permit.permitted_mode?(0o701)).to be_falsey
48 | end
49 | end
50 | context "mode: 0o005" do
51 | let(:permit) { described_class.new(path:, mode: 0o005) }
52 | it "permits 0o005" do
53 | expect(permit.permitted_mode?(0o005)).to be_truthy
54 | end
55 | it "permits 0o003" do
56 | expect(permit.permitted_mode?(0o003)).to be_truthy
57 | end
58 | it "does not permit 0o007" do
59 | expect(permit.permitted_mode?(0o007)).to be_falsey
60 | end
61 | it "does not permit 0o600" do
62 | expect(permit.permitted_mode?(0o600)).to be_falsey
63 | end
64 | it "does not permit 0o105" do
65 | expect(permit.permitted_mode?(0o105)).to be_falsey
66 | end
67 | end
68 | end
69 | describe "boundaries" do
70 | context "mode: -1" do
71 | let(:permit) { described_class.new(path:, mode: -1) }
72 | it "does not permit 0o005" do
73 | expect{permit.permitted_mode?(0o005)}.to raise_error(ArgumentError)
74 | end
75 | end
76 | context "mode: 0o1000" do
77 | let(:permit) { described_class.new(path:, mode: 0o1000) }
78 | it "does not permit 0o1000" do
79 | expect{permit.permitted_mode?(0o005)}.to raise_error(ArgumentError)
80 | end
81 | end
82 | context "mode: 0o777" do
83 | let(:permit) { described_class.new(path:, mode: 0o777) }
84 | it "permits 0o777" do
85 | expect(permit.permitted_mode?(0o777)).to be_truthy
86 | end
87 | end
88 | context "mode: 0o000" do
89 | let(:permit) { described_class.new(path:, mode: 0o000) }
90 | it "permits 0o000" do
91 | expect(permit.permitted_mode?(0o000)).to be_truthy
92 | end
93 | end
94 | end
95 | end
96 |
97 | RSpec.describe Terminalwire::Client::Entitlement::Paths do
98 | let(:paths) { described_class.new }
99 |
100 | describe "#permit" do
101 | it "adds a permitted path to the @permitted list" do
102 | path = "/some/path"
103 | expanded_path = Pathname.new(path).expand_path
104 |
105 | paths.permit(path)
106 | expect(paths.map(&:path)).to include(expanded_path)
107 | end
108 | end
109 |
110 | describe "#permitted?" do
111 | before do
112 | paths.permit("/approved/path/**/*")
113 | end
114 |
115 | it "returns true if the path matches any permitted path" do
116 | expect(paths.permitted?("/approved/path/file.txt")).to be_truthy
117 | end
118 |
119 | it "returns false if the path does not match any permitted path" do
120 | expect(paths.permitted?("/unapproved/path/file.txt")).to be_falsey
121 | end
122 | end
123 | end
124 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/spec/entitlement/policy_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | RSpec.describe Terminalwire::Client::Entitlement::Policy::Base do
4 | let(:authority) { "localhost:3000" }
5 | let(:entitlement) { described_class.new(authority: authority) }
6 |
7 | describe "#initialize" do
8 | it "sets the authority attribute" do
9 | expect(entitlement.authority).to eq(authority)
10 | end
11 |
12 | it "initializes the paths and permits the domain directory" do
13 | permitted_path = Terminalwire::Client.root_path.join("authorities/#{authority}/storage/junk.txt")
14 | expect(entitlement.paths.permitted?(permitted_path)).to be_truthy
15 | end
16 |
17 | it "initializes the paths and permits the domain directory" do
18 | permitted_path = Terminalwire::Client.root_path.join("authorities/#{authority}/storage")
19 | expect(entitlement.paths.permitted?(permitted_path)).to be_truthy
20 | end
21 |
22 | it "initializes the paths and permits the http scheme" do
23 | permitted_url = "http://#{authority}"
24 | expect(entitlement.schemes.permitted?(permitted_url)).to be_truthy
25 | end
26 |
27 | it "initializes the paths and permits the https scheme" do
28 | permitted_url = "https://#{authority}"
29 | expect(entitlement.schemes.permitted?(permitted_url)).to be_truthy
30 | end
31 | end
32 |
33 | describe "#serialize" do
34 | it "returns a hash with the authority" do
35 | expect(entitlement.serialize).to eq(
36 | authority: "localhost:3000",
37 | schemes: [
38 | { scheme: "http" },
39 | { scheme: "https"}
40 | ],
41 | paths: [
42 | {
43 | location: Terminalwire::Client.root_path.join("authorities/localhost:3000/storage").to_s,
44 | mode: 384
45 | },
46 | {
47 | location: Terminalwire::Client.root_path.join("authorities/localhost:3000/storage/**/*").to_s,
48 | mode: 384
49 | }
50 | ],
51 | environment_variables: [
52 | { name: "TERMINALWIRE_HOME" }
53 | ]
54 | )
55 | end
56 | end
57 |
58 | describe ".resolve" do
59 | let(:authority) { "example.com:8080" }
60 |
61 | it "creates a new Entitlement object from a URL" do
62 | entitlement_resolve = Terminalwire::Client::Entitlement::Policy.resolve(authority:)
63 | expect(entitlement_resolve.authority).to eq("example.com:8080")
64 | end
65 |
66 | context "terminalwire.com" do
67 | let(:authority) { "terminalwire.com" }
68 | let(:entitlement) { Terminalwire::Client::Entitlement::Policy.resolve(authority:) }
69 | it "returns Policy::Root" do
70 | expect(entitlement).to be_a Terminalwire::Client::Entitlement::Policy::Root
71 | end
72 | describe "~/.terminalwire/bin" do
73 | it "has access to directory" do
74 | expect(entitlement.paths.permitted?(Terminalwire::Client.root_path.join("bin"))).to be_truthy
75 | end
76 | it "has change mode to executable permit" do
77 | expect(entitlement.paths.permitted?(Terminalwire::Client.root_path.join("bin/my-app"), mode: 0o755)).to be_truthy
78 | end
79 | end
80 | end
81 | end
82 | end
83 |
84 | RSpec.describe Terminalwire::Client::Entitlement::Policy::Root do
85 | let(:authority) { "terminalwire.com" }
86 | let(:entitlement) { described_class.new }
87 |
88 | describe "#initialize" do
89 | it "permits paths to authorities directory" do
90 | permitted_path = Terminalwire::Client.root_path.join("authorities/example.com/storage/junk.txt")
91 | expect(entitlement.paths.permitted?(permitted_path)).to be_truthy
92 | end
93 |
94 | it "permits paths to bin directory" do
95 | permitted_path = Terminalwire::Client.root_path.join("bin/example")
96 | expect(entitlement.paths.permitted?(permitted_path)).to be_truthy
97 | end
98 |
99 | it "denies paths to root directory" do
100 | permitted_path = Pathname.new("/")
101 | expect(entitlement.paths.permitted?(permitted_path)).to be_falsey
102 | end
103 |
104 | describe "environment variables" do
105 | subject { entitlement.environment_variables }
106 | it "permits SHELL" do
107 | expect(subject.permitted?("SHELL")).to be_truthy
108 | end
109 | it "permits PATH" do
110 | expect(subject.permitted?("PATH")).to be_truthy
111 | end
112 | end
113 | end
114 |
115 | describe ".resolve" do
116 | it "returns Policy::Root" do
117 | url = URI("https://terminalwire.com")
118 | expect(Terminalwire::Client::Entitlement::Policy.resolve(authority:)).to be_a Terminalwire::Client::Entitlement::Policy::Root
119 | end
120 | end
121 |
122 | describe "permitted paths" do
123 | it "shell initialization file paths" do
124 | expect(entitlement.paths.permitted?("~/.zprofile")).to be_truthy
125 | end
126 | end
127 | end
128 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/spec/entitlement/schemes_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | RSpec.describe Terminalwire::Client::Entitlement::Schemes do
4 | let(:schemes) { described_class.new }
5 |
6 | describe "#permit" do
7 | it "adds a permitted path to the @permitted list" do
8 | scheme = "http"
9 | schemes.permit(scheme)
10 | expect(schemes).to include(scheme)
11 | end
12 | end
13 |
14 | describe "#permitted?" do
15 | before do
16 | schemes.permit("http")
17 | end
18 |
19 | it "returns true if the scheme matches any permitted scheme" do
20 | expect(schemes.permitted?("http://example.com/")).to be_truthy
21 | end
22 |
23 | it "returns false if the scheme does not match any permitted scheme" do
24 | expect(schemes.permitted?("file:///secret-password")).to be_falsey
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/spec/entitlement_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | RSpec.describe Terminalwire::Client::Entitlement do
4 | end
5 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/spec/resource_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 | require "tmpdir"
3 |
4 | RSpec.describe Terminalwire::Client::Resource::File do
5 | let(:adapter) { Terminalwire::Adapter::Test.new }
6 | let(:entitlement) { Terminalwire::Client::Entitlement::Policy::Base.new(authority: "test") }
7 | let(:file) { Terminalwire::Client::Resource::File.new("file", adapter, entitlement:) }
8 | let(:response) { adapter.response }
9 | subject { response }
10 | before { FileUtils.mkdir_p(entitlement.storage_path) }
11 | after { FileUtils.rm_rf(entitlement.storage_path) }
12 |
13 | around do |example|
14 | original = ENV["TERMINALWIRE_HOME"]
15 | ENV["TERMINALWIRE_HOME"] = Dir.mktmpdir
16 |
17 | example.run
18 |
19 | ENV["TERMINALWIRE_HOME"] = original
20 | end
21 |
22 | describe "#write" do
23 | context "unpermitted path" do
24 | before{ file.command("write", path: "/usr/bin/howdy.txt") }
25 | it { is_expected.to include(
26 | event: "resource",
27 | response: "Client denied write",
28 | status: "failure",
29 | name: "file",
30 | command: "write",
31 | parameters: {
32 | path: "/usr/bin/howdy.txt"
33 | })
34 | }
35 | end
36 |
37 | context "permitted path" do
38 | describe "permitted implicit mode" do
39 | before {
40 | file.command(
41 | "write",
42 | path: Terminalwire::Client.root_path.join("authorities/test/storage/howdy.txt").to_s,
43 | content: ""
44 | )
45 | }
46 | it {
47 | is_expected.to include(
48 | event: "resource",
49 | status: "success",
50 | name: "file")
51 | }
52 | end
53 |
54 | describe "permitted explicit mode" do
55 | before {
56 | file.command(
57 | "write",
58 | path: Terminalwire::Client.root_path.join("authorities/test/storage/howdy.txt").to_s,
59 | content: "",
60 | mode: 0o500
61 | )
62 | }
63 | it { is_expected.to include(
64 | event: "resource",
65 | status: "success",
66 | name: "file")
67 | }
68 | end
69 |
70 | describe "unpermitted explicit mode" do
71 | before {
72 | file.command(
73 | "write",
74 | path: Terminalwire::Client.root_path.join("authorities/test/storage/howdy.txt").to_s,
75 | content: "",
76 | mode: 0o700
77 | )
78 | }
79 |
80 | it { is_expected.to include(
81 | event: "resource",
82 | response: "Client denied write",
83 | status: "failure",
84 | name: "file",
85 | command: "write",
86 | parameters: {
87 | path: Terminalwire::Client.root_path.join("authorities/test/storage/howdy.txt").to_s,
88 | mode: 0o700,
89 | content: ""
90 | })
91 | }
92 | end
93 | end
94 | end
95 |
96 | describe "#change_mode" do
97 | let(:path) { Terminalwire::Client.root_path.join("authorities/test/storage/howdy.txt").to_s }
98 | before { file.command("write", path:, content: "") }
99 | before { file.command("change_mode", path:, mode:) }
100 |
101 | context "permitted_mode" do
102 | let(:mode) { 0o500 }
103 | it { is_expected.to include(
104 | event: "resource",
105 | status: "success",
106 | name: "file")
107 | }
108 | end
109 | context "unpermitted mode" do
110 | let(:mode) { 0o700 }
111 | it { is_expected.to include(
112 | command: "change_mode",
113 | event: "resource",
114 | name: "file",
115 | status: "success",
116 | parameters: {
117 | mode: 448,
118 | path: Terminalwire::Client.root_path.join("authorities/test/storage/howdy.txt").to_s
119 | },
120 | response: "Client denied change_mode",
121 | status: "failure")
122 | }
123 | end
124 | end
125 | end
126 |
127 | RSpec.describe Terminalwire::Client::Resource::Directory do
128 | let(:adapter) { Terminalwire::Adapter::Test.new }
129 | let(:entitlement) { Terminalwire::Client::Entitlement::Policy::Base.new(authority: "test") }
130 | let(:directory) { Terminalwire::Client::Resource::Directory.new("directory", adapter, entitlement:) }
131 | let(:response) { adapter.response }
132 | before { FileUtils.mkdir_p(entitlement.storage_path) }
133 | after { FileUtils.rm_rf(entitlement.storage_path) }
134 | subject { response }
135 |
136 | describe "#create" do
137 | context "unpermitted access" do
138 | before{ directory.command("create", path: "/usr/bin/howdy") }
139 | it { is_expected.to include(
140 | event: "resource",
141 | response: "Client denied create",
142 | status: "failure",
143 | name: "directory",
144 | command: "create",
145 | parameters: {
146 | path: "/usr/bin/howdy"
147 | })
148 | }
149 | end
150 |
151 | context "permitted access" do
152 | before{
153 | directory.command("create",
154 | path: Terminalwire::Client.root_path.join("authorities/test/storage/howdy").to_s
155 | )
156 | }
157 | it { is_expected.to include(
158 | event: "resource",
159 | status: "success",
160 | name: "directory")
161 | }
162 | end
163 | end
164 | end
165 |
166 | RSpec.describe Terminalwire::Client::Resource::Browser do
167 | let(:adapter) { Terminalwire::Adapter::Test.new }
168 | let(:entitlement) { Terminalwire::Client::Entitlement::Policy::Base.new(authority: "test") }
169 | let(:browser) { Terminalwire::Client::Resource::Browser.new("browser", adapter, entitlement:) }
170 | let(:response) { adapter.response }
171 | subject { response }
172 |
173 | describe "#launch" do
174 | context "unpermitted scheme" do
175 | before{ browser.command("launch", url: "file:///usr/bin/env") }
176 | it { is_expected.to include(
177 | event: "resource",
178 | response: "Client denied launch",
179 | status: "failure",
180 | name: "browser",
181 | command: "launch",
182 | parameters: {
183 | url: "file:///usr/bin/env"
184 | })
185 | }
186 | end
187 |
188 | context "permitted scheme" do
189 | # Intercept the call that actually launches the browser window.
190 | before { expect(Launchy).to receive(:open).once }
191 | before{ browser.command("launch", url: "http://example.com") }
192 | it { is_expected.to include(
193 | event: "resource",
194 | status: "success",
195 | name: "browser")
196 | }
197 | end
198 | end
199 | end
200 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | require_relative "../../../spec/spec_helper.rb"
2 |
--------------------------------------------------------------------------------
/gem/terminalwire-client/terminalwire-client.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | core = Gem::Specification.load File.expand_path("../terminalwire-core/terminalwire-core.gemspec", __dir__)
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "terminalwire-client"
7 | spec.version = core.version
8 | spec.authors = core.authors
9 | spec.email = core.email
10 |
11 | spec.summary = core.summary
12 | spec.description = core.description
13 | spec.homepage = core.homepage
14 | spec.license = core.license
15 | spec.required_ruby_version = core.required_ruby_version
16 |
17 | spec.metadata = core.metadata
18 | spec.metadata["source_code_uri"] = "https://github.com/terminalwire/ruby/tree/main/#{spec.name}"
19 |
20 | # Specify which files should be added to the gem when it is released.
21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22 | # Specify which files should be added to the gem when it is released.
23 | spec.files = (
24 | Dir.glob("{lib,exe,ext}/**/*") + Dir.glob("{README*,LICENSE*}")
25 | ).select { |f| File.file?(f) }
26 | spec.require_paths = core.require_paths
27 |
28 | # Uncomment to register a new dependency of your gem
29 | spec.add_dependency "launchy", "~> 3.0"
30 | spec.add_dependency "terminalwire-core", core.version
31 | end
32 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/lib/terminalwire-core.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'terminalwire'
4 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/lib/terminalwire.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "terminalwire/version"
4 |
5 | require 'forwardable'
6 | require 'uri'
7 |
8 | require 'async'
9 | require 'async/http/endpoint'
10 | require 'async/websocket/client'
11 | require 'async/websocket/adapters/rack'
12 | require 'uri-builder'
13 |
14 | require "zeitwerk"
15 | Zeitwerk::Loader.for_gem.tap do |loader|
16 | loader.ignore File.join(__dir__, "terminalwire-core.rb")
17 | loader.setup
18 | end
19 |
20 | module Terminalwire
21 | class Error < StandardError; end
22 |
23 | # Used by Terminalwire client to connect to Terminalire.com for license
24 | # validations, etc.
25 | TERMINALWIRE_URL = "https://terminalwire.com".freeze
26 | def self.url = URI.build(TERMINALWIRE_URL)
27 |
28 | module Resource
29 | class Base
30 | attr_reader :name, :adapter
31 |
32 | def initialize(name, adapter)
33 | @name = name.to_s
34 | @adapter = adapter
35 | end
36 |
37 | def connect; end
38 | def disconnect; end
39 |
40 | def fail(response, **data)
41 | respond(status: "failure", response:, **data)
42 | end
43 |
44 | def succeed(response, **data)
45 | respond(status: "success", response:, **data)
46 | end
47 |
48 | private
49 |
50 | def respond(**response)
51 | adapter.write(event: "resource", name: @name, **response)
52 | end
53 | end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/lib/terminalwire/adapter.rb:
--------------------------------------------------------------------------------
1 | require 'msgpack'
2 |
3 | module Terminalwire::Adapter
4 | # Works with Test, TCP, Unix, WebSocket, and other socket-like abstractions.
5 | class Socket
6 | include Terminalwire::Logging
7 |
8 | attr_reader :transport
9 |
10 | def initialize(transport)
11 | @transport = transport
12 | end
13 |
14 | def write(data)
15 | logger.debug "Adapter: Sending #{data.inspect}"
16 | packed_data = MessagePack.pack(data, symbolize_keys: true)
17 | @transport.write(packed_data)
18 | end
19 |
20 | def read
21 | logger.debug "Adapter: Reading"
22 | packed_data = @transport.read
23 | return nil if packed_data.nil?
24 | data = MessagePack.unpack(packed_data, symbolize_keys: true)
25 | logger.debug "Adapter: Received #{data.inspect}"
26 | data
27 | end
28 |
29 | def close
30 | @transport.close
31 | end
32 | end
33 |
34 | # This is a test adapter that can be used for testing purposes.
35 | class Test
36 | attr_reader :responses
37 |
38 | def initialize(responses = [])
39 | @responses = responses
40 | end
41 |
42 | def write(**data)
43 | @responses << data
44 | end
45 |
46 | def response
47 | @responses.pop
48 | end
49 |
50 | def close
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/lib/terminalwire/binary.rb:
--------------------------------------------------------------------------------
1 | require "uri"
2 | require "yaml"
3 |
4 | module Terminalwire
5 | # Generates Terminalwire binary file stubs. These files then run using
6 | # the `terminalwire-exec` command.
7 | class Binary
8 | SHEBANG = "#!/usr/bin/env terminalwire-exec".freeze
9 |
10 | ASSIGNABLE_KEYS = %w[url]
11 |
12 | attr_reader :url
13 |
14 | def initialize(url: nil)
15 | self.url = url if url
16 | end
17 |
18 | def url=(value)
19 | @url = URI(value)
20 | end
21 |
22 | def body
23 | <<~BASH
24 | #{SHEBANG}
25 | url: "#{url.to_s}"
26 | BASH
27 | end
28 |
29 | def assign(**hash)
30 | ASSIGNABLE_KEYS.each do |key|
31 | public_send "#{key}=", hash[key] if hash.key? key
32 | end
33 | self
34 | end
35 |
36 | # Writes the binary to the given path.
37 | def write(path)
38 | File.open(path, "w") do |file|
39 | file.write body
40 | file.chmod 0755
41 | end
42 | end
43 |
44 | def self.open(path)
45 | new.assign **YAML.safe_load_file(path)
46 | end
47 |
48 | def self.write(url:, to:)
49 | new(url: url).write to
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/lib/terminalwire/cache.rb:
--------------------------------------------------------------------------------
1 | require "pathname"
2 | require "msgpack"
3 | require "base64"
4 | require "time"
5 | require "fileutils"
6 |
7 | # Caches used on the client side for licesens, HTTP requests, etc.
8 | module Terminalwire::Cache
9 | module File
10 | # Hoist the File class to avoid conflicts with the standard library.
11 | File = ::File
12 |
13 | class Store
14 | include Enumerable
15 |
16 | def initialize(path:)
17 | @path = Pathname.new(path).expand_path
18 | FileUtils.mkdir_p(@path) unless @path.directory?
19 | end
20 |
21 | def entry(key)
22 | Entry.new(path: @path.join(Entry.key_path(key)))
23 | end
24 | alias :[] :entry
25 |
26 | def evict
27 | each(&:evict)
28 | end
29 |
30 | def destroy
31 | each(&:destroy)
32 | end
33 |
34 | def each
35 | @path.each_child do |path|
36 | yield Entry.new(path:)
37 | end
38 | end
39 | end
40 |
41 | class Entry
42 | VERSION = "1.0"
43 |
44 | def self.key_path(value)
45 | Base64.urlsafe_encode64(value)
46 | end
47 |
48 | attr_accessor :value, :expires
49 |
50 | def initialize(path:)
51 | @path = path
52 | deserialize if persisted?
53 | end
54 |
55 | def nil?
56 | @value.nil?
57 | end
58 |
59 | def present?
60 | not nil?
61 | end
62 |
63 | def persisted?
64 | File.exist? @path
65 | end
66 |
67 | def expired?(time: Time.now)
68 | @expires && @expires < time.utc
69 | end
70 |
71 | def fresh?(...)
72 | not expired?(...)
73 | end
74 |
75 | def hit?
76 | persisted? and fresh?
77 | end
78 |
79 | def miss?
80 | not hit?
81 | end
82 |
83 | def save
84 | File.open(@path, "wb") do |file| # Write in binary mode
85 | file.write(serialize)
86 | end
87 | end
88 |
89 | def evict
90 | destroy if expired?
91 | end
92 |
93 | def deserialize
94 | case MessagePack.unpack(File.open(@path, "rb", &:read), symbolize_keys: true)
95 | in { value:, expires:, version: VERSION }
96 | @value = value
97 | @expires = Time.parse(expires).utc if expires
98 | end
99 | end
100 |
101 | def destroy
102 | File.delete(@path)
103 | end
104 |
105 | private
106 |
107 | def serialize
108 | MessagePack.pack(
109 | value: @value,
110 | expires: @expires&.utc&.iso8601,
111 | version: VERSION
112 | )
113 | end
114 | end
115 | end
116 | end
117 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/lib/terminalwire/logging.rb:
--------------------------------------------------------------------------------
1 | require 'logger'
2 |
3 | module Terminalwire
4 | module Logging
5 | DEVICE = Logger.new($stdout, level: ENV.fetch("TERMINALWIRE_LOG_LEVEL", "info"))
6 | def logger = DEVICE
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/lib/terminalwire/shells.rb:
--------------------------------------------------------------------------------
1 | module Terminalwire
2 | module Shells
3 | # This is used to detect what the user is running for a shell. Terminalwire then
4 | # then uses this information to determine what files to write to for the root policy.
5 | #
6 | class Shell
7 | attr_reader :name, :login_files, :interactive_files, :logout_files
8 |
9 | def initialize(name)
10 | @name = name
11 | @login_files = Set.new
12 | @interactive_files = Set.new
13 | @logout_files = Set.new
14 | end
15 |
16 | class Configuration
17 | attr_reader :shell
18 |
19 | def initialize(shell, &block)
20 | @shell = shell
21 | instance_eval(&block) if block_given?
22 | end
23 |
24 | def login_file(*paths)
25 | shell.login_files.merge paths.flatten
26 | end
27 | alias :login_files :login_file
28 |
29 | def interactive_file(*paths)
30 | shell.interactive_files.merge paths.flatten
31 | end
32 | alias :interactive_files :interactive_file
33 |
34 | def logout_file(*paths)
35 | shell.logout_files.merge paths.flatten
36 | end
37 | alias :logout_files :logout_file
38 | end
39 |
40 | def configure(&block)
41 | Configuration.new(self, &block).shell
42 | end
43 | end
44 |
45 | # Encapsulates a collection of shells.
46 | class Collection
47 | attr_reader :shells
48 | include Enumerable
49 |
50 | def initialize
51 | @shells = []
52 | end
53 |
54 | def shell(name, &)
55 | shells << Shell.new(name).configure(&)
56 | end
57 |
58 | def each(&)
59 | shells.each(&)
60 | end
61 |
62 | def login_files
63 | shells.flat_map { |shell| shell.login_files.to_a }.reject(&:empty?)
64 | end
65 |
66 | def interactive_files
67 | shells.flat_map { |shell| shell.interactive_files.to_a }.reject(&:empty?)
68 | end
69 |
70 | def logout_files
71 | shells.flat_map { |shell| shell.logout_files.to_a }.reject(&:empty?)
72 | end
73 |
74 | def names
75 | shells.map(&:name)
76 | end
77 |
78 | def find_by_shell(name)
79 | shells.find { |shell| shell.name == name }
80 | end
81 |
82 | def find_by_shell_path(path)
83 | return if path.nil?
84 | find_by_shell(File.basename(path))
85 | end
86 |
87 | def files
88 | login_files + interactive_files + logout_files
89 | end
90 |
91 | def self.configure(&block)
92 | Collection.new.tap do |collection|
93 | collection.instance_eval(&block) if block_given?
94 | end
95 | end
96 | end
97 |
98 | All = Collection.configure do
99 | shell "bash" do
100 | login_files %w[~/.bash_profile ~/.bash_login ~/.profile]
101 | interactive_file "~/.bashrc"
102 | logout_file "~/.bash_logout"
103 | end
104 |
105 | shell "zsh" do
106 | login_files %w[~/.zprofile ~/.zshenv]
107 | interactive_file "~/.zshrc"
108 | logout_file "~/.zlogout"
109 | end
110 |
111 | shell "sh" do
112 | login_files %w[~/.profile]
113 | end
114 |
115 | shell "dash" do
116 | login_files %w[~/.profile]
117 | end
118 |
119 | shell "fish" do
120 | interactive_file "~/.config/fish/config.fish"
121 | end
122 |
123 | shell "ksh" do
124 | login_files %w[~/.profile]
125 | interactive_file "~/.kshrc"
126 | end
127 |
128 | shell "csh" do
129 | login_files %w[~/.cshrc ~/.login]
130 | interactive_file "~/.cshrc"
131 | logout_file "~/.logout"
132 | end
133 |
134 | shell "tcsh" do
135 | login_files %w[~/.cshrc ~/.login]
136 | interactive_file "~/.cshrc"
137 | logout_file "~/.logout"
138 | end
139 | end
140 | end
141 | end
142 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/lib/terminalwire/transport.rb:
--------------------------------------------------------------------------------
1 | require 'uri'
2 | require 'async/websocket/client'
3 |
4 | module Terminalwire
5 | module Transport
6 | class Base
7 | def self.connect(url)
8 | raise NotImplementedError, "Subclass must implement .connect"
9 | end
10 |
11 | def self.listen(url)
12 | raise NotImplementedError, "Subclass must implement .listen"
13 | end
14 |
15 | def read
16 | raise NotImplementedError, "Subclass must implement #read"
17 | end
18 |
19 | def write(data)
20 | raise NotImplementedError, "Subclass must implement #write"
21 | end
22 |
23 | def close
24 | raise NotImplementedError, "Subclass must implement #close"
25 | end
26 | end
27 |
28 | class WebSocket < Base
29 | include Logging
30 |
31 | def self.connect(url)
32 | uri = URI(url)
33 | endpoint = Async::HTTP::Endpoint.parse(uri)
34 | adapter = Async::WebSocket::Client.connect(endpoint)
35 | new(adapter)
36 | end
37 |
38 | def self.listen(url)
39 | # This would need to be implemented with a WebSocket server library
40 | raise NotImplementedError, "WebSocket server not implemented"
41 | end
42 |
43 | def initialize(websocket)
44 | logger.debug "Transport::WebSocket(#{object_id}): Initializing"
45 | @websocket = websocket
46 | end
47 |
48 | def read
49 | logger.debug "Transport::WebSocket(#{object_id}): Reading"
50 | @websocket.read&.buffer
51 | end
52 |
53 | def write(data)
54 | logger.debug "Transport::WebSocket(#{object_id}): Writing"
55 | @websocket.write(data)
56 | end
57 |
58 | def close
59 | logger.debug "Transport::WebSocket(#{object_id}): Closing"
60 | @websocket.close
61 | end
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/lib/terminalwire/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Terminalwire
4 | VERSION = "0.3.5.alpha2"
5 | end
6 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/spec/binary_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | RSpec.describe Terminalwire::Binary do
4 | let(:url) { "https://example.com" }
5 | let(:binary) { described_class.new(url: url) }
6 |
7 | describe "#body" do
8 | it "returns the correct body" do
9 | expect(binary.body).to eq <<~BASH
10 | #{described_class::SHEBANG}
11 | url: "#{url}"
12 | BASH
13 | end
14 | end
15 |
16 | context "file" do
17 | let(:path) { File.join(Dir.mktmpdir, "example") }
18 | before do
19 | binary.write(path)
20 | end
21 |
22 | describe "#write" do
23 | it "writes body to file" do
24 | expect(File.read(path)).to eq binary.body
25 | end
26 |
27 | it "is executable" do
28 | expect(File.executable?(path)).to be true
29 | end
30 | end
31 |
32 | describe ".read" do
33 | let(:opened) { described_class.open path }
34 | it "reads file" do
35 | expect(opened.url.to_s).to eq "https://example.com"
36 | end
37 | end
38 |
39 | after do
40 | File.delete(path) if File.exist?(path)
41 | end
42 | end
43 |
44 | describe ".write" do
45 | it "writes binary to file" do
46 | File.join(Dir.mktmpdir, "executable").tap do |to|
47 | described_class.write(url:, to:)
48 | expect(File.read(to)).to eq binary.body
49 | end
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/spec/cache_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 | require "tempfile"
3 |
4 | RSpec.describe Terminalwire::Cache::File::Store do
5 | let(:cache_dir) { Dir.mktmpdir }
6 | let(:store) { described_class.new(path: cache_dir) }
7 | let(:key) { "test_key" }
8 | let(:entry) { store.entry(key) }
9 |
10 | after do
11 | FileUtils.remove_entry(cache_dir)
12 | end
13 |
14 | describe "#find" do
15 | subject { entry }
16 |
17 | it { is_expected.to be_a(Terminalwire::Cache::File::Entry) }
18 |
19 | it "generates the correct file path" do
20 | expect(subject.instance_variable_get(:@path).to_s).to eq(
21 | File.join(cache_dir, Terminalwire::Cache::File::Entry.key_path(key))
22 | )
23 | end
24 | end
25 |
26 | describe "#each" do
27 | subject { store.to_a }
28 |
29 | context "with no entries" do
30 | it { is_expected.to be_empty }
31 | end
32 |
33 | context "with entries" do
34 | before { entry.save }
35 |
36 | it "yields each entry in the directory" do
37 | expect(subject.count).to eq(1)
38 | expect(subject.first).to be_a(Terminalwire::Cache::File::Entry)
39 | end
40 | end
41 | end
42 |
43 | describe "#evict" do
44 | before do
45 | entry.value = "test_data"
46 | entry.expires = Time.now.utc - 3600
47 | entry.save
48 | end
49 |
50 | it "removes expired entries" do
51 | expect { store.evict }.to change { entry.persisted? }.from(true).to(false)
52 | end
53 | end
54 |
55 | describe "#destroy" do
56 | before { entry.save }
57 |
58 | it "deletes all entries" do
59 | expect { store.destroy }.to change { entry.persisted? }.from(true).to(false)
60 | end
61 | end
62 | end
63 |
64 | RSpec.describe Terminalwire::Cache::File::Entry do
65 | let(:cache_dir) { Dir.mktmpdir }
66 | let(:store) { Terminalwire::Cache::File::Store.new(path: cache_dir) }
67 | let(:key) { "test_key" }
68 | let(:entry) { store.entry(key) }
69 | subject { entry }
70 |
71 | after do
72 | FileUtils.remove_entry(cache_dir)
73 | end
74 |
75 | describe "initial state" do
76 | it { is_expected.to be_miss }
77 | it { is_expected.to_not be_persisted }
78 | it { is_expected.to be_nil }
79 | end
80 |
81 | describe "#save" do
82 | let(:value) { "test_data" }
83 | let(:expires) { Time.now.utc + 3600 }
84 |
85 | before do
86 | entry.value = value
87 | entry.expires = expires
88 | entry.save
89 | end
90 |
91 | it { is_expected.to be_persisted }
92 | it { is_expected.to be_present }
93 | it { is_expected.to be_hit }
94 |
95 | it "writes data to the file" do
96 | expect(File.exist?(entry.instance_variable_get(:@path))).to be true
97 | end
98 |
99 | it "writes data to the file with correct encoding" do
100 | expect(File.open(entry.instance_variable_get(:@path), "rb", &:read).encoding).to eq(Encoding::ASCII_8BIT)
101 | end
102 |
103 | it "serializes data and expiration correctly" do
104 | serialized_data = MessagePack.unpack(File.read(entry.instance_variable_get(:@path)), symbolize_keys: true)
105 | expect(serialized_data[:value]).to eq(value)
106 | expect(Time.parse(serialized_data[:expires])).to be_within(1).of(expires)
107 | end
108 | end
109 |
110 | describe "#hit? and #miss?" do
111 | context "when the entry exists but is expired" do
112 | before do
113 | entry.value = "test_data"
114 | entry.expires = Time.now.utc - 3600
115 | entry.save
116 | end
117 |
118 | it { is_expected.to be_miss }
119 | it { is_expected.to_not be_hit }
120 | end
121 |
122 | context "when the entry exists and is not expired" do
123 | before do
124 | entry.value = "test_data"
125 | entry.expires = Time.now.utc + 3600
126 | entry.save
127 | end
128 |
129 | it { is_expected.to be_hit }
130 | it { is_expected.to_not be_miss }
131 | end
132 |
133 | context "when the entry has a nil value and is not expired" do
134 | before do
135 | entry.value = nil
136 | entry.expires = Time.now.utc + 3600
137 | entry.save
138 | end
139 |
140 | it { is_expected.to be_hit }
141 | it { is_expected.to_not be_miss }
142 | end
143 | end
144 |
145 | describe "#expired?" do
146 | context "when no expiration is set" do
147 | before { entry.value = "test_data" }
148 | it { is_expected.to_not be_expired }
149 | end
150 |
151 | context "when the entry is expired" do
152 | before do
153 | entry.value = "test_data"
154 | entry.expires = Time.now.utc - 3600
155 | end
156 | it { is_expected.to be_expired }
157 | end
158 |
159 | context "when the entry is not expired" do
160 | before do
161 | entry.value = "test_data"
162 | entry.expires = Time.now.utc + 3600
163 | end
164 | it { is_expected.to_not be_expired }
165 | end
166 | end
167 |
168 | describe "#deserialize" do
169 | let(:value) { "test_data" }
170 | let(:expires) { Time.now.utc + 3600 }
171 |
172 | before do
173 | entry.value = value
174 | entry.expires = expires
175 | entry.save
176 | end
177 |
178 | it "loads the value and expiration from the file" do
179 | new_entry = store.entry(key)
180 | expect(new_entry.value).to eq(value)
181 | expect(new_entry.expires).to be_within(1).of(expires)
182 | end
183 | end
184 |
185 | describe "#destroy" do
186 | before { entry.save }
187 |
188 | it "deletes the cache file" do
189 | expect { entry.destroy }.to change { File.exist?(entry.instance_variable_get(:@path)) }.from(true).to(false)
190 | end
191 | end
192 | end
193 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/spec/shell_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | RSpec.describe Terminalwire::Shells::All do
4 | subject { described_class }
5 | describe ".find_by_shell_path" do
6 | context "'/bin/bash'" do
7 | it "finds bash" do
8 | expect(subject.find_by_shell_path("/bin/bash").name).to eq "bash"
9 | end
10 | end
11 | end
12 | it "has shells" do
13 | expect(subject.names).to include "bash", "zsh", "fish"
14 | end
15 | it "has login_files" do
16 | expect(subject.login_files).to include "~/.bash_profile", "~/.zprofile"
17 | end
18 | it "has interactive_files" do
19 | expect(subject.interactive_files).to include "~/.bashrc", "~/.zshrc"
20 | end
21 | it "has logout_files" do
22 | expect(subject.logout_files).to include "~/.zlogout"
23 | end
24 | it "only has user files" do
25 | expect(subject.files).to all start_with "~/"
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | require_relative "../../../spec/spec_helper.rb"
2 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/spec/terminalwire_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Terminalwire do
4 | it "has a version number" do
5 | expect(Terminalwire::VERSION).not_to be nil
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/gem/terminalwire-core/terminalwire-core.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "lib/terminalwire/version"
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "terminalwire-core"
7 | spec.version = Terminalwire::VERSION
8 | spec.authors = ["Brad Gessler"]
9 | spec.email = ["brad@terminalwire.com"]
10 |
11 | spec.summary = "Ship a CLI for your web app. No API required."
12 | spec.description = "Stream command-line apps from your server without a web API"
13 | spec.homepage = "https://terminalwire.com/ruby"
14 | spec.license = "AGPL"
15 | spec.required_ruby_version = ">= 3.0.0"
16 |
17 | spec.metadata["allowed_push_host"] = "https://rubygems.org/"
18 |
19 | spec.metadata["homepage_uri"] = spec.homepage
20 | spec.metadata["source_code_uri"] = "https://github.com/terminalwire/ruby"
21 | spec.metadata["changelog_uri"] = "https://github.com/terminalwire/ruby/tags"
22 | spec.metadata["funding_uri"] = "https://terminalwire.com/funding"
23 |
24 | # Specify which files should be added to the gem when it is released.
25 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26 | # Specify which files should be added to the gem when it is released.
27 | spec.files = (
28 | Dir.glob("{lib,exe,ext}/**/*") + Dir.glob("{README*,LICENSE*}")
29 | ).select { |f| File.file?(f) }
30 | spec.require_paths = ["lib"]
31 |
32 | # Uncomment to register a new dependency of your gem
33 | spec.add_dependency "async-websocket", "~> 0.30"
34 | spec.add_dependency "zeitwerk", "~> 2.0"
35 | spec.add_dependency "msgpack", "~> 1.7"
36 | spec.add_dependency "uri-builder", "~> 0.1.9"
37 | spec.add_dependency "base64", "~> 0.2.0"
38 |
39 | spec.add_development_dependency "rake", "~> 13.0"
40 | spec.add_development_dependency "rspec", "~> 3.0"
41 |
42 | # For more information and examples about making a new gem, check out our
43 | # guide at: https://bundler.io/guides/creating_gem.html
44 | end
45 |
--------------------------------------------------------------------------------
/gem/terminalwire-rails/lib/generators/terminalwire/install/USAGE:
--------------------------------------------------------------------------------
1 | Description:
2 | Installs Terminalwire
3 |
4 | Example:
5 | bin/rails generate terminalwire:install
6 |
7 | This will create:
8 | app/terminal/application_terminal.rb
9 | bin/
10 |
--------------------------------------------------------------------------------
/gem/terminalwire-rails/lib/generators/terminalwire/install/install_generator.rb:
--------------------------------------------------------------------------------
1 | require "bundler"
2 |
3 | class Terminalwire::InstallGenerator < Rails::Generators::Base
4 | source_root File.expand_path("templates", __dir__)
5 |
6 | argument :binary_name, type: :string, required: true, banner: "binary_name"
7 |
8 | def create_terminal_files
9 | template "application_terminal.rb.tt", Rails.root.join("app/terminal/application_terminal.rb")
10 | template "main_terminal.rb", Rails.root.join("app/terminal/main_terminal.rb")
11 | end
12 |
13 | def create_binary_files
14 | copy_file "bin/terminalwire", binary_path
15 | chmod binary_path, 0755, verbose: false
16 | end
17 |
18 | def add_route
19 | route <<~ROUTE
20 | match "/terminal",
21 | to: Terminalwire::Rails::Thor.new(MainTerminal),
22 | via: [:get, :connect]
23 | ROUTE
24 | end
25 |
26 | def bundle_development_dependencies
27 | # Add the terminalwire gem to the development group in the Gemfile.
28 | gem "terminalwire", group: :development
29 | end
30 |
31 | def print_post_install_message
32 | say ""
33 | say "Terminalwire has been successfully installed!", :green
34 | say "Run `#{binary_path.relative_path_from(Rails.root)}` to verify everything is in working order. For support visit https://terminalwire.com."
35 | say ""
36 | end
37 |
38 | private
39 |
40 | def binary_path
41 | Rails.root.join("bin", binary_name)
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/gem/terminalwire-rails/lib/generators/terminalwire/install/templates/application_terminal.rb.tt:
--------------------------------------------------------------------------------
1 | # Learn how to use Thor at http://whatisthor.com.
2 | class ApplicationTerminal < Thor
3 | # Enables IO Streaming.
4 | include Terminalwire::Thor
5 |
6 | # The name of your binary. Thor uses this for its help output.
7 | def self.basename = "<%= binary_name %>"
8 |
9 | private
10 |
11 | def current_user=(user)
12 | # The Session object is a hash-like object that encrypts and signs a hash that's
13 | # stored on the client's file sytem. Conceptually, it's similar to Rails signed
14 | # and encrypted client-side cookies.
15 | session["user_id"] = user.id
16 | end
17 |
18 | def current_user
19 | @current_user ||= User.find_by(id: session["user_id"])
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/gem/terminalwire-rails/lib/generators/terminalwire/install/templates/bin/terminalwire:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env terminalwire-exec
2 | url: "ws://localhost:3000/terminal"
3 |
--------------------------------------------------------------------------------
/gem/terminalwire-rails/lib/generators/terminalwire/install/templates/main_terminal.rb:
--------------------------------------------------------------------------------
1 | class MainTerminal < ApplicationTerminal
2 | desc "hello NAME", "say hello to NAME"
3 | def hello(name)
4 | puts "Hello #{name}"
5 | end
6 |
7 | desc "login", "Login to your account"
8 | def login
9 | print "Email: "
10 | email = gets.chomp
11 |
12 | print "Password: "
13 | password = getpass
14 |
15 | # Replace this with your own authentication logic; this is an example
16 | # of how you might do this with Devise.
17 | user = User.find_for_authentication(email: email)
18 | if user && user.valid_password?(password)
19 | self.current_user = user
20 | puts "Successfully logged in as #{current_user.email}."
21 | else
22 | fail "Could not find a user with that email and password."
23 | end
24 | end
25 |
26 | desc "whoami", "Displays current user information."
27 | def whoami
28 | if self.current_user
29 | puts "Logged in as #{current_user.email}."
30 | else
31 | fail "Not logged in. Run `#{self.class.basename} login` to login."
32 | end
33 | end
34 |
35 | desc "logout", "Logout of your account"
36 | def logout
37 | session.reset
38 | puts "Successfully logged out."
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/gem/terminalwire-rails/lib/terminalwire-rails.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'terminalwire/rails'
4 |
--------------------------------------------------------------------------------
/gem/terminalwire-rails/lib/terminalwire/rails.rb:
--------------------------------------------------------------------------------
1 | require "terminalwire-server"
2 | require "thor"
3 | require "rails"
4 | require "jwt"
5 |
6 | module Terminalwire
7 | module Rails
8 | class Channel
9 | # Prefix used to for channel name to avoid conflicts.
10 | NAMESPACE = "terminalwire".freeze
11 |
12 | # Length of random channel name.
13 | RANDOM_NAME_LENGTH = 32
14 |
15 | # 5 minutes, which is really way too much time.
16 | TIMEOUT_SECONDS = 5 * 60
17 |
18 | attr_reader :name, :server, :logger, :timeout
19 |
20 | def initialize(name: self.class.random_name, server: self.class.server, logger: self.class.logger, timeout: TIMEOUT_SECONDS)
21 | @server = server
22 | @name = name
23 | @queue = Queue.new
24 | @logger = logger
25 | @timeout = timeout
26 |
27 | yield self if block_given?
28 | end
29 |
30 | def broadcast(message)
31 | @logger.info "ActiveExchange: Publishing #{message.inspect} to #{@name.inspect}"
32 | @server.broadcast(@name, message)
33 | end
34 | alias :publish :broadcast
35 |
36 | def subscribe
37 | return if @queue.closed?
38 | @logger.info "ActiveExchange: Subscribed to #{@name.inspect}"
39 | @server.subscribe(@name, -> (message) { @queue << message })
40 | end
41 |
42 | def read
43 | Timeout::timeout @timeout do
44 | subscribe
45 | @logger.debug "Queue: waiting"
46 | @queue.pop
47 | end
48 | ensure
49 | @logger.debug "Queue: closed"
50 | @queue.close
51 | end
52 | alias :pop :read
53 |
54 | def to_param
55 | self.class.verifier.generate(@name, expires_in: @timeout)
56 | end
57 |
58 | class << self
59 | def server
60 | ActionCable.server.pubsub
61 | end
62 |
63 | def logger
64 | ::Rails.logger
65 | end
66 |
67 | def random_name
68 | [NAMESPACE, SecureRandom.hex(RANDOM_NAME_LENGTH)].join(":")
69 | end
70 |
71 | # Use Rails' secret_key_base for signing
72 | def verifier
73 | @verifier ||= ActiveSupport::MessageVerifier.new(::Rails.application.secret_key_base)
74 | end
75 |
76 | def from_param(...)
77 | new name: verifier.verify(...)
78 | end
79 | alias :find :from_param
80 | end
81 | end
82 |
83 | class Session
84 | # JWT file name for the session file.
85 | FILENAME = "session.jwt"
86 |
87 | # Empty dictionary the user can stash all their session data into.
88 | EMPTY_SESSION = {}.freeze
89 |
90 | extend Forwardable
91 |
92 | # Delegate `dig` and `fetch` to the `read` method
93 | def_delegators :read,
94 | :dig, :fetch, :[]
95 |
96 | def initialize(context:, path: nil, secret_key: self.class.secret_key)
97 | @context = context
98 | @path = Pathname.new(path || context.storage_path)
99 | @config_file_path = @path.join(FILENAME)
100 | @secret_key = secret_key
101 |
102 | ensure_file
103 | end
104 |
105 | def read
106 | jwt_token = @context.file.read(@config_file_path)
107 | decoded_data = JWT.decode(jwt_token, @secret_key, true, algorithm: 'HS256')
108 | decoded_data[0] # JWT payload is the first element in the array
109 | rescue JWT::DecodeError => e
110 | raise "Invalid or tampered file: #{e.message}"
111 | end
112 |
113 | def reset
114 | @context.file.delete @config_file_path
115 | end
116 |
117 | def edit
118 | config = read
119 | yield config
120 | write(config)
121 | end
122 |
123 | def []=(key, value)
124 | edit { |config| config[key] = value }
125 | end
126 |
127 | def write(config)
128 | token = JWT.encode(config, @secret_key, 'HS256')
129 | @context.file.write(@config_file_path, token)
130 | end
131 |
132 | private
133 |
134 | def ensure_file
135 | return true if @context.file.exist? @config_file_path
136 | # Create the path if it doesn't exist on the client.
137 | @context.directory.create @path
138 | # Write an empty configuration on initialization
139 | write(EMPTY_SESSION)
140 | end
141 |
142 | def self.secret_key
143 | ::Rails.application.secret_key_base
144 | end
145 | end
146 |
147 | class Thor < Server::WebSocket
148 | include Logging
149 |
150 | def initialize(cli_class)
151 | @cli_class = cli_class
152 |
153 | unless @cli_class.included_modules.include?(Terminalwire::Server::Thor)
154 | raise 'Add `include Terminalwire::Server::Thor` to the #{@cli_class.inspect} class.'
155 | end
156 | end
157 |
158 | def error_message
159 | "An error occurred. Please try again."
160 | end
161 |
162 | def handle(adapter:, env:)
163 | logger.info "ThorServer: Running #{@cli_class.inspect}"
164 | while message = adapter.read
165 | case message
166 | in { event: "initialization", protocol:, program: { arguments: }, entitlement: }
167 | context = Terminalwire::Server::Context.new(adapter:, entitlement:)
168 | exit_code = 0
169 |
170 | begin
171 | @cli_class.terminalwire arguments:, context: do |cli|
172 | cli.default_url_options[:host] = env["HTTP_HOST"]
173 | end
174 | rescue ::Thor::UndefinedCommandError, ::Thor::InvocationError => e
175 | context.stdout.puts e.message
176 | rescue ::StandardError => e
177 | # Log the error
178 | handler_error_message = <<~ERROR
179 | #{e.class.name} (#{e.message})
180 |
181 | #{e.backtrace.join("\n")}
182 | ERROR
183 |
184 | ::Rails.logger.error(handler_error_message)
185 | # Report the error to Rails' notification system
186 | ::Rails.error.report(e, handled: true)
187 |
188 | if ::Rails.application.config.consider_all_requests_local
189 | # Show the full error message with stack trace in development
190 | context.stderr.puts handler_error_message
191 | else
192 | # Show a generic message in production
193 | context.stderr.puts error_message
194 | end
195 | exit_code = 1
196 | ensure
197 | context.exit exit_code
198 | end
199 | end
200 | end
201 | end
202 | end
203 | end
204 |
205 | # If I move this, then the current production integration breaks because
206 | # it wants `include Terminalwire::Thor`.
207 | module Thor
208 | class Shell < Terminalwire::Server::Thor::Shell
209 | attr_reader :session
210 |
211 | def initialize(context, *, **, &)
212 | @session = Terminalwire::Rails::Session.new(context:)
213 | super(context, *,**,&)
214 | end
215 | end
216 |
217 | def self.included(base)
218 | base.include Terminalwire::Server::Thor
219 | base.extend ClassMethods
220 |
221 | # I have to do this in a block to deal with some of Thor's DSL
222 | base.class_eval do
223 | protected
224 |
225 | no_commands do
226 | def_delegators :shell,
227 | :session
228 |
229 | include ::Rails.application.routes.url_helpers
230 | end
231 | end
232 | end
233 |
234 | module ClassMethods
235 | # Use the extend Rails shell with this shell
236 | def terminalwire_shell(context)
237 | Terminalwire::Thor::Shell.new(context)
238 | end
239 | end
240 | end
241 | end
242 |
--------------------------------------------------------------------------------
/gem/terminalwire-rails/terminalwire-rails.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | core = Gem::Specification.load File.expand_path("../terminalwire-core/terminalwire-core.gemspec", __dir__)
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "terminalwire-rails"
7 | spec.version = core.version
8 | spec.authors = core.authors
9 | spec.email = core.email
10 |
11 | spec.summary = core.summary
12 | spec.description = core.description
13 | spec.homepage = core.homepage
14 | spec.license = core.license
15 | spec.required_ruby_version = core.required_ruby_version
16 |
17 | spec.metadata = core.metadata
18 | spec.metadata["source_code_uri"] = "https://github.com/terminalwire/ruby/tree/main/#{spec.name}"
19 |
20 | # Specify which files should be added to the gem when it is released.
21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22 | # Specify which files should be added to the gem when it is released.
23 | spec.files = (
24 | Dir.glob("{lib,exe,ext,rails}/**/*") + Dir.glob("{README*,LICENSE*}")
25 | ).select { |f| File.file?(f) }
26 | spec.require_paths = core.require_paths
27 |
28 | # Uncomment to register a new dependency of your gem
29 | spec.add_dependency "terminalwire-server", core.version
30 | spec.add_development_dependency "rails", "~> 7.2"
31 | spec.add_dependency "jwt", "~> 2.0"
32 | end
33 |
--------------------------------------------------------------------------------
/gem/terminalwire-server/lib/terminalwire-server.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'terminalwire/server'
4 |
--------------------------------------------------------------------------------
/gem/terminalwire-server/lib/terminalwire/server.rb:
--------------------------------------------------------------------------------
1 | require "terminalwire"
2 | require "terminalwire/logging"
3 |
4 | require "zeitwerk"
5 | Zeitwerk::Loader.for_gem_extension(Terminalwire).tap do |loader|
6 | loader.setup
7 | end
8 |
9 | module Terminalwire
10 | module Server
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/gem/terminalwire-server/lib/terminalwire/server/context.rb:
--------------------------------------------------------------------------------
1 | require "fileutils"
2 |
3 | module Terminalwire::Server
4 | # Contains all of the resources that are accessible to the server on the client-side.
5 | # It's the primary interface for the server to interact with the client and is integrated
6 | # into other libraries like Thor, etc.
7 | class Context
8 | extend Forwardable
9 |
10 | attr_reader \
11 | :stdout, :stdin, :stderr,
12 | :browser,
13 | :file, :directory,
14 | :environment_variable,
15 | :authority,
16 | :root_path,
17 | :authority_path,
18 | :storage_path
19 |
20 | def_delegators :@stdout, :puts, :print
21 | def_delegators :@stdin, :gets, :getpass
22 |
23 | def initialize(adapter:, entitlement:)
24 | @adapter = adapter
25 | @entitlement = entitlement
26 |
27 | # Initialize resources
28 | @stdout = Resource::STDOUT.new("stdout", @adapter)
29 | @stdin = Resource::STDIN.new("stdin", @adapter)
30 | @stderr = Resource::STDERR.new("stderr", @adapter)
31 | @browser = Resource::Browser.new("browser", @adapter)
32 | @file = Resource::File.new("file", @adapter)
33 | @directory = Resource::Directory.new("directory", @adapter)
34 | @environment_variable = Resource::EnvironmentVariable.new("environment_variable", @adapter)
35 |
36 | # Authority is provided by the client.
37 | @authority = @entitlement.fetch(:authority)
38 | # The Terminalwire home path is provided by the client and set
39 | # as an environment variable.
40 | @root_path = Pathname.new(
41 | @environment_variable.read("TERMINALWIRE_HOME")
42 | )
43 | # Now derive the rest of the paths from the Terminalwire home path.
44 | @authority_path = @root_path.join("authorities", @authority)
45 | @storage_path = @authority_path.join("storage")
46 |
47 | if block_given?
48 | begin
49 | yield self
50 | ensure
51 | exit
52 | end
53 | end
54 | end
55 |
56 | # Wraps the environment variables in a hash-like object that can be accessed
57 | # from client#ENV. This makes it look and feel just like the ENV object in Ruby.
58 | class Env
59 | def initialize(context:)
60 | @context = context
61 | end
62 |
63 | def [](name)
64 | @context.environment_variable.read(name)
65 | end
66 | end
67 |
68 | def ENV
69 | @ENV ||= Env.new(context: self)
70 | end
71 |
72 | def exit(status = 0)
73 | @adapter.write(event: "exit", status: status)
74 | ensure
75 | close
76 | end
77 |
78 | def close
79 | @adapter.close
80 | end
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/gem/terminalwire-server/lib/terminalwire/server/resource.rb:
--------------------------------------------------------------------------------
1 | module Terminalwire::Server
2 | # Representation of the resources avilable to the server on the client-side. These
3 | # classes encapsulate the API alls to the client and provide a more Ruby-like interface.
4 | module Resource
5 | class Base < Terminalwire::Resource::Base
6 | private
7 |
8 | def command(command, **parameters)
9 | @adapter.write(
10 | event: "resource",
11 | name: @name,
12 | action: "command",
13 | command: command,
14 | parameters: parameters
15 | )
16 |
17 | response = @adapter.read
18 | case response.fetch(:status)
19 | when "success"
20 | response.fetch(:response)
21 | when "failure"
22 | raise Terminalwire::Error, response.inspect
23 | end
24 | end
25 | end
26 |
27 | class EnvironmentVariable < Base
28 | # Accepts a list of environment variables to permit.
29 | def read(name)
30 | command("read", name:)
31 | end
32 |
33 | # def write(name:, value:)
34 | # command("write", name:, value:)
35 | # end
36 | end
37 |
38 | class STDOUT < Base
39 | def puts(data)
40 | command("print_line", data: data)
41 | end
42 |
43 | def print(data)
44 | command("print", data: data)
45 | end
46 |
47 | def flush
48 | # Do nothing
49 | end
50 | end
51 |
52 | class STDERR < STDOUT
53 | end
54 |
55 | class STDIN < Base
56 | def getpass
57 | command("read_password")
58 | end
59 |
60 | def gets
61 | command("read_line")
62 | end
63 | end
64 |
65 | class File < Base
66 | def read(path)
67 | command("read", path: path.to_s)
68 | end
69 |
70 | def write(path, content)
71 | command("write", path: path.to_s, content:)
72 | end
73 |
74 | def append(path, content)
75 | command("append", path: path.to_s, content:)
76 | end
77 |
78 | def delete(path)
79 | command("delete", path: path.to_s)
80 | end
81 | alias :rm :delete
82 |
83 | def exist?(path)
84 | command("exist", path: path.to_s)
85 | end
86 |
87 | def change_mode(path, mode)
88 | command("change_mode", path: path.to_s, mode:)
89 | end
90 | alias :chmod :change_mode
91 | end
92 |
93 | class Directory < Base
94 | def list(path)
95 | command("list", path: path.to_s)
96 | end
97 | alias :ls :list
98 |
99 | def create(path)
100 | command("create", path: path.to_s)
101 | end
102 | alias :mkdir :create
103 |
104 | def exist?(path)
105 | command("exist", path: path.to_s)
106 | end
107 |
108 | def delete(path)
109 | command("delete", path: path.to_s)
110 | end
111 | alias :rm :delete
112 | end
113 |
114 | class Browser < Base
115 | def launch(url)
116 | command("launch", url: url)
117 | end
118 | end
119 | end
120 | end
121 |
--------------------------------------------------------------------------------
/gem/terminalwire-server/lib/terminalwire/server/thor.rb:
--------------------------------------------------------------------------------
1 | require 'thor'
2 |
3 | module Terminalwire
4 | module Server
5 | module Thor
6 | class Shell < ::Thor::Shell::Basic
7 | extend Forwardable
8 |
9 | # Encapsulates all of the IO resources for a Terminalwire adapter.
10 | attr_reader :context
11 |
12 | def_delegators :context,
13 | :stdin, :stdout, :stderr
14 |
15 | def initialize(context, *, **, &)
16 | @context = context
17 | super(*,**,&)
18 | end
19 | end
20 |
21 | def self.included(base)
22 | base.extend ClassMethods
23 |
24 | # I have to do this in a block to deal with some of Thor's DSL
25 | base.class_eval do
26 | extend Forwardable
27 |
28 | protected
29 |
30 | no_commands do
31 | def_delegators :shell,
32 | :context
33 | def_delegators :context,
34 | :stdout, :stdin, :stderr, :browser
35 | def_delegators :stdout,
36 | :puts, :print
37 | def_delegators :stdin,
38 | :gets, :getpass
39 |
40 | # Prints text to the standard error stream.
41 | def warn(...)
42 | stderr.puts(...)
43 | end
44 |
45 | # Prints text to the standard error stream and exits the program.
46 | def fail(...)
47 | stderr.puts(...)
48 | context.exit 1
49 | ensure
50 | super
51 | end
52 | # Feels more naturual to call `client.files` etc. from
53 | # the serve since it's more apparent that it's a client.
54 | alias :client :context
55 | end
56 | end
57 | end
58 |
59 | module ClassMethods
60 | def terminalwire(arguments:, context:)
61 | # I have to manually hack into the Thor dispatcher to get access to the instance
62 | # of the CLI so I can slap the Rails helper methods in there, or other helpes
63 | # raise [context.inspect, arguments.inspect, self.inspect].inspect
64 | dispatch(nil, arguments.dup, nil, shell: terminalwire_shell(context)) do |instance|
65 | yield instance
66 | end
67 | end
68 |
69 | def terminalwire_shell(context)
70 | Terminalwire::Server::Thor::Shell.new(context)
71 | end
72 | end
73 | end
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/gem/terminalwire-server/lib/terminalwire/server/web_socket.rb:
--------------------------------------------------------------------------------
1 | module Terminalwire
2 | module Server
3 | class WebSocket
4 | include Logging
5 |
6 | def call(env)
7 | Async::WebSocket::Adapters::Rack.open(env, protocols: ['ws']) do |connection|
8 | handle(
9 | adapter: Adapter::Socket.new(Terminalwire::Transport::WebSocket.new(connection)),
10 | env:
11 | )
12 | end or [200, { "Content-Type" => "text/plain" }, ["Connect via WebSockets"]]
13 | end
14 |
15 | def handle(adapter:, env:)
16 | while message = adapter.read
17 | puts message
18 | end
19 | end
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/gem/terminalwire-server/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | require_relative "../../../spec/spec_helper.rb"
2 |
--------------------------------------------------------------------------------
/gem/terminalwire-server/terminalwire-server.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | core = Gem::Specification.load File.expand_path("../terminalwire-core/terminalwire-core.gemspec", __dir__)
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "terminalwire-server"
7 | spec.version = core.version
8 | spec.authors = core.authors
9 | spec.email = core.email
10 |
11 | spec.summary = core.summary
12 | spec.description = core.description
13 | spec.homepage = core.homepage
14 | spec.license = core.license
15 | spec.required_ruby_version = core.required_ruby_version
16 |
17 | spec.metadata = core.metadata
18 | spec.metadata["source_code_uri"] = "https://github.com/terminalwire/ruby/tree/main/#{spec.name}"
19 |
20 | # Specify which files should be added to the gem when it is released.
21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22 | # Specify which files should be added to the gem when it is released.
23 | spec.files = (
24 | Dir.glob("{lib,exe,ext}/**/*") + Dir.glob("{README*,LICENSE*}")
25 | ).select { |f| File.file?(f) }
26 | spec.require_paths = core.require_paths
27 |
28 | # Uncomment to register a new dependency of your gem
29 | spec.add_dependency "thor", "~> 1.3"
30 | spec.add_dependency "terminalwire-core", core.version
31 | end
32 |
--------------------------------------------------------------------------------
/gem/terminalwire/Rakefile:
--------------------------------------------------------------------------------
1 | require 'pathname'
2 |
3 | task :default do
4 | ruby_version = "3.3.6"
5 | os = "osx"
6 | platform = "arm64"
7 | url = "https://github.com/YOU54F/traveling-ruby/releases/download/rel-20241122/traveling-ruby-20241122-#{ruby_version}-#{os}-#{platform}-full.tar.gz"
8 | path = Pathname.new("package/#{os}-#{platform}")
9 | gem = Gem::Specification.load("terminalwire.gemspec")
10 |
11 | sh "mkdir -p #{path}"
12 |
13 | # Don't bother downloading if we already have the file
14 | unless File.exist? path.join("lib/ruby/info/RUBY_COMPAT_VERSION")
15 | sh "mkdir -p #{path.join("lib/ruby")}"
16 | sh "curl -L #{url} | tar -xzf - -C #{path.join("lib/ruby")}"
17 | end
18 |
19 | # Install the base gem and all of its dependencies in the vendor directory.
20 | sh "gem install #{gem.name} --version '#{gem.version}' --install-dir #{path.join("lib/vendor")} --no-document --verbose"
21 |
22 | # Remove caches to make package smaller
23 | sh "rm -rf #{path.join("lib/vendor/cache")}"
24 | sh "rm -rf #{path.join("lib/vendor/bin")}"
25 | sh "rm -rf #{path.join("lib/vendor/doc")}"
26 | sh "rm -rf #{path.join("lib/vendor/plugins")}"
27 |
28 | File.write path.join("lib/boot.rb"), <<~RUBY
29 | # Resolve the base directory
30 | base_dir = File.expand_path("../..", __FILE__)
31 |
32 | # Add all gem paths under lib/vendor to the load path
33 | Dir.glob(File.join(base_dir, "lib/vendor/gems/*/lib")).each do |gem_path|
34 | $LOAD_PATH.unshift gem_path
35 | end
36 | RUBY
37 |
38 | gems = path.glob("lib/vendor/specifications/*.gemspec").each_with_object({}) do |spec_path, hash|
39 | spec = Gem::Specification.load(spec_path.to_s)
40 | hash[spec.name] = spec
41 | end
42 |
43 | terminalwire_gem = gems.fetch("terminalwire")
44 |
45 | gem_path = Pathname.new(terminalwire_gem.full_gem_path).relative_path_from path.expand_path
46 | exe_path = gem_path.join(terminalwire_gem.bindir, "terminalwire-exec")
47 |
48 | # Let's write the executable path into the thing...'
49 | File.write path.join("terminalwire-exec"), <<~RUBY
50 | #!/bin/bash
51 | # Resolve the directory of the current script
52 | SELFDIR="$(cd "$(dirname "$0")" && pwd)"
53 |
54 | # Path to the embedded Ruby executable
55 | RUBY_EXEC="$SELFDIR/lib/ruby/bin/ruby"
56 | BOOT_SCRIPT="$SELFDIR/lib/boot.rb"
57 | PROGRAM="$SELFDIR/#{exe_path}"
58 |
59 | # Pass the boot script and the main Ruby script to Ruby
60 | exec "$RUBY_EXEC" -r"$BOOT_SCRIPT" "$PROGRAM" "$@"
61 | RUBY
62 |
63 | File.chmod 0755, path.join("terminalwire-exec")
64 |
65 | # TODO: download and install each gem extension for its platform.
66 | native_gems = gems.values.select{ _1.extensions.any? }
67 | native_gems.each do |native_gem|
68 | p native_gem
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/gem/terminalwire/exe/terminalwire:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require "terminalwire/client"
3 |
4 | begin
5 | Terminalwire::Client.websocket(url: "wss://terminalwire.com/terminal")
6 | rescue Terminalwire::Error => e
7 | puts e.message
8 | exit 1
9 | end
10 |
--------------------------------------------------------------------------------
/gem/terminalwire/exe/terminalwire-exec:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require "terminalwire/client"
3 |
4 | Zeitwerk::Loader.eager_load_all
5 |
6 | begin
7 | Terminalwire::Client::Exec.start
8 | rescue Terminalwire::Error => e
9 | puts e.message
10 | exit 1
11 | end
12 |
--------------------------------------------------------------------------------
/gem/terminalwire/terminalwire.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | core = Gem::Specification.load File.expand_path("../terminalwire-core/terminalwire-core.gemspec", __dir__)
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "terminalwire"
7 | spec.version = core.version
8 | spec.authors = core.authors
9 | spec.email = core.email
10 |
11 | spec.summary = core.summary
12 | spec.description = core.description
13 | spec.homepage = core.homepage
14 | spec.license = core.license
15 | spec.required_ruby_version = core.required_ruby_version
16 |
17 | spec.metadata = core.metadata
18 | spec.metadata["source_code_uri"] = "https://github.com/terminalwire/ruby/tree/main/#{spec.name}"
19 |
20 | # Specify which files should be added to the gem when it is released.
21 | spec.files = (
22 | Dir.glob("{lib,exe,ext}/**/*") + Dir.glob("{README*,LICENSE*}")
23 | ).select { |f| File.file?(f) }
24 |
25 | spec.bindir = "exe"
26 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
27 | spec.require_paths = core.require_paths
28 |
29 | # Uncomment to register a new dependency of your gem
30 | spec.add_dependency "terminalwire-client", core.version
31 | end
32 |
--------------------------------------------------------------------------------
/spec/integration/license_verification_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | RSpec.describe "Terminalwire license verification", type: :system do
4 | around do |example|
5 | Async do
6 | example.run
7 | end
8 | end
9 |
10 | let(:service_license_verification) {
11 | Terminalwire::Client::ServerLicenseVerification.new(url:)
12 | }
13 |
14 | context "licensed server" do
15 | let(:url) { "https://tinyzap.com/terminal" }
16 | subject { service_license_verification.message }
17 | it { is_expected.to be_nil }
18 | end
19 |
20 | context "unlicensed server" do
21 | let(:url) { "https://tinyzap.com/unlicensed-terminal" }
22 | subject { service_license_verification.message }
23 | it { is_expected.to eql "\n⚠️ Can't find a valid Terminalwire server license for https://tinyzap.com/unlicensed-terminal.\n\n" }
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/spec/integration/rails_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 | require "fileutils"
3 | require "open3"
4 | require "tmpdir"
5 | require "pathname"
6 | require "pty"
7 | require "io/wait"
8 |
9 | RSpec.describe "Terminalwire Install", type: :system do
10 | DOCKER_IMAGE = "terminalwire-rails-server"
11 | BINARY_NAME = "bin/hello"
12 | PORT = 3000
13 |
14 | before(:all) do
15 | build_command = "docker build -t #{DOCKER_IMAGE} -f containers/rails/Dockerfile ."
16 | system(build_command) or raise "Docker build failed: #{build_command}"
17 |
18 | # Run the container without external port binding and capture the container ID.
19 | @docker_id = `docker run --rm -d #{DOCKER_IMAGE}`.chomp
20 | raise "Docker run failed" if @docker_id.empty?
21 |
22 | wait_for_server_in_container(timeout: 15)
23 | end
24 |
25 | after(:all) do
26 | system("docker stop #{@docker_id}") if @docker_id
27 | end
28 |
29 | def console(&)
30 | Pity::REPL.new("docker exec -it #{@docker_id} bash", &)
31 | end
32 |
33 | it "runs Terminalwire client against server" do
34 | console do
35 | it.puts "#{BINARY_NAME} hello World"
36 | expect(it.gets).to include("Hello World")
37 | end
38 | end
39 |
40 | it "logs in successfully" do
41 | console do
42 | it.puts "#{BINARY_NAME} login"
43 | it.expect "Email: "
44 | it.puts "brad@example.com"
45 | it.expect "Password: "
46 | it.puts "password123"
47 | expect(it.gets).to include("Successfully logged in as brad@example.com.")
48 | end
49 | end
50 |
51 | it "runs default task with no arguments" do
52 | console do
53 | it.puts "#{BINARY_NAME}"
54 | expect(it.expect("Commands:")).to include("Commands:")
55 | end
56 | end
57 |
58 | it "prints stack trace" do
59 | console do
60 | it.puts "#{BINARY_NAME} integration exception"
61 | # The `gsub` normalizes the line endings from the HEREDOC to
62 | # match how PTY/stdio changes the line endings.
63 | expect(it.gets).to include <<~ERROR.gsub(/\n/, "\r\n")
64 | RuntimeError (An exception occurred)
65 |
66 | /rails/app/terminal/integration_terminal.rb:4:in `exception'
67 | ERROR
68 | end
69 | end
70 |
71 | context "Thor::UndefinedCommandError" do
72 | it "prints error message" do
73 | console do |repl|
74 | repl.puts "#{BINARY_NAME} nothingburger"
75 | repl.gets.tap do |buffer|
76 | expect(buffer).to include("Could not find command \"nothingburger\".")
77 | expect(buffer).to_not include("Thor::UndefinedCommandError")
78 | end
79 | end
80 | end
81 | end
82 |
83 | context "Thor::InvocationError" do
84 | it "prints error message" do
85 | console do |repl|
86 | repl.puts "#{BINARY_NAME} hello"
87 | repl.gets.tap do |buffer|
88 | expect(buffer).to include("\"hello hello\" was called with no arguments")
89 | expect(buffer).to_not include("Thor::InvocationError")
90 | end
91 | end
92 | end
93 | end
94 |
95 | private
96 |
97 | def wait_for_server_in_container(timeout:)
98 | start_time = Time.now
99 | until Time.now - start_time > timeout
100 | response = `docker exec #{@docker_id} curl -s http://localhost:3000/health`
101 | return if !response.strip.empty?
102 | sleep 0.5
103 | end
104 | raise "Server did not start within #{timeout} seconds"
105 | end
106 | end
107 |
--------------------------------------------------------------------------------
/spec/package/local_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | RSpec.describe "Local package" do
4 | let(:path) { Pathname.new("build/stage/macos/arm64") }
5 | before do
6 | @env = ENV.to_h
7 | ENV.replace(
8 | "PATH" => [path.join("bin"), ENV.fetch("PATH")].join(":")
9 | )
10 | end
11 | after do
12 | ENV.replace @env
13 | end
14 |
15 | it "runs terminalwire" do
16 | expect(`terminalwire`).to include <<~TEXT
17 | Commands:
18 | terminalwire apps # List apps installed i...
19 | terminalwire distribution # Publish & manage dist...
20 | terminalwire distribution create NAME # Distribute app from t...
21 | TEXT
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/spec/package/ubuntu_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | RSpec.describe "Ubuntu package" do
4 | let(:path) { Pathname.new("build/stage/ubuntu/arm64") }
5 | let(:container_name) { "terminalwire_ubuntu_specs" }
6 | before do
7 | `docker build -t #{container_name} containers/ubuntu`
8 | end
9 | it "runs terminalwire" do
10 | expect(`docker run -v #{path.expand_path}:/opt/terminalwire #{container_name} terminalwire`).to include <<~TEXT
11 | Commands:
12 | terminalwire apps # List apps installed i...
13 | terminalwire distribution # Publish & manage dist...
14 | terminalwire distribution create NAME # Distribute app from t...
15 | TEXT
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "terminalwire-core"
4 | require "terminalwire-server"
5 | require "terminalwire-client"
6 | require "terminalwire"
7 | require "pathname"
8 | require "uri"
9 | require "tmpdir"
10 | require "pity"
11 |
12 | # This will smoke out more bugs that could come up in environments like
13 | # Rails.
14 | require "zeitwerk"
15 | Zeitwerk::Loader.eager_load_all
16 |
17 | RSpec.configure do |config|
18 | # Enable flags like --only-failures and --next-failure
19 | config.example_status_persistence_file_path = ".rspec_status"
20 |
21 | # Disable RSpec exposing methods globally on `Module` and `main`
22 | config.disable_monkey_patching!
23 |
24 | config.expect_with :rspec do |c|
25 | c.syntax = :expect
26 | end
27 | end
28 |
29 | RSpec.configure do |config|
30 | config.around(:each) do |example|
31 | Dir.mktmpdir do |tmp_dir|
32 | original_terminalwire_home = ENV['TERMINALWIRE_HOME']
33 | begin
34 | ENV['TERMINALWIRE_HOME'] = tmp_dir
35 | example.run
36 | ensure
37 | ENV['TERMINALWIRE_HOME'] = original_terminalwire_home
38 | end
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/support/terminalwire.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "terminalwire"
3 |
4 | module Terminalwire
5 | class Project
6 | # We need to worry about the order of paths here because when commands like
7 | # `rake install` are run, it needs to do it in the order of dependencies since
8 | # RubyGems hasn't yet built a dependency graph for us.
9 | GEM_PATHS = %w[
10 | gem/terminalwire-core
11 | gem/terminalwire-client
12 | gem/terminalwire
13 | gem/terminalwire-server
14 | gem/terminalwire-rails
15 | ]
16 |
17 | attr_reader :dir, :name
18 |
19 | def initialize(dir)
20 | @dir = dir
21 | @name = File.basename(dir)
22 | end
23 |
24 | def chdir
25 | Dir.chdir(dir) do
26 | puts "cd #{Dir.pwd}"
27 | yield
28 | end
29 | puts "cd #{Dir.pwd}"
30 | end
31 |
32 | def gem_tasks
33 | Bundler::GemHelper.install_tasks(dir:, name:)
34 | end
35 |
36 | def rake_task(task)
37 | Rake::Task[rake_task_name(task)]
38 | end
39 |
40 | def task_namespace
41 | name.tr("-", "_") # Ensure namespaces are valid Ruby identifiers
42 | end
43 |
44 | def rake_task_name(*segments)
45 | segments.prepend(task_namespace).join(":")
46 | end
47 |
48 | def self.all
49 | GEM_PATHS.map { |it| new(it) }
50 | end
51 | end
52 | end
53 |
--------------------------------------------------------------------------------