diff --git a/.github/workflows/alpine_x86_64_release.yml b/.github/workflows/alpine_x86_64_release.yml new file mode 100644 index 0000000..f86297f --- /dev/null +++ b/.github/workflows/alpine_x86_64_release.yml @@ -0,0 +1,57 @@ +on: + push: + tags: + - "v*.*.*" + +jobs: + build: + runs-on: ubuntu-latest + container: + image: crystallang/crystal:latest-alpine + steps: + - name: Cache shards + uses: actions/cache@v4 + with: + path: ~/.cache/shards + key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} + restore-keys: ${{ runner.os }}-shards- + + - name: Download source + uses: actions/checkout@v4 + + - name: Check formatting + run: crystal tool format --check + + - name: Install shards + run: shards check || shards install --without-development + + - name: Disable git safe repository checks + run: git config --global --add safe.directory '*' + + - name: Run tests + run: crystal spec --order=random --error-on-warnings + + - name: Collect package information + run: | + echo "BINARY_NAME=bin/$(cat shard.yml |grep targets -A1|tail -n1 |sed 's#[ :]##g')" >> $GITHUB_ENV + echo "PKG_ARCH=x86_64" >> $GITHUB_ENV + echo "PLATFORM=unknown-linux-musl.tar.gz" >> $GITHUB_ENV + echo "BUILD_ARGS=--static --link-flags=\"-s -Wl,-z,relro,-z,now\"" >> $GITHUB_ENV + + - name: Set asset name + run: | + echo "ASSERT_NAME=${{env.BINARY_NAME}}-${{github.ref_name}}-${{env.PKG_ARCH}}-${{env.PLATFORM}}" >> $GITHUB_ENV + + - name: Build release binary + id: release + run: | + echo "ASSERT_NAME=${{env.ASSERT_NAME}}" >> $GITHUB_OUTPUT + shards build --production --progress --no-debug -Dstrict_multi_assign -Dno_number_autocast ${{env.BUILD_ARGS}} + tar zcvf ${{env.ASSERT_NAME}} ${{env.BINARY_NAME}} LICENSE + + - name: Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: | + ${{steps.release.outputs.ASSERT_NAME}} diff --git a/.github/workflows/gnu_x86_64_release.yml b/.github/workflows/gnu_x86_64_release.yml new file mode 100644 index 0000000..b1a0a3b --- /dev/null +++ b/.github/workflows/gnu_x86_64_release.yml @@ -0,0 +1,55 @@ +on: + push: + tags: + - "v*.*.*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Cache shards + uses: actions/cache@v4 + with: + path: ~/.cache/shards + key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} + restore-keys: ${{ runner.os }}-shards- + + - name: Download source + uses: actions/checkout@v4 + + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + + - name: Check formatting + run: crystal tool format --check + + - name: Install shards + run: shards check || shards install --without-development + + - name: Run tests + run: KEMAL_ENV=test crystal spec --order=random --error-on-warnings + + - name: Collect package information + run: | + echo "BINARY_NAME=bin/$(cat shard.yml |grep targets -A1|tail -n1 |sed 's#[ :]##g')" >> $GITHUB_ENV + echo "PKG_ARCH=x86_64" >> $GITHUB_ENV + echo "PLATFORM=unknown-linux-gnu.tar.gz" >> $GITHUB_ENV + echo "BUILD_ARGS=--link-flags=\"-s -Wl,-z,relro,-z,now\"" >> $GITHUB_ENV + + - name: Set asset name + run: | + echo "ASSERT_NAME=${{env.BINARY_NAME}}-${{github.ref_name}}-${{env.PKG_ARCH}}-${{env.PLATFORM}}" >> $GITHUB_ENV + + - name: Build release binary + id: release + run: | + echo "ASSERT_NAME=${{env.ASSERT_NAME}}" >> $GITHUB_OUTPUT + shards build --production --progress --no-debug -Dstrict_multi_assign -Dno_number_autocast ${{env.BUILD_ARGS}} + tar zcvf ${{env.ASSERT_NAME}} ${{env.BINARY_NAME}} LICENSE + + - name: Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: | + ${{steps.release.outputs.ASSERT_NAME}} diff --git a/.github/workflows/macos_release.yml b/.github/workflows/macos_release.yml new file mode 100644 index 0000000..0b2ec00 --- /dev/null +++ b/.github/workflows/macos_release.yml @@ -0,0 +1,55 @@ +on: + push: + tags: + - "v*.*.*" + +jobs: + build: + runs-on: macos-latest + steps: + - name: Cache shards + uses: actions/cache@v4 + with: + path: ~/.cache/shards + key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} + restore-keys: ${{ runner.os }}-shards- + + - name: Download source + uses: actions/checkout@v4 + + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + + - name: Check formatting + run: crystal tool format --check + + - name: Install shards + run: shards check || shards install --without-development + + - name: Run tests + run: KEMAL_ENV=test crystal spec --order=random --error-on-warnings + + - name: Collect package information + run: | + echo "BINARY_NAME=bin/$(cat shard.yml |grep targets -A1|tail -n1 |sed 's#[ :]##g')" >> $GITHUB_ENV + echo "PKG_ARCH=x86_64" >> $GITHUB_ENV + echo "PLATFORM=apple-darwin.tar.gz" >> $GITHUB_ENV + echo "BUILD_ARGS=" >> $GITHUB_ENV + + - name: Set asset name + run: | + echo "ASSERT_NAME=${{env.BINARY_NAME}}-${{github.ref_name}}-${{env.PKG_ARCH}}-${{env.PLATFORM}}" >> $GITHUB_ENV + + - name: Build release binary + id: release + run: | + echo "ASSERT_NAME=${{env.ASSERT_NAME}}" >> $GITHUB_OUTPUT + shards build --production --progress --no-debug -Dstrict_multi_assign -Dno_number_autocast ${{env.BUILD_ARGS}} + tar zcvf ${{env.ASSERT_NAME}} ${{env.BINARY_NAME}} LICENSE + + - name: Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: | + ${{steps.release.outputs.ASSERT_NAME}} diff --git a/.github/workflows/windows-msvc.yml b/.github/workflows/windows-msvc.yml new file mode 100644 index 0000000..45396f9 --- /dev/null +++ b/.github/workflows/windows-msvc.yml @@ -0,0 +1,49 @@ +on: + push: + tags: + - "v*.*.*" + +jobs: + build: + runs-on: windows-2022 + steps: + - name: Cache shards + uses: actions/cache@v4 + with: + path: ~/.cache/shards + key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} + restore-keys: ${{ runner.os }}-shards- + + - name: Download source + uses: actions/checkout@v4 + + - name: Install Crystal + uses: crystal-lang/install-crystal@v1 + + - name: Install shards + run: shards check || shards install --without-development + + - name: Collect package information + run: | + echo "BINARY_NAME=bin/$(cat shard.yml |Select-String -Pattern 'targets:' -Context 1 |%{$_ -replace '> targets:',''}|%{$_ -replace '[\s:]*',''})" >> $Env:GITHUB_ENV + echo "PKG_ARCH=x86_64" >> $Env:GITHUB_ENV + echo "PLATFORM=pc-windows-msvc.zip" >> $Env:GITHUB_ENV + echo "BUILD_ARGS=" >> $Env:GITHUB_ENV + + - name: Set asset name + run: | + echo "ASSERT_NAME=${{env.BINARY_NAME}}-${{github.ref_name}}-${{env.PKG_ARCH}}-${{env.PLATFORM}}" >> $Env:GITHUB_ENV + + - name: Build release binary + id: release + run: | + echo "ASSERT_NAME=${{env.ASSERT_NAME}}" >> $Env:GITHUB_OUTPUT + shards build --production --progress --no-debug -Dstrict_multi_assign -Dno_number_autocast ${{env.BUILD_ARGS}} + 7z a ${{env.ASSERT_NAME}} ${{env.BINARY_NAME}}.exe LICENSE + + - name: Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: | + ${{steps.release.outputs.ASSERT_NAME}} diff --git a/.gitignore b/.gitignore index 3ccdda2..2e7f203 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ # Libraries don't need dependency lock # Dependencies will be locked in application that uses them /shard.lock - +/bin diff --git a/.sentry.example.yml b/.sentry.example.yml index c1e38fe..2d1784a 100644 --- a/.sentry.example.yml +++ b/.sentry.example.yml @@ -8,27 +8,38 @@ # The name of your application when displayed in log output. By default, this # is the app name specified in `shard.yml`. -display_name: my-program-name +display_name: sentry # Set this to `true` to show configuration information when starting Sentry. info: true -# The command used to compile the application. Setting this option to `nil` or -# an empty string will act like specifying `--no-build` on the command line. -build: crystal build ./src/sentry_cli.cr -o ./my-program-name +# Set this to `false` to removes colorization from output. +colorize: false -# Any additional arguments to pass to the build command. Build args may only -# be given if the build command is a single argument. -build_args: +# Set this to `false` to skips the attempt to play audio file with `aplay' +# from `alsa-utils' package when building on Linux succeeds or fails. +play_audio: false + +# Set this to `false` to skips the build step. +should_build: false + +# Set this to `true` to run `shards install` once before Sentry build and run commands. +run_shards_install: true + +# The command used to compile the application. +build_command: crystal + +# Any additional arguments to pass to the build command. +build_args: build ./src/sentry_cli.cr -o ./bin/sentry # The command used to run the compiled application. -run: ./my-program-name +run_command: ./bin/sentry -# Any additional arguments to pass to the run command. Run args may only be -# given if the run command is a single argument. -run_args: +# Any additional arguments to pass to the run command. +run_args: -p 3288 # The list of patterns of files for sentry to watch. watch: - ./src/**/*.cr - ./src/**/*.ecr + - ./spec/**/*.cr diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ed341a4 --- /dev/null +++ b/Makefile @@ -0,0 +1,93 @@ +-include Makefile.local # for optional local options + +NAME = sentry + +COMPILER ?= crystal +SHARDS ?= shards + +SOURCES != find src -name '*.cr' +LIB_SOURCES != find lib -name '*.cr' 2>/dev/null +SPEC_SOURCES != find spec -name '*.cr' 2>/dev/null + +CRYSTAL_ENTRY_FILE != cat shard.yml |grep main: |cut -d: -f2|cut -d" " -f2 +OUTPUT_FILE != cat shard.yml |grep main: -B1 |head -n1 |awk '{print $$1}'|awk -F: '{print $$1}' +CRYSTAL_ENTRY_PATH := $(shell pwd)/$(CRYSTAL_ENTRY_FILE) + +CACHE_DIR != $(COMPILER) env CRYSTAL_CACHE_DIR +CACHE_DIR := $(CACHE_DIR)/$(subst /,-,${shell echo $(CRYSTAL_ENTRY_PATH) |cut -c2-}) + +FLAGS ?= --progress -Dstrict_multi_assign -Dno_number_autocast -Dpreview_overload_order +RELEASE_FLAGS ?= --no-debug --link-flags=-s --release --progress -Dstrict_multi_assign -Dno_number_autocast -Dpreview_overload_order + +# INSTALL: +DESTDIR ?= /usr/local +BINDIR ?= $(DESTDIR)/bin +INSTALL ?= /usr/bin/install + +O := bin/$(OUTPUT_FILE) + +.PHONY: all +all: build ## build [default] + +.PHONY: build +build: $(O) ## Build the application binary + +$(O): $(SOURCES) $(LIB_SOURCES) lib bin + $(COMPILER) build $(FLAGS) $(CRYSTAL_ENTRY_FILE) -o $(O) + +# 注意, 这些不带 .PHONY 通常都是真实文件名或目录名 +lib: ## Run shards install to install dependencies + $(SHARDS) install + +.PHONY: spec +spec: $(SPEC_SOURCES) $(SOURCES) $(LIB_SOURCES) lib bin ## Run spec + $(COMPILER) spec $(FLAGS) --order=random --error-on-warnings + +.PHONY: format +format: ## Apply source code formatting + $(COMPILER) tool format src spec + +.PHONY: install +install: release ## Install the compiler at DESTDIR + $(INSTALL) -d -m 0755 "$(BINDIR)/" + $(INSTALL) -m 0755 "$(O)" "$(BINDIR)/$(NAME)" + +.PHONY: uninstall +uninstall: ## Uninstall the compiler from DESTDIR + rm -f "$(BINDIR)/$(NAME)" + +.PHONY: check +check: ## Check dependencies, run shards install if necessary + $(SHARDS) check || $(SHARDS) install + +.PHONY: clean +clean: ## Delete built binary + rm -f $(O) + +.PHONY: cleanall +cleanall: clean # Delete built binary with cache + rm -rf ${CACHE_DIR} + +.PHONY: release +release: $(SOURCES) $(LIB_SOURCES) lib bin ## Build release binary + $(COMPILER) build $(RELEASE_FLAGS) $(CRYSTAL_ENTRY_FILE) -o $(O) + +bin: + @mkdir -p bin + +.PHONY: help +help: ## Show this help + @echo + @printf '\033[34mtargets:\033[0m\n' + @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) |\ + sort |\ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' + @echo + @printf '\033[34moptional variables:\033[0m\n' + @grep -hE '^[a-zA-Z_-]+ \?=.*?## .*$$' $(MAKEFILE_LIST) |\ + sort |\ + awk 'BEGIN {FS = " \\?=.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' + @echo + @printf '\033[34mrecipes:\033[0m\n' + @grep -hE '^##.*$$' $(MAKEFILE_LIST) |\ + awk 'BEGIN {FS = "## "}; /^## [a-zA-Z_-]/ {printf " \033[36m%s\033[0m\n", $$2}; /^## / {printf " %s\n", $$2}' diff --git a/README.md b/README.md index 4d98ab6..7cb4a51 100644 --- a/README.md +++ b/README.md @@ -4,99 +4,82 @@


