├── .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 | --------------------------------------------------------------------------------