diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8f0c87a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +[*.cr] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23ec656 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/doc/ +/lib/ +/bin/ +/.shards/ + +# Libraries don't need dependency lock +# Dependencies will be locked in application that uses them +/shard.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..ffc7b6a --- /dev/null +++ b/.travis.yml @@ -0,0 +1 @@ +language: crystal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d770aa8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2018 Jon Egeland + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..82f83ee --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# mtenv + +`mtenv` is a version manager for the Myst programming language, inspired by rbenv and other similar version managers. + +**It is currently a work in progress and should not be used for actual installations**. See the installation instructions in [Myst's README](https://github.com/myst-lang/myst#installation) for the time being. + + +# Installation + +Since `mtenv` is contained in a single executable file, installing it is as simple as copying the single `mtenv` file to somewhere on your `$PATH`. + +From a command line, something as simple as + +```shell +# Download the script +curl -o mtenv 'https://raw.githubusercontent.com/myst-lang/mtenv/master/mtenv' +# Copy it onto the `PATH` +cp ./mtenv /usr/local/bin/mtenv +# Make sure it's executable +chmod +x /usr/local/bin/mtenv +``` + +And that's it! Running `mtenv` should now work and show you the usage instructions. However, `mtenv` also expects and depends on a `.mtenv` directory existing in your home directory (i.e., `~/.mtenv/`). This directory is used to store the installed versions of Myst and some other settings. + +When first installing `mtenv`, run `mtenv setup` to make sure this directory exists and has the proper content: + +```shell +mtenv setup +``` + +`mtenv setup` will: + + - ensure that `~/.mtenv/` exists and add some basic configuration. + - create `/usr/local/bin/myst` as a symlink to `~/.mtenv/shims/myst`, the executable that `mtenv` manages to control `myst` versions. + +Now you are fully set up to use `mtenv` and install new versions of Myst. + +_Note_: If you have existing versions of Myst installed via Homebrew or any other method, be sure to remove them from your PATH before using `mtenv`! Otherwise `mtenv` may not take precedence and may cause unexpected behavior._ + + +# Usage + +`mtenv` uses sub-commands to perform its various tasks. To see these commands listed out, just run `mtenv` to see the usage instructions. Further explanations are given here. + +- `mtenv install `: install the version of Myst given by the argument. This will _not_ override the currently in-use version. +- `mtenv version`: show the version number of the currently in-use version of Myst. +- `mtenv installled`: show all currently-installed versions of Myst. +- `mtenv available`: show all versinos of Myst that are available for installation. diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..308f5e7 --- /dev/null +++ b/shard.yml @@ -0,0 +1,18 @@ +name: mtenv +version: 0.1.0 +crystal: 0.24.1 +license: MIT + +description: | + The Myst language environment manager, inspired by rbenv and others. + +authors: + - Jon + +targets: + mtenv: + main: src/mtenv.cr + +dependencies: + admiral: + github: jwaldrip/admiral.cr diff --git a/spec/env_spec.cr b/spec/env_spec.cr new file mode 100644 index 0000000..994e65d --- /dev/null +++ b/spec/env_spec.cr @@ -0,0 +1,9 @@ +require "./spec_helper" + +describe Env do + # TODO: Write tests + + it "works" do + false.should eq(true) + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..bd93df7 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/env" diff --git a/src/commands/active.cr b/src/commands/active.cr new file mode 100644 index 0000000..3a851d2 --- /dev/null +++ b/src/commands/active.cr @@ -0,0 +1,16 @@ +class MTENV + class Active < Admiral::Command + define_help description: "Show the currently-active Myst version." + + def run + active_version = `cat #{File.expand_path("~/.mtenv/global")}` + if active_version.empty? + puts "No Myst version is currently active." + else + puts active_version + end + end + end + + register_sub_command active : Active +end diff --git a/src/commands/available.cr b/src/commands/available.cr new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/implode.cr b/src/commands/implode.cr new file mode 100644 index 0000000..0ed1b73 --- /dev/null +++ b/src/commands/implode.cr @@ -0,0 +1,35 @@ +class MTENV + class Implode < Admiral::Command + define_help description: "Completely uninstall mtenv and all Myst versions." + + def run + require_confirmation! + + unless Dir.exists?(File.expand_path("~/.mtenv")) + abort "~/.mtenv does not exist. Cannot ensure implosion." + end + + shims_path = File.read(File.expand_path("~/.mtenv/shims_dir")) + puts ". Removing links to shims from #{shims_path}" + FileUtils.rm(File.join(shims_path, "myst")) + + puts ". Removing `~/.mtenv`" + FileUtils.rm_r(File.expand_path("~/.mtenv")) + + puts "Successfully imploded mtenv." + end + + + private def require_confirmation! + puts "Are you sure you want to completely uninstall mtenv? This cannot be undone." + puts "The `mtenv` command will remain installed, but all installations will be lost." + puts "Type 'implode' to confirm your intent." + print "> " + unless (confirmation = gets) && confirmation == "implode" + abort("Not uninstalling. Confirmation did not match 'implode'.") + end + end + end + + register_sub_command implode : Implode +end diff --git a/src/commands/install.cr b/src/commands/install.cr new file mode 100644 index 0000000..2612512 --- /dev/null +++ b/src/commands/install.cr @@ -0,0 +1,70 @@ +require "tempfile" + +class MTENV + class Install < Admiral::Command + define_help description: "Install a new version of Myst." + + define_argument version : String, + description: "A SemVer version number of commit SHA to install", + required: true + + define_argument name : String, + description: "An alias name to use for the new installation." + + GITHUB_URL = "api.github.com/repos/myst-lang/myst/tarball" + + def run + Util.require_setup! + validate_version! + + tarball = Tempfile.new("myst-#{version_name}", ".tar.gz") + if download_version_tarball(arguments.version, to: tarball.path) + install_location = File.expand_path("~/.mtenv/versions/#{version_name}") + FileUtils.mkdir_p(install_location) + unpack_tarball(tarball.path, to: install_location) + build_executable(install_location) + else + STDERR.puts "Could not find a Myst version matching '#{arguments.version}'." + exit(1) + end + + tarball.unlink + end + + def version_name + arguments.name || arguments.version + end + + + private def validate_version! + unless Util.versionish?(arguments.version) + STDERR.puts "'#{arguments.version}' is not a valid version identifier." + STDERR.puts "Versions must be given as either SemVer numbers (vX.X.X) or commit SHAs" + exit(1) + end + end + + private def download_version_tarball(version, to file) + # curl GitHub for a tarball of the myst repository at the requested version. + # -sL - run silently, and follow GitHub's redirects automatically + # > file - output the content into `file`. + `curl -sL #{GITHUB_URL}/#{version} > #{file}` + end + + private def unpack_tarball(tar_path, to destination) + # Unpack the given tar ball into the given destination. + # -xf - unpack from a file + # -C - sets the destination for the unpack + # --strip-components=1 - remove the containing directory name from GitHub. + `tar -xf #{tar_path} -C #{destination} --strip-components=1` + end + + private def build_executable(install_location) + FileUtils.cd(install_location) do + `shards build` + end + end + end + + register_sub_command install : Install +end diff --git a/src/commands/setup.cr b/src/commands/setup.cr new file mode 100644 index 0000000..085a4d9 --- /dev/null +++ b/src/commands/setup.cr @@ -0,0 +1,59 @@ +require "file_utils" + +# The shim is written in `src/scripts/shim.sh`. This macro loads the content +# of that file into a string at compile time, avoiding the need to use a +# heredoc or bundle resource files when distributing this tool. +MYST_SHIM = {{ "#{`cat #{__DIR__}/../scripts/shim.sh`}" }} + +class MTENV + class Setup < Admiral::Command + define_help description: "Ensure that `mtenv` is properly and fully installed." + + def run + shims_location = ask_for_shims_location! + # Ensure the `.mtenv` dir exists in the home directory and has all of the + # necessary components (versions, global, shims, etc.). + FileUtils.cd(ENV["HOME"]) do + puts "Initializing `~/.mtenv/`" + FileUtils.mkdir_p(".mtenv/versions") + FileUtils.touch(".mtenv/global") + FileUtils.mkdir_p(".mtenv/shims") + # Store the shims location for use in the future (e.g., `implode`). + File.write(".mtenv/shims_dir", shims_location) + + puts "Creating shims" + File.open(".mtenv/shims/myst", mode: "w", perm: 0o755) do |f| + f.truncate + f.puts(MYST_SHIM) + end + + # Create mtenv-controlled shims for the Myst binary. + puts "Linking shims to #{shims_location}" + myst_path = File.join(shims_location, "myst") + File.symlink(File.expand_path("~/.mtenv/shims/myst"), myst_path) + end + + puts "\nmtenv setup finished successfully." + end + + + private def ask_for_shims_location! + print "Where should mtenv create links to shims? (default '/usr/local/bin'): " + location = gets + if location.nil? || location.empty? + location = "/usr/local/bin" + end + + location = File.expand_path(location) + + if location && Dir.exists?(location) + return location + else + abort "Requested shims location `#{location}` does not exist. Aborting setup." + end + + end + end + + register_sub_command setup : Setup +end diff --git a/src/commands/uninstall.cr b/src/commands/uninstall.cr new file mode 100644 index 0000000..bed22ec --- /dev/null +++ b/src/commands/uninstall.cr @@ -0,0 +1,24 @@ +require "file_utils" + +class MTENV + class Uninstall < Admiral::Command + define_help description: "List all currently-installed Myst versions." + + define_argument version : String, + description: "The name of the version to uninstall", + required: true + + def run + version_path = File.expand_path("~/.mtenv/versions/#{arguments.version}") + if Dir.exists?(version_path) + puts "Uninstalling '#{arguments.version}'" + FileUtils.rm_r(version_path) + puts "Successfully uninstalled #{arguments.version}" + else + puts "Version '#{version_path}' is not an installed Myst version." + end + end + end + + register_sub_command uninstall : Uninstall +end diff --git a/src/commands/use.cr b/src/commands/use.cr new file mode 100644 index 0000000..869e3a7 --- /dev/null +++ b/src/commands/use.cr @@ -0,0 +1,26 @@ +class MTENV + class Use < Admiral::Command + define_help description: "Set the version of Myst to use." + + define_argument version : String, + description: "The name of the version to uninstall", + required: true + + def run + version_path = File.expand_path("~/.mtenv/versions/#{arguments.version}") + + unless Dir.exists?(version_path) + abort "Version #{arguments.version} is not currently installed." + end + + File.open(File.expand_path("~/.mtenv/global"), "w") do |f| + f.truncate + f.print(arguments.version) + end + + puts "Active Myst version is now #{arguments.version}" + end + end + + register_sub_command use : Use +end diff --git a/src/commands/versions.cr b/src/commands/versions.cr new file mode 100644 index 0000000..08d4580 --- /dev/null +++ b/src/commands/versions.cr @@ -0,0 +1,11 @@ +class MTENV + class Versions < Admiral::Command + define_help description: "List all currently-installed Myst versions." + + def run + Process.exec("ls", [File.expand_path("~/.mtenv/versions")]) + end + end + + register_sub_command versions : Versions +end diff --git a/src/mtenv.cr b/src/mtenv.cr new file mode 100644 index 0000000..c75af2d --- /dev/null +++ b/src/mtenv.cr @@ -0,0 +1,15 @@ +require "admiral" + +require "./util.cr" + +class MTENV < Admiral::Command + define_help description: "The Myst language environment manager." + + def run + puts help + end +end + +require "./commands/*" + +MTENV.run diff --git a/src/scripts/shim.sh b/src/scripts/shim.sh new file mode 100755 index 0000000..4309f90 --- /dev/null +++ b/src/scripts/shim.sh @@ -0,0 +1,11 @@ +current_version=`cat ~/.mtenv/global` + +if [ -z $current_version ]; then + echo "No version of Myst is currently active." + echo "Run \`mtenv use \` to set a global version." + exit +fi + +executable_path="~/.mtenv/versions/$current_version/bin/myst" + +`$executable_path $@` diff --git a/src/util.cr b/src/util.cr new file mode 100644 index 0000000..1539cc0 --- /dev/null +++ b/src/util.cr @@ -0,0 +1,41 @@ +module Util + extend self + + # Determine whether the system is set up to run `mtenv`. If it is not, abort + # execution and inform the user of their installation problems. + def require_setup! + Dir.exists?(File.expand_path("~/.mtenv")) || fail_setup!("`~/.mtenv/` directory does not exist.") + Dir.exists?(File.expand_path("~/.mtenv/versions")) || fail_setup!("`~/.mtenv/installs` directory does not exist.") + File.exists?(File.expand_path("~/.mtenv/shims_dir")) || fail_setup!("`~/.mtenv/shims_dir` reference file does not exist.") + end + + + # Determine whether `version` is "versionish". That is, that it is able to + # indicate a specific version of Myst to install. This includes semver + # version numbers and commit SHAs, among potential others. + # + # The input is expected to already be trimmed. + # + # Returns true if `version` satisifies that condition, false otherwise. + def versionish?(version) + is_version = false + [ + # SemVer release numbers: + # v0.0.1 + # v1.0.2 + # v12.52.12 + /^v\d+\.\d+\.\d+$/, + # commit SHAs (must be at least 7 characters long) + /^[0-9a-fA-F]{7,40}$/ + ].any?{ |re| re.match(version) } || false + end + + + private def fail_setup!(message) + STDERR.puts("Setup check failed! Cause:") + STDERR.puts(message) + STDERR.puts("\n`mtenv` requires a valid setup to run.") + STDERR.puts("Run `mtenv setup` to attempt an automatic fix.") + exit(1) + end +end