-# Sentry 🤖 - -Build/Runs your crystal application, watches files, and rebuilds/reruns app on file changes - -## Installation - -To install in your project, from the root directory of your project, run: - -```bash -curl -fsSLo- https://raw.githubusercontent.com/samueleaton/sentry/master/install.cr | crystal eval -``` - -If using Crystal version `0.24.2` try the following: - -```bash -curl -fsSLo- https://raw.githubusercontent.com/samueleaton/sentry/crystal-v0.24.2/install.cr | crystal eval -``` +# Breaking Changes -If using Crystal version `0.23.1` or lower try the following: +1. --build-command=COMMAND need specify the build command without args, e.g. crystal + In the configuration file, the corresponding `build` has been changed to `build_command` +2. --build-args=ARGS need specify build string but without the command part, e.g. `build src/sentry_cli.cr -o bin/sentry` + In the configuration file, the corresponding `build` has been changed to `build_args` +3. The `-b` is still keep for backwards compatibility, but without the long-command form, + using `--src=src/foo.cr` is always recommended when there is no `shard.yml`. +4. When build crystal program, if a valid shard.yml was found, will create run command binary in the `./bin` + folder instead of in the project root(`./`) respect the rule of `shards build`. + -```bash -curl -fsSLo- https://raw.githubusercontent.com/samueleaton/sentry/crystal-v0.23.1/install.cr | crystal eval -``` +# New feature -This will install the Sentry CLI tool. To use the Crystal API, see [CRYSTAL_API.md](./CRYSTAL_API.md). +1. Many bugs get fixed. +2. sentry will play a audio file when build success/fail, individually. (Linux only) +3. now, configuration file support settings all options, except `--src`, latter tend to use in command line only, + instead of setting `--build-command` and `--build-args` or `-b` when there is no `shard.yml` exists. -

- sentry -

- -**Troubleshooting the install:** This install script is just a convenience. If it does not work, simply: (1) place the files located in the `src` dir into a your project in a `dev/` dir, and (2) compile sentry by doing `crystal build --release dev/sentry_cli.cr -o ./sentry`. +# Sentry 🤖 -## Usage +Build/Runs your crystal application, watches files, and rebuilds/reruns app on file changes -Assuming `sentry.cr` was correctly placed in `[your project name]/dev/sentry.cr` and compiled into the root of your app as `sentry`, simply run: +## Installation -```bash -./sentry [options] -``` +Download released binary from [release page](https://github.com/crystal-china/sentry/releases), run it! ### Options -#### Show Help Menu - -```bash -./sentry --help -``` - -Example +You don't need to set any options if you're using the `shards` to manage build. ```bash -$ ./sentry -h - -Usage: ./sentry [options] - -n NAME, --name=NAME Sets the display name of the app process (default name: ) - --src=PATH Sets the entry path for the main crystal file (default is inferred from shards.yaml) - -b COMMAND, --build=COMMAND Overrides the default build command (will override --src flag) - --build-args=ARGS Specifies arguments for the build command - --no-build Skips the build step - -r COMMAND, --run=COMMAND Overrides the default run command - --run-args=ARGS Specifies arguments for the run command - -w FILE, --watch=FILE Overrides default files and appends to list of watched files - -c FILE, --config=FILE Specifies a file to load for automatic configuration (default: '.sentry.yml') - --install Run 'shards install' once before running Sentry build and run commands - --no-color Removes colorization from output - -i, --info Shows the values for build/run commands, build/run args, and watched files - -h, --help Show this help + ╰──➤ $ sentry +🤖 Your SentryBot is vigilant. beep-boop... +🤖 watching file: ./src/daka/version.cr +🤖 watching file: ./src/daka.cr +🤖 watching file: ./src/records.ecr +🤖 compiling daka... +🤖 starting daka... +[development] Kemal is ready to lead at http://0.0.0.0:3000 ``` -#### Override Default Build Command +If you are don't use shards, specify the entry path for the main crystal file use --src should enough. ```bash -./sentry -b "crystal build --release ./src/my_app.cr" +sentry --src=src/sentry.cr ``` -The default build command is `crystal build ./src/[app_name].cr`. The release flag is omitted by default for faster compilation time while you are developing. - -#### Override Default Run Command +For the detailed usage, please check following command-line help or check [.sentry.example.yml](./.sentry.example.yml) ```bash -./sentry -r "./my_app" + Usage: ./sentry [options] + -n NAME, --name=NAME Sets the display name of the app process (default: sentry) + --src=PATH Sets the entry path for the main crystal file inferred from shard.yml (default: src/sentry_cli.cr) + --build-command=COMMAND Overrides the default build command (default: crystal) + --build-args=ARGS Specifies arguments for the build command (default: build src/sentry_cli.cr -o ./bin/sentry) + -b FULL_COMMAND Set both `BUILD COMMAND' and `BUILD ARGS', for backwards compatibility (default: crystal build src/sentry_cli.cr -o ./bin/sentry) + --no-build Skips the build step + -r COMMAND, --run=COMMAND Overrides the default run command inferred from shard.yml (default: ./bin/sentry) + --run-args=ARGS Specifies arguments for the run command, (default: '') + -w FILE, --watch=FILE Appends to list of watched files, (will overrides default: ["./src/**/*.cr", "./src/**/*.ecr"]) + -c FILE, --config=FILE Specifies a file to load for automatic configuration (default: .sentry.yml) + --install Run `shards install' once before running Sentry build and run commands + --no-color Removes colorization from output + --not-play-audio Skips the attempt to play audio file with `aplay' from `alsa-utils' package when building on Linux succeeds or fails + -i, --info Shows the configuration informations + -V, --version Shows version + -h, --help Show this help ``` -The default run command is `./[app_name]`. - #### Override Default Files to Watch ```bash ./sentry -w "./src/**/*.cr" -w "./lib/**/*.cr" ``` -The default files being watched are `["./src/**/*.cr", "./src/**/*.ecr"]`. - By specifying files to watch, the default will be omitted. So if you want to watch all of the file in your `src` directory, you will need to specify that like in the above example. #### Show Info Before Running @@ -104,27 +87,28 @@ By specifying files to watch, the default will be omitted. So if you want to wat This shows the values for the build command, run command, and watched files. ```bash -./sentry -i -``` - -Example - -``` -$ ./sentry -i - 🤖 Sentry configuration: - display name: my_app - shard name: my_app - install shards: true - info: true - build: crystal build ./src/my_app.cr - build_args: [] - run: ./my_app - run_args: [] - watch: ["./src/**/*.cr", "./src/**/*.ecr"] + display name: sentry + shard name: sentry + src_path: src/sentry_cli.cr + build_command: crystal + build_args: build src/sentry_cli.cr -o ./bin/sentry + run_command: ./bin/sentry + run_args: + watched files: ["./src/**/*.cr", "./src/**/*.ecr"] + colorize: true + run shards install: false + should play audio: true + should build: true + should print info: true 🤖 Your SentryBot is vigilant. beep-boop... -... -... +🤖 watching file: ./src/sentry/process_runner.cr +🤖 watching file: ./src/sentry/config.cr +🤖 watching file: ./src/sentry/sound_file_storage.cr +🤖 watching file: ./src/sentry.cr +🤖 watching file: ./src/sentry_cli.cr +🤖 compiling sentry... +🤖 starting sentry... ``` #### Setting Build or Run Arguments @@ -132,9 +116,21 @@ $ ./sentry -i If you prefer granularity, you can specify arguments to the build or run commands using the `--build-args` or `--run-args` flags followed by a string of arguments. ```bash -./sentry -r "crystal" --run-args "spec --debug" +# For run spec automatically when file changes +KEMAL_ENV=test sentry -r 'crystal' --run-args='spec' --no-build ``` +You can run multiple sentry process on same project by open a new terminal. + +```bash +# For run tailwindcss generate output.css +sentry -r 'tailwindcss' --run-args='-o output.css' --no-build +``` + +__NOTICE__, When set `-r`, `--run-args` manually, with `--no-build` usually a good +idea to skip (unused) crystal build process. + + #### Running `shards install` Before Starting This is especially usefull when initiating Sentry from a `Dockerfile` or `package.json` file. It guarantees all the shards are installed before running. @@ -147,7 +143,7 @@ This is especially usefull when initiating Sentry from a `Dockerfile` or `packag Sentry will automatically read configurations from `.sentry.yml` if it exists. This can be changed with `-c FILE` or `--config=FILE`. -See the `YAML.mapping` definition in the `Config` class in [the `/src/sentry.cr` file](src/sentry.cr) for valid file properties. +See definition in [.sentry.example.yml](./.sentry.example.yml) for valid file properties. #### Removing Colorization @@ -184,6 +180,7 @@ Now, for development, simply run sentry in your docker container, and it will re ## Contributors - [samueleaton](https://github.com/samueleaton) Sam Eaton - creator, maintainer +- [billy](http://github.com/zw963) Billy.Zheng - maintainer ## Disclaimer diff --git a/install.cr b/install.cr deleted file mode 100644 index 38caafc..0000000 --- a/install.cr +++ /dev/null @@ -1,52 +0,0 @@ -require "uri" -require "http/client" -require "file_utils" - -print "🤖 Fetching sentry files..." - -# Fetch sentry.cr -sentry_uri = "https://raw.githubusercontent.com/samueleaton/sentry/master/src/sentry.cr" -fetch_sentry_response = HTTP::Client.get sentry_uri - -if fetch_sentry_response.status_code > 299 - puts "HTTP request error. Could not fetch #{sentry_uri}" - puts fetch_sentry_response.body - exit 1 -end - -sentry_code = fetch_sentry_response.body - -# Fetch sentry_cli.cr -sentry_cli_uri = "https://raw.githubusercontent.com/samueleaton/sentry/master/src/sentry_cli.cr" -fetch_cli_response = HTTP::Client.get sentry_cli_uri - -if fetch_cli_response.status_code > 299 - puts "HTTP request error. Could not fetch #{sentry_cli_uri}" - puts fetch_cli_response.body - exit 1 -end - -sentry_cli_code = fetch_cli_response.body - -puts " success" - -# Write files to dev directory -FileUtils.mkdir_p "./dev" -File.write "./dev/sentry.cr", sentry_code -File.write "./dev/sentry_cli.cr", sentry_cli_code - -# compile sentry files -puts "🤖 Compiling sentry using --release flag..." -build_args = ["build", "--release", "./dev/sentry_cli.cr", "-o", "./sentry"] -compile_success = system "crystal", build_args - -if compile_success - puts "🤖 Sentry installed!" - puts "\nTo execute sentry, do: - ./sentry\n" - puts "\nTo see options: - ./sentry --help\n\n" -else - puts "🤖 Bzzt. There was an error compiling sentry." - exit 1 -end diff --git a/install.rb b/install.rb deleted file mode 100644 index bce73ad..0000000 --- a/install.rb +++ /dev/null @@ -1,46 +0,0 @@ -require "net/http" -require "uri" -require 'fileutils' - -sentry_uri = URI.parse("https://raw.githubusercontent.com/samueleaton/sentry/master/src/sentry.cr") -req = Net::HTTP.new(sentry_uri.host, sentry_uri.port) -req.use_ssl = (sentry_uri.scheme == "https") -response = req.request(Net::HTTP::Get.new(sentry_uri.request_uri)) - -if response.code.to_i > 299 - puts "HTTP request error" - puts response.msg - exit 1 -end - -sentry_code = response.body - -sentry_cli_uri = URI.parse("https://raw.githubusercontent.com/samueleaton/sentry/master/src/sentry_cli.cr") -req = Net::HTTP.new(sentry_cli_uri.host, sentry_cli_uri.port) -req.use_ssl = (sentry_cli_uri.scheme == "https") -response = req.request(Net::HTTP::Get.new(sentry_cli_uri.request_uri)) - -if response.code.to_i > 299 - puts "HTTP request error" - puts response.msg - exit 1 -end - -sentry_cli_code = response.body - -FileUtils.mkdir_p "./dev" -File.write "./dev/sentry.cr", sentry_code -File.write "./dev/sentry_cli.cr", sentry_cli_code - -puts "Compiling sentry using --release flag..." -compile_success = system "crystal build --release ./dev/sentry_cli.cr -o ./sentry" - -if compile_success - puts "🤖 sentry installed!" - puts "\nTo execute sentry, do: - ./sentry\n" - puts "\nTo see options: - ./sentry --help\n\n" -else - puts "🤖 Bzzt. There was an error compiling sentry." -end diff --git a/shard.yml b/shard.yml index a760f09..4e356bc 100644 --- a/shard.yml +++ b/shard.yml @@ -1,7 +1,17 @@ name: sentry -version: 0.3.2 +version: 0.7.2 + +targets: + sentry: + main: src/sentry_cli.cr + +dependencies: + baked_file_system: + github: schovi/baked_file_system + version: 0.10.0 authors: - Sam Eaton + - Billy.Zheng (vil963@gmail.com) crystal: ">= 0.34.0" license: ISC diff --git a/spec/apps/empty/.gitkeep b/spec/apps/empty/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spec/apps/full/.sentry.yml b/spec/apps/full/.sentry.yml new file mode 100644 index 0000000..506d9be --- /dev/null +++ b/spec/apps/full/.sentry.yml @@ -0,0 +1,14 @@ +display_name: app +info: true +colorize: false +play_audio: false +should_build: false +run_shards_install: true +build_command: crystal +build_args: build ./src/app.cr -o ./bin/app +run_command: ./bin/app +run_args: -p 3288 +watch: + - ./src/**/*.cr + - ./src/**/*.ecr + - ./spec/**/*.cr diff --git a/spec/apps/full/.sentry1.yml b/spec/apps/full/.sentry1.yml new file mode 100644 index 0000000..7f8f9d8 --- /dev/null +++ b/spec/apps/full/.sentry1.yml @@ -0,0 +1 @@ +display_name: new_app diff --git a/spec/apps/full/shard.yml b/spec/apps/full/shard.yml new file mode 100644 index 0000000..b0a68e4 --- /dev/null +++ b/spec/apps/full/shard.yml @@ -0,0 +1,16 @@ +name: sentry +version: 0.7.0 + +targets: + sentry: + main: src/sentry_cli.cr + +dependencies: + baked_file_system: + github: schovi/baked_file_system + version: 0.10.0 + +authors: + - Sam Eaton +crystal: ">= 0.34.0" +license: ISC diff --git a/spec/apps/with_config/.sentry.yml b/spec/apps/with_config/.sentry.yml new file mode 100644 index 0000000..b5c5462 --- /dev/null +++ b/spec/apps/with_config/.sentry.yml @@ -0,0 +1,45 @@ +# This file is used to override the default Sentry configuration without +# having to specify the options on the command line. +# +# All configuration options in this file are optional, and will fall back +# to the default values that Sentry determines based on your `shard.yml`. +# +# Options passed through the command line will override these settings. + +# The name of your application when displayed in log output. By default, this +# is the app name specified in `shard.yml`. +display_name: app + +# Set this to `true` to show configuration information when starting Sentry. +info: true + +# Set this to `false` to removes colorization from output. +colorize: false + +# Set this to `false` to skips the attempt to play audio file with `aplay' +# from `alsa-utils' package when building on Linux succeeds or fails. +play_audio: false + +# Set this to `false` to skips the build step. +should_build: false + +# Set this to `true` to run `shards install` once before Sentry build and run commands. +run_shards_install: true + +# The command used to compile the application. +build_command: crystal + +# Any additional arguments to pass to the build command. +build_args: build ./src/app.cr -o ./bin/app + +# The command used to run the compiled application. +run_command: ./bin/app + +# Any additional arguments to pass to the run command. +run_args: -p 3288 + +# The list of patterns of files for sentry to watch. +watch: + - ./src/**/*.cr + - ./src/**/*.ecr + - ./spec/**/*.cr diff --git a/spec/apps/with_shard_yml/shard.yml b/spec/apps/with_shard_yml/shard.yml new file mode 100644 index 0000000..b0a68e4 --- /dev/null +++ b/spec/apps/with_shard_yml/shard.yml @@ -0,0 +1,16 @@ +name: sentry +version: 0.7.0 + +targets: + sentry: + main: src/sentry_cli.cr + +dependencies: + baked_file_system: + github: schovi/baked_file_system + version: 0.10.0 + +authors: + - Sam Eaton +crystal: ">= 0.34.0" +license: ISC diff --git a/spec/config_spec.cr b/spec/config_spec.cr new file mode 100644 index 0000000..5acc434 --- /dev/null +++ b/spec/config_spec.cr @@ -0,0 +1,137 @@ +require "./spec_helper" + +describe Sentry::Config do + context "cli config default" do + it "should return default cli config inferred from shard.yml in a shards manager project" do + Dir.cd "./spec/apps/with_shard_yml" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry" + ) + + cli_config = cli.cli_config + + cli_config.sets_build_full_command?.should be_false + cli_config.sets_run_command?.should be_false + cli_config.sets_display_name?.should be_false + cli_config.sets_build_command?.should be_false + cli_config.sets_build_args?.should be_false + cli_config.sets_should_play_audio?.should be_false + cli_config.sets_should_build?.should be_false + cli_config.sets_colorize?.should be_false + cli_config.sets_watch?.should be_false + + cli_config.display_name.should eq "sentry" + cli_config.src_path.should eq "./src/sentry_cli.cr" + cli_config.build_command.should eq "crystal" + cli_config.build_args.should eq "build ./src/sentry_cli.cr -o bin/sentry" + cli_config.run_command.should eq "bin/sentry" + cli_config.run_args.should eq "" + cli_config.should_build?.should be_true + cli_config.should_play_audio?.should be_true + cli_config.watch.should eq ["./src/**/*.cr", "./src/**/*.ecr"] + cli_config.colorize?.should be_true + cli_config.info?.should be_false + cli_config.run_shards_install?.should be_false + end + end + end + + context "config default" do + it "should return default config inferred from shard.yml in a shards manager project" do + Dir.cd "./spec/apps/with_shard_yml" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry" + ) + + config = cli.config + + config.sets_build_full_command?.should be_false + config.sets_run_command?.should be_false + config.sets_display_name?.should be_false + config.sets_build_command?.should be_false + config.sets_build_args?.should be_false + config.sets_should_play_audio?.should be_false + config.sets_should_build?.should be_false + config.sets_colorize?.should be_false + config.sets_watch?.should be_false + + config.display_name.should eq "sentry" + config.src_path.should eq "./src/sentry_cli.cr" + config.build_command.should eq "crystal" + config.build_args.should eq "build ./src/sentry_cli.cr -o bin/sentry" + config.run_command.should eq "bin/sentry" + config.run_args.should eq "" + config.should_build?.should be_true + config.should_play_audio?.should be_true + config.watch.should eq ["./src/**/*.cr", "./src/**/*.ecr"] + config.colorize?.should be_true + config.info?.should be_false + config.run_shards_install?.should be_false + end + end + + it "should return config from .sentry.yml" do + Dir.cd "./spec/apps/with_config" do + cli = SentryCli.new + + config = cli.config + + config.sets_build_full_command?.should be_false + config.sets_run_command?.should be_false + config.sets_display_name?.should be_false + config.sets_build_command?.should be_false + config.sets_build_args?.should be_false + config.sets_should_play_audio?.should be_false + config.sets_should_build?.should be_false + config.sets_colorize?.should be_false + config.sets_watch?.should be_false + + config.display_name.should eq "app" + config.src_path.should be_nil + config.build_command.should eq "crystal" + config.build_args.should eq "build ./src/app.cr -o ./bin/app" + config.run_command.should eq "./bin/app" + config.run_args.should eq "-p 3288" + config.should_build?.should be_false + config.should_play_audio?.should be_false + config.watch.should eq ["./src/**/*.cr", "./src/**/*.ecr", "./spec/**/*.cr"] + config.colorize?.should be_false + config.info?.should be_true + config.run_shards_install?.should be_true + end + end + + it "should return default config inferred from --src in a non-shards project" do + Dir.cd "./spec/apps/empty" do + cli = SentryCli.new(opts: ["--src=./src/foo.cr"]) + + config = cli.config + + config.sets_build_full_command?.should be_false + config.sets_run_command?.should be_true + config.sets_display_name?.should be_false + config.sets_build_command?.should be_false + config.sets_build_args?.should be_true + config.sets_should_play_audio?.should be_false + config.sets_should_build?.should be_false + config.sets_colorize?.should be_false + config.sets_watch?.should be_false + + config.display_name.should eq "sentry" + config.src_path.should eq "./src/foo.cr" + config.build_command.should eq "crystal" + config.build_args.should eq "build ./src/foo.cr -o foo" + config.run_command.should eq "foo" + config.run_args.should eq "" + config.should_build?.should be_true + config.should_play_audio?.should be_true + config.watch.should eq ["./src/**/*.cr", "./src/**/*.ecr"] + config.colorize?.should be_true + config.info?.should be_false + config.run_shards_install?.should be_false + end + end + end +end diff --git a/spec/option_spec.cr b/spec/option_spec.cr new file mode 100644 index 0000000..e595812 --- /dev/null +++ b/spec/option_spec.cr @@ -0,0 +1,187 @@ +require "./spec_helper" + +describe OptionParser do + context "cli config default" do + it "should set project name" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["--name=foo"] + ) + + config = cli.config + + config.display_name.should eq "foo" + end + end + + it "should set src path" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["--src=./src/foo.cr"] + ) + + config = cli.config + + config.src_path.should eq "./src/foo.cr" + config.build_args.should eq "build ./src/foo.cr -o foo" + config.run_command.should eq "foo" + config.sets_build_args?.should be_true + config.sets_run_command?.should be_true + end + end + + it "should set build command" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["--build-command=cr"] + ) + + config = cli.config + + config.build_command.should eq "cr" + config.sets_build_command?.should be_true + end + end + + it "should set build args" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["--build-args=build src/foo.cr -o foo"] + ) + + config = cli.config + + config.build_args.should eq "build src/foo.cr -o foo" + config.sets_build_args?.should be_true + end + end + + it "should set full build command" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["-b cr build src/bar.cr -o bar"] + ) + + config = cli.config + + config.build_command.should eq "cr" + config.build_args.should eq "build src/bar.cr -o bar" + cli.cli_config.sets_build_full_command?.should be_true + end + end + + it "should not build before respawn process" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["--no-build"] + ) + + config = cli.config + + config.should_build?.should be_false + config.sets_should_build?.should be_true + end + end + + it "should set run command and run args" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["--run=crystal", "--run-args=spec --debug"] + ) + + config = cli.config + + config.run_command.should eq "crystal" + config.run_args.should eq "spec --debug" + config.sets_run_command?.should be_true + end + end + + it "should watched folders" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["--watch=spec/*.cr", "--watch=src/*.cr"] + ) + + config = cli.config + + config.watch.should eq ["spec/*.cr", "src/*.cr"] + config.sets_watch?.should be_true + end + end + + it "run shards install after the first time start sentry" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["--install"] + ) + + config = cli.config + + config.run_shards_install?.should be_true + end + end + + it "run shards install after the first time start sentry" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["--no-color"] + ) + + config = cli.config + + config.colorize?.should be_false + config.sets_colorize?.should be_true + end + end + + it "not play audio" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["--not-play-audio"] + ) + + config = cli.config + + config.should_play_audio?.should be_false + config.sets_should_play_audio?.should be_true + end + end + + it "not play audio" do + Dir.cd "./spec/apps/full" do + cli = SentryCli.new( + shard_src_path: "./src/sentry_cli.cr", + shard_run_command: "bin/sentry", + opts: ["--info"] + ) + + config = cli.config + + config.info?.should be_true + end + end + end +end diff --git a/spec/sentry_spec.cr b/spec/sentry_spec.cr deleted file mode 100644 index bf6aa7f..0000000 --- a/spec/sentry_spec.cr +++ /dev/null @@ -1,9 +0,0 @@ -require "./spec_helper" - -describe Sentry 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 index 3177639..608f79a 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,2 +1,9 @@ require "spec" -require "../src/sentry" +require "../src/sentry_cli" + +module Sentry + class ProcessRunner + def run + end + end +end diff --git a/src/sentry.cr b/src/sentry.cr index a948a85..f135ac5 100644 --- a/src/sentry.cr +++ b/src/sentry.cr @@ -1,279 +1,14 @@ require "yaml" -require "colorize" +require "./sentry/config" +require "./sentry/sound_file_storage" +require "./sentry/process_runner.cr" module Sentry - FILE_TIMESTAMPS = {} of String => String # {file => timestamp} - - class Config - include YAML::Serializable - - # `shard_name` is set as a class property so that it can be inferred from - # the `shard.yml` in the project directory. - class_property shard_name : String? - - @[YAML::Field(ignore: true)] - property? sets_display_name : Bool = false - - @[YAML::Field(ignore: true)] - property? sets_build_command : Bool = false - - @[YAML::Field(ignore: true)] - property? sets_run_command : Bool = false - - property info : Bool = false - - property? colorize : Bool = true - - property src_path : String = "./src/#{Sentry::Config.shard_name}.cr" - - property? install_shards : Bool = false - - setter build_args : String = "" - - setter run_args : String = "" - - property watch : Array(String) = ["./src/**/*.cr", "./src/**/*.ecr"] - - property install_shards : Bool = false - - # Initializing an empty configuration provides no default values. - def initialize - @display_name = nil - @sets_display_name = false - @info = false - @src_path = "./src/#{Sentry::Config.shard_name}.cr" - @build = nil - @build_args = "" - @run = nil - @run_args = "" - @watch = [] of String - @install_shards = false - @colorize = true - end - - @display_name : String? - - def display_name - @display_name ||= self.class.shard_name - end - - def display_name=(new_display_name : String) - @sets_display_name = true - @display_name = new_display_name - end - - def display_name! - display_name.not_nil! - end - - @build : String? - - def build - @build ||= "crystal build #{self.src_path}" - end - - def build=(new_command : String) - @sets_build_command = true - @build = new_command - end - - def build_args - @build_args.strip.split(" ").reject(&.empty?) - end - - @run : String? - - def run - @run ||= "./#{self.class.shard_name}" - end - - def run=(new_command : String) - @sets_run_command = true - @run = new_command - end - - def run_args - @run_args.strip.split(" ").reject(&.empty?) - end - - @[YAML::Field(ignore: true)] - setter should_build : Bool = true - - def should_build? - @should_build ||= begin - if build_command = @build - build_command.empty? - else - false - end - end - end - - def merge!(other : self) - self.display_name = other.display_name! if other.sets_display_name? - self.info = other.info if other.info - self.build = other.build if other.sets_build_command? - self.build_args = other.build_args.join(" ") unless other.build_args.empty? - self.run = other.run if other.sets_run_command? - self.run_args = other.run_args.join(" ") unless other.run_args.empty? - self.watch = other.watch unless other.watch.empty? - self.install_shards = other.install_shards? - self.colorize = other.colorize? - self.src_path = other.src_path - end - - def to_s(io : IO) - io << <<-CONFIG - 🤖 Sentry configuration: - display name: #{display_name} - shard name: #{self.class.shard_name} - install shards: #{install_shards?} - info: #{info} - build: #{build} - build_args: #{build_args} - src_path: #{src_path} - run: #{run} - run_args: #{run_args} - watch: #{watch} - colorize: #{colorize?} - CONFIG - end - end - - class ProcessRunner - getter app_process : (Nil | Process) = nil - property display_name : String - property should_build = true - property files = [] of String - - def initialize( - @display_name : String, - @build_command : String, - @run_command : String, - @build_args : Array(String) = [] of String, - @run_args : Array(String) = [] of String, - files = [] of String, - should_build = true, - install_shards = false, - colorize = true - ) - @files = files - @should_build = should_build - @should_kill = false - @app_built = false - @should_install_shards = install_shards - @colorize = colorize - end - - private def stdout(str : String) - if @colorize - puts str.colorize.fore(:yellow) - else - puts str - end - end - - private def build_app_process - stdout "🤖 compiling #{display_name}..." - build_args = @build_args - if build_args.size > 0 - Process.run(@build_command, build_args, shell: true, output: Process::Redirect::Inherit, error: Process::Redirect::Inherit) - else - Process.run(@build_command, shell: true, output: Process::Redirect::Inherit, error: Process::Redirect::Inherit) - end - end - - private def create_app_process - app_process = @app_process - if app_process.is_a? Process - unless app_process.terminated? - stdout "🤖 killing #{display_name}..." - app_process.signal(:kill) - app_process.wait - end - end - - stdout "🤖 starting #{display_name}..." - run_args = @run_args - if run_args.size > 0 - @app_process = Process.new(@run_command, run_args, output: Process::Redirect::Inherit, error: Process::Redirect::Inherit) - else - @app_process = Process.new(@run_command, output: Process::Redirect::Inherit, error: Process::Redirect::Inherit) - end - end - - private def get_timestamp(file : String) - File.info(file).modification_time.to_unix.to_s - end - - # Compiles and starts the application - # - def start_app - return create_app_process unless @should_build - build_result = build_app_process() - if build_result && build_result.success? - @app_built = true - create_app_process() - elsif !@app_built # if build fails on first time compiling, then exit - stdout "🤖 Compile time errors detected. SentryBot shutting down..." - exit 1 - end - end - - # Scans all of the `@files` - # - def scan_files - file_changed = false - app_process = @app_process - files = @files - begin - Dir.glob(files) do |file| - timestamp = get_timestamp(file) - if FILE_TIMESTAMPS[file]? && FILE_TIMESTAMPS[file] != timestamp - FILE_TIMESTAMPS[file] = timestamp - file_changed = true - stdout "🤖 #{file}" - elsif FILE_TIMESTAMPS[file]?.nil? - stdout "🤖 watching file: #{file}" - FILE_TIMESTAMPS[file] = timestamp - file_changed = true if (app_process && !app_process.terminated?) - end - end - rescue ex : File::Error - # The underlining lib for reading directories will fail very rarely, crashing Sentry - # This catches that error and allows Sentry to carry on normally - # https://github.com/crystal-lang/crystal/blob/677422167cbcce0aeea49531896dbdcadd2762db/src/crystal/system/unix/dir.cr#L19 - end - - start_app() if (file_changed || app_process.nil?) - end - - def run_install_shards - stdout "🤖 Installing shards..." - install_result = Process.run("shards", ["install"], shell: true, output: Process::Redirect::Inherit, error: Process::Redirect::Inherit) - if !install_result || !install_result.success? - stdout "🤖 Error installing shards. SentryBot shutting down..." - exit 1 - end - end - - def run - stdout "🤖 Your SentryBot is vigilant. beep-boop..." - - run_install_shards if @should_install_shards - - loop do - if @should_kill - stdout "🤖 Powering down your SentryBot..." - break - end - scan_files - sleep 1 - end - end - - def kill - @should_kill = true - end - end + VERSION = {{ + `shards version "#{__DIR__}"`.chomp.stringify + + " (rev " + + `git rev-parse --short HEAD`.chomp.stringify + + ")" + + `date '+ %Y-%m-%d %H:%M:%S'`.chomp.stringify + }} end diff --git a/src/sentry/config.cr b/src/sentry/config.cr new file mode 100644 index 0000000..74de504 --- /dev/null +++ b/src/sentry/config.cr @@ -0,0 +1,149 @@ +module Sentry + class Config + include YAML::Serializable + + # `shard_name` is set as a class property so that it can be inferred from + # the `shard.yml` in the project directory. + class_property shard_name : String? + + @[YAML::Field(ignore: true)] + property? sets_build_full_command : Bool = false + @[YAML::Field(ignore: true)] + property? sets_run_command : Bool = false + @[YAML::Field(ignore: true)] + + @[YAML::Field(ignore: true)] + getter? sets_display_name : Bool = false + @[YAML::Field(ignore: true)] + getter? sets_build_command : Bool = false + getter? sets_build_args : Bool = false + @[YAML::Field(ignore: true)] + getter? sets_should_play_audio : Bool = false + @[YAML::Field(ignore: true)] + getter? sets_should_build : Bool = false + @[YAML::Field(ignore: true)] + getter? sets_colorize : Bool = false + @[YAML::Field(ignore: true)] + getter? sets_watch : Bool = false + + property src_path : String? + + getter display_name : String { self.class.shard_name.to_s } + + getter build_command : String = "crystal" + getter build_args : String? { "build #{src_path} -o #{run_command}" } + + getter run_command : String? { "#{src_path.to_s[%r(/([^/]*).cr$), 1]?}" } + property run_args : String = "" + + getter? should_build : Bool { !build_command.blank? } + + @[YAML::Field(key: "play_audio")] + getter? should_play_audio : Bool = true + + getter watch : Array(String) = ["./src/**/*.cr", "./src/**/*.ecr"] + + getter? colorize : Bool = true + + property? info : Bool = false + + property? run_shards_install : Bool = false + + # Initializing an empty configuration provides no default values. + def initialize + end + + def display_name=(new : String) + @sets_display_name = true + @display_name = new + end + + def build_command=(new : String) + @sets_build_command = true + @build_command = new + end + + def build_args=(new : String?) + @sets_build_args = true + @build_args = new + end + + def build_args_list : Array(String) + build_args.strip.split(" ").reject(&.empty?) + end + + def run_command=(new : String?) + @sets_run_command = true + @run_command = new + end + + def should_play_audio=(new : Bool) + @sets_should_play_audio = true + @should_play_audio = new + end + + def should_build=(new : Bool) + @sets_should_build = true + @should_build = new + end + + def colorize=(new : Bool) + @sets_colorize = true + @colorize = new + end + + def watch=(new : Array(String)) + @sets_watch = true + @watch = new + end + + def run_args_list : Array(String) + run_args.strip.split(" ").reject(&.empty?) + end + + def merge!(cli_config : self) : Nil + self.src_path = cli_config.src_path + + self.display_name = cli_config.display_name if cli_config.sets_display_name? + + self.build_command = cli_config.build_command if cli_config.sets_build_command? + self.build_args = cli_config.build_args if cli_config.sets_build_args? + + if cli_config.sets_build_full_command? + self.build_command = cli_config.build_command + self.build_args = cli_config.build_args + end + + self.run_command = cli_config.run_command if cli_config.sets_run_command? + self.run_args = cli_config.run_args unless cli_config.run_args.empty? + + self.should_build = cli_config.should_build? if cli_config.sets_should_build? + self.should_play_audio = cli_config.should_play_audio? if cli_config.sets_should_play_audio? + self.watch = cli_config.watch if cli_config.sets_watch? + self.colorize = cli_config.colorize? if cli_config.sets_colorize? + + # following properties default value is false in cli_config, so it's work. + self.info = cli_config.info? if cli_config.info? + self.run_shards_install = cli_config.run_shards_install? if cli_config.run_shards_install? + end + + def to_s(io : IO) : IO + io << <<-CONFIG + 🤖 Sentry configuration: + display name: #{display_name} + shard name: #{self.class.shard_name} + src_path: #{src_path} + build_command: #{build_command} + build_args: #{build_args} + run_command: #{run_command} + run_args: #{run_args} + watched files: #{watch} + colorize: #{colorize?} + run shards install: #{run_shards_install?} + should play audio: #{should_play_audio?} + should build: #{should_build?} + should print info: #{info?} + CONFIG + end + end +end diff --git a/src/sentry/process_runner.cr b/src/sentry/process_runner.cr new file mode 100644 index 0000000..fb4e960 --- /dev/null +++ b/src/sentry/process_runner.cr @@ -0,0 +1,200 @@ +module Sentry + class ProcessRunner + FILE_TIMESTAMPS = {} of String => String # {file => timestamp} + + {% if flag?(:linux) %} + @audio_player : AudioPlayer? + {% end %} + + @app_process : Process? + + def initialize( + @display_name : String, + @build_command : String, + @run_command : String, + @build_args_list : Array(String) = [] of String, + @run_args_list : Array(String) = [] of String, + @files = [] of String, + @should_build = true, + @run_shards_install = false, + @should_play_audio = true, + @colorize = true, + ) + @should_kill = false + @app_built = false + + Process.on_terminate do |reason| + case reason + when .interrupted? + @should_kill = true + end + end + + {% if flag?(:linux) %} + @audio_player = AudioPlayer.new if @should_play_audio + {% end %} + end + + def run_command : String + {% if flag?(:win32) %} + "#{@run_command}.exe" + {% else %} + @run_command + {% end %} + end + + def run : Nil + stdout "🤖 Your SentryBot is vigilant. beep-boop..." + + run_shards_install if @run_shards_install + + File.delete?(run_command) if @should_build + + loop do + if @should_kill + stdout "🤖 Powering down your SentryBot..." + + break + end + + scan_files + + sleep 1.second + end + end + + private def run_shards_install : Nil + stdout "🤖 Installing shards..." + + install_result = Process.run( + "shards", + ["install"], + output: :inherit, + error: :inherit + ) + + if !install_result || !install_result.success? + stdout "🤖 Error installing shards. SentryBot shutting down..." + + exit 1 + end + end + + # Scans all of the `@files` + # + private def scan_files : Process? + file_changed = false + app_process = @app_process + + begin + Dir.glob(@files) do |file| + timestamp = File.info(file).modification_time.to_unix.to_s + + if FILE_TIMESTAMPS[file]? && FILE_TIMESTAMPS[file] != timestamp + FILE_TIMESTAMPS[file] = timestamp + file_changed = true + + stdout "🤖 #{file}" + elsif FILE_TIMESTAMPS[file]?.nil? + stdout "🤖 watching file: #{file}" + + FILE_TIMESTAMPS[file] = timestamp + file_changed = true if (app_process && !app_process.terminated?) + end + end + rescue ex : File::Error + # The underlining lib for reading directories will fail very rarely, crashing Sentry + # This catches that error and allows Sentry to carry on normally + # https://github.com/crystal-lang/crystal/blob/677422167cbcce0aeea49531896dbdcadd2762db/src/crystal/system/unix/dir.cr#L19 + end + + start_app() if file_changed || app_process.nil? + end + + # Compiles and starts the application + # + private def start_app : Process? + return create_app_process unless @should_build + + audio_player = nil + + {% if flag?(:linux) %} + audio_player = @audio_player + {% end %} + + build_result = build_app_process + + if build_result && build_result.success? + @app_built = true + process = create_app_process + + audio_player.success unless audio_player.nil? + + process + elsif !@app_built # if build fails on first time compiling, then exit + stdout "🤖 Compile time errors detected. SentryBot shutting down..." + + audio_player.error unless audio_player.nil? + + exit 1 + else + audio_player.error unless audio_player.nil? + + nil + end + end + + private def build_app_process : Process::Status + stdout "🤖 compiling #{@display_name}..." + + {% if flag?(:win32) %} + if (app_process = @app_process).is_a? Process + stdout "🤖 killing #{@display_name}..." + app_process.terminate + # app_process.wait + end + {% end %} + + Process.run( + @build_command, + @build_args_list, + output: :inherit, + error: :inherit + ) + end + + private def create_app_process : Process + if (app_process = @app_process).is_a? Process + unless app_process.terminated? + stdout "🤖 killing #{@display_name}..." + app_process.terminate + app_process.wait + end + end + + stdout "🤖 starting #{@display_name}..." + + if File.file?(run_command) + @app_process = Process.new( + run_command, + @run_args_list, + output: :inherit, + error: :inherit + ) + else + puts "🤖 Sentry error: the inferred run command file(#{run_command}) \ +does not exist. either set correct run command use `-r COMMAND' or fix the \ +`BUILD ARGS' to output correct run command. SentryBot shutting down..." + exit 1 + end + end + + private def stdout(str : String) : Nil + if @colorize + puts str.colorize.fore(:yellow) + else + puts str + end + end + end +end diff --git a/src/sentry/sound_file_storage.cr b/src/sentry/sound_file_storage.cr new file mode 100644 index 0000000..d4c6955 --- /dev/null +++ b/src/sentry/sound_file_storage.cr @@ -0,0 +1,33 @@ +{% skip_file if flag?(:win32) %} + +require "baked_file_system" + +class SoundFileStorage + extend BakedFileSystem + + bake_folder "./sounds" +end + +class AudioPlayer + @success_wav : BakedFileSystem::BakedFile = SoundFileStorage.get("success.wav") + @error_wav : BakedFileSystem::BakedFile = SoundFileStorage.get("error.wav") + @player : String? + + def initialize + @player = Process.find_executable("aplay") + end + + def success + if (player = @player) + Process.new(command: player, input: @success_wav) + @success_wav.rewind + end + end + + def error + if (player = @player) + Process.new(command: player, input: @error_wav) + @error_wav.rewind + end + end +end diff --git a/src/sentry/sounds/error.wav b/src/sentry/sounds/error.wav new file mode 100644 index 0000000..74483b1 Binary files /dev/null and b/src/sentry/sounds/error.wav differ diff --git a/src/sentry/sounds/success.wav b/src/sentry/sounds/success.wav new file mode 100644 index 0000000..79f8de2 Binary files /dev/null and b/src/sentry/sounds/success.wav differ diff --git a/src/sentry_cli.cr b/src/sentry_cli.cr index 7f4866b..769cfe2 100644 --- a/src/sentry_cli.cr +++ b/src/sentry_cli.cr @@ -1,104 +1,235 @@ +require "yaml" require "option_parser" require "colorize" require "./sentry" begin shard_yml = YAML.parse File.read("shard.yml") - name = shard_yml["name"]? - Sentry::Config.shard_name = name.as_s if name + shard_name = shard_yml["name"]? + Sentry::Config.shard_name = shard_name.as_s if shard_name rescue e end -cli_config = Sentry::Config.new -cli_config_file_name = ".sentry.yml" - -# Set the default entry src path from shard.yml +# Set the default entry src path and build output binary name from shard.yml if shard_yml && (targets = shard_yml["targets"]?) - if targets - # use targets[]["main"] if exists - if name && (main_path = targets.dig?(name, "main")) - cli_config.src_path = main_path.as_s - elsif ((raw = targets.raw) && raw.is_a?(Hash)) - # otherwise, use the first key you find targets[]["main"] - if (first_key = raw.keys[0]?) && (main_path = targets.dig?(first_key, "main")) - cli_config.src_path = main_path.as_s - end + # use targets[]["main"] if exists + if shard_name && (main_path = targets.dig?(shard_name, "main")) + shard_run_command = "./bin/#{shard_name.as_s}" + shard_src_path = main_path.as_s + elsif (raw = targets.raw) && raw.is_a?(Hash) + # otherwise, use the first key you find targets[]["main"] + if (first_key = raw.keys[0]?) && (main_path = targets.dig?(first_key, "main")) + shard_run_command = "./bin/#{first_key.as_s}" + shard_src_path = main_path.as_s end end end -OptionParser.parse do |parser| - parser.banner = "Usage: ./sentry [options]" - parser.on( - "-n NAME", - "--name=NAME", - "Sets the display name of the app process (default name: #{Sentry::Config.shard_name})") { |name| cli_config.display_name = name } - parser.on( - "--src=PATH", - "Sets the entry path for the main crystal file (default inferred from shard.yaml)") { |path| cli_config.src_path = path } - parser.on( - "-b COMMAND", - "--build=COMMAND", - "Overrides the default build command (will override --src flag)") { |command| cli_config.build = command } - parser.on( - "--build-args=ARGS", - "Specifies arguments for the build command") { |args| cli_config.build_args = args } - parser.on( - "--no-build", - "Skips the build step") { cli_config.should_build = false } - parser.on( - "-r COMMAND", - "--run=COMMAND", - "Overrides the default run command") { |command| cli_config.run = command } - parser.on( - "--run-args=ARGS", - "Specifies arguments for the run command") { |args| cli_config.run_args = args } - parser.on( - "-w FILE", - "--watch=FILE", - "Overrides default files and appends to list of watched files") do |file| - cli_config.watch << file - end - parser.on( - "-c FILE", - "--config=FILE", - "Specifies a file to load for automatic configuration (default: '.sentry.yml')") do |file| - cli_config_file_name = file - end - parser.on( - "--install", - "Run 'shards install' once before running Sentry build and run commands") do - cli_config.install_shards = true - end - parser.on( - "--no-color", - "Removes colorization from output") do - cli_config.colorize = false - end - parser.on( - "-i", - "--info", - "Shows the values for build/run commands, build/run args, and watched files") do - cli_config.info = true +class SentryCli + @cli_config_file_name : String = ".sentry.yml" + @cli_config : Sentry::Config? + getter shard_src_path : String? + getter shard_run_command : String? + + def initialize( + @shard_src_path : String? = nil, + @shard_run_command : String? = nil, + @opts : Array(String) = ARGV, + ) end - parser.on( - "-h", - "--help", - "Show this help") do - puts parser - exit 0 + + def cli_config : Sentry::Config + @cli_config ||= begin + cli_config = Sentry::Config.new + + if shard_run_command.nil? || shard_src_path.nil? + cli_config.src_path = nil + cli_config.run_command = nil + else + Dir.mkdir("./bin") unless Dir.exists?("./bin") + cli_config.src_path = shard_src_path + cli_config.run_command = shard_run_command + end + + cli_config.sets_run_command = false + + OptionParser.parse(@opts) do |parser| + parser.banner = "Usage: ./sentry [options]" + parser.on( + "-n NAME", + "--name=NAME", + "Sets the display name of the app process (default: #{cli_config.display_name})" + ) do |opt| + cli_config.display_name = opt + end + + parser.on( + "--src=PATH", + "Sets the entry path for the main crystal file inferred from shard.yml (\ +default: #{cli_config.src_path})" + ) do |opt| + cli_config.src_path = opt + # Update build_args, run_command to nil make both getter re-evaluate + # use default value. + cli_config.build_args = nil + cli_config.run_command = nil + end + + parser.on( + "--build-command=COMMAND", + "Overrides the default build command (default: #{cli_config.build_command})" + ) do |command| + cli_config.build_command = command + end + + parser.on( + "--build-args=ARGS", + "Specifies arguments for the build command (default: #{cli_config.build_args})" + ) do |args| + cli_config.build_args = args + end + + parser.on( + "-b FULL_COMMAND", + "Set both `BUILD COMMAND' and `BUILD ARGS', for backwards compatibility (\ + default: #{cli_config.build_command} #{cli_config.build_args})" + ) do |full_command| + cli_config.sets_build_full_command = true + cli_config.build_command, cli_config.build_args = full_command.lstrip.split(" ", 2) + end + + parser.on( + "--no-build", + "Skips the build step" + ) do + cli_config.should_build = false + end + + parser.on( + "-r COMMAND", + "--run=COMMAND", + "Overrides the default run command inferred from shard.yml (default: #{cli_config.run_command})" + ) do |opt| + cli_config.run_command = opt + end + + parser.on( + "--run-args=ARGS", + "Specifies arguments for the run command, (default: '#{cli_config.run_args}')" + ) do |opt| + cli_config.run_args = opt + end + + parser.on( + "-w FILE", + "--watch=FILE", + "Appends to list of watched files, (will overrides default: #{cli_config.watch})" + ) do |file| + cli_config.watch = [] of String unless cli_config.sets_watch? + + cli_config.watch << file + end + + parser.on( + "-c FILE", + "--config=FILE", + "Specifies a file to load for automatic configuration (default: #{@cli_config_file_name})" + ) do |opt| + @cli_config_file_name = opt + end + + parser.on( + "--install", + "Run `shards install' once before running Sentry build and run commands" + ) do + cli_config.run_shards_install = true + end + + parser.on( + "--no-color", + "Removes colorization from output" + ) do + cli_config.colorize = false + end + + parser.on( + "--not-play-audio", + "Skips the attempt to play audio file with `aplay' from `alsa-utils' package \ +when building on Linux succeeds or fails" + ) do + cli_config.should_play_audio = false + end + + parser.on( + "-i", + "--info", + "Shows the configuration informations" + ) do + cli_config.info = true + end + + parser.on( + "-V", + "--version", + "Shows version" + ) do + puts Sentry::VERSION + exit + end + + parser.on( + "-h", + "--help", + "Show this help" + ) do + puts parser + exit + end + end + + cli_config + end end -end -config_yaml = "" -if File.exists?(cli_config_file_name) - config_yaml = File.read(cli_config_file_name) + def config : Sentry::Config + # It's necessary to run it once here to set correct @cli_config_file_name if use -c option. + cli_config = self.cli_config + + if File.exists?(@cli_config_file_name) + config_yaml = File.read(@cli_config_file_name) + else + config_yaml = "" + end + + if config_yaml.blank? && cli_config.src_path.nil? + puts "🤖 Sentry error: please set the entry path for the main crystal file use \ + --src or create a valid shard.yml" + + exit 1 + end + + # 这里配置文件的顺序是: + # 1. 如果配置文件中有, 使用它 + # 2. 如果配置文件中没有, 使用 propety 的默认值, 1, 2 的行为就是反序列化的默认行为 + # 3. 如果通过某种方式判断, cli_config 中手动设定了某个值, 总是使用该值 (见 merge! 方法定义) + + # configurations deserialized from yaml use default values settings in getter/property. + config = Sentry::Config.from_yaml(config_yaml) + + if config.run_command.blank? && !@shard_run_command.nil? + config.run_command = @shard_run_command + config.sets_run_command = false + end + + config.merge!(cli_config) + + config + end end -config = Sentry::Config.from_yaml(config_yaml) -config.merge!(cli_config) +config = SentryCli.new(shard_src_path, shard_run_command).config -if config.info +if config.info? if config.colorize? puts config.to_s.colorize.fore(:yellow) else @@ -106,21 +237,17 @@ if config.info end end -if Sentry::Config.shard_name - process_runner = Sentry::ProcessRunner.new( - display_name: config.display_name!, - build_command: config.build, - run_command: config.run, - build_args: config.build_args, - run_args: config.run_args, - should_build: config.should_build?, - files: config.watch, - install_shards: config.install_shards?, - colorize: config.colorize? - ) +process_runner = Sentry::ProcessRunner.new( + display_name: config.display_name, + build_command: config.build_command, + run_command: config.run_command, + build_args_list: config.build_args_list, + run_args_list: config.run_args_list, + should_build: config.should_build?, + files: config.watch, + run_shards_install: config.run_shards_install?, + should_play_audio: config.should_play_audio?, + colorize: config.colorize? +) - process_runner.run -else - puts "🤖 Sentry error: 'name' not given and not found in shard.yml" - exit 1 -end +process_runner.run