├── .custom-hooks └── pre-commit ├── .editorconfig ├── .env.example ├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── sweep-template.yml ├── SECURITY.md ├── codecov.yml ├── codeql.yml ├── dependabot.yml └── workflows │ ├── build-and-test.yml │ ├── codeql.yml │ ├── dependabot-auto-merge.yml │ └── run-goreleaser.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── Taskfile.dist.yaml ├── app ├── main-windows.go └── main.go ├── assets └── stackup-app-512px.png ├── go.mod ├── go.sum ├── lib ├── app │ ├── AppFlags.go │ ├── ChecksumVerificationState.go │ ├── IncludedTemplate.go │ ├── WorkflowInclude.go │ ├── WorkflowIncludeTypes.go │ ├── WorkflowInclude_test.go │ ├── WorkflowPrecondition.go │ ├── WorkflowState.go │ ├── app.go │ ├── commands │ │ └── init_config_file.go │ ├── tasks.go │ └── workflow.go ├── cache │ ├── CacheEntry.go │ ├── cache.go │ └── cache_test.go ├── checksums │ ├── algorithms.go │ ├── checksums.go │ ├── hashes.go │ ├── matching.go │ └── utils.go ├── consts │ └── consts.go ├── debug │ └── debug.go ├── downloader │ ├── downloader.go │ └── s3downloader.go ├── gateway │ ├── ValidateUrlMiddleware.go │ ├── VerifyContentTypeMIddleware.go │ ├── VerifyFileTypeMiddleware.go │ ├── gateway.go │ ├── gateway_test.go │ └── middleware.go ├── messages │ └── messages.go ├── notifications │ ├── desktop.go │ ├── slack.go │ └── telegram.go ├── scripting │ ├── extensions │ │ ├── app_extension │ │ │ └── scriptApp.go │ │ ├── dev_extension │ │ │ ├── scriptComposerJson.go │ │ │ ├── scriptDev.go │ │ │ ├── scriptPackageJson.go │ │ │ └── scriptRequirementsTxt.go │ │ ├── fs_extension │ │ │ └── scriptFs.go │ │ ├── functions_extension │ │ │ └── scriptFunctions.go │ │ ├── net_extension │ │ │ └── scriptNet.go │ │ └── vars_extension │ │ │ └── scriptVars.go │ ├── scriptEngine.go │ └── scriptNotifications.go ├── semver │ └── semver.go ├── settings │ └── settings.go ├── support │ ├── helpers.go │ └── helpers_test.go ├── telemetry │ ├── telemetry.go │ └── transport.go ├── types │ ├── enums.go │ └── types.go ├── updater │ ├── Release.go │ └── updater.go ├── utils │ ├── utils.go │ └── utils_test.go └── version │ └── app-version.go ├── sweep.yaml ├── templates ├── init.stackup.template.yaml ├── remote-includes │ ├── checksums.sha256.txt │ ├── containers.yaml │ ├── laravel.yaml │ ├── node.yaml │ ├── php.yaml │ ├── python.yaml │ └── shared-settings.yaml ├── stackup.dist.yaml └── stackup.laravel.yaml └── tools ├── generate-version-file.go └── get-version.go /.custom-hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | PROJECT_BASE_PATH=$(git rev-parse --show-toplevel) 4 | cd $PROJECT_BASE_PATH 5 | 6 | NPX_BIN=$(which bunx npx | grep -v 'not found' | head -n 1 || echo "") 7 | GOLINT_BIN=$(which golangci-lint | grep -v 'not found' | head -n 1 || echo "") 8 | 9 | GIT_STAGED_FILES=$(git diff --name-only --staged) 10 | GO_STAGED_FILES_COUNT=$(printf "$GIT_STAGED_FILES" | grep -E '\.go$' | wc -l | grep -E '[1-9][0-9]*$' || echo "0") 11 | MARKDOWN_STAGED_FILES=$(printf "$GIT_STAGED_FILES" | grep -E '\.md$' | tr '\n' ' ') 12 | PRETTIER_STAGED_FILES=$(printf "$GIT_STAGED_FILES" | grep -E '\.(json|yaml|yml)$' | tr '\n' ' ') 13 | 14 | runCommand() { 15 | CMD=$1 16 | shift 1 17 | echo "[pre-commit] $CMD $@" 18 | $CMD $@ 19 | } 20 | 21 | if [ "$GO_STAGED_FILES_COUNT" != "0" ] && [ "$GOLINT_BIN" != "" ]; then 22 | LAST_COMMIT=$(git rev-parse HEAD) 23 | runCommand "$GOLINT_BIN" run -c ./.golangci.yaml --new-from-rev $LAST_COMMIT . 24 | fi 25 | 26 | if [ "$MARKDOWN_STAGED_FILES" != "" ] && [ "$NPX_BIN" != "" ]; then 27 | runCommand "$NPX_BIN" markdownlint-cli --fix $MARKDOWN_STAGED_FILES 28 | git add $MARKDOWN_STAGED_FILES 29 | fi 30 | 31 | if [ "$PRETTIER_STAGED_FILES" != "" ] && [ "$NPX_BIN" != "" ]; then 32 | runCommand "$NPX_BIN" prettier --write $PRETTIER_STAGED_FILES 33 | git add $PRETTIER_STAGED_FILES 34 | fi 35 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [Makefile] 12 | indent_style = tab 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [*.{yml,yaml}] 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | FRONTEND_PROJECT_PATH= 2 | LOCAL_BACKEND_PROJECT_PATH= 3 | 4 | TELEGRAM_API_KEY= 5 | TELEGRAM_CHAT_ID_1= 6 | TELEGRAM_CHAT_ID_2= 7 | 8 | SLACK_CHANNEL_1= 9 | SLACK_WEBHOOK_URL= 10 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 14 | world that developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 17 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 18 | 19 | ## Viability 20 | 21 | When requesting or submitting new features, first consider whether it might be useful to others. Open 22 | source projects are used by many developers, who may have entirely different needs to your own. Think about 23 | whether or not your feature is likely to be used by other users of the project. 24 | 25 | ## Procedure 26 | 27 | Before filing an issue: 28 | 29 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 30 | - Check to make sure your feature suggestion isn't already present within the project. 31 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 32 | - Check the pull requests tab to ensure that the feature isn't already in progress. 33 | 34 | Before submitting a pull request: 35 | 36 | - Check the codebase to ensure that your feature doesn't already exist. 37 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 38 | 39 | ## Requirements 40 | 41 | If the project maintainer has any additional requirements, you will find them listed here. 42 | 43 | - **Use Modern JS/TS** - Unless the project is specifically javascript, TypeScript is preferable. 44 | 45 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 46 | 47 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 48 | 49 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 50 | 51 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 52 | 53 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 54 | 55 | **Happy coding**! 56 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: permafrost-dev 2 | custom: https://permafrost-dev/open-source 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/sweep-template.yml: -------------------------------------------------------------------------------- 1 | name: Sweep Issue 2 | title: 'Sweep: ' 3 | description: For small bugs, features, refactors, and tests to be handled by Sweep, an AI-powered junior developer. 4 | labels: sweep 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Details 10 | description: Tell Sweep where and what to edit and provide enough context for a new developer to the codebase 11 | placeholder: | 12 | Unit Tests: Write unit tests for . Test each function in the file. Make sure to test edge cases. 13 | Bugs: The bug might be in . Here are the logs: ... 14 | Features: the new endpoint should use the ... class from because it contains ... logic. 15 | Refactors: We are migrating this function to ... version because ... -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | If you discover any security related issues, please email patrick@permafrost.dev instead of using the issue tracker. 4 | 5 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: true 3 | 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | informational: true 9 | target: auto 10 | threshold: 30% 11 | patch: 12 | default: 13 | informational: true 14 | precision: 2 15 | round: nearest 16 | range: "70...100" 17 | 18 | parsers: 19 | javascript: 20 | enable_partials: yes 21 | gcov: 22 | branch_detection: 23 | conditional: yes 24 | loop: yes 25 | method: no 26 | macro: no 27 | 28 | comment: 29 | layout: "reach,diff,flags,files,footer" 30 | behavior: default 31 | require_changes: true 32 | 33 | ignore: 34 | - "build/" 35 | - "dist/" 36 | - "coverage/" 37 | - "tools/" 38 | - "main.go" 39 | -------------------------------------------------------------------------------- /.github/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | disable-default-queries: false 4 | 5 | paths: 6 | - app 7 | - lib 8 | 9 | paths-ignore: 10 | - tools 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: gomod 5 | directory: "/" 6 | open-pull-requests-limit: 10 7 | schedule: 8 | interval: weekly 9 | 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | open-pull-requests-limit: 10 13 | schedule: 14 | interval: weekly 15 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: build-and-test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - v* 8 | pull_request: 9 | pull_request_target: 10 | types: [opened, edited] 11 | 12 | env: 13 | GO_VERSION: "~1.23" 14 | GO111MODULE: "on" 15 | 16 | jobs: 17 | test-build: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Install Task 22 | uses: arduino/setup-task@v2 23 | 24 | - name: Install Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: ${{ env.GO_VERSION }} 28 | 29 | - name: Checkout code 30 | uses: actions/checkout@v4 31 | 32 | - name: Download and tidy Go modules 33 | run: task mod 34 | 35 | - name: Build Application 36 | id: build 37 | run: task build-stackup 38 | 39 | run-tests: 40 | runs-on: ubuntu-latest 41 | 42 | steps: 43 | - name: Install Task 44 | uses: arduino/setup-task@v2 45 | 46 | - name: Install Go 47 | uses: actions/setup-go@v5 48 | with: 49 | go-version: ${{ env.GO_VERSION }} 50 | 51 | - name: Checkout code 52 | uses: actions/checkout@v4 53 | 54 | - name: Download and tidy Go modules 55 | run: task mod 56 | 57 | - name: Run Tests with Coverage 58 | run: go test -cover -coverprofile coverage.out -v ./lib/** 59 | 60 | - name: Upload coverage reports to Codecov 61 | if: ${{success()}} 62 | uses: codecov/codecov-action@v5 63 | with: 64 | token: ${{ secrets.CODECOV_TOKEN }} 65 | files: ./coverage.out 66 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | push: 16 | branches: ["main"] 17 | pull_request: 18 | branches: ["main"] 19 | schedule: 20 | - cron: "27 22 * * 5" 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze Go 25 | runs-on: "ubuntu-latest" 26 | permissions: 27 | security-events: write 28 | packages: read 29 | actions: read 30 | contents: read 31 | 32 | strategy: 33 | fail-fast: false 34 | 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v4 38 | 39 | - name: Initialize CodeQL 40 | uses: github/codeql-action/init@v3 41 | with: 42 | languages: go 43 | build-mode: autobuild 44 | config-file: ./.github/codeql.yml 45 | queries: security-and-quality 46 | # packs: codeql/go-all 47 | 48 | - name: Perform CodeQL Analysis 49 | uses: github/codeql-action/analyze@v3 50 | with: 51 | category: "/language:go" 52 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-auto-merge 2 | on: pull_request_target 3 | 4 | permissions: 5 | pull-requests: write 6 | contents: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v2.4.0 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | 19 | - name: Dependabot auto-merge minor & patch updates 20 | if: ${{steps.metadata.outputs.update-type != 'version-update:semver-major'}} 21 | run: gh pr merge --auto --merge "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 25 | 26 | - name: Dependabot auto-merge actions major updates (if compat >= 90%) 27 | if: ${{steps.metadata.outputs.package-ecosystem == 'github_actions' && steps.metadata.outputs.update-type == 'version-update:semver-major' && steps.metadata.outputs.compatibility-score >= 90}} 28 | run: gh pr merge --auto --merge "$PR_URL" 29 | env: 30 | PR_URL: ${{github.event.pull_request.html_url}} 31 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 32 | -------------------------------------------------------------------------------- /.github/workflows/run-goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: run-goreleaser 2 | 3 | on: 4 | push: 5 | # run only against tags 6 | tags: 7 | - "v*" 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | artifact-build: 14 | runs-on: ${{ matrix.os }} 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [ubuntu-latest] 20 | 21 | steps: 22 | - name: Install Task 23 | uses: arduino/setup-task@v2 24 | 25 | - uses: actions/setup-go@v5 26 | with: 27 | go-version: ">=1.23.0" 28 | 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 0 33 | 34 | - run: git fetch --force --tags 35 | 36 | # validate the configuration file 37 | - uses: goreleaser/goreleaser-action@v6 38 | with: 39 | distribution: goreleaser-pro 40 | version: latest 41 | args: check 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 45 | 46 | - uses: goreleaser/goreleaser-action@v6 47 | with: 48 | distribution: goreleaser-pro 49 | version: latest 50 | args: release --clean 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/go,goland,visualstudiocode,dotenv,git 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=go,goland,visualstudiocode,dotenv,git 3 | 4 | Taskfile.yaml 5 | Taskfile.yml 6 | stackup.dev.yaml 7 | dist 8 | .task 9 | .trunk 10 | build 11 | 12 | ### dotenv ### 13 | .env 14 | .env.* 15 | !.env.example 16 | 17 | ### Git ### 18 | # Created by git for backups. To disable backups in Git: 19 | # $ git config --global mergetool.keepBackup false 20 | *.orig 21 | 22 | # Created by git when using merge tools for conflicts 23 | *.BACKUP.* 24 | *.BASE.* 25 | *.LOCAL.* 26 | *.REMOTE.* 27 | *_BACKUP_*.txt 28 | *_BASE_*.txt 29 | *_LOCAL_*.txt 30 | *_REMOTE_*.txt 31 | 32 | ### Go ### 33 | # If you prefer the allow list template instead of the deny list, see community template: 34 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 35 | # 36 | # Binaries for programs and plugins 37 | *.exe 38 | *.exe~ 39 | *.dll 40 | *.so 41 | *.dylib 42 | 43 | # Test binary, built with `go test -c` 44 | *.test 45 | 46 | # Output of the go coverage tool, specifically when used with LiteIDE 47 | *.out 48 | 49 | # Dependency directories (remove the comment below to include it) 50 | vendor/ 51 | 52 | # Go workspace file 53 | go.work 54 | 55 | /Godeps/ 56 | 57 | # User-specific stuff 58 | .idea/ 59 | .idea_modules/ 60 | 61 | 62 | ### VisualStudioCode ### 63 | .vscode/* 64 | !.vscode/tasks.json 65 | !.vscode/launch.json 66 | 67 | # Local History for Visual Studio Code 68 | .history/ 69 | 70 | ### VisualStudioCode Patch ### 71 | # Ignore all local history of files 72 | .history 73 | .ionide 74 | 75 | # Ignore code-workspaces 76 | *.code-workspace 77 | 78 | # custom 79 | *.test 80 | *.local 81 | dist/ 82 | build/ 83 | .task/ 84 | 85 | dist/ 86 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 15s 3 | modules-download-mode: readonly 4 | 5 | # Include test files or not. Default: true 6 | tests: true 7 | 8 | allow-parallel-runners: true 9 | concurrency: 8 10 | 11 | linters: 12 | disable-all: true 13 | fast: true 14 | enable: 15 | - gocyclo 16 | - govet 17 | - maintidx 18 | - misspell 19 | - unused 20 | - unparam 21 | 22 | severity: 23 | default-severity: warning 24 | case-sensitive: false 25 | 26 | linters-settings: 27 | gocyclo: 28 | min-complexity: 30 29 | 30 | funlen: 31 | lines: 100 32 | statements: 80 33 | 34 | gocritic: 35 | disabled-checks: 36 | - exitAfterDefer 37 | 38 | gosec: 39 | severity: medium 40 | config: 41 | G301: "0755" # Maximum allowed permissions mode for os.Mkdir and os.MkdirAll - Default: "0750" 42 | G302: "0644" # Maximum allowed permissions mode for os.OpenFile and os.Chmod - Default: "0600" 43 | G306: "0644" # Maximum allowed permissions mode for os.WriteFile and ioutil.WriteFile - Default: "0600" 44 | 45 | maintidx: 46 | under: 25 47 | 48 | misspell: 49 | locale: US 50 | 51 | nestif: 52 | min-complexity: 4 # Minimal complexity of if statements to report. - Default: 5 53 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | 3 | version: 2 4 | 5 | env: 6 | - GO111MODULE=on 7 | - CGO_ENABLED=0 8 | 9 | before: 10 | hooks: 11 | - go mod tidy 12 | - task update-version-file 13 | 14 | dist: build 15 | 16 | archives: 17 | - format_overrides: 18 | - goos: windows 19 | format: zip 20 | 21 | builds: 22 | - main: "./main.go" 23 | binary: "stackup" 24 | mod_timestamp: "{{ .CommitTimestamp }}" 25 | flags: 26 | - -trimpath 27 | ldflags: 28 | - -s -w -X main.build={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} 29 | targets: 30 | - linux_amd64 31 | - darwin_arm64 32 | - darwin_amd64 33 | - windows_amd64 34 | 35 | checksum: 36 | name_template: "checksums.txt" 37 | algorithm: sha256 38 | 39 | snapshot: 40 | version_template: "{{ incpatch .Version }}-next" 41 | 42 | changelog: 43 | sort: asc 44 | filters: 45 | exclude: 46 | - "^docs:" 47 | - "^test:" 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Permafrost Software LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Taskfile.dist.yaml: -------------------------------------------------------------------------------- 1 | # This file can be run with the `task` utility: https://taskfile.dev/ 2 | version: '3' 3 | 4 | dotenv: ['.env', '.env.local'] 5 | 6 | vars: 7 | APP_VERSION_FILE: './lib/version/app-version.go' 8 | BUILD_OUTPUT_DIR: './dist' 9 | DIST_TARGET_FILE: '{{.BUILD_OUTPUT_DIR}}/stackup' 10 | ENTRY_FILENAME: './app/main.go' 11 | 12 | tasks: 13 | 14 | mod: 15 | desc: Downloads and tidy Go modules 16 | cmds: 17 | - go mod download 18 | - go mod tidy 19 | 20 | test: 21 | desc: Runs tests 22 | cmds: 23 | - go test -cover ./lib/** 24 | 25 | build: 26 | desc: Builds application 27 | preconditions: 28 | - task: prepare-dist-folder 29 | - task: mod 30 | deps: 31 | - task: update-version-file 32 | cmds: 33 | - task: build-stackup 34 | - task: copy-config-files-to-dist 35 | 36 | clean: 37 | desc: Cleans up build artifacts 38 | preconditions: 39 | - test -d {{.BUILD_OUTPUT_DIR}} 40 | - test -f {{.DIST_TARGET_FILE}} 41 | cmds: 42 | - rm -f {{.DIST_TARGET_FILE}} 43 | 44 | lint: 45 | dir: . 46 | preconditions: 47 | - which golangci-lint 48 | - test -f .golangci.yaml 49 | - which dotenv-linter 50 | - which typos 51 | - which actionlint 52 | - which shellcheck 53 | cmds: 54 | - golangci-lint run 55 | - dotenv-linter --quiet --not-check-updates --recursive --exclude dist/.env 56 | - actionlint 57 | - shellcheck ./.custom-hooks/* 58 | - typos 59 | 60 | update-version-file: 61 | cmds: 62 | - go run tools/generate-version-file.go 63 | 64 | prepare-dist-folder: 65 | desc: Prepares dist folder 66 | silent: true 67 | cmds: 68 | - mkdir -p {{.BUILD_OUTPUT_DIR}} 69 | 70 | build-stackup: 71 | desc: Builds stackup binary 72 | vars: 73 | GIT_COMMIT: 74 | sh: git log -n 1 --format=%h 75 | deps: 76 | - task: prepare-dist-folder 77 | sources: 78 | - '{{.ENTRY_FILENAME}}' 79 | - ./lib/**/*.go 80 | - ./lib/*.go 81 | generates: 82 | - '{{.DIST_TARGET_FILE}}' 83 | cmds: 84 | - go build -trimpath -ldflags="-s -w -X main.Version={{.VERSION}}-{{.GIT_COMMIT}}" -o {{.DIST_TARGET_FILE}} {{.ENTRY_FILENAME}} 85 | 86 | copy-config-files-to-dist: 87 | desc: Copies config template to dist folder 88 | deps: 89 | - task: prepare-dist-folder 90 | cmds: 91 | - cp -f ./templates/*.yaml {{.BUILD_OUTPUT_DIR}} 92 | - cp -f ./.env {{.BUILD_OUTPUT_DIR}} 93 | 94 | update-checksums: 95 | cmds: 96 | - sha256sum ./templates/remote-includes/*.yaml > ./templates/remote-includes/checksums.sha256.txt 97 | - sed 's/\.\/templates\/remote-includes\///g' ./templates/remote-includes/checksums.sha256.txt --in-place 98 | 99 | autobuild: 100 | interactive: true 101 | desc: Watches for changes and automatically rebuilds the project binary, displays a minimal system notification on start/finish 102 | preconditions: 103 | - which watchexec 104 | cmds: 105 | - watchexec --exts go --fs-events create,modify,remove -N --debounce 300 -w ./lib -- task build -f 106 | -------------------------------------------------------------------------------- /app/main-windows.go: -------------------------------------------------------------------------------- 1 | //go:build WINDOWS 2 | 3 | package main 4 | 5 | import ( 6 | "os/exec" 7 | 8 | "github.com/stackup-app/stackup/lib/app" 9 | "github.com/stackup-app/stackup/lib/utils" 10 | ) 11 | 12 | func main() { 13 | app.App = app.NewApplication() 14 | 15 | app.App.CmdStartCallback = func(cmd *exec.Cmd) {} 16 | app.App.KillCommandCallback = func(cmd *exec.Cmd) { 17 | utils.KillProcessOnWindows(cmd) 18 | } 19 | 20 | app.App.Run() 21 | } 22 | -------------------------------------------------------------------------------- /app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "syscall" 6 | 7 | "github.com/stackup-app/stackup/lib/app" 8 | ) 9 | 10 | func main() { 11 | app.App = app.NewApplication() 12 | 13 | app.App.CmdStartCallback = func(cmd *exec.Cmd) { 14 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 15 | } 16 | 17 | app.App.KillCommandCallback = func(cmd *exec.Cmd) { 18 | syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) 19 | } 20 | 21 | app.App.Run() 22 | } 23 | -------------------------------------------------------------------------------- /assets/stackup-app-512px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/permafrost-dev/stackup/5af090f4a686c666488768062fe20008e3aea79e/assets/stackup-app-512px.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/stackup-app/stackup 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/blang/semver v3.5.1+incompatible 9 | github.com/dotenv-org/godotenvvault v0.6.0 10 | github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 11 | github.com/gobwas/glob v0.2.3 12 | github.com/joho/godotenv v1.5.1 13 | github.com/logrusorgru/aurora v2.0.3+incompatible 14 | github.com/nikoksr/notify v0.41.1 15 | github.com/robertkrimen/otto v0.5.1 16 | github.com/robfig/cron/v3 v3.0.1 17 | github.com/slack-go/slack v0.15.0 18 | github.com/stretchr/testify v1.10.0 19 | gopkg.in/yaml.v2 v2.4.0 20 | ) 21 | 22 | require ( 23 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 24 | github.com/dromara/carbon/v2 v2.6.7 // indirect 25 | github.com/dustin/go-humanize v1.0.1 // indirect 26 | github.com/go-ini/ini v1.67.0 // indirect 27 | github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect 28 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect 29 | github.com/goccy/go-json v0.10.5 // indirect 30 | github.com/godbus/dbus/v5 v5.1.0 // indirect 31 | github.com/google/uuid v1.6.0 // indirect 32 | github.com/gorilla/websocket v1.5.3 // indirect 33 | github.com/klauspost/compress v1.18.0 // indirect 34 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 35 | github.com/minio/crc64nvme v1.0.1 // indirect 36 | github.com/minio/md5-simd v1.1.2 // indirect 37 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect 38 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect 39 | github.com/pkg/errors v0.9.1 // indirect 40 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 41 | github.com/rs/xid v1.6.0 // indirect 42 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect 43 | github.com/technoweenie/multipartstreamer v1.0.1 // indirect 44 | github.com/tinylib/msgp v1.3.0 // indirect 45 | golang.org/x/crypto v0.36.0 // indirect 46 | golang.org/x/net v0.38.0 // indirect 47 | golang.org/x/sync v0.12.0 // indirect 48 | golang.org/x/sys v0.31.0 // indirect 49 | golang.org/x/text v0.23.0 // indirect 50 | gopkg.in/sourcemap.v1 v1.0.5 // indirect 51 | gopkg.in/yaml.v3 v3.0.1 // indirect 52 | ) 53 | 54 | require ( 55 | github.com/denisbrodbeck/machineid v1.0.1 56 | github.com/emirpasic/gods v1.18.1 57 | github.com/gen2brain/beeep v0.0.0-20230602101333-f384c29b62dd 58 | github.com/golang-module/carbon/v2 v2.6.7 59 | github.com/kr/pretty v0.3.1 // indirect 60 | github.com/minio/minio-go v6.0.14+incompatible 61 | github.com/minio/minio-go/v7 v7.0.92 62 | github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69 63 | go.etcd.io/bbolt v1.4.0 64 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 65 | ) 66 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= 3 | github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 8 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= 10 | github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= 11 | github.com/dotenv-org/godotenvvault v0.6.0 h1:e6rUPELZaPmf6SgxxdB3nACG9VQAE8+omrSSZm0QUgk= 12 | github.com/dotenv-org/godotenvvault v0.6.0/go.mod h1:q/635WfmO04uUBVwrDWchRPOvPWaplWC6Udm+illcS4= 13 | github.com/dromara/carbon/v2 v2.6.7 h1:seSMHv6SbVKWXRF2WMCm2JQCIQMy39aeIXq7aR3g82A= 14 | github.com/dromara/carbon/v2 v2.6.7/go.mod h1:7GXqCUplwN1s1b4whGk2zX4+g4CMCoDIZzmjlyt0vLY= 15 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 16 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 17 | github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= 18 | github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= 19 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 20 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 21 | github.com/gen2brain/beeep v0.0.0-20230602101333-f384c29b62dd h1:eVPIv7aXHQYJ5lbhXHoJyfPhivIn+BvH2xPoG62lT2w= 22 | github.com/gen2brain/beeep v0.0.0-20230602101333-f384c29b62dd/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4= 23 | github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= 24 | github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= 25 | github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= 26 | github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= 27 | github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= 28 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 29 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= 30 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= 31 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 32 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 33 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 34 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 35 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 36 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 37 | github.com/golang-module/carbon/v2 v2.6.7 h1:mkd6BCBg96XPCA61QWIGC567iMFHiXVXZWGRiQsw7iU= 38 | github.com/golang-module/carbon/v2 v2.6.7/go.mod h1:qHWBSjG/EhY9CLPSPbdA5QCpMCVgzzPF/25IrmbieLA= 39 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 40 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 41 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 42 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 43 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 44 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 45 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 46 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 47 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 48 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 49 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 50 | github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= 51 | github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= 52 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 53 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 54 | github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 55 | github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 56 | github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 57 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 58 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 59 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 60 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 61 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 62 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 63 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 64 | github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= 65 | github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= 66 | github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY= 67 | github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= 68 | github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= 69 | github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= 70 | github.com/minio/minio-go v6.0.14+incompatible h1:fnV+GD28LeqdN6vT2XdGKW8Qe/IfjJDswNVuni6km9o= 71 | github.com/minio/minio-go v6.0.14+incompatible/go.mod h1:7guKYtitv8dktvNUGrhzmNlA5wrAABTQXCoesZdFQO8= 72 | github.com/minio/minio-go/v7 v7.0.92 h1:jpBFWyRS3p8P/9tsRc+NuvqoFi7qAmTCFPoRFmobbVw= 73 | github.com/minio/minio-go/v7 v7.0.92/go.mod h1:vTIc8DNcnAZIhyFsk8EB90AbPjj3j68aWIEQCiPj7d0= 74 | github.com/nikoksr/notify v0.41.1 h1:k2N8gO3O7PG0Tf29ZdpwDvu/5VgkuWBIJ7v+BPG9+E4= 75 | github.com/nikoksr/notify v0.41.1/go.mod h1:axNOKFPdBEeJ8fbC0k8hDT+k653oWoHAtaiGbpZUAkM= 76 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 77 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 78 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= 79 | github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= 80 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 81 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 82 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 83 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 84 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 85 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 86 | github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69 h1:01dHVodha5BzrMtVmcpPeA4VYbZEsTXQ6m4123zQXJk= 87 | github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69/go.mod h1:migYMxlAqcnQy+3eN8mcL0b2tpKy6R+8Zc0lxwk4dKM= 88 | github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0= 89 | github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8= 90 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 91 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 92 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 93 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 94 | github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= 95 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 96 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 97 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 98 | github.com/slack-go/slack v0.15.0 h1:LE2lj2y9vqqiOf+qIIy0GvEoxgF1N5yLGZffmEZykt0= 99 | github.com/slack-go/slack v0.15.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= 100 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 101 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 102 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 103 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= 104 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= 105 | github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= 106 | github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= 107 | github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= 108 | github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= 109 | github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 110 | go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= 111 | go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= 112 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 113 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 114 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 115 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 116 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 117 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 118 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 119 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 120 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 121 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 122 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 123 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 124 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 125 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 126 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 127 | gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= 128 | gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= 129 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 130 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 131 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 132 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 133 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 134 | -------------------------------------------------------------------------------- /lib/app/AppFlags.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/stackup-app/stackup/lib/app/commands" 9 | "github.com/stackup-app/stackup/lib/version" 10 | ) 11 | 12 | type AppFlags struct { 13 | DisplayHelp *bool 14 | DisplayVersion *bool 15 | NoUpdateCheck *bool 16 | ConfigFile *string 17 | app *Application 18 | } 19 | 20 | func (af *AppFlags) Parse() { 21 | flag.Parse() 22 | 23 | if af.ConfigFile != nil && *af.ConfigFile != "" { 24 | af.app.ConfigFilename = *af.ConfigFile 25 | } 26 | 27 | af.handle() 28 | } 29 | 30 | func (af *AppFlags) handle() { 31 | if *af.DisplayHelp { 32 | flag.Usage() 33 | os.Exit(0) 34 | } 35 | 36 | if *af.DisplayVersion { 37 | fmt.Println("StackUp version " + version.APP_VERSION) 38 | os.Exit(0) 39 | } 40 | 41 | if len(os.Args) > 1 && os.Args[1] == "init" { 42 | commands.CreateNewConfigFile(af.app.Gateway) 43 | os.Exit(0) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/app/ChecksumVerificationState.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // ENUM(not verified, pending, verified, mismatch, error) 9 | type ChecksumVerificationState int 10 | 11 | const ( 12 | // ChecksumVerificationStateNotVerified is a ChecksumVerificationState of type Not Verified. 13 | ChecksumVerificationStateNotVerified ChecksumVerificationState = iota 14 | // ChecksumVerificationStatePending is a ChecksumVerificationState of type Pending. 15 | ChecksumVerificationStatePending 16 | // ChecksumVerificationStateVerified is a ChecksumVerificationState of type Verified. 17 | ChecksumVerificationStateVerified 18 | // ChecksumVerificationStateMismatch is a ChecksumVerificationState of type Mismatch. 19 | ChecksumVerificationStateMismatch 20 | // ChecksumVerificationStateError is a ChecksumVerificationState of type Error. 21 | ChecksumVerificationStateError 22 | ) 23 | 24 | type ChecksumVerificationStates []ChecksumVerificationState 25 | 26 | var AllFinalCHecksumVerificationStates = ChecksumVerificationStates{ 27 | ChecksumVerificationStateVerified, 28 | ChecksumVerificationStateMismatch, 29 | ChecksumVerificationStateError, 30 | } 31 | 32 | var NonErrorFinalChecksumVerificationStates = ChecksumVerificationStates{ 33 | ChecksumVerificationStateVerified, 34 | ChecksumVerificationStateMismatch, 35 | } 36 | 37 | var ErrInvalidChecksumVerificationState = errors.New("not a valid ChecksumVerificationState") 38 | 39 | const _ChecksumVerificationStateName = "not verifiedpendingverifiedmismatcherror" 40 | 41 | var _ChecksumVerificationStateMap = map[ChecksumVerificationState]string{ 42 | ChecksumVerificationStateNotVerified: _ChecksumVerificationStateName[0:12], 43 | ChecksumVerificationStatePending: _ChecksumVerificationStateName[12:19], 44 | ChecksumVerificationStateVerified: _ChecksumVerificationStateName[19:27], 45 | ChecksumVerificationStateMismatch: _ChecksumVerificationStateName[27:35], 46 | ChecksumVerificationStateError: _ChecksumVerificationStateName[35:40], 47 | } 48 | 49 | // String implements the Stringer interface. 50 | func (x ChecksumVerificationState) String() string { 51 | if str, ok := _ChecksumVerificationStateMap[x]; ok { 52 | return str 53 | } 54 | return fmt.Sprintf("ChecksumVerificationState(%d)", x) 55 | } 56 | 57 | func (x ChecksumVerificationState) IsVerified() bool { 58 | return x == ChecksumVerificationStateVerified 59 | } 60 | 61 | func (x ChecksumVerificationState) IsPending() bool { 62 | return x == ChecksumVerificationStatePending 63 | } 64 | 65 | func (x ChecksumVerificationState) IsMismatch() bool { 66 | return x == ChecksumVerificationStateMismatch 67 | } 68 | 69 | func (x ChecksumVerificationState) IsError() bool { 70 | return x == ChecksumVerificationStateError 71 | } 72 | 73 | // IsValid provides a quick way to determine if the typed value is 74 | // part of the allowed enumerated values 75 | func (x ChecksumVerificationState) IsValid() bool { 76 | _, ok := _ChecksumVerificationStateMap[x] 77 | return ok 78 | } 79 | 80 | var _ChecksumVerificationStateValue = map[string]ChecksumVerificationState{ 81 | _ChecksumVerificationStateName[0:12]: ChecksumVerificationStateNotVerified, 82 | _ChecksumVerificationStateName[12:19]: ChecksumVerificationStatePending, 83 | _ChecksumVerificationStateName[19:27]: ChecksumVerificationStateVerified, 84 | _ChecksumVerificationStateName[27:35]: ChecksumVerificationStateMismatch, 85 | _ChecksumVerificationStateName[35:40]: ChecksumVerificationStateError, 86 | } 87 | 88 | var _ChecksumVerificationStateTransitionMap = map[ChecksumVerificationState]ChecksumVerificationStates{ 89 | ChecksumVerificationStateNotVerified: {ChecksumVerificationStatePending}, 90 | ChecksumVerificationStatePending: AllFinalCHecksumVerificationStates, // {ChecksumVerificationStateVerified, ChecksumVerificationStateMismatch, ChecksumVerificationStateError}, 91 | ChecksumVerificationStateVerified: {}, 92 | ChecksumVerificationStateMismatch: {}, 93 | ChecksumVerificationStateError: {}, 94 | } 95 | 96 | // ParseChecksumVerificationState attempts to convert a string to a ChecksumVerificationState. 97 | func ParseChecksumVerificationState(name string) (ChecksumVerificationState, error) { 98 | if x, ok := _ChecksumVerificationStateValue[name]; ok { 99 | return x, nil 100 | } 101 | return ChecksumVerificationState(0), fmt.Errorf("%s is %w", name, ErrInvalidChecksumVerificationState) 102 | } 103 | 104 | func (x *ChecksumVerificationState) IsInFinalState() bool { 105 | for _, finalState := range AllFinalCHecksumVerificationStates { 106 | if *x == finalState { 107 | return true 108 | } 109 | } 110 | 111 | return false 112 | } 113 | 114 | func (x *ChecksumVerificationState) TransitionToNext(err error, matched bool) *ChecksumVerificationState { 115 | possibleStates := _ChecksumVerificationStateTransitionMap[*x] 116 | 117 | if len(possibleStates) == 0 { 118 | return x 119 | } 120 | 121 | if len(possibleStates) == 1 { 122 | *x = possibleStates[0] 123 | return x 124 | } 125 | 126 | for _, state := range possibleStates { 127 | if state == ChecksumVerificationStateError { 128 | *x = ChecksumVerificationStateError 129 | return x 130 | } 131 | } 132 | 133 | for _, state := range possibleStates { 134 | for _, finalState := range AllFinalCHecksumVerificationStates { 135 | if state == finalState { 136 | x.SetVerified(matched) 137 | return x 138 | } 139 | } 140 | } 141 | 142 | return x 143 | } 144 | 145 | // MarshalText implements the text marshaller method. 146 | func (x ChecksumVerificationState) MarshalText() ([]byte, error) { 147 | return []byte(x.String()), nil 148 | } 149 | 150 | // UnmarshalText implements the text unmarshaller method. 151 | func (x *ChecksumVerificationState) UnmarshalText(text []byte) error { 152 | name := string(text) 153 | tmp, err := ParseChecksumVerificationState(name) 154 | if err != nil { 155 | return err 156 | } 157 | *x = tmp 158 | return nil 159 | } 160 | 161 | func (x *ChecksumVerificationState) SetVerified(value bool) { 162 | value = value && !x.IsError() 163 | 164 | if value { 165 | *x = ChecksumVerificationStateVerified 166 | return 167 | } 168 | 169 | *x = ChecksumVerificationStateMismatch 170 | } 171 | 172 | func (x *ChecksumVerificationState) Reset() { 173 | *x = ChecksumVerificationStateNotVerified 174 | } 175 | 176 | func (x *ChecksumVerificationState) ResetIf(value bool) { 177 | if value { 178 | x.Reset() 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /lib/app/IncludedTemplate.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import "github.com/stackup-app/stackup/lib/settings" 4 | 5 | type IncludedTemplate struct { 6 | Name string `yaml:"name"` 7 | Version string `yaml:"version"` 8 | Checksum string `yaml:"checksum"` 9 | LastModified string `yaml:"last-modified"` 10 | Author string `yaml:"author"` 11 | Description string `yaml:"description"` 12 | Settings *settings.Settings `yaml:"settings"` 13 | Init string `yaml:"init"` 14 | Tasks []*Task `yaml:"tasks"` 15 | Preconditions []*WorkflowPrecondition `yaml:"preconditions"` 16 | Startup []*TaskReference `yaml:"startup"` 17 | Shutdown []*TaskReference `yaml:"shutdown"` 18 | Servers []*TaskReference `yaml:"servers"` 19 | } 20 | 21 | func (template *IncludedTemplate) Initialize(workflow *StackupWorkflow) { 22 | for _, task := range template.Tasks { 23 | task.Initialize(workflow) 24 | } 25 | 26 | for _, precondition := range template.Preconditions { 27 | precondition.Initialize(workflow) 28 | } 29 | 30 | for _, startup := range template.Startup { 31 | startup.Initialize(workflow) 32 | } 33 | 34 | for _, shutdown := range template.Shutdown { 35 | shutdown.Initialize(workflow) 36 | } 37 | 38 | for _, server := range template.Servers { 39 | server.Initialize(workflow) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/app/WorkflowInclude.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "strings" 8 | 9 | "github.com/stackup-app/stackup/lib/cache" 10 | "github.com/stackup-app/stackup/lib/checksums" 11 | "github.com/stackup-app/stackup/lib/utils" 12 | ) 13 | 14 | type WorkflowInclude struct { 15 | Url string `yaml:"url"` 16 | Headers []string `yaml:"headers"` 17 | File string `yaml:"file"` 18 | ChecksumUrl string `yaml:"checksum-url"` 19 | VerifyChecksum bool `yaml:"verify,omitempty"` 20 | AccessKey string `yaml:"access-key"` 21 | SecretKey string `yaml:"secret-key"` 22 | Secure bool `yaml:"secure"` 23 | ValidationState ChecksumVerificationState 24 | Contents string 25 | Hash string 26 | FoundChecksum string 27 | HashAlgorithm checksums.ChecksumAlgorithm 28 | FromCache bool 29 | Workflow *StackupWorkflow 30 | } 31 | 32 | func expandUrlPrefixes(url string) string { 33 | var mapppd = map[string]string{ 34 | "gh:": "https://raw.githubusercontent.com/", 35 | "s3:": "https://s3.amazonaws.com/", 36 | } 37 | 38 | for k, v := range mapppd { 39 | if strings.HasPrefix(url, k) { 40 | return strings.Replace(url, k, v, 1) 41 | } 42 | } 43 | 44 | return url 45 | } 46 | 47 | func IsHashUrlForFileUrl(urlstr, filename string) bool { 48 | return strings.HasSuffix(urlstr, filename+".sha256") || strings.HasSuffix(urlstr, filename+".sha512") 49 | } 50 | 51 | func (wi *WorkflowInclude) Initialize(workflow *StackupWorkflow) { 52 | wi.Workflow = workflow 53 | wi.expandHeaders() 54 | wi.setDefaults() 55 | } 56 | 57 | func (wi *WorkflowInclude) IncludeType() IncludeType { 58 | return DetermineIncludeType(wi.FullUrl(), wi.Filename()) 59 | } 60 | 61 | func (wi *WorkflowInclude) SetContents(contents string, storeInCache bool) { 62 | wi.Contents = contents 63 | wi.UpdateHash() 64 | 65 | if storeInCache { 66 | wi.Workflow.Cache.Set(wi.Identifier(), wi.NewCacheEntry(), wi.Workflow.Settings.Cache.TtlMinutes) 67 | } 68 | } 69 | 70 | func (wi *WorkflowInclude) expandHeaders() { 71 | for i, v := range wi.Headers { 72 | if wi.Workflow.JsEngine.IsEvaluatableScriptString(v) { 73 | wi.Headers[i] = wi.Workflow.JsEngine.Evaluate(v).(string) 74 | } 75 | wi.Headers[i] = os.ExpandEnv(v) 76 | } 77 | } 78 | 79 | func (wi *WorkflowInclude) setDefaults() { 80 | wi.VerifyChecksum = wi.Workflow.Settings.ChecksumVerification 81 | wi.ValidationState = ChecksumVerificationStateNotVerified 82 | } 83 | 84 | func (wi *WorkflowInclude) loadedStatusText() string { 85 | result := "fetched" 86 | 87 | if wi.FromCache { 88 | result = "cached" 89 | } 90 | 91 | return fmt.Sprintf("%s, %s", result, wi.ValidationState.String()) 92 | } 93 | 94 | func (wi *WorkflowInclude) setLoadedFromCache(loaded bool, data *cache.CacheEntry) { 95 | wi.FromCache = loaded 96 | 97 | if !loaded { 98 | return 99 | } 100 | 101 | wi.Hash = data.Hash 102 | wi.HashAlgorithm = checksums.ParseChecksumAlgorithm(data.Algorithm) 103 | wi.SetContents(data.Value, false) 104 | } 105 | 106 | func (wi *WorkflowInclude) UpdateChecksumFromChecksumsFile(contents string) { 107 | cs := checksums.FindFilenameChecksum(path.Base(wi.FullUrl()), contents) 108 | if cs != nil { 109 | wi.FoundChecksum = cs.Hash 110 | } 111 | 112 | wi.UpdateChecksumAlgorithm() 113 | } 114 | 115 | func (wi *WorkflowInclude) shouldVerifyChecksum() bool { 116 | return wi.IncludeType() == IncludeTypeHttp || wi.VerifyChecksum || wi.Workflow.Settings.ChecksumVerification 117 | } 118 | 119 | func (wi *WorkflowInclude) ValidateChecksum() bool { 120 | wi.UpdateHash() 121 | wi.ValidationState.Reset() 122 | 123 | if !wi.shouldVerifyChecksum() { 124 | return true 125 | } 126 | 127 | wi.ValidationState = ChecksumVerificationStatePending 128 | found := false 129 | 130 | for _, url := range wi.Workflow.getPossibleIncludedChecksumUrls() { 131 | urlText, err := wi.Workflow.Gateway.GetUrl(url) 132 | if err != nil || urlText == "" { 133 | continue 134 | } 135 | 136 | baseFn := path.Base(wi.FullUrl()) 137 | if !strings.Contains(urlText, baseFn) && !IsHashUrlForFileUrl(url, baseFn) { 138 | continue 139 | } 140 | 141 | found = true 142 | wi.ChecksumUrl = url 143 | wi.UpdateChecksumFromChecksumsFile(urlText) 144 | 145 | break 146 | } 147 | 148 | matched := found && !wi.ValidationState.IsError() && checksums.HashesMatch(wi.Hash, wi.FoundChecksum) 149 | wi.ValidationState.SetVerified(matched) 150 | 151 | if !found { 152 | wi.ValidationState = ChecksumVerificationStateError 153 | } 154 | 155 | return wi.ValidationState.IsVerified() 156 | } 157 | 158 | func (wi *WorkflowInclude) Filename() string { 159 | return utils.AbsoluteFilePath(wi.File) 160 | } 161 | 162 | func (wi *WorkflowInclude) FullUrl() string { 163 | return expandUrlPrefixes(wi.Url) 164 | } 165 | 166 | func (wi *WorkflowInclude) Identifier() string { 167 | return utils.ConsistentUniqueId(wi.FullUrl() + wi.Filename()) 168 | } 169 | 170 | func (wi WorkflowInclude) DisplayName() string { 171 | return utils.FirstNonEmpty( 172 | utils.FormatDisplayUrl(wi.FullUrl()), 173 | wi.Filename(), 174 | "", 175 | ) 176 | } 177 | 178 | func (wi *WorkflowInclude) UpdateChecksumAlgorithm() { 179 | wi.HashAlgorithm = checksums.DetermineChecksumAlgorithm([]string{wi.FoundChecksum, wi.Hash}, wi.ChecksumUrl) 180 | } 181 | 182 | func (wi *WorkflowInclude) UpdateHash() { 183 | originalHash := wi.Hash 184 | 185 | wi.Hash, wi.HashAlgorithm = checksums.CalculateSha256Hash(wi.Contents) 186 | wi.ValidationState.ResetIf(wi.Hash != originalHash) 187 | } 188 | 189 | func (wi *WorkflowInclude) NewCacheEntry() *cache.CacheEntry { 190 | wi.UpdateHash() 191 | 192 | return wi.Workflow.Cache.CreateEntry( 193 | wi.Contents, 194 | cache.CreateExpiresAtPtr(wi.Workflow.Settings.Cache.TtlMinutes), 195 | wi.Hash, 196 | wi.HashAlgorithm.String(), 197 | cache.CreateCarbonNowPtr(), 198 | ) 199 | } 200 | 201 | func (wi *WorkflowInclude) TransitionToNext(err error, matched bool) { 202 | possibleStates := _ChecksumVerificationStateTransitionMap[wi.ValidationState] 203 | 204 | if len(possibleStates) == 0 { 205 | return 206 | } 207 | 208 | if len(possibleStates) == 1 { 209 | wi.ValidationState = possibleStates[0] 210 | return 211 | } 212 | 213 | for _, state := range possibleStates { 214 | if state == ChecksumVerificationStateError { 215 | wi.ValidationState = ChecksumVerificationStateError 216 | return 217 | } 218 | } 219 | 220 | for _, state := range possibleStates { 221 | for _, finalState := range AllFinalCHecksumVerificationStates { 222 | if state == finalState { 223 | wi.ValidationState.SetVerified(true) 224 | return 225 | } 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /lib/app/WorkflowIncludeTypes.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/stackup-app/stackup/lib/utils" 9 | ) 10 | 11 | // ENUM(http, s3, file) 12 | type IncludeType int 13 | 14 | const ( 15 | // IncludeTypeHttp is a IncludeType of type Http. 16 | IncludeTypeHttp IncludeType = iota 17 | // IncludeTypeS3 is a IncludeType of type S3. 18 | IncludeTypeS3 19 | // IncludeTypeFile is a IncludeType of type File. 20 | IncludeTypeFile 21 | IncludeTypeUnknown 22 | ) 23 | 24 | var ErrInvalidIncludeType = errors.New("not a valid IncludeType") 25 | 26 | const _IncludeTypeName = "https3fileunknown" 27 | 28 | var _IncludeTypeMap = map[IncludeType]string{ 29 | IncludeTypeHttp: _IncludeTypeName[0:4], 30 | IncludeTypeS3: _IncludeTypeName[4:6], 31 | IncludeTypeFile: _IncludeTypeName[6:10], 32 | IncludeTypeUnknown: _IncludeTypeName[10:17], 33 | } 34 | 35 | func DetermineIncludeType(strs ...string) IncludeType { 36 | for _, str := range strs { 37 | if strings.HasPrefix(str, "http") { 38 | return IncludeTypeHttp 39 | } 40 | 41 | if strings.HasPrefix(str, "s3:") { 42 | return IncludeTypeS3 43 | } 44 | 45 | if utils.IsFile(str) || len(str) > 0 { 46 | return IncludeTypeFile 47 | } 48 | } 49 | 50 | return IncludeTypeUnknown 51 | } 52 | 53 | // String implements the Stringer interface. 54 | func (x IncludeType) String() string { 55 | if str, ok := _IncludeTypeMap[x]; ok { 56 | return str 57 | } 58 | return fmt.Sprintf("IncludeType(%d)", x) 59 | } 60 | 61 | // IsValid provides a quick way to determine if the typed value is 62 | // part of the allowed enumerated values 63 | func (x IncludeType) IsValid() bool { 64 | _, ok := _IncludeTypeMap[x] 65 | return ok 66 | } 67 | 68 | var _IncludeTypeValue = map[string]IncludeType{ 69 | _IncludeTypeName[0:4]: IncludeTypeHttp, 70 | _IncludeTypeName[4:6]: IncludeTypeS3, 71 | _IncludeTypeName[6:10]: IncludeTypeFile, 72 | _IncludeTypeName[10:17]: IncludeTypeUnknown, 73 | } 74 | 75 | // ParseIncludeType attempts to convert a string to a IncludeType. 76 | func ParseIncludeType(name string) (IncludeType, error) { 77 | if x, ok := _IncludeTypeValue[name]; ok { 78 | return x, nil 79 | } 80 | return IncludeType(0), fmt.Errorf("%s is %w", name, ErrInvalidIncludeType) 81 | } 82 | 83 | // MarshalText implements the text marshaller method. 84 | func (x IncludeType) MarshalText() ([]byte, error) { 85 | return []byte(x.String()), nil 86 | } 87 | 88 | // UnmarshalText implements the text unmarshaller method. 89 | func (x *IncludeType) UnmarshalText(text []byte) error { 90 | name := string(text) 91 | tmp, err := ParseIncludeType(name) 92 | if err != nil { 93 | return err 94 | } 95 | *x = tmp 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /lib/app/WorkflowInclude_test.go: -------------------------------------------------------------------------------- 1 | package app_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stackup-app/stackup/lib/app" 8 | "github.com/stackup-app/stackup/lib/checksums" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestWorkflowIncludeGetChecksumAlgorithm(t *testing.T) { 13 | hashUrls := map[string]checksums.ChecksumAlgorithm{ 14 | "https://test/sha256sum": checksums.ChecksumAlgorithmSha256, 15 | "https://test/sha256sum.txt": checksums.ChecksumAlgorithmSha256, 16 | "https://test/sha512sum": checksums.ChecksumAlgorithmSha512, 17 | "https://test/sha512sum.txt": checksums.ChecksumAlgorithmSha512, 18 | } 19 | 20 | hashLengths := map[checksums.ChecksumAlgorithm]int{ 21 | //checksums.ChecksumAlgorithmSha1: 40, 22 | checksums.ChecksumAlgorithmSha256: 64, 23 | // checksums.ChecksumAlgorithmSha3: 96, 24 | checksums.ChecksumAlgorithmSha512: 128, 25 | } 26 | 27 | for url, expected := range hashUrls { 28 | wi := app.WorkflowInclude{ChecksumUrl: url} 29 | wi.UpdateChecksumAlgorithm() 30 | assert.True(t, wi.HashAlgorithm.IsValid()) 31 | assert.Equal(t, expected.String(), wi.HashAlgorithm.String(), "expected %s for url %s", expected, url) 32 | if expected.IsSupportedAlgorithm() { 33 | assert.Equal(t, expected.GetHashLength(), wi.HashAlgorithm.GetHashLength()) 34 | } 35 | } 36 | 37 | for name, length := range hashLengths { 38 | wi := app.WorkflowInclude{FoundChecksum: strings.Repeat("a", length)} 39 | wi.UpdateHash() 40 | wi.UpdateChecksumAlgorithm() 41 | 42 | assert.True(t, wi.HashAlgorithm.IsValid()) 43 | assert.Equal(t, name.String(), wi.HashAlgorithm.String()) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/app/WorkflowPrecondition.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/stackup-app/stackup/lib/consts" 7 | "github.com/stackup-app/stackup/lib/scripting" 8 | "github.com/stackup-app/stackup/lib/support" 9 | ) 10 | 11 | type WorkflowPrecondition struct { 12 | Name string `yaml:"name"` 13 | Check string `yaml:"check"` 14 | OnFail string `yaml:"on-fail,omitempty"` 15 | MaxRetries int `yaml:"max-retries,omitempty"` 16 | FromRemote bool 17 | Attempts int 18 | JsEngine *scripting.JavaScriptEngine 19 | Workflow *StackupWorkflow 20 | } 21 | 22 | func (p *WorkflowPrecondition) Initialize(workflow *StackupWorkflow) { 23 | p.Workflow = workflow 24 | p.JsEngine = workflow.JsEngine 25 | p.Attempts = 0 26 | p.MaxRetries = consts.MAX_TASK_RUNS 27 | } 28 | 29 | func (p *WorkflowPrecondition) HandleOnFailure() bool { 30 | if p.JsEngine.IsEvaluatableScriptString(p.OnFail) { 31 | return p.JsEngine.Evaluate(p.OnFail).(bool) 32 | } 33 | 34 | if task, found := p.Workflow.GetTaskById(p.OnFail); found { 35 | return task.RunSync() 36 | } 37 | 38 | return true 39 | } 40 | 41 | func (wp *WorkflowPrecondition) CanRun() bool { 42 | return wp.Attempts < wp.MaxRetries 43 | } 44 | 45 | func (wp *WorkflowPrecondition) Run() bool { 46 | if wp.Check == "" { 47 | return true 48 | } 49 | 50 | result := wp.CanRun() 51 | if !result { 52 | support.FailureMessageWithXMark(wp.Name) 53 | return result 54 | } 55 | 56 | wp.Attempts++ 57 | 58 | scriptResult := wp.JsEngine.Evaluate(wp.Check).(any) 59 | resultType, resultValue, _ := wp.JsEngine.ResultType(scriptResult) 60 | 61 | if resultType == reflect.String && resultValue != "" { 62 | return wp.JsEngine.Evaluate(resultValue.(string)).(bool) 63 | } 64 | 65 | if resultType == reflect.Bool && resultValue == false { 66 | if wp.handleOnFail() { 67 | return result 68 | } 69 | support.FailureMessageWithXMark(wp.Name) 70 | } 71 | 72 | // if result.(bool) == false { 73 | // if result = wp.handleOnFail(); result { 74 | // return result 75 | // } 76 | // support.FailureMessageWithXMark(wp.Name) 77 | // } 78 | return result 79 | } 80 | 81 | func (wp *WorkflowPrecondition) handleOnFail() bool { 82 | if len(wp.OnFail) == 0 { 83 | return false 84 | } 85 | 86 | support.FailureMessageWithXMark(wp.Name) 87 | 88 | if wp.JsEngine.IsEvaluatableScriptString(wp.OnFail) { 89 | return wp.JsEngine.Evaluate(wp.OnFail).(bool) 90 | } 91 | 92 | if task, found := wp.Workflow.GetTaskById(wp.OnFail); found { 93 | return task.RunSync() 94 | } 95 | 96 | if wp.HandleOnFailure() { 97 | return wp.Run() 98 | } 99 | 100 | return false 101 | } 102 | -------------------------------------------------------------------------------- /lib/app/WorkflowState.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | lls "github.com/emirpasic/gods/stacks/linkedliststack" 5 | ) 6 | 7 | type WorkflowState struct { 8 | CurrentTask *Task 9 | Stack *lls.Stack 10 | History *lls.Stack 11 | } 12 | 13 | type CleanupCallback = func() 14 | type SetActiveTaskCallback = func(task *Task) CleanupCallback 15 | 16 | func NewWorkflowState() WorkflowState { 17 | return WorkflowState{ 18 | CurrentTask: nil, 19 | Stack: lls.New(), 20 | History: lls.New(), 21 | } 22 | } 23 | 24 | // sets the current task, and pushes the previous task onto the stack if it was still running. 25 | // returns a cleanup function callback that restores the state to its previous value. 26 | func (ws *WorkflowState) SetCurrent(task *Task) CleanupCallback { 27 | if ws.CurrentTask != nil { 28 | ws.Stack.Push(ws.CurrentTask) 29 | } 30 | 31 | ws.CurrentTask = task 32 | 33 | if task == nil { 34 | return func() {} 35 | } 36 | 37 | ws.History.Push(task.Uuid) 38 | 39 | return func() { 40 | ws.CurrentTask = nil 41 | 42 | value, ok := ws.Stack.Pop() 43 | if ok && value != nil { 44 | ws.CurrentTask = value.(*Task) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "os/signal" 9 | "path" 10 | "sync" 11 | "syscall" 12 | 13 | "github.com/eiannone/keyboard" 14 | "github.com/joho/godotenv" 15 | "github.com/robfig/cron/v3" 16 | "github.com/stackup-app/stackup/lib/cache" 17 | "github.com/stackup-app/stackup/lib/consts" 18 | "github.com/stackup-app/stackup/lib/debug" 19 | "github.com/stackup-app/stackup/lib/downloader" 20 | "github.com/stackup-app/stackup/lib/gateway" 21 | "github.com/stackup-app/stackup/lib/messages" 22 | "github.com/stackup-app/stackup/lib/scripting" 23 | "github.com/stackup-app/stackup/lib/settings" 24 | "github.com/stackup-app/stackup/lib/support" 25 | "github.com/stackup-app/stackup/lib/telemetry" 26 | "github.com/stackup-app/stackup/lib/types" 27 | "github.com/stackup-app/stackup/lib/updater" 28 | "github.com/stackup-app/stackup/lib/utils" 29 | "github.com/stackup-app/stackup/lib/version" 30 | "gopkg.in/yaml.v2" 31 | ) 32 | 33 | var App *Application 34 | 35 | type Application struct { 36 | Workflow *StackupWorkflow 37 | JsEngine *scripting.JavaScriptEngine 38 | cronEngine *cron.Cron 39 | ProcessMap *sync.Map 40 | Vars *sync.Map 41 | flags AppFlags 42 | CmdStartCallback types.CommandCallback 43 | KillCommandCallback types.CommandCallback 44 | ConfigFilename string 45 | Gateway *gateway.Gateway 46 | Analytics *telemetry.Telemetry 47 | // types.AppInterface 48 | } 49 | 50 | func NewApplication() *Application { 51 | result := &Application{ 52 | ProcessMap: &sync.Map{}, 53 | Vars: &sync.Map{}, 54 | flags: AppFlags{ 55 | DisplayHelp: flag.Bool("help", false, "Display help"), 56 | DisplayVersion: flag.Bool("version", false, "Display version"), 57 | NoUpdateCheck: flag.Bool("no-update-check", false, "Disable update check"), 58 | ConfigFile: flag.String("config", "", "Load a specific config file"), 59 | }, 60 | ConfigFilename: support.FindExistingFile([]string{"stackup.dist.yaml", "stackup.yaml"}, "stackup.yaml"), 61 | Gateway: gateway.New(nil), 62 | cronEngine: cron.New(cron.WithChain(cron.SkipIfStillRunning(cron.DiscardLogger))), 63 | } 64 | result.flags.app = result 65 | result.Workflow = CreateWorkflow(result.Gateway, result.ProcessMap) 66 | 67 | return result 68 | } 69 | 70 | func (app *Application) GetGateway() types.GatewayContract { 71 | return app.Gateway 72 | } 73 | 74 | func (app *Application) GetJsEngine() types.JavaScriptEngineContract { 75 | var result interface{} = app.JsEngine 76 | return result.(types.JavaScriptEngineContract) 77 | } 78 | 79 | func (app *Application) GetSettings() *settings.Settings { 80 | return app.Workflow.Settings 81 | } 82 | 83 | func (app *Application) GetVars() *sync.Map { 84 | return app.Vars 85 | } 86 | 87 | func (app *Application) GetWorkflow() types.AppWorkflowContract { 88 | var result interface{} = app.Workflow 89 | return result.(types.AppWorkflowContract) 90 | } 91 | 92 | func (app *Application) ToInterface() *Application { 93 | return app 94 | } 95 | 96 | func (app *Application) GetEnviron() []string { 97 | return os.Environ() 98 | } 99 | 100 | func (a *Application) loadWorkflowFile(filename string, wf *StackupWorkflow) { 101 | wf.ExitAppFunc = a.exitApp 102 | wf.Gateway = a.Gateway 103 | wf.ProcessMap = a.ProcessMap 104 | wf.CommandStartCb = a.CmdStartCallback 105 | 106 | contents, err := os.ReadFile(filename) 107 | if err != nil { 108 | return 109 | } 110 | 111 | err = yaml.Unmarshal(contents, wf) 112 | if err != nil { 113 | fmt.Printf("error loading configuration file: %v", err) 114 | return 115 | } 116 | 117 | wf.State = NewWorkflowState() 118 | wf.ConfigureDefaultSettings() 119 | 120 | if !wf.Debug { 121 | wf.Debug = os.Getenv("DEBUG") == "true" || os.Getenv("DEBUG") == "1" 122 | } 123 | } 124 | 125 | // parse command-line flags, load the workflow file, load .env files, 126 | // initialize the workflow, gateway and js engine 127 | func (a *Application) Initialize() { 128 | utils.EnsureConfigDirExists(utils.GetDefaultConfigurationBasePath("~", "."), consts.APP_CONFIG_PATH_BASE_NAME) 129 | a.flags.Parse() 130 | 131 | a.loadWorkflowFile(a.ConfigFilename, a.Workflow) 132 | godotenv.Load(a.Workflow.Settings.DotEnvFiles...) 133 | debug.Dbg.SetEnabled(a.Workflow.Debug) 134 | 135 | a.JsEngine = scripting.CreateNewJavascriptEngine(a) 136 | a.Analytics = telemetry.New(a.Workflow.Settings.AnonymousStatistics, a.Gateway) 137 | a.Gateway.Initialize(a.Workflow.Settings, a.JsEngine.AsContract(), nil) 138 | a.initializeCache() 139 | a.Workflow.Initialize(a.JsEngine, a.GetConfigurationPath()) 140 | a.JsEngine.Initialize() 141 | 142 | a.Analytics.EventOnly("app.start") 143 | a.checkForApplicationUpdates(!*a.flags.NoUpdateCheck) 144 | 145 | downloader.New(a.Gateway).Download(consts.APP_ICON_URL, a.GetApplicationIconPath()) 146 | } 147 | 148 | func (a *Application) initializeCache() { 149 | a.Workflow.Cache = cache.New("stackup", a.GetConfigurationPath(), a.Workflow.Settings.Cache.TtlMinutes) 150 | a.Gateway.Cache = a.Workflow.Cache 151 | } 152 | 153 | func (a *Application) hookSignals() { 154 | c := make(chan os.Signal, 1) 155 | signal.Notify(c, os.Interrupt, syscall.SIGTERM, syscall.SIGSEGV, syscall.SIGQUIT, syscall.SIGHUP) 156 | 157 | go func() { 158 | <-c 159 | a.exitApp() 160 | }() 161 | } 162 | 163 | func (a *Application) hookKeyboard() { 164 | go func() { 165 | for { 166 | char, key, err := keyboard.GetSingleKey() 167 | 168 | if err != nil { 169 | return 170 | } 171 | 172 | if key == keyboard.KeyCtrlC || char == 'q' { 173 | a.exitApp() 174 | } 175 | } 176 | }() 177 | } 178 | 179 | func (a *Application) exitApp() { 180 | a.cronEngine.Stop() 181 | a.stopServerProcesses() 182 | support.StatusMessageLine("Running shutdown tasks...", true) 183 | a.runShutdownTasks() 184 | 185 | for _, uid := range a.Workflow.State.History.Values() { 186 | task := a.Workflow.FindTaskByUuid(uid.(string)) 187 | if task == nil { 188 | continue 189 | } 190 | 191 | runs := fmt.Sprintf("runs: %v", task.RunCount) 192 | support.StatusMessageLine("[task history] task: "+task.GetDisplayName()+" ("+runs+")", true) 193 | } 194 | 195 | os.Exit(1) 196 | } 197 | 198 | func (a *Application) createScheduledTasks() { 199 | support.StatusMessage("Creating scheduled tasks...", false) 200 | 201 | for _, def := range a.Workflow.Scheduler { 202 | def.Workflow = a.Workflow 203 | def.JsEngine = a.JsEngine 204 | 205 | _, found := a.Workflow.GetTaskById(def.TaskId()) 206 | 207 | if !found { 208 | support.FailureMessageWithXMark(messages.TaskNotFound(def.TaskId())) 209 | continue 210 | } 211 | 212 | cron := def.Cron 213 | taskId := def.TaskId() 214 | 215 | a.cronEngine.AddFunc(cron, func() { 216 | task, found := a.Workflow.GetTaskById(taskId) 217 | if found { 218 | task.RunSync() 219 | } 220 | }) 221 | } 222 | 223 | a.cronEngine.Start() 224 | support.PrintCheckMarkLine() 225 | } 226 | 227 | func (a *Application) stopServerProcesses() { 228 | a.ProcessMap.Range(func(key any, value any) bool { 229 | t := a.Workflow.FindTaskByUuid(key.(string)) 230 | if t != nil { 231 | support.StatusMessage("Stopping "+t.GetDisplayName()+"...", false) 232 | } 233 | 234 | if value != nil { 235 | a.KillCommandCallback(value.(*exec.Cmd)) 236 | } 237 | 238 | support.PrintCheckMarkLine() 239 | 240 | return true 241 | }) 242 | } 243 | 244 | func (a *Application) runEventLoop() { 245 | support.StatusMessageLine("Running event loop...", true) 246 | 247 | for { 248 | utils.WaitForStartOfNextMinute() 249 | } 250 | } 251 | 252 | func (a *Application) runTaskReferences(refs []*TaskReference) { 253 | for _, def := range refs { 254 | def.Workflow = a.Workflow 255 | def.JsEngine = a.JsEngine 256 | 257 | task, found := a.Workflow.GetTaskById(def.TaskId()) 258 | if !found { 259 | support.SkippedMessageWithSymbol(messages.TaskNotFound(def.TaskId())) 260 | continue 261 | } 262 | 263 | task.RunSync() 264 | } 265 | } 266 | 267 | func (a *Application) runStartupTasks() { 268 | support.StatusMessageLine("Running startup tasks...", true) 269 | 270 | a.runTaskReferences(a.Workflow.Startup) 271 | } 272 | 273 | func (a *Application) runShutdownTasks() { 274 | a.runTaskReferences(a.Workflow.Shutdown) 275 | } 276 | 277 | func (a *Application) runServerTasks() { 278 | support.StatusMessageLine("Starting server processes...", true) 279 | 280 | for _, def := range a.Workflow.Servers { 281 | task, found := a.Workflow.GetTaskById(def.TaskId()) 282 | 283 | if !found { 284 | support.SkippedMessageWithSymbol(messages.TaskNotFound(def.TaskId())) 285 | continue 286 | } 287 | 288 | task.RunAsync() 289 | } 290 | } 291 | 292 | func (a Application) runPreconditions() { 293 | support.StatusMessageLine("Running precondition checks...", true) 294 | 295 | for _, c := range a.Workflow.Preconditions { 296 | if !c.Run() { 297 | support.FailureMessageWithXMark(c.Name) 298 | os.Exit(1) 299 | } 300 | support.SuccessMessageWithCheck(c.Name) 301 | } 302 | } 303 | 304 | func (a *Application) checkForApplicationUpdates(canCheck bool) { 305 | if !canCheck { 306 | return 307 | } 308 | 309 | if hasUpdate, release := updater.New(a.Gateway).IsUpdateAvailable(consts.APP_REPOSITORY, version.APP_VERSION); hasUpdate { 310 | support.WarningMessage("A new version of StackUp is available, released " + release.TimeSinceRelease()) 311 | } 312 | } 313 | 314 | func (a *Application) GetConfigurationPath() string { 315 | pathname, _ := utils.EnsureConfigDirExists( 316 | utils.GetDefaultConfigurationBasePath("~", "."), 317 | consts.APP_CONFIG_PATH_BASE_NAME, 318 | ) 319 | 320 | return pathname 321 | } 322 | 323 | func (a *Application) GetApplicationIconPath() string { 324 | return path.Join(a.GetConfigurationPath(), "/stackup-icon.png") 325 | } 326 | 327 | func (a *Application) runInitScript() { 328 | support.StatusMessageLine("Running init script...", true) 329 | 330 | a.JsEngine.Evaluate(a.Workflow.Init) 331 | } 332 | 333 | func (a *Application) Run() { 334 | a.Initialize() 335 | defer a.Workflow.Cache.Cleanup(false) 336 | 337 | a.hookSignals() 338 | a.hookKeyboard() 339 | 340 | a.runInitScript() 341 | a.runPreconditions() 342 | a.runStartupTasks() 343 | a.runServerTasks() 344 | a.createScheduledTasks() 345 | 346 | a.runEventLoop() 347 | } 348 | -------------------------------------------------------------------------------- /lib/app/commands/init_config_file.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "text/template" 8 | 9 | "github.com/stackup-app/stackup/lib/consts" 10 | "github.com/stackup-app/stackup/lib/types" 11 | "github.com/stackup-app/stackup/lib/utils" 12 | ) 13 | 14 | func CreateNewConfigFile(gw types.GatewayContract) { 15 | filename := "stackup.yaml" 16 | 17 | if _, err := os.Stat(filename); err == nil { 18 | fmt.Printf("%s already exists.\n", filename) 19 | return 20 | } 21 | 22 | var dependencyBin string = "php" 23 | 24 | if utils.IsFile("composer.json") { 25 | dependencyBin = "php" 26 | } else if utils.IsFile("package.json") { 27 | dependencyBin = "node" 28 | } else if utils.IsFile("requirements.txt") { 29 | dependencyBin = "python" 30 | } 31 | 32 | templateText, err := utils.GetUrlContents(consts.APP_NEW_CONFIG_TEMPLATE_URL, &gw) 33 | if err != nil { 34 | fmt.Printf("error retrieving configuration file template: %v\n", err) 35 | return 36 | } 37 | 38 | var writer strings.Builder 39 | 40 | tmpl, _ := template.New(filename).Parse(templateText) 41 | tmpl.Execute(&writer, map[string]string{"ProjectType": dependencyBin}) 42 | 43 | os.WriteFile(filename, []byte(writer.String()), 0644) 44 | } 45 | -------------------------------------------------------------------------------- /lib/app/tasks.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "runtime" 5 | "strings" 6 | 7 | "github.com/stackup-app/stackup/lib/consts" 8 | "github.com/stackup-app/stackup/lib/scripting" 9 | "github.com/stackup-app/stackup/lib/settings" 10 | "github.com/stackup-app/stackup/lib/support" 11 | "github.com/stackup-app/stackup/lib/types" 12 | "github.com/stackup-app/stackup/lib/utils" 13 | ) 14 | 15 | type Task struct { 16 | Name string `yaml:"name"` 17 | Command string `yaml:"command"` 18 | If string `yaml:"if,omitempty"` 19 | Id string `yaml:"id,omitempty"` 20 | Silent bool `yaml:"silent"` 21 | Path string `yaml:"path"` 22 | Platforms []string `yaml:"platforms,omitempty"` 23 | MaxRuns int `yaml:"maxRuns,omitempty"` 24 | Include string `yaml:"include,omitempty"` 25 | RunCount int 26 | Uuid string 27 | FromRemote bool 28 | CommandStartCb types.CommandCallback 29 | //Workflow *StackupWorkflow //*types.AppWorkflowContract 30 | JsEngine *scripting.JavaScriptEngine 31 | setActive SetActiveTaskCallback 32 | StoreProcess types.SetProcessCallback 33 | // types.AppWorkflowTaskContract 34 | } 35 | 36 | type TaskReferenceContract interface { 37 | TaskId() string 38 | Initialize(wf *StackupWorkflow, jse *scripting.JavaScriptEngine) 39 | } 40 | 41 | type TaskReference struct { 42 | Task string `yaml:"task"` 43 | Workflow *StackupWorkflow 44 | JsEngine *scripting.JavaScriptEngine 45 | TaskReferenceContract 46 | } 47 | 48 | type ScheduledTask struct { 49 | Task string `yaml:"task"` 50 | Cron string `yaml:"cron"` 51 | Workflow *StackupWorkflow 52 | JsEngine *scripting.JavaScriptEngine 53 | TaskReferenceContract 54 | } 55 | 56 | func (task *Task) canRunOnCurrentPlatform() bool { 57 | if task.Platforms == nil || len(task.Platforms) == 0 { 58 | return true 59 | } 60 | 61 | for _, name := range task.Platforms { 62 | if strings.EqualFold(runtime.GOOS, name) { 63 | return true 64 | } 65 | } 66 | 67 | return false 68 | } 69 | 70 | func (task *Task) canRunConditionally() bool { 71 | if len(strings.TrimSpace(task.If)) == 0 { 72 | return true 73 | } 74 | 75 | return task.JsEngine.Evaluate(task.If).(bool) 76 | } 77 | 78 | func (task *Task) Initialize(workflow *StackupWorkflow) { //} *scripting.JavaScriptEngine, cmdStartCb types.CommandCallback, setActive SetActiveTaskCallback, storeProcess types.SetProcessCallback) { 79 | task.JsEngine = workflow.JsEngine 80 | if workflow.State.CurrentTask != nil { 81 | task.setActive = workflow.State.CurrentTask.setActive 82 | } else { 83 | task.setActive = func(task *Task) CleanupCallback { return func() {} } 84 | } 85 | task.CommandStartCb = workflow.CommandStartCb 86 | task.StoreProcess = workflow.ProcessMap.Store 87 | task.Uuid = utils.GenerateTaskUuid() 88 | 89 | task.RunCount = 0 90 | task.MaxRuns = utils.Max(task.MaxRuns, 0) 91 | 92 | if task.MaxRuns == 0 { 93 | task.MaxRuns = consts.MAX_TASK_RUNS 94 | } 95 | 96 | task.If = task.JsEngine.MakeStringEvaluatable(task.If) 97 | 98 | if task.JsEngine.IsEvaluatableScriptString(task.Name) { 99 | task.Name = task.JsEngine.Evaluate(task.Name).(string) 100 | } 101 | 102 | task.setDefaultSettings(workflow.Settings) 103 | } 104 | 105 | func (task *Task) setDefaultSettings(s *settings.Settings) { 106 | task.Silent = s.Defaults.Tasks.Silent 107 | 108 | if task.Path == "" { 109 | task.Path = utils.FirstNonEmpty(s.Defaults.Tasks.Path, consts.DEFAULT_CWD_SETTING) 110 | } 111 | 112 | if len(task.Platforms) == 0 { 113 | copy(task.Platforms, s.Defaults.Tasks.Platforms) 114 | } 115 | } 116 | 117 | func (task Task) GetDisplayName() string { 118 | if len(task.Include) > 0 { 119 | return strings.TrimPrefix(task.Include, "https://") 120 | } 121 | 122 | if len(task.Name) > 0 { 123 | return task.Name 124 | } 125 | 126 | if len(task.Id) > 0 { 127 | return task.Id 128 | } 129 | 130 | return task.Uuid 131 | } 132 | 133 | func (task *Task) getCommand() string { 134 | result := task.Command 135 | 136 | if task.JsEngine.IsEvaluatableScriptString(result) { 137 | result = task.JsEngine.Evaluate(result).(string) 138 | } 139 | 140 | return result 141 | } 142 | 143 | func (task *Task) prepareRun() (bool, func()) { 144 | if task.Uuid == "" { 145 | task.Uuid = utils.GenerateTaskUuid() 146 | } 147 | 148 | result := task.setActive(task) 149 | 150 | if task.RunCount >= task.MaxRuns && task.MaxRuns > 0 { 151 | support.SkippedMessageWithSymbol(task.GetDisplayName()) 152 | return false, nil 153 | } 154 | 155 | task.RunCount++ 156 | 157 | // allow the path property to be an environment variable reference without wrapping it in `{{ }}` 158 | if utils.MatchesPattern(task.Path, "^\\$[\\w_]+$") { 159 | task.Path = task.JsEngine.MakeStringEvaluatable(task.Path) 160 | } 161 | 162 | if task.JsEngine.IsEvaluatableScriptString(task.Path) { 163 | task.Path = task.JsEngine.Evaluate(task.Path).(string) 164 | } 165 | 166 | if !task.canRunConditionally() { 167 | support.SkippedMessageWithSymbol(task.GetDisplayName()) 168 | return false, nil 169 | } 170 | 171 | if !task.canRunOnCurrentPlatform() { 172 | support.SkippedMessageWithSymbol("Task '" + task.GetDisplayName() + "' is not supported on this operating system.") 173 | return false, nil 174 | } 175 | 176 | support.StatusMessage(task.GetDisplayName()+"...", false) 177 | 178 | return true, result 179 | } 180 | 181 | func (task *Task) RunSync() bool { 182 | var canRun bool 183 | var cleanup func() 184 | 185 | if canRun, cleanup = task.prepareRun(); !canRun { 186 | return false 187 | } 188 | 189 | defer cleanup() 190 | 191 | cmd, err := utils.RunCommandInPath(task.getCommand(), task.Path, task.Silent) 192 | if err != nil { 193 | support.FailureMessageWithXMark(task.GetDisplayName()) 194 | return false 195 | } 196 | 197 | if cmd != nil && task.Silent { 198 | support.PrintCheckMarkLine() 199 | } else if cmd != nil { 200 | support.SuccessMessageWithCheck(task.GetDisplayName()) 201 | } 202 | 203 | if cmd == nil && task.Silent { 204 | support.PrintXMarkLine() 205 | } else if cmd == nil { 206 | support.FailureMessageWithXMark(task.GetDisplayName()) 207 | } 208 | 209 | return true 210 | } 211 | 212 | func (task *Task) RunAsync() { 213 | var canRun bool 214 | var cleanup func() 215 | 216 | if canRun, cleanup = task.prepareRun(); !canRun { 217 | return 218 | } 219 | 220 | defer cleanup() 221 | 222 | command := task.getCommand() 223 | cmd := utils.StartCommand(command, task.Path, false) 224 | 225 | if cmd == nil { 226 | support.FailureMessageWithXMark(task.GetDisplayName()) 227 | return 228 | } 229 | 230 | task.CommandStartCb(cmd) 231 | err := cmd.Start() 232 | 233 | if err != nil { 234 | support.PrintXMarkLine() 235 | } else { 236 | support.PrintCheckMarkLine() 237 | } 238 | 239 | task.StoreProcess(task.Uuid, cmd) 240 | } 241 | 242 | func (tr *TaskReference) Initialize(workflow *StackupWorkflow) { 243 | tr.Workflow = workflow 244 | tr.JsEngine = workflow.JsEngine 245 | } 246 | 247 | func (tr *TaskReference) TaskId() string { 248 | if tr.JsEngine.IsEvaluatableScriptString(tr.Task) { 249 | return tr.JsEngine.Evaluate(tr.Task).(string) 250 | } 251 | 252 | return tr.Task 253 | } 254 | 255 | func (st *ScheduledTask) TaskId() string { 256 | if st.JsEngine.IsEvaluatableScriptString(st.Task) { 257 | return st.JsEngine.Evaluate(st.Task).(string) 258 | } 259 | 260 | return st.Task 261 | } 262 | 263 | func (st *ScheduledTask) Initialize(workflow *StackupWorkflow) { 264 | st.Workflow = workflow 265 | st.JsEngine = workflow.JsEngine 266 | 267 | if workflow.JsEngine.IsEvaluatableScriptString(st.Task) { 268 | st.Task = workflow.JsEngine.Evaluate(st.Task).(string) 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /lib/app/workflow.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "strings" 7 | "sync" 8 | 9 | "github.com/dotenv-org/godotenvvault" 10 | "github.com/stackup-app/stackup/lib/cache" 11 | "github.com/stackup-app/stackup/lib/checksums" 12 | "github.com/stackup-app/stackup/lib/consts" 13 | "github.com/stackup-app/stackup/lib/debug" 14 | "github.com/stackup-app/stackup/lib/gateway" 15 | "github.com/stackup-app/stackup/lib/messages" 16 | "github.com/stackup-app/stackup/lib/scripting" 17 | "github.com/stackup-app/stackup/lib/settings" 18 | "github.com/stackup-app/stackup/lib/support" 19 | "github.com/stackup-app/stackup/lib/types" 20 | "github.com/stackup-app/stackup/lib/utils" 21 | "gopkg.in/yaml.v2" 22 | ) 23 | 24 | type StackupWorkflow struct { 25 | Name string `yaml:"name"` 26 | Description string `yaml:"description"` 27 | Version string `yaml:"version"` 28 | Settings *settings.Settings `yaml:"settings"` 29 | Env []string `yaml:"env"` 30 | Init string `yaml:"init"` 31 | Preconditions []*WorkflowPrecondition `yaml:"preconditions"` 32 | Tasks []*Task `yaml:"tasks"` 33 | Startup []*TaskReference `yaml:"startup"` 34 | Shutdown []*TaskReference `yaml:"shutdown"` 35 | Servers []*TaskReference `yaml:"servers"` 36 | Scheduler []*ScheduledTask `yaml:"scheduler"` 37 | Includes []WorkflowInclude `yaml:"includes"` 38 | Debug bool `yaml:"debug"` 39 | State WorkflowState 40 | Cache *cache.Cache 41 | JsEngine *scripting.JavaScriptEngine 42 | Gateway *gateway.Gateway 43 | ProcessMap *sync.Map 44 | CommandStartCb types.CommandCallback 45 | ExitAppFunc func() 46 | types.AppWorkflowContract 47 | } 48 | 49 | func CreateWorkflow(gw *gateway.Gateway, processMap *sync.Map) *StackupWorkflow { 50 | return &StackupWorkflow{ 51 | Settings: &settings.Settings{}, 52 | Preconditions: []*WorkflowPrecondition{}, 53 | Tasks: []*Task{}, 54 | State: WorkflowState{}, 55 | Includes: []WorkflowInclude{}, 56 | Gateway: gw, 57 | ProcessMap: processMap, 58 | } 59 | } 60 | 61 | func (workflow *StackupWorkflow) FindTaskById(id string) (any, bool) { 62 | return workflow.GetTaskById(id) 63 | } 64 | 65 | func (workflow *StackupWorkflow) GetTaskById(id string) (*Task, bool) { 66 | for _, task := range workflow.Tasks { 67 | if strings.EqualFold(task.Id, id) && len(id) > 0 { 68 | return task, true 69 | } 70 | } 71 | 72 | return nil, false 73 | } 74 | 75 | func (workflow *StackupWorkflow) FindTaskByUuid(uuid string) *Task { 76 | for _, task := range workflow.Tasks { 77 | if strings.EqualFold(task.Uuid, uuid) && len(uuid) > 0 { 78 | return task 79 | } 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func (workflow *StackupWorkflow) TryLoadDotEnvVaultFile() { 86 | if !utils.ArrayContains(workflow.Env, "dotenv://vault") { 87 | return 88 | } 89 | 90 | if !utils.IsFile(utils.WorkingDir(".env.vault")) { 91 | return 92 | } 93 | 94 | vars, err := godotenvvault.Read() 95 | if err != nil { 96 | return 97 | } 98 | 99 | for k, v := range vars { 100 | os.Setenv(k, v) 101 | } 102 | } 103 | 104 | func (workflow *StackupWorkflow) GetAllTaskReferences() []*TaskReference { 105 | return utils.CombineArrays([]*TaskReference{}, workflow.Startup, workflow.Shutdown, workflow.Servers) 106 | } 107 | 108 | func (workflow *StackupWorkflow) Initialize(engine *scripting.JavaScriptEngine, configPath string) { 109 | workflow.JsEngine = engine 110 | 111 | utils.ImportEnvDefsIntoEnvironment(workflow.Env) 112 | workflow.TryLoadDotEnvVaultFile() 113 | workflow.InitializeSections() 114 | workflow.processIncludes() 115 | } 116 | 117 | func (workflow *StackupWorkflow) InitializeSections() { 118 | for _, t := range workflow.Tasks { 119 | t.Initialize(workflow) 120 | } 121 | 122 | // init startup, shutdown, servers sections 123 | for _, t := range workflow.GetAllTaskReferences() { 124 | t.Initialize(workflow) 125 | } 126 | 127 | for _, st := range workflow.Scheduler { 128 | st.Initialize(workflow) 129 | } 130 | 131 | for _, pc := range workflow.Preconditions { 132 | pc.Initialize(workflow) 133 | } 134 | } 135 | 136 | func (workflow *StackupWorkflow) ConfigureDefaultSettings() { 137 | utils.SetIfEmpty(&workflow.Settings.Defaults.Tasks.Path, consts.DEFAULT_CWD_SETTING) 138 | utils.SetIfEmpty(&workflow.Settings.Defaults.Tasks.Platforms, consts.ALL_PLATFORMS) 139 | utils.SetIfEmpty(&workflow.Settings.Domains.Allowed, consts.DEFAULT_ALLOWED_DOMAINS) 140 | utils.SetIfEmpty(&workflow.Settings.Domains.Blocked, []string{}) 141 | utils.SetIfEmpty(&workflow.Settings.Cache.TtlMinutes, consts.DEFAULT_CACHE_TTL_MINUTES) 142 | utils.SetIfEmpty(&workflow.Settings.DotEnvFiles, []string{".env"}) 143 | utils.SetIfEmpty(&workflow.Settings.Gateway.Middleware, consts.DEFAULT_GATEWAY_MIDDLEWARE) 144 | 145 | workflow.expandEnvVars(&workflow.Settings.Notifications.Slack.ChannelIds) 146 | workflow.expandEnvVars(&workflow.Settings.Notifications.Telegram.ChatIds) 147 | 148 | for _, host := range workflow.Settings.Domains.Hosts { 149 | if host.Gateway == "allow" || host.Gateway == "" { 150 | workflow.Settings.Domains.Allowed = append(workflow.Settings.Domains.Allowed, host.Hostname) 151 | } 152 | if host.Gateway == "block" { 153 | workflow.Settings.Domains.Blocked = append(workflow.Settings.Domains.Blocked, host.Hostname) 154 | } 155 | if len(host.Headers) > 0 { 156 | workflow.Gateway.DomainHeaders.Store(host.Hostname, host.Headers) 157 | } 158 | } 159 | 160 | utils.UniqueInPlace(&workflow.Settings.Domains.Allowed) 161 | utils.UniqueInPlace(&workflow.Settings.Domains.Blocked) 162 | 163 | // workflow.setDefaultOptionsForTasks() 164 | } 165 | 166 | func (workflow *StackupWorkflow) expandEnvVars(items *[]string) { 167 | expanded := make([]string, len(*items)) 168 | 169 | for i, item := range *items { 170 | expanded[i] = os.ExpandEnv(item) 171 | } 172 | 173 | copy(*items, expanded) 174 | } 175 | 176 | // copy the default task settings into each task if the settings are not already set 177 | // func (workflow *StackupWorkflow) setDefaultOptionsForTasks() { 178 | // for _, task := range workflow.Tasks { 179 | // task.SetDefaultSettings(workflow.Settings) 180 | // } 181 | // } 182 | 183 | // processIncludes loads the includes and processes all included files in the workflow asynchronously, 184 | // so the order in which they are loaded is not guaranteed. 185 | func (workflow *StackupWorkflow) processIncludes() { 186 | var wgPreload sync.WaitGroup 187 | 188 | // cache requests so async loading doesn't cause the same file to be loaded multiple times 189 | for _, url := range workflow.getPossibleIncludedChecksumUrls() { 190 | wgPreload.Add(1) 191 | go func(s string) { 192 | defer wgPreload.Done() 193 | workflow.Gateway.GetUrl(s) 194 | }(url) 195 | } 196 | wgPreload.Wait() 197 | 198 | var wgLoadIncludes sync.WaitGroup 199 | for _, include := range workflow.Includes { 200 | wgLoadIncludes.Add(1) 201 | go func(inc WorkflowInclude) { 202 | defer wgLoadIncludes.Done() 203 | workflow.processInclude(&inc) 204 | }(include) 205 | } 206 | wgLoadIncludes.Wait() 207 | 208 | workflow.InitializeSections() 209 | } 210 | 211 | func (workflow *StackupWorkflow) getIncludedUrls() []string { 212 | result := []string{} 213 | 214 | for _, include := range workflow.Includes { 215 | result = append(result, include.FullUrl()) 216 | } 217 | 218 | return utils.GetUniqueStrings(result) 219 | } 220 | 221 | func (workflow *StackupWorkflow) getPossibleIncludedChecksumUrls() []string { 222 | result := []string{} 223 | 224 | for _, url := range workflow.getIncludedUrls() { 225 | result = append(result, checksums.GetChecksumUrls(url)...) 226 | } 227 | 228 | return result 229 | } 230 | 231 | func (workflow *StackupWorkflow) tryLoadingCachedData(include *WorkflowInclude) bool { 232 | data, loaded := workflow.Cache.Get(include.Identifier()) 233 | include.setLoadedFromCache(loaded, data) 234 | 235 | return loaded 236 | } 237 | 238 | func (workflow *StackupWorkflow) loadRemoteFileInclude(include *WorkflowInclude) (error, bool) { 239 | var err error = nil 240 | var contents string 241 | 242 | if contents, err = workflow.Gateway.GetUrl(include.FullUrl()); err != nil { 243 | return err, false 244 | } 245 | 246 | include.SetContents(contents, true) 247 | 248 | return err, err == nil 249 | } 250 | 251 | func (workflow *StackupWorkflow) handleChecksumVerification(include *WorkflowInclude) bool { 252 | var result bool = include.ValidateChecksum() 253 | 254 | if include.ValidationState.IsMismatch() && workflow.Settings.ExitOnChecksumMismatch { 255 | support.FailureMessageWithXMark(messages.ExitDueToChecksumMismatch()) 256 | workflow.ExitAppFunc() 257 | } 258 | 259 | return result 260 | } 261 | 262 | func (workflow *StackupWorkflow) loadAndImportInclude(rawYaml string) error { 263 | var template IncludedTemplate 264 | 265 | if err := yaml.Unmarshal([]byte(rawYaml), &template); err != nil { 266 | return err 267 | } 268 | 269 | template.Initialize(workflow) 270 | 271 | workflow.Tasks = append(workflow.Tasks, template.Tasks...) 272 | workflow.Preconditions = append(workflow.Preconditions, template.Preconditions...) 273 | workflow.Startup = append(workflow.Startup, template.Startup...) 274 | workflow.Shutdown = append(workflow.Shutdown, template.Shutdown...) 275 | workflow.Servers = append(workflow.Servers, template.Servers...) 276 | workflow.Init = strings.TrimSpace(workflow.Init + "\n" + template.Init) 277 | 278 | return nil 279 | } 280 | 281 | func (workflow *StackupWorkflow) processInclude(include *WorkflowInclude) error { 282 | include.Initialize(workflow) 283 | 284 | var err error = nil 285 | loaded := workflow.tryLoadingCachedData(include) 286 | 287 | if !loaded { 288 | debug.Logf("include not loaded from cache: %s", include.DisplayName()) 289 | 290 | err, loaded = workflow.loadRemoteFileInclude(include) 291 | if !loaded { 292 | support.FailureMessageWithXMark(messages.RemoteIncludeStatus("rejected: "+err.Error(), include.DisplayName())) 293 | return err 294 | } 295 | } 296 | 297 | if !loaded { 298 | support.FailureMessageWithXMark(messages.RemoteIncludeStatus("failed", include.DisplayName())) 299 | return errors.New(messages.RemoteIncludeCannotLoad(include.DisplayName())) 300 | } 301 | 302 | if err := workflow.loadAndImportInclude(include.Contents); err != nil { 303 | support.FailureMessageWithXMark(messages.RemoteIncludeStatus("cache load failed", include.DisplayName())) 304 | return err 305 | } 306 | 307 | if !workflow.handleChecksumVerification(include) { 308 | // the app terminiates during handleChecksumVerification if the 'exit-on-checksum-mismatch' setting is enabled 309 | // so we can only show a wanring message here. 310 | support.WarningMessage(messages.RemoteIncludeChecksumMismatch(include.DisplayName())) 311 | return nil 312 | } 313 | 314 | support.SuccessMessageWithCheck(messages.RemoteIncludeStatus(include.loadedStatusText(), include.DisplayName())) 315 | 316 | return nil 317 | } 318 | -------------------------------------------------------------------------------- /lib/cache/CacheEntry.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "encoding/base64" 5 | 6 | carbon "github.com/golang-module/carbon/v2" 7 | ) 8 | 9 | type CacheEntry struct { 10 | Value string `json:"value"` 11 | Hash string `json:"hash"` 12 | Algorithm string `json:"algorithm"` 13 | ExpiresAtTs carbon.Carbon 14 | UpdatedAtTs carbon.Carbon 15 | ExpiresAt string `json:"expires_at"` 16 | UpdatedAt string `json:"updated_at"` 17 | } 18 | 19 | func (ce *CacheEntry) IsExpired() bool { 20 | if ce.ExpiresAt == "" { 21 | return true 22 | } 23 | 24 | return ce.ExpiresAtTs.IsPast() 25 | } 26 | 27 | func (ce *CacheEntry) EncodeValue() { 28 | ce.Value = base64.StdEncoding.EncodeToString([]byte(ce.Value)) 29 | } 30 | 31 | func (ce *CacheEntry) DecodeValue() { 32 | if ce.Value == "" { 33 | return 34 | } 35 | 36 | decoded, err := base64.StdEncoding.DecodeString(ce.Value) 37 | if err != nil { 38 | return 39 | } 40 | 41 | ce.Value = string(decoded) 42 | } 43 | 44 | func (ce *CacheEntry) UpdateTimestampsFromStrings() error { 45 | if parsed := carbon.Parse(ce.ExpiresAt); parsed.Error == nil { 46 | ce.ExpiresAtTs = parsed 47 | } else { 48 | return parsed.Error 49 | } 50 | 51 | if parsed := carbon.Parse(ce.UpdatedAt); parsed.Error == nil { 52 | ce.UpdatedAtTs = parsed 53 | } else { 54 | return parsed.Error 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func (ce *CacheEntry) UpdateTimestampsFromObjects() { 61 | if ce.ExpiresAtTs.IsValid() { 62 | ce.ExpiresAt = ce.ExpiresAtTs.ToIso8601String() 63 | } 64 | 65 | if ce.UpdatedAtTs.IsValid() { 66 | ce.UpdatedAt = ce.UpdatedAtTs.ToIso8601String() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | carbon "github.com/golang-module/carbon/v2" 12 | "github.com/stackup-app/stackup/lib/consts" 13 | "github.com/stackup-app/stackup/lib/utils" 14 | bolt "go.etcd.io/bbolt" 15 | ) 16 | 17 | type Cache struct { 18 | Db *bolt.DB 19 | Enabled bool 20 | Name string 21 | Path string 22 | Filename string 23 | DefaultTtl int 24 | ticker *time.Ticker 25 | } 26 | 27 | type HashAlgorithmType byte 28 | 29 | const ( 30 | HashAlgorithmSha1 HashAlgorithmType = iota 31 | HashAlgorithmSha256 32 | HashAlgorithmSha384 33 | HashAlgorithmSha512 34 | ) 35 | 36 | // creates a new Cache instance. `name` is used to determine the boltdb filename, and `storagePath` is 37 | // used as the path for the db file. If `name` is empty, it defaults to the name of the current binary. 38 | func New(name string, storagePath string, ttlMinutes int) *Cache { 39 | if !utils.FileExists(storagePath) { 40 | os.MkdirAll(storagePath, 0744) 41 | } 42 | 43 | result := Cache{Name: name, Enabled: false, Path: storagePath, DefaultTtl: ttlMinutes} 44 | 45 | return result.Initialize() 46 | } 47 | 48 | func NewCacheEntry(obj any, ttlMinutes int) *CacheEntry { 49 | updatedObj := carbon.Now() 50 | expiresObj := carbon.Now().AddMinutes(ttlMinutes) 51 | 52 | value, err := json.Marshal(obj) 53 | 54 | if err != nil { 55 | return nil 56 | } 57 | 58 | return &CacheEntry{ 59 | Value: string(value), 60 | Algorithm: "", 61 | Hash: "", 62 | ExpiresAt: expiresObj.ToIso8601String(), 63 | UpdatedAt: updatedObj.ToIso8601String(), 64 | } 65 | } 66 | 67 | func CreateCarbonNowPtr() *carbon.Carbon { 68 | result := carbon.Now() 69 | return &result 70 | } 71 | 72 | func CreateExpiresAtPtr(ttlMinutes int) *carbon.Carbon { 73 | result := CreateCarbonNowPtr().AddMinutes(ttlMinutes) 74 | 75 | return &result 76 | } 77 | 78 | func (c *Cache) AutoPurgeInterval() time.Duration { 79 | interval, err := time.ParseDuration("60s") 80 | if err != nil { 81 | return time.Minute 82 | } 83 | 84 | return interval 85 | } 86 | 87 | func (c *Cache) Cleanup(removeFile bool) { 88 | if c.Db != nil { 89 | c.Db.Close() 90 | c.Db = nil 91 | } 92 | 93 | if removeFile && utils.FileExists(c.Filename) { 94 | os.Remove(c.Filename) 95 | } 96 | } 97 | 98 | func (c *Cache) CreateEntry(value string, expiresAt *carbon.Carbon, hash string, algorithm string, updatedAt *carbon.Carbon) *CacheEntry { 99 | updatedObj := carbon.Now() 100 | expiresObj := carbon.Now().AddMinutes(c.DefaultTtl) 101 | 102 | if updatedAt != nil { 103 | updatedObj = *updatedAt 104 | } 105 | 106 | if expiresAt != nil { 107 | expiresObj = *expiresAt 108 | } 109 | 110 | return &CacheEntry{ 111 | Value: value, 112 | Algorithm: algorithm, 113 | Hash: hash, 114 | ExpiresAt: expiresObj.ToIso8601String(), 115 | UpdatedAt: updatedObj.ToIso8601String(), 116 | } 117 | } 118 | 119 | func (c *Cache) MakeKey(key string) string { 120 | result := key 121 | suffixes := []string{"_expires_at", "_hash", "_updated_at"} 122 | for _, suffix := range suffixes { 123 | result = strings.TrimSuffix(result, suffix) 124 | } 125 | 126 | return result 127 | } 128 | 129 | // The `Initialize` function in the `Cache` struct is used to initialize the cache by setting up the 130 | // necessary configurations and opening the database connection. Here's a breakdown of what it does: 131 | func (c *Cache) Initialize() *Cache { 132 | if c.Db != nil { 133 | return c 134 | } 135 | 136 | filename := utils.EnforceSuffix(utils.FsSafeName(c.Name), ".db") 137 | 138 | if strings.TrimSuffix(filename, ".db") == "" { 139 | filename = utils.EnforceSuffix(consts.APPLICATION_NAME, ".db") 140 | } 141 | 142 | if c.Name == "" { 143 | c.Name = strings.TrimSuffix(filename, ".db") 144 | } 145 | 146 | c.Filename = filepath.Join(c.Path, filename) 147 | 148 | var err error 149 | if c.Db, err = bolt.Open(c.Filename, 0644, &bolt.Options{Timeout: 5 * time.Second}); err != nil { 150 | return c 151 | } 152 | 153 | // create a new project bucket if it does not already exist 154 | c.Db.Update(func(tx *bolt.Tx) error { 155 | if _, err := tx.CreateBucketIfNotExists([]byte(c.Name)); err != nil { 156 | return fmt.Errorf("error creating cache bucket: %s", err) 157 | } 158 | return nil 159 | }) 160 | 161 | c.Enabled = true 162 | c.startAutoPurge() 163 | 164 | return c 165 | } 166 | 167 | func (c *Cache) startAutoPurge() { 168 | // prevent multiple tickers from being created 169 | if c.ticker != nil { 170 | return 171 | } 172 | 173 | // perform an initial purge 174 | c.purgeExpired() 175 | c.ticker = time.NewTicker(c.AutoPurgeInterval()) 176 | 177 | go func() { 178 | for { 179 | select { 180 | case <-c.ticker.C: 181 | c.purgeExpired() 182 | } 183 | } 184 | }() 185 | } 186 | 187 | // The `Get` function in the `Cache` struct is used to retrieve the value of a cache entry with a given 188 | // key. It takes a `key` parameter (string) and returns the corresponding value (string). 189 | // returns a valid, unexpired cache item or nil if the item is expired or not found. 190 | // note: do not call `Cache.Has()` from here. 191 | func (c *Cache) Get(key string) (*CacheEntry, bool) { 192 | var err error 193 | entry := &CacheEntry{} 194 | 195 | c.Db.View(func(tx *bolt.Tx) error { 196 | bucket := tx.Bucket([]byte(c.Name)) 197 | bytes := bucket.Get([]byte(c.MakeKey(key))) 198 | err = json.Unmarshal(bytes, &entry) 199 | 200 | if entry != nil { 201 | entry.UpdateTimestampsFromStrings() 202 | entry.DecodeValue() 203 | } 204 | 205 | return nil 206 | }) 207 | 208 | // return nothing if there was an error or the entry was found, but is expired 209 | if err != nil || entry.IsExpired() { 210 | return nil, false 211 | } 212 | 213 | // return a valid, unexpired cache item 214 | return entry, true 215 | } 216 | 217 | // The `purgeExpired` function in the `Cache` struct is used to remove any cache entries that have 218 | // expired. It iterates through all the keys in the cache bucket and checks if each key has expired 219 | // using the `IsExpired` function. If a key is expired, it is deleted from the cache bucket. This 220 | // function ensures that expired cache entries are automatically removed from the cache to free up 221 | // space and maintain cache integrity. 222 | func (c *Cache) purgeExpired() { 223 | primaryKeys := []string{} 224 | 225 | c.Db.View(func(tx *bolt.Tx) error { 226 | b := tx.Bucket([]byte(c.Name)) 227 | 228 | b.ForEach(func(k, v []byte) error { 229 | primaryKeys = append(primaryKeys, string(k)) 230 | return nil 231 | }) 232 | 233 | return nil 234 | }) 235 | 236 | if len(primaryKeys) == 0 { 237 | return 238 | } 239 | 240 | c.Db.Update(func(tx *bolt.Tx) error { 241 | b := tx.Bucket([]byte(c.Name)) 242 | 243 | for _, key := range primaryKeys { 244 | if c.IsExpired(key) { 245 | b.Delete([]byte(key)) 246 | } 247 | } 248 | 249 | return nil 250 | }) 251 | } 252 | 253 | // The `IsExpired` function in the `Cache` struct is used to check if a cache entry with a given key 254 | // has expired. 255 | func (c *Cache) IsExpired(key string) bool { 256 | item, found := c.Get(key) 257 | if !found { 258 | return true 259 | } 260 | 261 | return item.IsExpired() 262 | } 263 | 264 | // The `Set` function in the `Cache` struct is used to set a cache entry with a given key and value. It 265 | // takes three parameters: `key` (string), `value` (any), and `ttlMinutes` (int). 266 | // the .Value attribute of `value.Value` attribute is automatically base64 encoded before being stored. 267 | func (c *Cache) Set(key string, value *CacheEntry, ttlMinutes int) { 268 | c.Db.Update(func(tx *bolt.Tx) error { 269 | b := tx.Bucket([]byte(c.Name)) 270 | 271 | value.EncodeValue() 272 | defer value.DecodeValue() 273 | 274 | var err error 275 | if code, err := json.Marshal(value); err == nil { 276 | err = b.Put([]byte(key), code) 277 | } 278 | 279 | return err 280 | }) 281 | } 282 | 283 | // The `Has` function in the `Cache` struct is used to check if a cache entry with a given key exists 284 | // and is not expired. 285 | func (c *Cache) Has(key string) bool { 286 | found := false 287 | 288 | // c.Db.View(func(tx *bolt.Tx) error { 289 | // b := tx.Bucket([]byte(c.Name)) 290 | //v := b.Get([]byte(key)) 291 | item, ok := c.Get(key) 292 | if ok { 293 | found = !item.IsExpired() 294 | } 295 | // return nil 296 | // }) 297 | 298 | return found 299 | } 300 | 301 | // The `Remove` function in the `Cache` struct is used to remove a cache entry with a given key. It 302 | // calls the `Set` function with a `value` parameter of `nil` and a `ttlMinutes` parameter of `0`. This 303 | // effectively sets the cache entry to be empty and expired, effectively removing it from the cache. 304 | func (c *Cache) Remove(key string) { 305 | if len(key) == 0 { 306 | return 307 | } 308 | 309 | c.Db.Update(func(tx *bolt.Tx) error { 310 | b := tx.Bucket([]byte(c.Name)) 311 | b.Delete([]byte(key)) 312 | return nil 313 | }) 314 | } 315 | 316 | // builds a cache key using a prefix and name 317 | func (c *Cache) MakeCacheKey(prefix string, name string) string { 318 | prefix = strings.TrimSuffix(prefix, ":") 319 | if len(prefix) == 0 { 320 | return name 321 | } 322 | 323 | return prefix + ":" + name 324 | } 325 | -------------------------------------------------------------------------------- /lib/cache/cache_test.go: -------------------------------------------------------------------------------- 1 | package cache_test 2 | 3 | import ( 4 | "testing" 5 | 6 | carbon "github.com/golang-module/carbon/v2" 7 | "github.com/stackup-app/stackup/lib/cache" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCacheSetAndGet(t *testing.T) { 12 | c := cache.New("stackup-test", "/tmp", 60) 13 | defer c.Cleanup(true) 14 | 15 | expires := carbon.Now().SubMinutes(5) 16 | entry := c.CreateEntry("test", &expires, "", "", nil) 17 | 18 | c.Set("test", entry, -2) 19 | _, found := c.Get("test") 20 | assert.False(t, found) 21 | assert.True(t, c.IsExpired("test")) 22 | 23 | expires = carbon.Now().AddMinutes(5) 24 | entry2 := c.CreateEntry("test2", &expires, "", "", nil) 25 | 26 | c.Set("test2", entry2, 5) 27 | found2 := c.Has("test2") 28 | assert.True(t, found2) 29 | } 30 | 31 | func TestCacheRemove(t *testing.T) { 32 | c := cache.New("stackup-test", "/tmp", 60) 33 | defer c.Cleanup(true) 34 | 35 | expires := carbon.Now().AddMinutes(5) 36 | entry := c.CreateEntry("test3", &expires, "", "", nil) 37 | 38 | c.Set("test3", entry, 5) 39 | assert.True(t, c.Has("test3")) 40 | 41 | c.Remove("test3") 42 | assert.False(t, c.Has("test3")) 43 | } 44 | -------------------------------------------------------------------------------- /lib/checksums/algorithms.go: -------------------------------------------------------------------------------- 1 | package checksums 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | // ENUM(sha1, sha256, sha512, unsupported, error) 9 | type ChecksumAlgorithm int 10 | 11 | const ( 12 | // ChecksumAlgorithmSha1 is a ChecksumAlgorithm of type Sha1. 13 | ChecksumAlgorithmSha1 ChecksumAlgorithm = iota 14 | // ChecksumAlgorithmSha256 is a ChecksumAlgorithm of type Sha256. 15 | ChecksumAlgorithmSha256 16 | // ChecksumAlgorithmSha512 is a ChecksumAlgorithm of type Sha512. 17 | ChecksumAlgorithmSha512 18 | // ChecksumAlgorithmUnsupported is a ChecksumAlgorithm of type Unsupported. 19 | ChecksumAlgorithmUnsupported 20 | // ChecksumAlgorithmError is a ChecksumAlgorithm of type Error. 21 | ChecksumAlgorithmError 22 | ) 23 | 24 | var ErrInvalidChecksumAlgorithm = errors.New("not a valid ChecksumAlgorithm") 25 | 26 | const _ChecksumAlgorithmName = "sha1sha256sha512unsupportederror" 27 | 28 | var _ChecksumAlgorithmMap = map[ChecksumAlgorithm]string{ 29 | ChecksumAlgorithmSha1: _ChecksumAlgorithmName[0:4], 30 | ChecksumAlgorithmSha256: _ChecksumAlgorithmName[4:10], 31 | ChecksumAlgorithmSha512: _ChecksumAlgorithmName[10:16], 32 | ChecksumAlgorithmUnsupported: _ChecksumAlgorithmName[16:27], 33 | ChecksumAlgorithmError: _ChecksumAlgorithmName[27:32], 34 | } 35 | 36 | // String implements the Stringer interface. 37 | func (x ChecksumAlgorithm) String() string { 38 | if str, ok := _ChecksumAlgorithmMap[x]; ok { 39 | return str 40 | } 41 | return fmt.Sprintf("ChecksumAlgorithm(%d)", x) 42 | } 43 | 44 | // IsValid provides a quick way to determine if the typed value is 45 | // part of the allowed enumerated values 46 | func (x ChecksumAlgorithm) IsValid() bool { 47 | _, ok := _ChecksumAlgorithmMap[x] 48 | return ok 49 | } 50 | 51 | func (x ChecksumAlgorithm) UnsupportedError() error { 52 | if x.IsSupportedAlgorithm() { 53 | return nil 54 | } 55 | return fmt.Errorf("algorithm %s is not supported", x.String()) 56 | } 57 | 58 | func (x ChecksumAlgorithm) IsSupportedAlgorithm() bool { 59 | return x == ChecksumAlgorithmSha256 || x == ChecksumAlgorithmSha512 60 | } 61 | 62 | var _ChecksumAlgorithmValue = map[string]ChecksumAlgorithm{ 63 | _ChecksumAlgorithmName[0:4]: ChecksumAlgorithmSha1, 64 | _ChecksumAlgorithmName[4:10]: ChecksumAlgorithmSha256, 65 | _ChecksumAlgorithmName[10:16]: ChecksumAlgorithmSha512, 66 | _ChecksumAlgorithmName[16:27]: ChecksumAlgorithmUnsupported, 67 | _ChecksumAlgorithmName[27:32]: ChecksumAlgorithmError, 68 | } 69 | 70 | var _ChecksumAlgorithmLengths = map[ChecksumAlgorithm]int{ 71 | //: ChecksumAlgorithmSha1, 72 | ChecksumAlgorithmSha256: 64, 73 | ChecksumAlgorithmSha512: 128, 74 | // _ChecksumAlgorithmName[16:27]: ChecksumAlgorithmUnsupported, 75 | // _ChecksumAlgorithmName[27:32]: ChecksumAlgorithmError, 76 | } 77 | 78 | // ParseChecksumAlgorithm attempts to convert a string to a ChecksumAlgorithm. 79 | func ParseChecksumAlgorithm(name string) ChecksumAlgorithm { 80 | if x, ok := _ChecksumAlgorithmValue[name]; ok { 81 | return x 82 | } 83 | 84 | return ChecksumAlgorithmUnsupported 85 | // return ChecksumAlgorithm(0), fmt.Errorf("%s is %w", name, ErrInvalidChecksumAlgorithm) 86 | } 87 | 88 | func (x ChecksumAlgorithm) GetHashLength() int { 89 | result, ok := _ChecksumAlgorithmLengths[x] 90 | if !ok { 91 | return 0 92 | } 93 | 94 | return result 95 | } 96 | 97 | // MarshalText implements the text marshaller method. 98 | func (x ChecksumAlgorithm) MarshalText() ([]byte, error) { 99 | return []byte(x.String()), nil 100 | } 101 | 102 | // UnmarshalText implements the text unmarshaller method. 103 | func (x *ChecksumAlgorithm) UnmarshalText(text []byte) error { 104 | name := string(text) 105 | *x = ParseChecksumAlgorithm(name) 106 | 107 | return nil 108 | } 109 | -------------------------------------------------------------------------------- /lib/checksums/checksums.go: -------------------------------------------------------------------------------- 1 | package checksums 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type CalculateHashFunction = func(input string) (string, ChecksumAlgorithm) 8 | 9 | type Checksum struct { 10 | Hash string 11 | Algorithm ChecksumAlgorithm 12 | Filename string 13 | } 14 | 15 | func FindFilenameChecksum(filename string, contents string) *Checksum { 16 | for _, line := range stringToLines(contents) { 17 | hash, fn, err := matchHashAndFilename(line) 18 | if err == nil && strings.EqualFold(fn, filename) { 19 | return &Checksum{Hash: hash, Filename: fn, Algorithm: ChecksumAlgorithmSha256} 20 | } 21 | } 22 | 23 | return nil 24 | } 25 | 26 | func DetermineChecksumAlgorithm(hashes []string, checksumUrl string) ChecksumAlgorithm { 27 | // find the algorithm using length of the hash 28 | if algorithm := matchAlgorithmByLength(hashes); algorithm.IsSupportedAlgorithm() { 29 | return algorithm 30 | } 31 | 32 | // find the algorithm using the checksum url 33 | if algorithm := matchAlgorithmByUrl(checksumUrl); algorithm.IsSupportedAlgorithm() { 34 | return algorithm 35 | } 36 | 37 | return ChecksumAlgorithmUnsupported 38 | } 39 | 40 | func HashesMatch(hash1 string, hash2 string) bool { 41 | return strings.EqualFold(hash1, hash2) && hash1 != "" 42 | } 43 | -------------------------------------------------------------------------------- /lib/checksums/hashes.go: -------------------------------------------------------------------------------- 1 | package checksums 2 | 3 | import ( 4 | "crypto/sha256" 5 | "crypto/sha512" 6 | "encoding/hex" 7 | ) 8 | 9 | func CalculateDefaultHash(input string) (string, ChecksumAlgorithm) { 10 | return CalculateSha256Hash(input) 11 | } 12 | 13 | func CalculateSha256Hash(input string) (string, ChecksumAlgorithm) { 14 | hashBytes := sha256.Sum256([]byte(input)) 15 | return hex.EncodeToString(hashBytes[:]), ChecksumAlgorithmSha256 16 | } 17 | 18 | func CalculateSha512Hash(input string) (string, ChecksumAlgorithm) { 19 | hashBytes := sha512.Sum512([]byte(input)) 20 | return hex.EncodeToString(hashBytes[:]), ChecksumAlgorithmSha512 21 | } 22 | -------------------------------------------------------------------------------- /lib/checksums/matching.go: -------------------------------------------------------------------------------- 1 | package checksums 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | func matchAlgorithmByUrl(url string) ChecksumAlgorithm { 9 | // regex to match against checksum filenames 10 | patterns := map[ChecksumAlgorithm]*regexp.Regexp{ 11 | ChecksumAlgorithmSha256: regexp.MustCompile("sha256(sum|\\.txt)"), 12 | ChecksumAlgorithmSha512: regexp.MustCompile("sha512(sum|\\.txt)"), 13 | } 14 | 15 | for name, pattern := range patterns { 16 | if pattern.MatchString(url) { 17 | return name 18 | } 19 | } 20 | 21 | return ChecksumAlgorithmUnsupported 22 | } 23 | 24 | func matchAlgorithmByLength(hashes []string) ChecksumAlgorithm { 25 | // hash length to name mapping 26 | hashTypeMap := map[int]ChecksumAlgorithm{ 27 | 16: ParseChecksumAlgorithm("md4"), 28 | 32: ParseChecksumAlgorithm("md5"), 29 | 40: ParseChecksumAlgorithm("sha1"), 30 | 64: ParseChecksumAlgorithm("sha256"), 31 | 96: ParseChecksumAlgorithm("sha384"), 32 | 128: ParseChecksumAlgorithm("sha512"), 33 | } 34 | 35 | for _, hash := range hashes { 36 | if hashType, ok := hashTypeMap[len(hash)]; ok { 37 | return hashType 38 | } 39 | } 40 | 41 | return ChecksumAlgorithmUnsupported 42 | } 43 | 44 | func matchHashAndFilename(input string) (string, string, error) { 45 | pattern := `([a-fA-F0-9]{48,})[\s\t]+([\w\d_\-\.\/\\]+)` 46 | regex := regexp.MustCompile(pattern) 47 | 48 | matches := regex.FindStringSubmatch(input) 49 | if len(matches) != 3 { 50 | return "", "", fmt.Errorf("input string does not match pattern") 51 | } 52 | 53 | hash := matches[1] 54 | filename := matches[2] 55 | 56 | return hash, filename, nil 57 | } 58 | -------------------------------------------------------------------------------- /lib/checksums/utils.go: -------------------------------------------------------------------------------- 1 | package checksums 2 | 3 | import ( 4 | "net/url" 5 | "path" 6 | "strings" 7 | ) 8 | 9 | func GetChecksumUrls(fullUrl string) []string { 10 | url, _ := url.Parse(fullUrl) 11 | reqFn := path.Base(url.Path) 12 | url.Path = path.Dir(url.Path) 13 | 14 | return []string{ 15 | url.JoinPath("checksums.txt").String(), 16 | url.JoinPath("checksums.sha256.txt").String(), 17 | url.JoinPath("checksums.sha512.txt").String(), 18 | url.JoinPath("sha256sum").String(), 19 | url.JoinPath("sha512sum").String(), 20 | url.JoinPath("sha512sum").String(), 21 | url.JoinPath(reqFn + ".sha256").String(), 22 | url.JoinPath(reqFn + ".sha512").String(), 23 | } 24 | } 25 | 26 | func stringToLines(contents string) []string { 27 | contents = strings.TrimSpace(contents) 28 | contents = strings.ReplaceAll(contents, "\\n", " # \n") 29 | contents = strings.ReplaceAll(contents, "\t", " ") 30 | 31 | lines := strings.Split(contents, "\\n") 32 | lines = strings.Split(lines[0], "\n") 33 | 34 | return lines 35 | } 36 | -------------------------------------------------------------------------------- /lib/consts/consts.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const APPLICATION_NAME = "stackup" 4 | const APP_REPOSITORY = "permafrost-dev/stackup" 5 | 6 | const APP_CONFIG_PATH_BASE_NAME = "stackup" 7 | const APP_ICON_URL string = "https://raw.githubusercontent.com/" + APP_REPOSITORY + "/main/assets/stackup-app-512px.png" 8 | 9 | const APP_NEW_CONFIG_TEMPLATE_URL = "https://raw.githubusercontent.com/permafrost-dev/stackup/main/templates/init.stackup.template.yaml" 10 | 11 | const DEFAULT_CACHE_TTL_MINUTES = 15 12 | const DEFAULT_CWD_SETTING = "{{ getCwd() }}" 13 | 14 | var DEFAULT_GATEWAY_MIDDLEWARE = []string{"validateUrl", "verifyFileType", "validateContentType"} 15 | 16 | const MAX_TASK_RUNS = 99999999 17 | 18 | var ALL_PLATFORMS = []string{"windows", "linux", "darwin"} 19 | 20 | var DEFAULT_ALLOWED_DOMAINS = []string{"raw.githubusercontent.com", "api.github.com"} 21 | var DISPLAY_URLS_REMOVABLE = []string{"https://", "github.com", "raw.githubusercontent.com", "s3:"} 22 | 23 | var INIT_CONFIG_FILE_CONTENTS string = `name: my stack 24 | description: application stack 25 | version: 1.0.0 26 | 27 | settings: 28 | anonymous-statistics: false 29 | exit-on-checksum-mismatch: false 30 | dotenv: ['.env', '.env.local'] 31 | checksum-verification: true 32 | cache: 33 | ttl-minutes: 15 34 | domains: 35 | allowed: 36 | - '*.githubusercontent.com' 37 | hosts: 38 | - hostname: '*.github.com' 39 | gateway: allow 40 | headers: 41 | - 'Accept: application/vnd.github.v3+json' 42 | gateway: 43 | content-types: 44 | allowed: 45 | - '*' 46 | 47 | includes: 48 | - url: gh:permafrost-dev/stackup/main/templates/remote-includes/containers.yaml 49 | - url: gh:permafrost-dev/stackup/main/templates/remote-includes/%s.yaml 50 | 51 | # project type preconditions are loaded from included file above 52 | preconditions: 53 | 54 | startup: 55 | - task: start-containers 56 | 57 | shutdown: 58 | - task: stop-containers 59 | 60 | servers: 61 | 62 | scheduler: 63 | 64 | # tasks are loaded from included files above 65 | tasks: 66 | ` 67 | -------------------------------------------------------------------------------- /lib/debug/debug.go: -------------------------------------------------------------------------------- 1 | package debug 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/stackup-app/stackup/lib/utils" 7 | ) 8 | 9 | type DebugContract interface { 10 | IsEnabled() bool 11 | SetEnabled(enabled bool) 12 | Logf(format string, a ...any) 13 | } 14 | 15 | type Debug struct { 16 | Enabled bool 17 | DebugContract 18 | } 19 | 20 | var Dbg = &Debug{Enabled: false} 21 | 22 | func (d *Debug) SetEnabled(enabled bool) { 23 | d.Enabled = enabled 24 | } 25 | 26 | func (d *Debug) IsEnabled() bool { 27 | return d.Enabled 28 | } 29 | 30 | func (d *Debug) Log(msg ...string) { 31 | if len(msg) == 0 { 32 | d.Logf("") 33 | return 34 | } 35 | 36 | for _, m := range msg { 37 | d.Logf("%s", m) 38 | } 39 | } 40 | 41 | func (d *Debug) Logf(format string, a ...any) { 42 | if !d.IsEnabled() { 43 | return 44 | } 45 | 46 | fmt.Printf(" [debug] "+utils.EnforceSuffix(format, "\n"), a) 47 | } 48 | 49 | func Logf(format string, a ...any) { 50 | Dbg.Logf(format, a) 51 | } 52 | 53 | func Log(msg ...string) { 54 | Dbg.Log(msg...) 55 | } 56 | -------------------------------------------------------------------------------- /lib/downloader/downloader.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | import ( 4 | "github.com/stackup-app/stackup/lib/gateway" 5 | "github.com/stackup-app/stackup/lib/utils" 6 | ) 7 | 8 | type Downloader struct { 9 | Gateway *gateway.Gateway 10 | } 11 | 12 | func New(gateway *gateway.Gateway) *Downloader { 13 | return &Downloader{Gateway: gateway} 14 | } 15 | 16 | func (d *Downloader) Download(url string, targetPath string) { 17 | if utils.IsNonEmptyFile(targetPath) { 18 | return 19 | } 20 | 21 | if utils.IsFile(targetPath) { 22 | utils.RemoveFile(targetPath) 23 | } 24 | 25 | d.Gateway.SaveUrlToFile(url, targetPath) 26 | } 27 | -------------------------------------------------------------------------------- /lib/downloader/s3downloader.go: -------------------------------------------------------------------------------- 1 | package downloader 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/minio/minio-go/pkg/s3utils" 11 | "github.com/minio/minio-go/v7" 12 | "github.com/minio/minio-go/v7/pkg/credentials" 13 | ) 14 | 15 | type S3Url struct { 16 | Endpoint string 17 | BucketName string 18 | FileName string 19 | } 20 | 21 | func ParseS3Url(urlstr string) (*S3Url, error) { 22 | temp := strings.Replace(urlstr, "s3://", "", 1) 23 | temp = strings.Replace(temp, "s3:", "", 1) 24 | temp = "s3://" + temp 25 | 26 | parsedUrl, err := url.Parse(urlstr) 27 | 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | // Extract the endpoint, bucket name, and filename from the URL 33 | endpoint := parsedUrl.Host 34 | bucketName := parsedUrl.Path 35 | fileName := "" 36 | 37 | // Remove the leading slash from the bucket name 38 | if strings.HasPrefix(bucketName, "/") { 39 | bucketName = bucketName[1:] 40 | } 41 | 42 | // Extract the filename from the bucket name 43 | if strings.Contains(bucketName, "/") { 44 | parts := strings.SplitN(bucketName, "/", 2) 45 | bucketName = parts[0] 46 | fileName = parts[1] 47 | } 48 | 49 | // Create a new S3Url struct 50 | s3UrlStruct := &S3Url{ 51 | Endpoint: endpoint, 52 | BucketName: bucketName, 53 | FileName: fileName, 54 | } 55 | 56 | return s3UrlStruct, nil 57 | } 58 | 59 | func ReadS3FileContents(s3url string, accessKey string, secretKey string, secure bool) string { 60 | data, err := ParseS3Url(s3url) 61 | 62 | s3Client, err := minio.New(data.Endpoint, &minio.Options{ 63 | Creds: credentials.NewStaticV4(accessKey, secretKey, ""), 64 | Secure: secure, 65 | }) 66 | 67 | if err != nil { 68 | fmt.Printf("%v", err) 69 | return "" 70 | } 71 | 72 | opts := minio.GetObjectOptions{} 73 | content, _ := FGetObject(*s3Client, context.Background(), data.BucketName, data.FileName, opts) 74 | 75 | return content 76 | } 77 | 78 | func FGetObject(client minio.Client, ctx context.Context, bucketName string, objectName string, opts minio.GetObjectOptions) (string, error) { 79 | // Input validation. 80 | if err := s3utils.CheckValidBucketName(bucketName); err != nil { 81 | return "", err 82 | } 83 | if err := s3utils.CheckValidObjectName(objectName); err != nil { 84 | return "", err 85 | } 86 | 87 | opts.SetRange(0, 8192*32) 88 | 89 | objectReader, err := client.GetObject(ctx, bucketName, objectName, opts) 90 | if err != nil { 91 | return "", err 92 | } 93 | 94 | objectReader.Seek(0, 0) 95 | content := make([]byte, 8192*32) 96 | objectReader.Read(content) 97 | 98 | return removeControlCharacters(string(content)), nil 99 | } 100 | 101 | func removeControlCharacters(input string) string { 102 | controlCharRegexp := regexp.MustCompile(`[\x00-\x08\x1A]`) 103 | return controlCharRegexp.ReplaceAllString(input, "") 104 | } 105 | -------------------------------------------------------------------------------- /lib/gateway/ValidateUrlMiddleware.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | 7 | "github.com/stackup-app/stackup/lib/messages" 8 | "github.com/stackup-app/stackup/lib/types" 9 | ) 10 | 11 | var ValidateUrlMiddleware = GatewayUrlRequestMiddleware{ 12 | Name: "validateUrl", 13 | Handler: func(g *Gateway, link string) error { 14 | if !g.Enabled { 15 | return nil 16 | } 17 | 18 | parsedUrl, err := url.Parse(link) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | if g.checkArrayForDomainMatch(&g.AllowedDomains, parsedUrl.Host) { 24 | return nil 25 | } 26 | 27 | if g.checkArrayForDomainMatch(&g.DeniedDomains, parsedUrl.Host) { 28 | return errors.New(messages.AccessBlocked(types.AccessTypeDomain, parsedUrl.Host)) 29 | } 30 | 31 | return errors.New(messages.NotExplicitlyAllowed(types.AccessTypeDomain, parsedUrl.Host)) 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /lib/gateway/VerifyContentTypeMIddleware.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/stackup-app/stackup/lib/messages" 9 | "github.com/stackup-app/stackup/lib/types" 10 | ) 11 | 12 | var VerifyContentTypeMIddleware = GatewayUrlResponseMiddleware{ 13 | Name: "verifyContentType", 14 | Handler: func(g *Gateway, resp *http.Response) error { 15 | if !g.Enabled { 16 | return nil 17 | } 18 | 19 | contentType, _, _ := strings.Cut(resp.Header.Get("Content-Type"), ";") 20 | 21 | allowedTypes := g.GetDomainContentTypes(resp.Request.URL.Hostname()) 22 | if g.checkArrayForDomainMatch(&allowedTypes, contentType) || len(allowedTypes) == 0 { 23 | return nil 24 | } 25 | 26 | blockedTypes := g.GetBlockedContentTypes(resp.Request.URL.Hostname()) 27 | if g.checkArrayForDomainMatch(&blockedTypes, contentType) { 28 | return errors.New(messages.AccessBlocked(types.AccessTypeContentType, contentType)) 29 | } 30 | 31 | return errors.New(messages.NotExplicitlyAllowed(types.AccessTypeContentType, contentType)) 32 | }, 33 | } 34 | -------------------------------------------------------------------------------- /lib/gateway/VerifyFileTypeMiddleware.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "path" 7 | "strings" 8 | 9 | "github.com/stackup-app/stackup/lib/messages" 10 | "github.com/stackup-app/stackup/lib/types" 11 | "github.com/stackup-app/stackup/lib/utils" 12 | ) 13 | 14 | var VerifyFileTypeMiddleware = GatewayUrlRequestMiddleware{ 15 | Name: "verifyFileType", 16 | Handler: func(g *Gateway, link string) error { 17 | if !g.Enabled { 18 | return nil 19 | } 20 | 21 | parsedUrl, err := url.Parse(link) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | fileExt := path.Ext(parsedUrl.Path) 27 | 28 | if fileExt == "." || fileExt == "" { 29 | return nil 30 | } 31 | 32 | for _, ext := range g.AllowedFileExts { 33 | if utils.GlobMatch(ext, fileExt, false) || strings.EqualFold(fileExt, ext) { 34 | return nil 35 | } 36 | } 37 | 38 | for _, ext := range g.BlockedFileExts { 39 | if utils.GlobMatch(ext, fileExt, false) || strings.EqualFold(fileExt, ext) { 40 | return errors.New(messages.AccessBlocked(types.AccessTypeFileExtension, fileExt)) 41 | } 42 | } 43 | 44 | return errors.New(messages.NotExplicitlyAllowed(types.AccessTypeFileExtension, fileExt)) 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /lib/gateway/gateway_test.go: -------------------------------------------------------------------------------- 1 | package gateway_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stackup-app/stackup/lib/app" 7 | "github.com/stackup-app/stackup/lib/gateway" 8 | "github.com/stackup-app/stackup/lib/scripting" 9 | "github.com/stackup-app/stackup/lib/settings" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestGatewayEnable(t *testing.T) { 14 | g := gateway.New(nil) 15 | g.Enabled = false 16 | g.Enable() 17 | assert.True(t, g.Enabled, "gateway should be enabled") 18 | } 19 | 20 | func TestGatewayDisable(t *testing.T) { 21 | g := gateway.New(nil) 22 | g.Enabled = true 23 | g.Disable() 24 | assert.False(t, g.Enabled, "gateway should be disabled") 25 | } 26 | 27 | func TestGatewayInitialize(t *testing.T) { 28 | s := &settings.Settings{ 29 | Domains: settings.WorkflowSettingsDomains{ 30 | Allowed: []string{"*.example.com", "*.one.example.net", "api.**.com"}, 31 | Blocked: []string{}, 32 | Hosts: []settings.WorkflowSettingsDomainsHost{}, 33 | }, 34 | Gateway: settings.WorkflowSettingsGateway{ 35 | Middleware: []string{"validateUrl"}, 36 | }, 37 | } 38 | 39 | g := gateway.New(nil) 40 | a := app.NewApplication() 41 | a.Gateway = g 42 | 43 | engine := scripting.CreateNewJavascriptEngine(a) 44 | g.Initialize(s, engine.AsContract(), nil) 45 | 46 | assert.True(t, g.Enabled, "gateway should be enabled") 47 | assert.Equal(t, 3, len(g.AllowedDomains), "gateway should have 3 allowed domains") 48 | assert.NotNil(t, g.HttpClient, "gateway should have a valid HttpClient property") 49 | } 50 | 51 | // func TestGatewayAllowed(t *testing.T) { 52 | // g := gateway.New([]string{}, []string{"*.example.com", "*.one.example.net", "api.**.com"}, []string{}, []string{}) 53 | // verifyChecksums := true 54 | // enableStats := false 55 | // gatewayAllow := "allow" 56 | 57 | // s := &settings.Settings{} 58 | // gateway.GatewayMiddleware.AddPreMiddleware(&gateway.ValidateUrlMiddleware) 59 | // gateway.GatewayMiddleware.AddPreMiddleware(&gateway.V) 60 | 61 | // g.Initialize(s, nil, nil) 62 | // assert.True(t, g.Allowed("https://www.example.com"), "www.example.com should be allowed") 63 | // assert.True(t, g.Allowed("https://example.com"), "example.com should be allowed") 64 | // assert.False(t, g.Allowed("https://www.example.net"), "www.example.net should not be allowed") 65 | // assert.True(t, g.Allowed("https://one.example.net"), "one.example.net should be allowed") 66 | // assert.True(t, g.Allowed("https://a.one.example.net"), "a.one.example.net should be allowed") 67 | // assert.True(t, g.Allowed("https://api.test.com"), "api.test.com should be allowed") 68 | // assert.True(t, g.Allowed("https://api.one.test.com"), "api.one.test.com should be allowed") 69 | // assert.False(t, g.Allowed("https://api.test.example.org"), "api.one.example.org should not be allowed") 70 | // // } 71 | -------------------------------------------------------------------------------- /lib/gateway/middleware.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type GatewayMiddlewareStore struct { 8 | PreMiddleware []*GatewayUrlRequestMiddleware 9 | PostMiddleware []*GatewayUrlResponseMiddleware 10 | } 11 | 12 | var GatewayMiddleware = &GatewayMiddlewareStore{ 13 | PreMiddleware: []*GatewayUrlRequestMiddleware{}, 14 | PostMiddleware: []*GatewayUrlResponseMiddleware{}, 15 | } 16 | 17 | func (mws *GatewayMiddlewareStore) HasMiddleware(name string) bool { 18 | for _, mw := range mws.PreMiddleware { 19 | if strings.EqualFold(mw.Name, name) { 20 | return true 21 | } 22 | } 23 | for _, mw := range mws.PostMiddleware { 24 | if strings.EqualFold(mw.Name, name) { 25 | return true 26 | } 27 | } 28 | return false 29 | } 30 | 31 | func (mws *GatewayMiddlewareStore) AddPreMiddleware(mw *GatewayUrlRequestMiddleware) { 32 | if mws.HasMiddleware(mw.Name) { 33 | return 34 | } 35 | 36 | mws.PreMiddleware = append(mws.PreMiddleware, mw) 37 | } 38 | 39 | func (mws *GatewayMiddlewareStore) AddPostMiddleware(mw *GatewayUrlResponseMiddleware) { 40 | if mws.HasMiddleware(mw.Name) { 41 | return 42 | } 43 | 44 | mws.PostMiddleware = append(mws.PostMiddleware, mw) 45 | } 46 | 47 | func (mws *GatewayMiddlewareStore) GetPreMiddleware(name string) *GatewayUrlRequestMiddleware { 48 | for _, mw := range mws.PreMiddleware { 49 | if strings.EqualFold(mw.Name, name) { 50 | return mw 51 | } 52 | } 53 | return nil 54 | } 55 | 56 | func (mws *GatewayMiddlewareStore) GetPostMiddleware(name string) *GatewayUrlResponseMiddleware { 57 | for _, mw := range mws.PostMiddleware { 58 | if strings.EqualFold(mw.Name, name) { 59 | return mw 60 | } 61 | } 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /lib/messages/messages.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/stackup-app/stackup/lib/types" 7 | ) 8 | 9 | func TaskNotFound(name string) string { 10 | return fmt.Sprintf("Task %s not found.", name) 11 | } 12 | 13 | func NotExplicitlyAllowed(at types.AccessType, str string) string { 14 | return fmt.Sprintf("Access to %s '%s' has not been explicitly allowed.", at.String(), str) 15 | } 16 | 17 | func AccessBlocked(at types.AccessType, str string) string { 18 | return fmt.Sprintf("Access to %s '%s' has is blocked.", at.String(), str) 19 | } 20 | 21 | func HttpRequestFailed(urlStr string, code int) string { 22 | return fmt.Sprintf("HTTP request failed: error %d (url: %s)", code, urlStr) 23 | } 24 | 25 | func ExitDueToChecksumMismatch() string { 26 | return "Exiting due to checksum mismatch." 27 | } 28 | 29 | func RemoteIncludeStatus(status string, name string) string { 30 | return "remote include (" + status + "): " + name 31 | } 32 | 33 | func RemoteIncludeChecksumMismatch(name string) string { 34 | return "checksum verification failed: " + name 35 | } 36 | 37 | func RemoteIncludeCannotLoad(name string) string { 38 | return "unable to load remote include: " + name 39 | } 40 | -------------------------------------------------------------------------------- /lib/notifications/desktop.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "github.com/gen2brain/beeep" 5 | ) 6 | 7 | type DesktopNotification struct { 8 | IconPath string 9 | } 10 | 11 | func NewDesktopNotification(iconPath string) *DesktopNotification { 12 | return &DesktopNotification{ 13 | IconPath: iconPath, 14 | } 15 | } 16 | 17 | func (dn *DesktopNotification) Send(title, message string) error { 18 | beeep.DefaultDuration = 8 19 | 20 | return beeep.Notify(title, message, dn.IconPath) 21 | } 22 | -------------------------------------------------------------------------------- /lib/notifications/slack.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | slackapi "github.com/slack-go/slack" 5 | ) 6 | 7 | type SlackNotification struct { 8 | WebhookUrl string 9 | ChannelIds []string 10 | } 11 | 12 | // NewSlackNotification creates a new instance of the TelegramNotification struct with the provided notifier, 13 | // API token, and chat IDs. 14 | func NewSlackNotification(webhookUrl string, channelIds ...string) *SlackNotification { 15 | return &SlackNotification{ 16 | WebhookUrl: webhookUrl, 17 | ChannelIds: channelIds, 18 | } 19 | } 20 | 21 | func (tn *SlackNotification) Send(title, message string) error { 22 | for _, channelId := range tn.ChannelIds { 23 | msg := slackapi.WebhookMessage{Channel: channelId, Text: message} 24 | slackapi.PostWebhook(tn.WebhookUrl, &msg) 25 | } 26 | 27 | return nil 28 | } 29 | -------------------------------------------------------------------------------- /lib/notifications/telegram.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/nikoksr/notify" 8 | "github.com/nikoksr/notify/service/telegram" 9 | ) 10 | 11 | type TelegramNotification struct { 12 | ApiToken string 13 | ChatIds []int64 14 | Service *telegram.Telegram 15 | Notifier *notify.Notify 16 | } 17 | 18 | // NewTelegramNotification creates a new instance of the TelegramNotification struct with the provided notifier, 19 | // API token, and chat IDs. 20 | func NewTelegramNotification(apiToken string, chatIds ...int64) *TelegramNotification { 21 | service, _ := telegram.New(os.ExpandEnv(apiToken)) 22 | 23 | return &TelegramNotification{ 24 | ApiToken: apiToken, 25 | ChatIds: chatIds, 26 | Service: service, 27 | } 28 | } 29 | 30 | func (tn *TelegramNotification) Send(title, message string) error { 31 | tn.Service.AddReceivers(tn.ChatIds...) 32 | notify.UseServices(tn.Service) 33 | 34 | err := notify.Send( 35 | context.Background(), 36 | title, 37 | message, 38 | ) 39 | 40 | return err 41 | } 42 | -------------------------------------------------------------------------------- /lib/scripting/extensions/app_extension/scriptApp.go: -------------------------------------------------------------------------------- 1 | package appextension 2 | 3 | import ( 4 | "github.com/stackup-app/stackup/lib/support" 5 | "github.com/stackup-app/stackup/lib/types" 6 | appvers "github.com/stackup-app/stackup/lib/version" 7 | ) 8 | 9 | const name = "app" 10 | 11 | // const version = "0.0.1" 12 | // const description = "Provides access to application methods." 13 | 14 | type ScriptApp struct { 15 | } 16 | 17 | func Create() *ScriptApp { 18 | return &ScriptApp{} 19 | } 20 | 21 | func (app *ScriptApp) GetName() string { 22 | return name 23 | } 24 | 25 | func (ex *ScriptApp) OnInstall(engine types.JavaScriptEngineContract) { 26 | engine.GetVm().Set(ex.GetName(), ex) 27 | } 28 | 29 | func (app *ScriptApp) StatusMessage(message string) { 30 | support.StatusMessage(message, false) 31 | } 32 | 33 | func (app *ScriptApp) StatusLine(message string) { 34 | support.StatusMessageLine(message, false) 35 | } 36 | 37 | func (app *ScriptApp) SuccessMessage(message string) { 38 | support.SuccessMessageWithCheck(message) 39 | } 40 | 41 | func (app *ScriptApp) FailureMessage(message string) { 42 | support.FailureMessageWithXMark(message) 43 | } 44 | 45 | func (app *ScriptApp) WarningMessage(message string) { 46 | support.WarningMessage(message) 47 | } 48 | 49 | func (app *ScriptApp) Version() string { 50 | return appvers.APP_VERSION 51 | } 52 | -------------------------------------------------------------------------------- /lib/scripting/extensions/dev_extension/scriptComposerJson.go: -------------------------------------------------------------------------------- 1 | package devextension 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/stackup-app/stackup/lib/utils" 8 | ) 9 | 10 | type Composer struct { 11 | Name string `json:"name"` 12 | Type string `json:"type"` 13 | Description string `json:"description"` 14 | Keywords []string `json:"keywords"` 15 | License string `json:"license"` 16 | Require map[string]string `json:"require"` 17 | RequireDev map[string]string `json:"require-dev"` 18 | } 19 | 20 | func LoadComposerJson(filename string) (*Composer, error) { 21 | composer := Composer{} 22 | 23 | if utils.IsDir(filename) { 24 | filename = filename + "/composer.json" 25 | } 26 | 27 | contents, err := os.ReadFile(filename) 28 | if err != nil { 29 | return &composer, err 30 | } 31 | 32 | err = json.Unmarshal(contents, &composer) 33 | if err != nil { 34 | return &composer, err 35 | } 36 | 37 | return &composer, nil 38 | } 39 | 40 | func (composer *Composer) HasDependency(name string) bool { 41 | for _, dependency := range composer.GetDependencies() { 42 | if dependency == name { 43 | return true 44 | } 45 | } 46 | return false 47 | } 48 | 49 | func (composer *Composer) GetDependencies() []string { 50 | dependencies := []string{} 51 | if composer.hasProperty("require") { 52 | require := composer.Require 53 | for name := range require { 54 | dependencies = append(dependencies, name) 55 | } 56 | } 57 | 58 | return dependencies 59 | } 60 | 61 | func (composer *Composer) GetDevDependencies() []string { 62 | dependencies := []string{} 63 | if composer.hasProperty("require-dev") { 64 | require := composer.RequireDev 65 | for name := range require { 66 | dependencies = append(dependencies, name) 67 | } 68 | } 69 | 70 | return dependencies 71 | } 72 | 73 | func (composer *Composer) GetDependency(name string) string { 74 | if composer.hasProperty("require") { 75 | require := composer.Require 76 | if version, ok := require[name]; ok { 77 | return version 78 | } 79 | } 80 | 81 | return "" 82 | } 83 | 84 | func (composer *Composer) GetDevDependency(name string) string { 85 | if composer.hasProperty("require-dev") { 86 | require := composer.RequireDev 87 | if version, ok := require[name]; ok { 88 | return version 89 | } 90 | } 91 | 92 | return "" 93 | } 94 | 95 | func (composer *Composer) hasProperty(name string) bool { 96 | return composer.getProperty(name) != nil 97 | } 98 | 99 | func (composer *Composer) getProperty(name string) interface{} { 100 | switch name { 101 | case "name": 102 | return composer.Name 103 | case "type": 104 | return composer.Type 105 | case "description": 106 | return composer.Description 107 | case "keywords": 108 | return composer.Keywords 109 | case "license": 110 | return composer.License 111 | case "require": 112 | return composer.Require 113 | case "require-dev": 114 | return composer.RequireDev 115 | default: 116 | return nil 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /lib/scripting/extensions/dev_extension/scriptDev.go: -------------------------------------------------------------------------------- 1 | package devextension 2 | 3 | import ( 4 | "github.com/stackup-app/stackup/lib/types" 5 | ) 6 | 7 | const name = "dev" 8 | 9 | // const version = "0.0.1" 10 | // const description = "Provides access to development tools." 11 | 12 | type ScriptDev struct { 13 | types.ScriptExtensionContract 14 | } 15 | 16 | func Create() *ScriptDev { 17 | return &ScriptDev{} 18 | } 19 | 20 | func (ex *ScriptDev) OnInstall(engine types.JavaScriptEngineContract) { 21 | engine.GetVm().Set(ex.GetName(), ex) 22 | } 23 | 24 | func (dev *ScriptDev) GetName() string { 25 | return name 26 | } 27 | 28 | func (dev *ScriptDev) ComposerJson(filename string) *Composer { 29 | result, _ := LoadComposerJson(filename) 30 | return result 31 | } 32 | 33 | func (dev *ScriptDev) PackageJson(filename string) *PackageJSON { 34 | result, _ := LoadPackageJson(filename) 35 | return result 36 | } 37 | 38 | func (dev *ScriptDev) RequirementsTxt(filename string) *RequirementsTxt { 39 | result, _ := LoadRequirementsTxt(filename) 40 | return result 41 | } 42 | -------------------------------------------------------------------------------- /lib/scripting/extensions/dev_extension/scriptPackageJson.go: -------------------------------------------------------------------------------- 1 | package devextension 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/stackup-app/stackup/lib/utils" 8 | ) 9 | 10 | type PackageJSON struct { 11 | Name string `json:"name"` 12 | Version string `json:"version"` 13 | Description string `json:"description"` 14 | Keywords []string `json:"keywords"` 15 | License string `json:"license"` 16 | Dependencies map[string]string `json:"dependencies"` 17 | DevDependencies map[string]string `json:"devDependencies"` 18 | Scripts map[string]string `json:"scripts"` 19 | } 20 | 21 | func LoadPackageJson(filename string) (*PackageJSON, error) { 22 | pkg := &PackageJSON{} 23 | 24 | if utils.IsDir(filename) { 25 | filename = filename + "/package.json" 26 | } 27 | 28 | contents, err := os.ReadFile(filename) 29 | if err != nil { 30 | return pkg, err 31 | } 32 | 33 | err = json.Unmarshal(contents, &pkg) 34 | if err != nil { 35 | return pkg, err 36 | } 37 | 38 | return pkg, nil 39 | } 40 | 41 | func (pkg *PackageJSON) HasDependency(name string) bool { 42 | _, ok := pkg.Dependencies[name] 43 | return ok 44 | } 45 | 46 | func (pkg *PackageJSON) GetDependencies() []string { 47 | dependencies := []string{} 48 | for name := range pkg.Dependencies { 49 | dependencies = append(dependencies, name) 50 | } 51 | return dependencies 52 | } 53 | 54 | func (pkg *PackageJSON) HasDevDependency(name string) bool { 55 | _, ok := pkg.DevDependencies[name] 56 | return ok 57 | } 58 | 59 | func (pkg *PackageJSON) GetDevDependencies() []string { 60 | dependencies := []string{} 61 | for name := range pkg.DevDependencies { 62 | dependencies = append(dependencies, name) 63 | } 64 | return dependencies 65 | } 66 | 67 | func (pkg *PackageJSON) HasScript(name string) bool { 68 | _, ok := pkg.Scripts[name] 69 | return ok 70 | } 71 | 72 | func (pkg *PackageJSON) GetScript(name string) string { 73 | return pkg.Scripts[name] 74 | } 75 | -------------------------------------------------------------------------------- /lib/scripting/extensions/dev_extension/scriptRequirementsTxt.go: -------------------------------------------------------------------------------- 1 | package devextension 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "strings" 7 | 8 | "github.com/stackup-app/stackup/lib/utils" 9 | ) 10 | 11 | type RequirementsTxt struct { 12 | Requirements map[string]string 13 | } 14 | 15 | func LoadRequirementsTxt(filename string) (*RequirementsTxt, error) { 16 | reqs := &RequirementsTxt{ 17 | Requirements: make(map[string]string), 18 | } 19 | 20 | if utils.IsDir(filename) { 21 | filename = filename + "/requirements.txt" 22 | } 23 | 24 | // Open the file 25 | file, err := os.Open(filename) 26 | if err != nil { 27 | return reqs, err 28 | } 29 | defer file.Close() 30 | 31 | // Read the file line by line 32 | scanner := bufio.NewScanner(file) 33 | for scanner.Scan() { 34 | line := scanner.Text() 35 | 36 | // Ignore comments and empty lines 37 | if strings.HasPrefix(line, "#") || len(line) == 0 { 38 | continue 39 | } 40 | 41 | // Split the line into a package name and version 42 | parts := strings.Split(line, "==") 43 | if len(parts) != 2 { 44 | return reqs, err 45 | } 46 | 47 | // Add the package name and version to the requirements map 48 | reqs.Requirements[parts[0]] = parts[1] 49 | } 50 | 51 | if err := scanner.Err(); err != nil { 52 | return reqs, err 53 | } 54 | 55 | return reqs, nil 56 | } 57 | 58 | func (reqs *RequirementsTxt) HasRequirement(name string) bool { 59 | _, ok := reqs.Requirements[name] 60 | return ok 61 | } 62 | 63 | func (reqs *RequirementsTxt) GetRequirement(name string) string { 64 | return reqs.Requirements[name] 65 | } 66 | -------------------------------------------------------------------------------- /lib/scripting/extensions/fs_extension/scriptFs.go: -------------------------------------------------------------------------------- 1 | package fsextension 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/stackup-app/stackup/lib/types" 9 | "github.com/stackup-app/stackup/lib/utils" 10 | ) 11 | 12 | type ScriptFs struct { 13 | types.ScriptExtensionContract 14 | } 15 | 16 | func Create() *ScriptFs { 17 | return &ScriptFs{} 18 | } 19 | 20 | func (fs *ScriptFs) GetName() string { 21 | return "fs" 22 | } 23 | 24 | func (ex *ScriptFs) OnInstall(engine types.JavaScriptEngineContract) { 25 | engine.GetVm().Set(ex.GetName(), ex) 26 | } 27 | 28 | func (fs *ScriptFs) ReadFile(filename string) (string, error) { 29 | content, err := os.ReadFile(filename) 30 | if err != nil { 31 | return "", err 32 | } 33 | 34 | return string(content), nil 35 | } 36 | 37 | func (fs *ScriptFs) WriteFile(filename string, content string) error { 38 | err := os.WriteFile(filename, []byte(content), 0644) 39 | 40 | return err 41 | } 42 | 43 | func (fs *ScriptFs) ReadJSON(filename string) (interface{}, error) { 44 | _, err := os.Stat(filename) 45 | if os.IsNotExist(err) { 46 | return nil, err 47 | } 48 | 49 | var data interface{} 50 | 51 | content, err := os.ReadFile(filename) 52 | if err != nil { 53 | return data, err 54 | } 55 | 56 | err = json.Unmarshal(content, &data) 57 | 58 | return data, err 59 | } 60 | 61 | func (fs *ScriptFs) WriteJSON(filename string, data interface{}) error { 62 | _, err := os.Stat(filename) 63 | if os.IsNotExist(err) { 64 | return err 65 | } 66 | 67 | content, err := json.MarshalIndent(data, "", " ") 68 | if err != nil { 69 | return err 70 | } 71 | 72 | err = os.WriteFile(filename, content, 0644) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func (fs *ScriptFs) Exists(filename string) bool { 81 | return utils.FileExists(filename) 82 | } 83 | 84 | func (fs *ScriptFs) IsDirectory(filename string) bool { 85 | return utils.IsDir(filename) 86 | } 87 | 88 | func (fs *ScriptFs) IsFile(filename string) bool { 89 | return utils.IsFile(filename) 90 | } 91 | 92 | func (fs *ScriptFs) GetFiles(directory string) ([]string, error) { 93 | var files []string 94 | 95 | fileInfos, err := os.ReadDir(directory) 96 | if err != nil { 97 | return files, err 98 | } 99 | 100 | for _, fileInfo := range fileInfos { 101 | if !fileInfo.IsDir() { 102 | files = append(files, filepath.Join(directory, fileInfo.Name())) 103 | } 104 | } 105 | 106 | return files, nil 107 | } 108 | -------------------------------------------------------------------------------- /lib/scripting/extensions/functions_extension/scriptFunctions.go: -------------------------------------------------------------------------------- 1 | package functionsextension 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | "strings" 7 | "time" 8 | 9 | "github.com/robertkrimen/otto" 10 | devextension "github.com/stackup-app/stackup/lib/scripting/extensions/dev_extension" 11 | "github.com/stackup-app/stackup/lib/semver" 12 | "github.com/stackup-app/stackup/lib/support" 13 | "github.com/stackup-app/stackup/lib/types" 14 | "github.com/stackup-app/stackup/lib/utils" 15 | ) 16 | 17 | type JavaScriptFunctions struct { 18 | Engine types.JavaScriptEngineContract 19 | types.ScriptExtensionContract 20 | } 21 | 22 | func Create(engine types.JavaScriptEngineContract) *JavaScriptFunctions { 23 | return &JavaScriptFunctions{Engine: engine} 24 | } 25 | 26 | func (jsf JavaScriptFunctions) GetName() string { 27 | return "functions" 28 | } 29 | 30 | func (jsf JavaScriptFunctions) OnInstall(engine types.JavaScriptEngineContract) { 31 | jsf.Register() 32 | } 33 | 34 | func (jsf *JavaScriptFunctions) Register() { 35 | jsf.Engine.GetVm().Set("binaryExists", jsf.createBinaryExists) 36 | jsf.Engine.GetVm().Set("composerJson", jsf.createComposerJsonFunction) 37 | jsf.Engine.GetVm().Set("env", jsf.createJavascriptFunctionEnv) 38 | jsf.Engine.GetVm().Set("exec", jsf.createJavascriptFunctionExec) 39 | jsf.Engine.GetVm().Set("exists", jsf.createJavascriptFunctionExists) 40 | jsf.Engine.GetVm().Set("fetch", jsf.createFetchFunction) 41 | jsf.Engine.GetVm().Set("fetchJson", jsf.createFetchJsonFunction) 42 | jsf.Engine.GetVm().Set("fileContains", jsf.createFileContainsFunction) 43 | jsf.Engine.GetVm().Set("getCwd", jsf.createGetCurrentWorkingDirectory) 44 | jsf.Engine.GetVm().Set("getVar", jsf.createGetVarFunction) 45 | jsf.Engine.GetVm().Set("hasEnv", jsf.createHasEnvFunction) 46 | jsf.Engine.GetVm().Set("hasFlag", jsf.createJavascriptFunctionHasFlag) 47 | jsf.Engine.GetVm().Set("hasVar", jsf.createHasVarFunction) 48 | jsf.Engine.GetVm().Set("outputOf", jsf.createOutputOfFunction) 49 | jsf.Engine.GetVm().Set("packageJson", jsf.createPackageJsonFunction) 50 | jsf.Engine.GetVm().Set("platform", jsf.createPlatformFunction) 51 | jsf.Engine.GetVm().Set("requirementsTxt", jsf.createRequirementsTxtFunction) 52 | jsf.Engine.GetVm().Set("script", jsf.createScriptFunction) 53 | jsf.Engine.GetVm().Set("selectTaskWhen", jsf.createSelectTaskWhen) 54 | jsf.Engine.GetVm().Set("semver", jsf.createSemverFunction) 55 | jsf.Engine.GetVm().Set("setVar", jsf.createSetVarFunction) 56 | jsf.Engine.GetVm().Set("setTimeout", jsf.createSetTimeoutFunction) 57 | jsf.Engine.GetVm().Set("statusMessage", jsf.createStatusMessageFunction) 58 | jsf.Engine.GetVm().Set("task", jsf.createTaskFunction) 59 | } 60 | 61 | func getResult(call otto.FunctionCall, v any) otto.Value { 62 | result, _ := call.Otto.ToValue(v) 63 | 64 | return result 65 | } 66 | 67 | func (jsf *JavaScriptFunctions) createSetTimeoutFunction(call otto.FunctionCall) otto.Value { 68 | // Get the callback function and delay time from the arguments 69 | callback := call.Argument(0) 70 | delay, _ := call.Argument(1).ToInteger() 71 | 72 | // Create a channel to wait for the delay time 73 | done := make(chan bool, 1) 74 | go func() { 75 | time.Sleep(time.Duration(delay) * time.Millisecond) 76 | done <- true 77 | }() 78 | 79 | // Call the callback function after the delay time 80 | go func() { 81 | <-done 82 | callback.Call(callback) 83 | }() 84 | 85 | value, _ := otto.ToValue(nil) 86 | return value 87 | } 88 | 89 | func (jsf *JavaScriptFunctions) createFetchFunction(call otto.FunctionCall) otto.Value { 90 | result, _ := jsf.Engine.GetGateway().GetUrl(call.Argument(0).String()) 91 | 92 | return getResult(call, result) 93 | } 94 | 95 | func (jsf *JavaScriptFunctions) createFetchJsonFunction(call otto.FunctionCall) otto.Value { 96 | var result interface{} 97 | gw := jsf.Engine.GetGateway() 98 | utils.GetUrlJson(call.Argument(0).String(), &result, &gw) 99 | 100 | return getResult(call, result) 101 | } 102 | 103 | func (jsf *JavaScriptFunctions) createRequirementsTxtFunction(call otto.FunctionCall) otto.Value { 104 | result, _ := devextension.LoadRequirementsTxt(call.Argument(0).String()) 105 | 106 | return getResult(call, result) 107 | } 108 | 109 | func (jsf *JavaScriptFunctions) createPackageJsonFunction(call otto.FunctionCall) otto.Value { 110 | result, _ := devextension.LoadPackageJson(call.Argument(0).String()) 111 | 112 | return getResult(call, result) 113 | } 114 | 115 | func (jsf *JavaScriptFunctions) createComposerJsonFunction(call otto.FunctionCall) otto.Value { 116 | result, _ := devextension.LoadComposerJson(call.Argument(0).String()) 117 | 118 | return getResult(call, result) 119 | } 120 | 121 | func (jsf *JavaScriptFunctions) createSemverFunction(call otto.FunctionCall) otto.Value { 122 | result := semver.ParseSemverString(call.Argument(0).String()) 123 | 124 | return getResult(call, result) 125 | } 126 | 127 | func (jsf *JavaScriptFunctions) createOutputOfFunction(call otto.FunctionCall) otto.Value { 128 | result := support.GetCommandOutput(call.Argument(0).String()) 129 | 130 | return getResult(call, result) 131 | } 132 | 133 | func (jsf *JavaScriptFunctions) createFileContainsFunction(call otto.FunctionCall) otto.Value { 134 | result := utils.SearchFileForString(call.Argument(0).String(), call.Argument(1).String()) 135 | 136 | return getResult(call, result) 137 | } 138 | 139 | func (jsf *JavaScriptFunctions) createStatusMessageFunction(call otto.FunctionCall) otto.Value { 140 | support.StatusMessage(call.Argument(0).String(), false) 141 | 142 | return getResult(call, true) 143 | } 144 | 145 | func (jsf *JavaScriptFunctions) createHasVarFunction(call otto.FunctionCall) otto.Value { 146 | _, result := jsf.Engine.GetAppVars().Load(call.Argument(0).String()) 147 | 148 | return getResult(call, result) 149 | } 150 | 151 | func (jsf *JavaScriptFunctions) createGetVarFunction(call otto.FunctionCall) otto.Value { 152 | v, _ := jsf.Engine.GetAppVars().Load(call.Argument(0).String()) 153 | 154 | return v.(otto.Value) 155 | } 156 | 157 | func (jsf *JavaScriptFunctions) createSetVarFunction(call otto.FunctionCall) otto.Value { 158 | jsf.Engine.GetAppVars().Store(call.Argument(0).String(), call.Argument(1)) 159 | 160 | return getResult(call, true) 161 | } 162 | 163 | func (jsf *JavaScriptFunctions) createHasEnvFunction(call otto.FunctionCall) otto.Value { 164 | _, result := os.LookupEnv(call.Argument(0).String()) 165 | 166 | return getResult(call, result) 167 | } 168 | 169 | func (jsf *JavaScriptFunctions) createPlatformFunction(call otto.FunctionCall) otto.Value { 170 | result := runtime.GOOS 171 | 172 | return getResult(call, result) 173 | } 174 | 175 | func (jsf *JavaScriptFunctions) createTaskFunction(call otto.FunctionCall) otto.Value { 176 | taskName := call.Argument(0).String() 177 | 178 | if strings.HasPrefix(taskName, "$") && len(taskName) > 1 { 179 | temp, _ := jsf.Engine.GetAppVars().Load(taskName[1:]) 180 | taskName = temp.(string) 181 | } 182 | 183 | task, _ := jsf.Engine.GetFindTaskById(taskName) 184 | return getResult(call, task) 185 | } 186 | 187 | func (jsf *JavaScriptFunctions) createScriptFunction(call otto.FunctionCall) otto.Value { 188 | filename := call.Argument(0).String() 189 | 190 | content, err := os.ReadFile(filename) 191 | 192 | if err != nil { 193 | support.WarningMessage("Could not read script file: " + filename) 194 | return getResult(call, false) 195 | } 196 | 197 | result := jsf.Engine.Evaluate(string(content)) 198 | 199 | return getResult(call, result) 200 | } 201 | 202 | func (jsf *JavaScriptFunctions) createSelectTaskWhen(call otto.FunctionCall) otto.Value { 203 | conditional, _ := call.Argument(0).ToBoolean() 204 | trueTaskName := call.Argument(1).String() 205 | falseTaskName := call.Argument(2).String() 206 | taskName := trueTaskName 207 | 208 | if !conditional { 209 | taskName = falseTaskName 210 | } 211 | 212 | t, _ := jsf.Engine.GetFindTaskById(taskName) 213 | 214 | return getResult(call, t) 215 | } 216 | 217 | func (jsf *JavaScriptFunctions) createGetCurrentWorkingDirectory(call otto.FunctionCall) otto.Value { 218 | result, _ := os.Getwd() 219 | 220 | return getResult(call, result) 221 | } 222 | 223 | func (jsf *JavaScriptFunctions) createBinaryExists(call otto.FunctionCall) otto.Value { 224 | result := utils.BinaryExistsInPath(call.Argument(0).String()) 225 | 226 | return getResult(call, result) 227 | } 228 | 229 | func (jsf *JavaScriptFunctions) createJavascriptFunctionExists(call otto.FunctionCall) otto.Value { 230 | _, err := os.Stat(call.Argument(0).String()) 231 | result := !os.IsNotExist(err) 232 | 233 | return getResult(call, result) 234 | } 235 | 236 | func (jsf *JavaScriptFunctions) createJavascriptFunctionEnv(call otto.FunctionCall) otto.Value { 237 | result := os.Getenv(call.Argument(0).String()) 238 | 239 | return getResult(call, result) 240 | } 241 | 242 | func (jsf *JavaScriptFunctions) createJavascriptFunctionExec(call otto.FunctionCall) otto.Value { 243 | result, err := utils.RunCommandInPath(call.Argument(0).String(), ".", false) 244 | 245 | if err != nil { 246 | support.WarningMessage(err.Error()) 247 | } 248 | 249 | finalResult, err := call.Otto.ToValue(result) 250 | 251 | if err != nil { 252 | support.WarningMessage(err.Error()) 253 | } 254 | 255 | return finalResult 256 | } 257 | 258 | func (jsf *JavaScriptFunctions) createJavascriptFunctionHasFlag(call otto.FunctionCall) otto.Value { 259 | result := false 260 | flag := call.Argument(0).String() 261 | 262 | // result, _ := vm.ToValue(temp) 263 | 264 | for _, v := range os.Args[1:] { 265 | if v == flag || v == "--"+flag { 266 | result = true 267 | break 268 | } 269 | } 270 | 271 | return getResult(call, result) 272 | } 273 | -------------------------------------------------------------------------------- /lib/scripting/extensions/net_extension/scriptNet.go: -------------------------------------------------------------------------------- 1 | package netextension 2 | 3 | import ( 4 | "github.com/stackup-app/stackup/lib/support" 5 | "github.com/stackup-app/stackup/lib/types" 6 | "github.com/stackup-app/stackup/lib/utils" 7 | ) 8 | 9 | type ScriptNet struct { 10 | gateway types.GatewayContract 11 | } 12 | 13 | func Create(gw types.GatewayContract) *ScriptNet { 14 | return &ScriptNet{ 15 | gateway: gw, 16 | } 17 | } 18 | 19 | func (net *ScriptNet) GetName() string { 20 | return "net" 21 | } 22 | 23 | func (ex *ScriptNet) OnInstall(engine types.JavaScriptEngineContract) { 24 | engine.GetVm().Set(ex.GetName(), ex) 25 | } 26 | 27 | func (net *ScriptNet) gatewayPtr() *types.GatewayContract { 28 | return &net.gateway 29 | } 30 | 31 | func (net *ScriptNet) Fetch(url string) any { 32 | // if !net.gateway.Allowed(url) { 33 | // support.FailureMessageWithXMark(" [script] fetch failed: access to " + url + " is not allowed.") 34 | // return "" 35 | // } 36 | 37 | // if not allowed by the gateway, an error message will be printed, see Gateway class 38 | result, err := utils.GetUrlContents(url, net.gatewayPtr()) 39 | 40 | if err != nil { 41 | return "" 42 | } 43 | 44 | return result 45 | } 46 | 47 | func (net *ScriptNet) FetchJson(url string) any { 48 | var result interface{} = nil 49 | 50 | if !net.gateway.Allowed(url) { 51 | support.FailureMessageWithXMark(" [script] fetchJson failed: access to " + url + " is not allowed.") 52 | return result 53 | } 54 | 55 | utils.GetUrlJson(url, result, net.gatewayPtr()) 56 | 57 | return result 58 | } 59 | 60 | func (net *ScriptNet) DownloadTo(url string, filename string) { 61 | if !net.gateway.Allowed(url) { 62 | support.FailureMessageWithXMark(" [script] DownloadTo() failed: access to '" + url + "' is not allowed.") 63 | return 64 | } 65 | 66 | net.gateway.SaveUrlToFile(url, filename) 67 | } 68 | -------------------------------------------------------------------------------- /lib/scripting/extensions/vars_extension/scriptVars.go: -------------------------------------------------------------------------------- 1 | package varsextension 2 | 3 | import ( 4 | "github.com/stackup-app/stackup/lib/types" 5 | ) 6 | 7 | type ScriptVars struct { 8 | engine types.JavaScriptEngineContract 9 | } 10 | 11 | func Create(e types.JavaScriptEngineContract) *ScriptVars { 12 | return &ScriptVars{ 13 | engine: e, 14 | } 15 | } 16 | 17 | func (sv *ScriptVars) GetName() string { 18 | return "vars" 19 | } 20 | 21 | func (ex *ScriptVars) OnInstall(engine types.JavaScriptEngineContract) { 22 | engine.GetVm().Set(ex.GetName(), ex) 23 | } 24 | 25 | func (sv *ScriptVars) Get(name string) any { 26 | v, _ := sv.engine.GetAppVars().Load(name) 27 | 28 | return v 29 | } 30 | 31 | func (sv *ScriptVars) Set(name string, value any) { 32 | sv.engine.GetAppVars().Store("$"+name, value) 33 | sv.engine.GetVm().Set("$"+name, value) 34 | } 35 | 36 | func (sv *ScriptVars) Has(name string) bool { 37 | _, result := sv.engine.GetAppVars().Load(name) 38 | 39 | return result 40 | } 41 | -------------------------------------------------------------------------------- /lib/scripting/scriptEngine.go: -------------------------------------------------------------------------------- 1 | package scripting 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | "sync" 8 | 9 | "github.com/robertkrimen/otto" 10 | appextension "github.com/stackup-app/stackup/lib/scripting/extensions/app_extension" 11 | devextension "github.com/stackup-app/stackup/lib/scripting/extensions/dev_extension" 12 | fsextension "github.com/stackup-app/stackup/lib/scripting/extensions/fs_extension" 13 | functionsextension "github.com/stackup-app/stackup/lib/scripting/extensions/functions_extension" 14 | netextension "github.com/stackup-app/stackup/lib/scripting/extensions/net_extension" 15 | varsextension "github.com/stackup-app/stackup/lib/scripting/extensions/vars_extension" 16 | "github.com/stackup-app/stackup/lib/support" 17 | "github.com/stackup-app/stackup/lib/types" 18 | "github.com/stackup-app/stackup/lib/utils" 19 | ) 20 | 21 | type FindTaskByIdFunc func(string) (any, bool) 22 | 23 | type JavaScriptEngine struct { 24 | Vm *otto.Otto 25 | InstalledExtensions *sync.Map 26 | initialized bool 27 | AppIntf types.AppInterface 28 | types.JavaScriptEngineContract 29 | } 30 | 31 | func CreateNewJavascriptEngine(appIntf types.AppInterface) *JavaScriptEngine { 32 | result := &JavaScriptEngine{ 33 | initialized: false, 34 | Vm: otto.New(), 35 | AppIntf: appIntf, 36 | InstalledExtensions: &sync.Map{}, 37 | } 38 | 39 | return result 40 | } 41 | 42 | func (e *JavaScriptEngine) GetApplicationIconPath() string { 43 | return e.App().GetApplicationIconPath() 44 | } 45 | 46 | func (e *JavaScriptEngine) GetAppVars() *sync.Map { 47 | return e.App().GetVars() 48 | } 49 | 50 | func (e *JavaScriptEngine) toInterface() interface{} { 51 | return e 52 | } 53 | 54 | func (e *JavaScriptEngine) App() types.AppInterface { 55 | return e.AppIntf 56 | } 57 | 58 | func (e *JavaScriptEngine) AsContract() types.JavaScriptEngineContract { 59 | return e.toInterface().(types.JavaScriptEngineContract) 60 | } 61 | 62 | func (e *JavaScriptEngine) Initialize() { 63 | if e.initialized { 64 | return 65 | } 66 | 67 | e.Vm = otto.New() 68 | 69 | e.initializeExtensions() 70 | 71 | // CreateScripNotificationsObject(workflow, e) 72 | e.initialized = true 73 | 74 | e.CreateAppVariables(e.App().GetVars()) 75 | e.CreateEnvironmentVariables(e.App().GetEnviron()) 76 | } 77 | 78 | func (e JavaScriptEngine) GetFindTaskById(id string) (any, bool) { //GetFindTaskById(id string) (any, error) { 79 | return e.App().GetWorkflow().FindTaskById(id) 80 | } 81 | 82 | func (e *JavaScriptEngine) initializeExtensions() { 83 | engine := e.App().GetJsEngine() 84 | 85 | devextension.Create().OnInstall(engine) 86 | varsextension.Create(engine).OnInstall(engine) 87 | netextension.Create(e.App().GetGateway()).OnInstall(engine) 88 | appextension.Create().OnInstall(engine) 89 | fsextension.Create().OnInstall(engine) 90 | functionsextension.Create(engine).OnInstall(engine) 91 | } 92 | 93 | func (e *JavaScriptEngine) CreateAppVariables(vars *sync.Map) { 94 | vars.Range(func(key, value any) bool { 95 | e.Vm.Set("$"+(key.(string)), value) 96 | return true 97 | }) 98 | } 99 | 100 | func (e *JavaScriptEngine) CreateEnvironmentVariables(vars []string) { 101 | for _, env := range vars { 102 | parts := strings.SplitN(env, "=", 2) 103 | e.Vm.Set("$"+parts[0], parts[1]) 104 | } 105 | } 106 | 107 | func (e *JavaScriptEngine) ToValue(value otto.Value) any { 108 | if value.IsBoolean() { 109 | v, _ := value.ToBoolean() 110 | return v 111 | } 112 | 113 | if value.IsString() { 114 | v, _ := value.ToString() 115 | return v 116 | } 117 | 118 | if value.IsNumber() { 119 | v, _ := value.ToInteger() 120 | return v 121 | } 122 | 123 | if value.IsObject() { 124 | v, _ := value.Object().Value().Export() 125 | return v 126 | } 127 | 128 | if value.IsNull() { 129 | return nil 130 | } 131 | 132 | if value.IsUndefined() { 133 | return nil 134 | } 135 | 136 | if value.IsNaN() { 137 | return nil 138 | } 139 | 140 | r, _ := value.ToString() 141 | 142 | return r 143 | } 144 | 145 | func (e *JavaScriptEngine) ResultType(v any) (reflect.Kind, interface{}, error) { 146 | var value any = reflect.ValueOf(v).Interface() 147 | valueOf := reflect.ValueOf(v) 148 | kind := reflect.TypeOf(v).Kind() 149 | 150 | if kind == reflect.String { 151 | value = valueOf.String() 152 | } else if kind == reflect.Int { 153 | value = valueOf.Int() 154 | } else if kind == reflect.Bool { 155 | value = valueOf.Bool() 156 | } else if kind == reflect.Uint { 157 | value = valueOf.Uint() 158 | } 159 | 160 | var err error = nil 161 | 162 | if kind == reflect.Invalid { 163 | err = fmt.Errorf("invalid type") 164 | } 165 | 166 | return kind, value, err 167 | } 168 | 169 | func (e *JavaScriptEngine) Evaluate(script string) any { 170 | tempScript := strings.TrimSpace(script) 171 | 172 | if len(tempScript) == 0 { 173 | return nil 174 | } 175 | 176 | if e.IsEvaluatableScriptString(tempScript) { 177 | tempScript = e.GetEvaluatableScriptString(tempScript) 178 | } 179 | 180 | result, err := e.Vm.Run(tempScript) 181 | 182 | if err != nil { 183 | support.WarningMessage(fmt.Sprintf("script error: %v\n", err)) 184 | return nil 185 | } 186 | 187 | if result.IsObject() { 188 | v := result.Object() 189 | keys := result.Object().Keys() 190 | 191 | if utils.StringArrayContains(keys, "Id") && utils.StringArrayContains(keys, "Name") && utils.StringArrayContains(keys, "Command") { 192 | v2, _ := v.Value().Object().Get("Id") 193 | return v2.String() 194 | } 195 | } 196 | 197 | if result.IsBoolean() { 198 | v, _ := result.ToBoolean() 199 | return v 200 | } 201 | 202 | if result.IsString() { 203 | v, _ := result.ToString() 204 | 205 | if e.IsEvaluatableScriptString(v) { 206 | return e.Evaluate(v) 207 | } 208 | 209 | return v 210 | } 211 | 212 | if result.IsNumber() { 213 | v, _ := result.ToInteger() 214 | return v 215 | } 216 | 217 | if result.IsNull() { 218 | return nil 219 | } 220 | 221 | if result.IsUndefined() { 222 | return nil 223 | } 224 | 225 | if result.IsNaN() { 226 | return nil 227 | } 228 | 229 | r, _ := result.ToString() 230 | 231 | return r 232 | } 233 | 234 | func (e *JavaScriptEngine) GetEvaluatableScriptString(s string) string { 235 | if e.IsEvaluatableScriptString(s) { 236 | return s[2 : len(s)-2] 237 | } 238 | return s 239 | } 240 | 241 | func (e *JavaScriptEngine) IsEvaluatableScriptString(s string) bool { 242 | temp := strings.TrimSpace(s) 243 | 244 | return strings.HasPrefix(temp, "{{") && strings.HasSuffix(temp, "}}") 245 | } 246 | 247 | func (e *JavaScriptEngine) MakeStringEvaluatable(script string) string { 248 | if e.IsEvaluatableScriptString(script) { 249 | return script 250 | } 251 | 252 | if strings.TrimSpace(script) == "" { 253 | return "" 254 | } 255 | 256 | return "{{ " + script + " }}" 257 | } 258 | 259 | func (e *JavaScriptEngine) GetVm() *otto.Otto { 260 | return e.Vm 261 | } 262 | -------------------------------------------------------------------------------- /lib/scripting/scriptNotifications.go: -------------------------------------------------------------------------------- 1 | package scripting 2 | 3 | import ( 4 | "github.com/stackup-app/stackup/lib/types" 5 | ) 6 | 7 | // type ScriptNotifications struct { 8 | // engine *JavaScriptEngine 9 | // settings *settings.Settings 10 | // telegramObj *ScriptNotificationsTelegram 11 | // slackObj *ScriptNotificationsSlack 12 | // desktopObj *DesktopNotification 13 | // GetApplicationIconPath func() string 14 | // } 15 | 16 | // type DesktopNotification struct { 17 | // state struct { 18 | // title string 19 | // message string 20 | // } 21 | // sn *ScriptNotifications 22 | // } 23 | 24 | // type ScriptNotificationsTelegram struct { 25 | // APIToken string 26 | // state struct { 27 | // chatIds []int64 28 | // title string 29 | // message string 30 | // } 31 | // sn *ScriptNotifications 32 | // } 33 | 34 | // type ScriptNotificationsSlack struct { 35 | // WebhookUrl string 36 | // state struct { 37 | // channelIds []string 38 | // title string 39 | // message string 40 | // } 41 | // sn *ScriptNotifications 42 | // } 43 | 44 | func CreateScripNotificationsObject(wf *types.AppWorkflowContract, e *JavaScriptEngine) { 45 | return 46 | } 47 | 48 | // return 49 | 50 | // // wc := e.GetWorkflowContract() 51 | // // fmt.Printf("wc: %v\n", (*wc).GetSettings()) 52 | 53 | // // if wf == nil { 54 | // // return 55 | // // } 56 | 57 | // // obj := &ScriptNotifications{ 58 | // // engine: e, 59 | // // settings: (*wf).GetSettings(), 60 | // // telegramObj: &ScriptNotificationsTelegram{ 61 | // // APIToken: "", 62 | // // }, 63 | // // slackObj: &ScriptNotificationsSlack{ 64 | // // WebhookUrl: "", 65 | // // }, 66 | // // desktopObj: &DesktopNotification{}, 67 | // // GetApplicationIconPath: e.GetApplicationIconPath, 68 | // // } 69 | // // obj.desktopObj.sn = obj 70 | // // obj.telegramObj.sn = obj 71 | // // obj.slackObj.sn = obj 72 | 73 | // // e.Vm.Set("notifications", obj) 74 | // } 75 | 76 | // func (dn *DesktopNotification) create() *DesktopNotification { 77 | // dn.resetState() 78 | 79 | // return dn 80 | // } 81 | 82 | // func (dn *DesktopNotification) Send() bool { 83 | // result := notifications.NewDesktopNotification(dn.sn.GetApplicationIconPath()). 84 | // Send(dn.state.title, dn.state.message) 85 | 86 | // dn.resetState() 87 | // return result == nil 88 | // } 89 | 90 | // func (dn *DesktopNotification) resetState() { 91 | // dn.state.title = "" 92 | // dn.state.message = "" 93 | // } 94 | 95 | // func (dn *DesktopNotification) Message(message string, title ...string) *DesktopNotification { 96 | // dn.state.message = message 97 | // dn.state.title = "notification" 98 | 99 | // if len(title) > 0 { 100 | // dn.state.title = title[0] 101 | // } 102 | 103 | // return dn 104 | // } 105 | 106 | // func (sn *ScriptNotifications) Desktop() *DesktopNotification { 107 | // return sn.desktopObj 108 | // } 109 | 110 | // func (sn *ScriptNotifications) Telegram() *ScriptNotificationsTelegram { 111 | // return nil 112 | // // token := sn.settings().Notifications.Telegram.APIKey 113 | // // return sn.telegramObj.create(token) 114 | // } 115 | 116 | // func (sn *ScriptNotifications) Slack() *ScriptNotificationsSlack { 117 | // return nil 118 | // // webhookUrl := sn.settings().Notifications.Slack.WebhookUrl 119 | // // return sn.slackObj.create(webhookUrl) 120 | // } 121 | 122 | // func (sns *ScriptNotificationsSlack) create(webhookUrl string) *ScriptNotificationsSlack { 123 | // sns.WebhookUrl = webhookUrl 124 | 125 | // return sns 126 | // } 127 | 128 | // func (sns *ScriptNotificationsSlack) Message(message string) *ScriptNotificationsSlack { 129 | // sns.state.message = message 130 | // sns.state.title = "notification" 131 | 132 | // return sns 133 | // } 134 | 135 | // func (sns *ScriptNotificationsSlack) To(channelIds ...string) *ScriptNotificationsSlack { 136 | // for _, channelIdStr := range channelIds { 137 | // sns.state.channelIds = append(sns.state.channelIds, channelIdStr) 138 | // } 139 | 140 | // return sns 141 | // } 142 | 143 | // func (sns *ScriptNotificationsSlack) Send() bool { 144 | // // temp := (*sns.sn.engine).toInterface() 145 | 146 | // // sns.To(temp.(JavaScriptEngine).GetWorkflowContract()).GetSettings().Notifications.Slack) 147 | // // webhookUrl := sns.sn.engine.Evaluate((*temp).GetSettings().Notifications.Slack.WebhookUrl).(string) 148 | 149 | // // result := notifications.NewSlackNotification(webhookUrl, sns.state.channelIds...). 150 | // // Send(sns.state.title, sns.state.message) 151 | 152 | // // sns.resetState() 153 | // return false 154 | // // return result == nil 155 | // } 156 | 157 | // func (sns *ScriptNotificationsSlack) resetState() { 158 | // sns.state.channelIds = []string{} 159 | // sns.state.title = "" 160 | // sns.state.message = "" 161 | // } 162 | 163 | // func (snt *ScriptNotificationsTelegram) resetState() { 164 | // snt.state.chatIds = []int64{} 165 | // snt.state.title = "" 166 | // snt.state.message = "" 167 | // } 168 | 169 | // // create is a method of the `ScriptNotificationsTelegram` struct. It takes an 170 | // // `apiToken` string as a parameter and returns a pointer to a `ScriptNotificationsTelegram` object. 171 | // func (snt *ScriptNotificationsTelegram) create(apiToken string) *ScriptNotificationsTelegram { 172 | // snt.APIToken = apiToken 173 | // snt.resetState() 174 | 175 | // return snt 176 | // } 177 | 178 | // func (snt *ScriptNotificationsTelegram) Message(message string) *ScriptNotificationsTelegram { 179 | // snt.state.message = message 180 | // snt.state.title = "notification" 181 | // return snt 182 | // } 183 | 184 | // func (snt *ScriptNotificationsTelegram) To(chatIDs ...string) *ScriptNotificationsTelegram { 185 | // for _, chatIdStr := range chatIDs { 186 | // id32, _ := strconv.Atoi(chatIdStr) 187 | // snt.state.chatIds = append(snt.state.chatIds, int64(id32)) 188 | // } 189 | 190 | // return snt 191 | // } 192 | 193 | // // Send is a method of the `ScriptNotificationsTelegram` struct. It takes three 194 | // // parameters: `chatId` of type `int64`, `title` of type `string`, and `message` of type `string`. 195 | // func (snt *ScriptNotificationsTelegram) Send() bool { 196 | // return false 197 | // // if len(snt.state.chatIds) == 0 { 198 | // // snt.To(snt.sn.settings().Notifications.Telegram.ChatIds...) 199 | // // } 200 | 201 | // // apiKey := snt.sn.engine.Evaluate(snt.sn.settings().Notifications.Telegram.APIKey).(string) 202 | // // result := notifications. 203 | // // NewTelegramNotification(apiKey, snt.state.chatIds...). 204 | // // Send(snt.state.title, snt.state.message) 205 | 206 | // // snt.resetState() 207 | 208 | // // return result == nil 209 | // } 210 | -------------------------------------------------------------------------------- /lib/semver/semver.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | type Semver struct { 11 | Major int 12 | Minor int 13 | Patch int 14 | PreRelease string 15 | Build string 16 | String string 17 | } 18 | 19 | // The function `ParseSemverString` takes a version string and returns a `Semver` struct with the 20 | // parsed version components. The version string is expected to be in the format `major.minor.patch`, 21 | // however this function will extract the version number from a string that contains other text as well. 22 | func ParseSemverString(version string) *Semver { 23 | tempVersion, err := CoerceSemverString(ExtractVersion(version)) 24 | if err != nil { 25 | return &Semver{} 26 | } 27 | 28 | re := regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)(?:-(.+))?(?:\+(.+))?$`) 29 | matches := re.FindStringSubmatch(tempVersion) 30 | 31 | if len(matches) < 4 { 32 | return &Semver{} 33 | } 34 | 35 | major, _ := strconv.Atoi(matches[1]) 36 | minor, _ := strconv.Atoi(matches[2]) 37 | patch, _ := strconv.Atoi(matches[3]) 38 | 39 | prerelease := "" 40 | build := "" 41 | 42 | if len(matches) > 4 { 43 | prerelease = matches[4] 44 | } 45 | 46 | if len(matches) > 5 { 47 | build = matches[5] 48 | } 49 | 50 | return &Semver{ 51 | Major: major, 52 | Minor: minor, 53 | Patch: patch, 54 | PreRelease: prerelease, 55 | Build: build, 56 | String: tempVersion, 57 | } 58 | } 59 | 60 | func CoerceSemverString(version string) (string, error) { 61 | semverRegex := regexp.MustCompile(`^(?:[\^v~>]?)?(\d+)\.(\d+)\.(\d+)(?:-(.+))?(?:\+(.+))?$`) 62 | 63 | if semverRegex.MatchString(version) { 64 | return version, nil 65 | } 66 | 67 | semverRegex = regexp.MustCompile(`(\d+)\.(\d+)\.(\d+)(?:-(.+))?(?:\+(.+))`) 68 | if semverRegex.MatchString(version) { 69 | matches := semverRegex.FindAllString(version, -1) 70 | versionParts := matches[1:4] 71 | otherParts := matches[4:] 72 | return strings.Join([]string{strings.Join(versionParts, "."), strings.Join(otherParts, ".")}, "-"), nil 73 | } 74 | 75 | // If the input string does not match the semver regex, try to coerce it 76 | coercedRegex := regexp.MustCompile(`(\d+)\.(\d+)`) 77 | if coercedRegex.MatchString(version) { 78 | return fmt.Sprintf("%s.0", version), nil 79 | } 80 | 81 | coercedRegex = regexp.MustCompile(`(\d+)`) 82 | if coercedRegex.MatchString(version) { 83 | return fmt.Sprintf("%s.0.0", version), nil 84 | } 85 | 86 | // If the input string cannot be coerced into a semver string, return an error 87 | return "", fmt.Errorf("invalid semver string: %s", version) 88 | } 89 | 90 | // The function ExtractVersion extracts a version number from a given string using regular expressions. 91 | func ExtractVersion(output string) string { 92 | versionRegex := regexp.MustCompile(`(\d+(\.\d+)?(\.\d+)?(\-.+$)?)`) 93 | 94 | match := versionRegex.FindStringSubmatch(output) 95 | if len(match) == 0 { 96 | return "0.0.0" 97 | } 98 | 99 | return strings.TrimSpace(match[0]) 100 | } 101 | 102 | // The `Compare` method is a comparison method for the `Semver` struct. It compares the current 103 | // `Semver` object with a given `version` string and returns an integer value indicating the result of 104 | // the comparison. It returns -1 if the current `Semver` object is less than the `version` string, 0 105 | // if the current `Semver` object is equal to the `version` string, and 1 if the current `Semver` 106 | // is greater than the `version` string. 107 | func (s *Semver) Compare(version string) int { 108 | semver1 := s 109 | semver2 := ParseSemverString(version) 110 | 111 | if semver1.Major < semver2.Major { 112 | return -1 113 | } else if semver1.Major > semver2.Major { 114 | return 1 115 | } 116 | 117 | if semver1.Minor < semver2.Minor { 118 | return -1 119 | } else if semver1.Minor > semver2.Minor { 120 | return 1 121 | } 122 | 123 | if semver1.Patch < semver2.Patch { 124 | return -1 125 | } else if semver1.Patch > semver2.Patch { 126 | return 1 127 | } 128 | 129 | if semver1.PreRelease == "" && semver2.PreRelease != "" { 130 | return 1 131 | } else if semver1.PreRelease != "" && semver2.PreRelease == "" { 132 | return -1 133 | } else if semver1.PreRelease != "" && semver2.PreRelease != "" { 134 | if semver1.PreRelease < semver2.PreRelease { 135 | return -1 136 | } else if semver1.PreRelease > semver2.PreRelease { 137 | return 1 138 | } 139 | } 140 | 141 | return 0 142 | } 143 | 144 | // The `GreaterThan` method is a comparison method for the `Semver` struct. It checks if the current 145 | // `Semver` object is greater than the `otherVersion` string. 146 | func (s *Semver) GreaterThan(otherVersion string) bool { 147 | other := ParseSemverString(otherVersion) 148 | 149 | if s.Major > other.Major { 150 | return true 151 | } else if s.Major < other.Major { 152 | return false 153 | } 154 | 155 | if s.Minor > other.Minor { 156 | return true 157 | } else if s.Minor < other.Minor { 158 | return false 159 | } 160 | 161 | if s.Patch > other.Patch { 162 | return true 163 | } else if s.Patch < other.Patch { 164 | return false 165 | } 166 | 167 | if s.PreRelease == "" && other.PreRelease != "" { 168 | return false 169 | } else if s.PreRelease != "" && other.PreRelease == "" { 170 | return true 171 | } 172 | 173 | return false 174 | } 175 | 176 | // The `Gte` method is a comparison method for the `Semver` struct. It checks if the current `Semver` 177 | // object is greater than or equal to the `otherVersion` string. 178 | func (s *Semver) Gte(otherVersion string) bool { 179 | return s.GreaterThan(otherVersion) || s.Equals(otherVersion) 180 | } 181 | 182 | // The `Lte` method is a comparison method for the `Semver` struct. It checks if the current `Semver` 183 | // object is less than or equal to the `otherVersion` string. 184 | func (s *Semver) Lte(otherVersion string) bool { 185 | return s.LessThan(otherVersion) || s.Equals(otherVersion) 186 | } 187 | 188 | // The `LessThan` method is a comparison method for the `Semver` struct. It checks if the current 189 | // `Semver` object is less than the `otherVersion` string. 190 | func (s *Semver) LessThan(otherVersion string) bool { 191 | other := ParseSemverString(otherVersion) 192 | 193 | if s.Major < other.Major { 194 | return true 195 | } else if s.Major > other.Major { 196 | return false 197 | } 198 | 199 | if s.Minor < other.Minor { 200 | return true 201 | } else if s.Minor > other.Minor { 202 | return false 203 | } 204 | 205 | if s.Patch < other.Patch { 206 | return true 207 | } else if s.Patch > other.Patch { 208 | return false 209 | } 210 | 211 | if s.PreRelease == "" && other.PreRelease != "" { 212 | return true 213 | } else if s.PreRelease != "" && other.PreRelease == "" { 214 | return false 215 | } 216 | 217 | return false 218 | } 219 | 220 | // The `Equals` method is a comparison method for the `Semver` struct. It checks if the current 221 | // `Semver` object is equal to the `otherVersion` string. 222 | func (s *Semver) Equals(otherVersion string) bool { 223 | other := ParseSemverString(otherVersion) 224 | 225 | return s.Major == other.Major && 226 | s.Minor == other.Minor && 227 | s.Patch == other.Patch && 228 | s.PreRelease == other.PreRelease && 229 | s.Build == other.Build 230 | } 231 | -------------------------------------------------------------------------------- /lib/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import "reflect" 4 | 5 | type Settings struct { 6 | Defaults WorkflowSettingsDefaults `yaml:"defaults"` 7 | ExitOnChecksumMismatch bool `yaml:"exit-on-checksum-mismatch"` 8 | ChecksumVerification bool `yaml:"checksum-verification"` 9 | DotEnvFiles []string `yaml:"dotenv"` 10 | Cache WorkflowSettingsCache `yaml:"cache"` 11 | Domains WorkflowSettingsDomains `yaml:"domains"` 12 | AnonymousStatistics bool `yaml:"anonymous-stats"` 13 | Gateway WorkflowSettingsGateway `yaml:"gateway"` 14 | Notifications WorkflowSettingsNotifications `yaml:"notifications"` 15 | Debug bool `yaml:"debug"` 16 | } 17 | 18 | type GatewayBlockAllowListsContract interface { 19 | GetAllowed() []string 20 | GetBlocked() []string 21 | SetAllowed(items []string) 22 | SetBlocked(items []string) 23 | } 24 | 25 | type GatewayBlockAllowLists struct { 26 | Blocked []string `yaml:"blocked"` 27 | Allowed []string `yaml:"allowed"` 28 | 29 | GatewayBlockAllowListsContract 30 | } 31 | 32 | type GatewayContentTypes struct { 33 | GatewayBlockAllowLists 34 | } 35 | 36 | type WorkflowSettingsGateway struct { 37 | ContentTypes *GatewayContentTypes `yaml:"content-types"` 38 | FileExtensions *WorkflowSettingsGatewayFileExtensions `yaml:"file-extensions"` 39 | Middleware []string `yaml:"middleware"` 40 | } 41 | 42 | type WorkflowSettingsGatewayFileExtensions struct { 43 | Allow []string `yaml:"allow"` 44 | Block []string `yaml:"block"` 45 | } 46 | 47 | type WorkflowSettingsDomains struct { 48 | Allowed []string `yaml:"allowed"` 49 | Blocked []string `yaml:"blocked"` 50 | Hosts []WorkflowSettingsDomainsHost `yaml:"hosts"` 51 | } 52 | 53 | type WorkflowSettingsDomainsHost struct { 54 | Hostname string `yaml:"hostname"` 55 | Gateway string `yaml:"gateway"` 56 | Headers []string `yaml:"headers"` 57 | } 58 | 59 | type WorkflowSettingsCache struct { 60 | TtlMinutes int `yaml:"ttl-minutes"` 61 | } 62 | type WorkflowSettingsDefaults struct { 63 | Tasks WorkflowSettingsDefaultsTasks `yaml:"tasks"` 64 | } 65 | 66 | type WorkflowSettingsDefaultsTasks struct { 67 | Silent bool `yaml:"silent"` 68 | Path string `yaml:"path"` 69 | Platforms []string `yaml:"platforms"` 70 | } 71 | 72 | type WorkflowSettingsNotifications struct { 73 | Telegram WorkflowSettingsNotificationsTelegram `yaml:"telegram"` 74 | Slack WorkflowSettingsNotificationsSlack `yaml:"slack"` 75 | } 76 | 77 | type WorkflowSettingsNotificationsTelegram struct { 78 | APIKey string `yaml:"api-key"` 79 | ChatIds []string `yaml:"chat-ids"` 80 | } 81 | 82 | type WorkflowSettingsNotificationsSlack struct { 83 | WebhookUrl string `yaml:"webhook-url"` 84 | ChannelIds []string `yaml:"channel-ids"` 85 | } 86 | 87 | func arrayContains[T comparable](array1 []T, array2 any) bool { 88 | // Create a map to store the items in array1 89 | items := make(map[T]bool) 90 | for _, item := range array1 { 91 | items[item] = true 92 | } 93 | 94 | var arr2 []T 95 | if reflect.TypeOf(array2).Kind() != reflect.Slice { 96 | arr2 = []T{array2.(T)} 97 | } else { 98 | arr2 = array2.([]T) 99 | } 100 | 101 | for _, item := range arr2 { 102 | if !items[item] { 103 | return false 104 | } 105 | } 106 | 107 | return true 108 | } 109 | 110 | func getUniqueStrings(items []string) []string { 111 | result := []string{} 112 | for _, item := range items { 113 | if !arrayContains(result, item) { 114 | result = append(result, item) 115 | } 116 | } 117 | return result 118 | } 119 | 120 | func (gba *GatewayBlockAllowLists) GetAllowed() []string { 121 | return gba.Allowed 122 | } 123 | 124 | func (gba *GatewayBlockAllowLists) GetBlocked() []string { 125 | return gba.Blocked 126 | } 127 | 128 | func (gba *GatewayBlockAllowLists) SetAllowed(items []string) { 129 | gba.Allowed = getUniqueStrings(items) 130 | } 131 | 132 | func (gba *GatewayBlockAllowLists) SetBlocked(items []string) { 133 | gba.Blocked = getUniqueStrings(items) 134 | } 135 | 136 | func (gba *GatewayBlockAllowLists) Allow(item string) { 137 | gba.SetAllowed(append(gba.Allowed, item)) 138 | } 139 | 140 | func (gba *GatewayBlockAllowLists) Block(item string) { 141 | gba.SetBlocked(append(gba.Blocked, item)) 142 | } 143 | -------------------------------------------------------------------------------- /lib/support/helpers.go: -------------------------------------------------------------------------------- 1 | package support 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | 9 | "github.com/logrusorgru/aurora" 10 | ) 11 | 12 | const ( 13 | MessageIndentation = " " 14 | ) 15 | 16 | func SkippedMessageWithSymbol(msg string) { 17 | fmt.Println(MessageIndentation + aurora.White(msg).String() + aurora.BrightYellow(" [skipped] ⚬").String()) 18 | } 19 | 20 | func SkippedMessageWitReason(msg string, reason string) { 21 | fmt.Println(MessageIndentation + aurora.White(reason).String() + aurora.BrightYellow(" [skipped] ⚬").String()) 22 | } 23 | 24 | func SuccessMessageWithCheck(msg string) { 25 | fmt.Println(MessageIndentation + aurora.White(msg).String() + aurora.BrightGreen(" ✓").String()) 26 | } 27 | 28 | func FailureMessageWithXMark(msg string) { 29 | fmt.Println(MessageIndentation + aurora.White(msg).String() + aurora.BrightRed(" ✗").String()) 30 | } 31 | 32 | func WarningMessage(msg string) { 33 | fmt.Println(MessageIndentation + aurora.BrightYellow(msg).String()) 34 | } 35 | 36 | func StatusMessageLine(msg string, highlight bool) { 37 | var text = aurora.White(msg) 38 | if highlight { 39 | text = aurora.BrightYellow(msg) 40 | } 41 | fmt.Println(MessageIndentation + text.String()) 42 | } 43 | 44 | func StatusMessage(msg string, highlight bool) { 45 | var text = aurora.White(msg) 46 | if highlight { 47 | text = aurora.BrightYellow(msg) 48 | } 49 | fmt.Print(MessageIndentation + text.String()) 50 | } 51 | 52 | func PrintCheckMark() { 53 | fmt.Print(aurora.BrightGreen(" ✓").String()) 54 | } 55 | 56 | func PrintCheckMarkLine() { 57 | fmt.Print(aurora.BrightGreen(" ✓\n").String()) 58 | } 59 | 60 | func PrintXMarkLine() { 61 | fmt.Print(aurora.BrightRed(" ✗\n").String()) 62 | } 63 | 64 | // The function `FindExistingFile` takes a list of filenames and a default filename, and returns the 65 | // first existing filename in the list or the default filename if none of the filenames exist. 66 | func FindExistingFile(filenames []string, defaultFilename string) string { 67 | for _, filename := range filenames { 68 | if _, err := os.Stat(filename); err == nil { 69 | return filename 70 | } 71 | } 72 | 73 | return defaultFilename 74 | } 75 | 76 | // The function `GetCommandOutput` takes a command as input, executes it, and returns the output as a 77 | // string. 78 | func GetCommandOutput(command string) string { 79 | parts := strings.Split(command, " ") 80 | 81 | cmd := exec.Command(parts[0], parts[1:]...) 82 | 83 | outputBytes, err := cmd.Output() 84 | if err != nil { 85 | return "" 86 | } 87 | 88 | return strings.TrimSpace(string(outputBytes)) 89 | } 90 | -------------------------------------------------------------------------------- /lib/support/helpers_test.go: -------------------------------------------------------------------------------- 1 | package support 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFindExistingFile(t *testing.T) { 8 | filenames := []string{"helpers.go", "file2.txt", "file3.txt"} 9 | defaultFilename := "default.txt" 10 | 11 | // Positive test case: existing file found 12 | existingFile := FindExistingFile(filenames, defaultFilename) 13 | if existingFile != "helpers.go" { 14 | t.Errorf("Expected 'file1.txt', but got '%s'", existingFile) 15 | } 16 | 17 | // Negative test case: no existing file found, default filename used 18 | noExistingFile := FindExistingFile([]string{}, defaultFilename) 19 | if noExistingFile != defaultFilename { 20 | t.Errorf("Expected '%s', but got '%s'", defaultFilename, noExistingFile) 21 | } 22 | } 23 | 24 | func TestFindExistingFileWithEmptyFilenames(t *testing.T) { 25 | filenames := []string{} 26 | defaultFilename := "default.txt" 27 | 28 | // Negative test case: empty filenames slice 29 | emptyFilenames := FindExistingFile(filenames, defaultFilename) 30 | if emptyFilenames != defaultFilename { 31 | t.Errorf("Expected '%s', but got '%s'", defaultFilename, emptyFilenames) 32 | } 33 | } 34 | 35 | func TestFindExistingFileWithNoDefaultFilename(t *testing.T) { 36 | filenames := []string{"file1.txt", "file2.txt", "file3.txt"} 37 | 38 | // Negative test case: no default filename provided 39 | noDefaultFilename := FindExistingFile(filenames, "") 40 | if noDefaultFilename != "" { 41 | t.Errorf("Expected empty string, but got '%s'", noDefaultFilename) 42 | } 43 | } 44 | 45 | func TestFindExistingFileWithExistingDefaultFilename(t *testing.T) { 46 | filenames := []string{"file1.txt", "file2.txt", "file3.txt"} 47 | defaultFilename := "file2.txt" 48 | 49 | // Positive test case: existing default filename found 50 | existingDefaultFile := FindExistingFile(filenames, defaultFilename) 51 | if existingDefaultFile != defaultFilename { 52 | t.Errorf("Expected '%s', but got '%s'", defaultFilename, existingDefaultFile) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/telemetry/telemetry.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | //"github.com/Infisical/infisical-merge/packages/util" 5 | "crypto/sha256" 6 | "net" 7 | "net/http" 8 | "os" 9 | "time" 10 | 11 | "github.com/denisbrodbeck/machineid" 12 | "github.com/posthog/posthog-go" 13 | "github.com/stackup-app/stackup/lib/gateway" 14 | ) 15 | 16 | // this is not a secret, it is a public key 17 | var POSTHOG_API_KEY_FOR_CLI = "V6yB20dWAVDZfG1yDOFkSQPeGachBJst3UPkVJJdkIS" 18 | 19 | type Telemetry struct { 20 | IsEnabled bool 21 | posthogClient posthog.Client 22 | } 23 | 24 | type NoOpLogger struct{} 25 | 26 | func (NoOpLogger) Logf(format string, args ...interface{}) { 27 | //log.Info().Msgf(format, args...) 28 | } 29 | 30 | func (NoOpLogger) Errorf(format string, args ...interface{}) { 31 | //log.Debug().Msgf(format, args...) 32 | //fmt.Printf(" Error: %v\n", args) 33 | } 34 | 35 | func New(telemetryIsEnabled bool, gw *gateway.Gateway) *Telemetry { 36 | if POSTHOG_API_KEY_FOR_CLI != "" { 37 | trans := CustomTransport{Gateway: gw, Transport: http.DefaultTransport} 38 | client, _ := posthog.NewWithConfig( 39 | "phc_"+POSTHOG_API_KEY_FOR_CLI, 40 | posthog.Config{ 41 | Transport: &trans, 42 | Logger: NoOpLogger{}, 43 | }, 44 | ) 45 | 46 | return &Telemetry{IsEnabled: telemetryIsEnabled, posthogClient: client} 47 | } else { 48 | return &Telemetry{IsEnabled: false} 49 | } 50 | } 51 | 52 | func (t *Telemetry) EventOnly(name string) { 53 | if !t.IsEnabled { 54 | return 55 | } 56 | 57 | t.Event(name, map[string]interface{}{}) 58 | } 59 | 60 | func (t *Telemetry) Event(name string, properties map[string]interface{}) { 61 | if !t.IsEnabled { 62 | return 63 | } 64 | 65 | userId, _ := t.GetDistinctId() 66 | posthogProps := posthog.NewProperties() 67 | for key, value := range properties { 68 | posthogProps.Set(key, value) 69 | } 70 | 71 | t.posthogClient.Enqueue(posthog.Capture{ 72 | DistinctId: userId, 73 | Event: name, 74 | Timestamp: time.Now(), 75 | Properties: posthogProps, 76 | }) 77 | 78 | defer t.posthogClient.Close() 79 | } 80 | 81 | func (t *Telemetry) CaptureEvent(eventName string, properties posthog.Properties) { 82 | userIdentity, err := t.GetDistinctId() 83 | if err != nil { 84 | return 85 | } 86 | 87 | if t.IsEnabled { 88 | t.posthogClient.Enqueue(posthog.Capture{ 89 | DistinctId: userIdentity, 90 | Event: eventName, 91 | Properties: properties, 92 | }) 93 | 94 | defer t.posthogClient.Close() 95 | } 96 | } 97 | 98 | func (t *Telemetry) GetDistinctId() (string, error) { 99 | var distinctId string 100 | var outputErr error 101 | 102 | machineId, err := machineid.ProtectedID("stackup") 103 | if err != nil { 104 | outputErr = err 105 | } 106 | 107 | if machineId == "" { 108 | machineId, _ = os.Hostname() 109 | interfaces, _ := net.Interfaces() 110 | for _, inter := range interfaces { 111 | if inter.HardwareAddr.String() != "" { 112 | machineId += inter.HardwareAddr.String() + "," 113 | } 114 | } 115 | } 116 | 117 | machineId = string(sha256.New().Sum([]byte(machineId)))[0:16] 118 | distinctId = "anonymous_cli_" + machineId 119 | 120 | return distinctId, outputErr 121 | } 122 | -------------------------------------------------------------------------------- /lib/telemetry/transport.go: -------------------------------------------------------------------------------- 1 | package telemetry 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/stackup-app/stackup/lib/gateway" 8 | ) 9 | 10 | type CustomTransport struct { 11 | Gateway *gateway.Gateway 12 | Transport http.RoundTripper 13 | } 14 | 15 | // RoundTrip implements the http.RoundTripper interface, allowing us to modify the request before sending it 16 | func (c *CustomTransport) RoundTrip(req *http.Request) (*http.Response, error) { 17 | if !c.Gateway.Allowed(req.URL.String()) { 18 | return nil, errors.New("access to " + req.URL.String() + " is not allowed.") 19 | } 20 | 21 | return c.Transport.RoundTrip(req) 22 | } 23 | -------------------------------------------------------------------------------- /lib/types/enums.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type AccessType int 4 | 5 | const ( 6 | AccessTypeUnknown AccessType = iota 7 | AccessTypeUrl 8 | AccessTypeFile 9 | AccessTypeFileExtension 10 | AccessTypeContentType 11 | AccessTypeDomain 12 | ) 13 | 14 | func (at AccessType) String() string { 15 | switch at { 16 | case AccessTypeUrl: 17 | return "url" 18 | case AccessTypeFile: 19 | return "file" 20 | case AccessTypeFileExtension: 21 | return "file extension" 22 | case AccessTypeContentType: 23 | return "content type" 24 | case AccessTypeDomain: 25 | return "domain" 26 | default: 27 | return "unknown" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "os/exec" 5 | "sync" 6 | 7 | "github.com/robertkrimen/otto" 8 | "github.com/stackup-app/stackup/lib/settings" 9 | ) 10 | 11 | type CommandCallback func(cmd *exec.Cmd) 12 | 13 | type SetProcessCallback func(key, value any) 14 | 15 | type AppInterface interface { 16 | GetGateway() GatewayContract 17 | GetJsEngine() JavaScriptEngineContract 18 | GetSettings() *settings.Settings 19 | GetVars() *sync.Map 20 | GetWorkflow() AppWorkflowContract 21 | GetEnviron() []string 22 | GetApplicationIconPath() string 23 | } 24 | 25 | type JavaScriptEngineContract interface { 26 | IsEvaluatableScriptString(s string) bool 27 | GetEvaluatableScriptString(s string) string 28 | MakeStringEvaluatable(script string) string 29 | Evaluate(script string) any 30 | CreateAppVariables(vars *sync.Map) 31 | GetVm() *otto.Otto 32 | GetGateway() GatewayContract 33 | GetAppVars() *sync.Map 34 | GetFindTaskById(id string) (any, bool) 35 | } 36 | 37 | type AppWorkflowTaskContract interface { 38 | // CanRunOnCurrentPlatform() bool 39 | // CanRunConditionally() bool 40 | Initialize() 41 | Run(synchronous bool) 42 | } 43 | 44 | type AppWorkflowContract interface { 45 | FindTaskById(id string) (any, bool) 46 | GetSettings() *settings.Settings 47 | GetJsEngine() *JavaScriptEngineContract 48 | } 49 | 50 | type AppWorkflowContractPtr *AppWorkflowContract 51 | 52 | type GatewayContract interface { 53 | Allowed(url string) bool 54 | SaveUrlToFile(url string, filename string) error 55 | GetUrl(url string, headers ...string) (string, error) 56 | } 57 | 58 | type ScriptExtensionContract interface { 59 | OnInstall(engine JavaScriptEngineContract) 60 | GetName() string 61 | } 62 | 63 | type ExtensionInfo struct { 64 | Name string 65 | Version string 66 | Description string 67 | createFn interface{} // CreateExtensionFunc 68 | } 69 | 70 | type ExtensionInfoMap map[string]*ExtensionInfo 71 | 72 | type CreateExtensionFunc func(info *ExtensionInfo) any //ScriptExtensionContract 73 | 74 | type ScriptExtension struct { 75 | Name string 76 | } 77 | 78 | func CreateNewExtension(name string) *ScriptExtension { 79 | return &ScriptExtension{ 80 | Name: name, 81 | } 82 | } 83 | 84 | func NewExtensionInfo(name string, version string, description string, createFn interface{}) *ExtensionInfo { 85 | return &ExtensionInfo{ 86 | Name: name, 87 | Version: version, 88 | Description: description, 89 | createFn: createFn, 90 | } 91 | } 92 | 93 | func (se *ScriptExtension) IsExtensionInstalled(name string) bool { 94 | return false 95 | } 96 | 97 | func (se *ScriptExtension) Install() { 98 | // do nothing 99 | } 100 | 101 | func (se *ScriptExtension) GetName() string { 102 | return se.Name 103 | } 104 | 105 | func (ei *ExtensionInfo) CreateExtension() ScriptExtensionContract { 106 | result := ei.createFn.(func(info *ExtensionInfo) any)(ei) 107 | return result.(ScriptExtensionContract) 108 | 109 | } 110 | 111 | func ExtensionInfoArrayToMap(array ...ExtensionInfo) map[string]*ExtensionInfo { 112 | result := map[string]*ExtensionInfo{} 113 | 114 | for _, item := range array { 115 | result[item.Name] = &item 116 | } 117 | 118 | return result 119 | } 120 | -------------------------------------------------------------------------------- /lib/updater/Release.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/golang-module/carbon/v2" 7 | "github.com/stackup-app/stackup/lib/semver" 8 | ) 9 | 10 | type Release struct { 11 | Name string `json:"name"` 12 | TagName string `json:"tag_name"` 13 | Prerelease bool `json:"prerelease"` 14 | PublishedAt string `json:"published_at"` 15 | } 16 | 17 | func NewReleaseFromJson(jsonString string) *Release { 18 | var release = Release{} 19 | json.Unmarshal([]byte(jsonString), &release) 20 | return &release 21 | } 22 | 23 | func (r *Release) TimeSinceRelease() string { 24 | return carbon.Parse(r.PublishedAt).DiffForHumans() 25 | } 26 | 27 | func (r *Release) ToJson() string { 28 | json, err := json.Marshal(r) 29 | if err != nil { 30 | return "" 31 | } 32 | 33 | return string(json) 34 | } 35 | 36 | func (r *Release) IsNewerThan(version string) bool { 37 | return semver.ParseSemverString(r.PublishedAt).GreaterThan(version) 38 | } 39 | -------------------------------------------------------------------------------- /lib/updater/updater.go: -------------------------------------------------------------------------------- 1 | package updater 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/stackup-app/stackup/lib/gateway" 8 | ) 9 | 10 | type Updater struct { 11 | gw *gateway.Gateway 12 | } 13 | 14 | func New(gw *gateway.Gateway) *Updater { 15 | return &Updater{gw: gw} 16 | } 17 | 18 | // Example: updater.New(gw).IsUpdateAvailable("v0.0.1", "permafrost-dev/stackup") 19 | func (u *Updater) IsUpdateAvailable(githubRepository string, currentVersion string) (bool, *Release) { 20 | release, err := u.fetchLatestRepositoryRelease(githubRepository) 21 | if err != nil { 22 | return false, nil 23 | } 24 | 25 | return release.IsNewerThan(currentVersion), release 26 | } 27 | 28 | func (u *Updater) fetchLatestRepositoryRelease(repository string) (*Release, error) { 29 | if !strings.Contains(repository, "/") { 30 | return nil, fmt.Errorf("invalid repository value: '%s'", repository) 31 | } 32 | 33 | body, err := u.gw.GetUrl(fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repository)) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return NewReleaseFromJson(body), nil 39 | } 40 | -------------------------------------------------------------------------------- /lib/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stackup-app/stackup/lib/utils" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFsSafeName(t *testing.T) { 12 | assert.Equal(t, "testbinary", utils.FsSafeName("testbinary"), "fs safe name should be 'testbinary'") 13 | assert.Equal(t, "testbinary", utils.FsSafeName("testbinary!$"), "fs safe name should be 'testbinary'") 14 | assert.Equal(t, "test-binary", utils.FsSafeName("test:binary!$"), "fs safe name should be 'test-binary'") 15 | } 16 | 17 | func TestGetUniqueStrings(t *testing.T) { 18 | assert.Equal(t, []string{"a", "b", "c"}, utils.GetUniqueStrings([]string{"a", "b", "c", "a", "b", "c"})) 19 | assert.Equal(t, []string{"a", "b", "c"}, utils.GetUniqueStrings([]string{"a", "b", "c"})) 20 | } 21 | 22 | func TestUnique(t *testing.T) { 23 | assert.Equal(t, []string{"a", "b", "c"}, utils.Unique([]string{"a", "b", "c", "a", "b", "c"})) 24 | assert.Equal(t, []string{"a", "b", "c"}, utils.Unique([]string{"a", "b", "c"})) 25 | 26 | assert.Equal(t, []int{1, 2, 3}, utils.Unique([]int{1, 2, 3, 1, 2, 3})) 27 | assert.Equal(t, []int{1, 2}, utils.Unique([]int{1, 2, 2, 1})) 28 | } 29 | 30 | func TestGenerateTaskUuid(t *testing.T) { 31 | uid := utils.GenerateTaskUuid() 32 | assert.LessOrEqual(t, 8, len(uid)) 33 | } 34 | 35 | func TestGenerateShortID(t *testing.T) { 36 | id := utils.GenerateShortID() 37 | assert.Equal(t, 8, len(id)) 38 | 39 | id = utils.GenerateShortID(12) 40 | assert.Equal(t, 12, len(id)) 41 | } 42 | 43 | func TestStringArrayContains(t *testing.T) { 44 | arr := []string{"a", "b", "c"} 45 | assert.True(t, utils.StringArrayContains(arr, "a")) 46 | assert.False(t, utils.StringArrayContains(arr, "d")) 47 | } 48 | 49 | func TestArrayContains(t *testing.T) { 50 | ptrArr := utils.Ptrs(1, 2, 3) 51 | // []*int{utils.IntPtr(1), utils.IntPtr(2), utils.IntPtr(3)} 52 | assert.True(t, utils.ArrayContains(ptrArr, ptrArr[1])) 53 | // assert.True(t, utils.ArrayContains(ptrArr, utils.Ptr(3))) 54 | 55 | assert.True(t, utils.ArrayContains([]string{"a", "b", "c"}, "a")) 56 | assert.True(t, utils.ArrayContains([]int{1, 2, 3}, 3)) 57 | assert.True(t, utils.ArrayContains([]bool{true, true}, true)) 58 | assert.True(t, utils.ArrayContains([]float32{1, 2, 3}, float32(1))) 59 | assert.True(t, utils.ArrayContains([]string{"1a", "2a", "3a"}, []string{"1a", "2a"})) 60 | assert.True(t, utils.ArrayContains([]int{1, 2, 3}, []int{1, 2})) 61 | 62 | assert.True(t, utils.ArrayContains([]string{"a", "bbb", "c"}, "bbb")) 63 | assert.False(t, utils.ArrayContains([]string{"a", "b", "c"}, "d")) 64 | assert.False(t, utils.ArrayContains([]string{"a", "b", "c"}, "")) 65 | assert.False(t, utils.ArrayContains([]string{"a", "b", "c"}, "aaa")) 66 | } 67 | 68 | func TestMatchesPattern(t *testing.T) { 69 | assert.True(t, utils.MatchesPattern("abc.test", "^abc\\..+$")) 70 | assert.False(t, utils.MatchesPattern("abc.def", "^test.+$")) 71 | } 72 | 73 | func TestBinaryExistsInPath(t *testing.T) { 74 | assert.True(t, utils.BinaryExistsInPath("go")) 75 | assert.False(t, utils.BinaryExistsInPath("missing-binary-asdf-1234")) 76 | } 77 | 78 | func TestFileSize(t *testing.T) { 79 | assert.Equal(t, int64(0), utils.FileSize("missing.txt")) 80 | assert.Less(t, int64(1024), utils.FileSize("./utils.go")) // filesize is gt 1kb 81 | } 82 | 83 | func TestFileExists(t *testing.T) { 84 | assert.False(t, utils.FileExists("missing.txt")) 85 | assert.True(t, utils.FileExists("./utils.go")) 86 | } 87 | 88 | func TestWaitForStartOfNextInterval(t *testing.T) { 89 | d, _ := time.ParseDuration("1s") 90 | currentTime := time.Now().UnixMicro() 91 | utils.WaitForStartOfNextInterval(d) 92 | assert.GreaterOrEqual(t, time.Now().UnixMicro(), currentTime) 93 | } 94 | 95 | func TestSaveStringToFile(t *testing.T) { 96 | fn := "./test.txt" 97 | defer utils.RemoveFile(fn) 98 | 99 | err := utils.SaveStringToFile("test", fn) 100 | assert.NoError(t, err) 101 | assert.True(t, utils.FileExists(fn)) 102 | } 103 | 104 | func TestReplaceFilenameInUrl(t *testing.T) { 105 | assert.Equal(t, "https://test.com/test2.txt", utils.ReplaceFilenameInUrl("https://test.com/test.txt", "test2.txt")) 106 | assert.Equal(t, "https://test.com", utils.ReplaceFilenameInUrl("https://test.com/test.txt", "")) 107 | assert.Equal(t, "https://test.com/a/b/c/test2.txt", utils.ReplaceFilenameInUrl("https://test.com/a/b/c/test.txt", "test2.txt")) 108 | assert.Equal(t, "https://test.com/a/b/c", utils.ReplaceFilenameInUrl("https://test.com/a/b/c/", "")) 109 | assert.Equal(t, "https://test.com/a/b", utils.ReplaceFilenameInUrl("https://test.com/a/b/c", "")) 110 | assert.Equal(t, "https://test.com/a/b/c/test.txt", utils.ReplaceFilenameInUrl("https://test.com/a/b/c/", "test.txt")) 111 | } 112 | 113 | func TestUrlBasePath(t *testing.T) { 114 | assert.Equal(t, "https://test.com", utils.UrlBasePath("https://test.com/test.txt")) 115 | assert.Equal(t, "https://test.com/a", utils.UrlBasePath("https://test.com/a/test.txt?a=1")) 116 | assert.Equal(t, "https://test.com/a/b/c", utils.UrlBasePath("https://test.com/a/b/c/test.txt")) 117 | assert.Equal(t, "https://test.com/a/b", utils.UrlBasePath("https://test.com/a/b/c/")) 118 | assert.Equal(t, "https://test.com/a/b", utils.UrlBasePath("https://test.com/a/b/c")) 119 | } 120 | 121 | func TestEnsureEnsureConfigDirExists(t *testing.T) { 122 | dirs := []string{utils.WorkingDir("test")} 123 | result, err := utils.EnsureConfigDirExists(dirs[0], "configtest") 124 | 125 | dirs = append(dirs, result) 126 | for _, dir := range dirs { 127 | defer utils.RemoveFile(dir) 128 | } 129 | 130 | assert.NotEmpty(t, result) 131 | assert.NoError(t, err) 132 | assert.GreaterOrEqual(t, len(dirs), 2) 133 | 134 | for _, dir := range dirs { 135 | assert.True(t, utils.PathExists(dir), "dir should exist: "+dir) 136 | } 137 | } 138 | 139 | func TestDomainGlobMatch(t *testing.T) { 140 | assert.True(t, utils.DomainGlobMatch("*.test", "abc.test")) 141 | assert.True(t, utils.DomainGlobMatch("*.test", "abc.def.test")) 142 | assert.False(t, utils.DomainGlobMatch("*.test", "abc.def")) 143 | assert.False(t, utils.DomainGlobMatch("*.test", "abc.def.ghi")) 144 | } 145 | 146 | func TestGlobMatch(t *testing.T) { 147 | assert.True(t, utils.GlobMatch("*.test", "abc.test", false)) 148 | assert.True(t, utils.GlobMatch("**.test", "abc.test", true)) 149 | assert.False(t, utils.GlobMatch("*test", "abc.def", false)) 150 | assert.False(t, utils.GlobMatch("?\\*\test\\", "abc.def", false)) 151 | } 152 | 153 | func TestEnforceSuffix(t *testing.T) { 154 | assert.Equal(t, "test.txt", utils.EnforceSuffix("test.txt", ".txt")) 155 | assert.Equal(t, "test.txt", utils.EnforceSuffix("test", ".txt")) 156 | assert.Equal(t, "test.txt.txt", utils.EnforceSuffix("test.txt.txt", ".txt")) 157 | assert.Equal(t, "test.txt", utils.EnforceSuffix("test.txt", "")) 158 | } 159 | 160 | func TestReverseArray(t *testing.T) { 161 | assert.Equal(t, []string{"c", "b", "a"}, utils.ReverseArray([]string{"a", "b", "c"})) 162 | assert.Equal(t, []int{3, 2, 1}, utils.ReverseArray([]int{1, 2, 3})) 163 | } 164 | 165 | func TestCombineArrays(t *testing.T) { 166 | assert.Equal(t, []string{"a", "b", "c"}, utils.CombineArrays([]string{"a", "b"}, []string{"c"})) 167 | assert.Equal(t, []string{"a", "b", "c"}, utils.CombineArrays([]string{"a"}, []string{"b"}, []string{"c"})) 168 | assert.Equal(t, []int{1, 2, 3}, utils.CombineArrays([]int{1, 2}, []int{3})) 169 | } 170 | 171 | func TestMax(t *testing.T) { 172 | assert.Equal(t, 3, utils.Max(1, 2, 3)) 173 | assert.Equal(t, 3, utils.Max(3, 2, 1)) 174 | assert.Equal(t, 3, utils.Max(3)) 175 | assert.Equal(t, 0, utils.Max()) 176 | } 177 | 178 | func TestMin(t *testing.T) { 179 | assert.Equal(t, 1, utils.Min(1, 2, 3)) 180 | assert.Equal(t, 1, utils.Min(3, 2, 1)) 181 | assert.Equal(t, -3, utils.Min(1, 2, -3)) 182 | assert.Equal(t, 3, utils.Min(3)) 183 | assert.Equal(t, 0, utils.Min()) 184 | } 185 | 186 | func TestCastAndCombineArrays(t *testing.T) { 187 | var t1 interface{} = "a" 188 | var t2 interface{} = "b" 189 | 190 | assert.Equal(t, []string{"a", "b", "c"}, utils.CastAndCombineArrays([]string{t1.(string), t2.(string)}, []interface{}{"c"}), "should combine arrays") 191 | assert.Equal(t, []string{"a", "b", "2"}, utils.CastAndCombineArrays([]string{"a", "b"}, []interface{}{"2"})) 192 | assert.Equal(t, []any{1, 2, "5"}, utils.CastAndCombineArrays([]interface{}{1, 2}, []interface{}{"5"})) 193 | } 194 | 195 | func TestGetUrlJson(t *testing.T) { 196 | var result interface{} 197 | 198 | err := utils.GetUrlJson("https://api.github.com/repos/permafrost-dev/stackup", &result, nil) 199 | assert.NoError(t, err) 200 | assert.Equal(t, "https://api.github.com/repos/permafrost-dev/stackup", result.(map[string]interface{})["url"]) 201 | assert.Equal(t, "https://api.github.com/repos/permafrost-dev/stackup/commits{/sha}", result.(map[string]interface{})["commits_url"]) 202 | } 203 | 204 | func TestAbsoluteFilePath(t *testing.T) { 205 | assert.Equal(t, utils.WorkingDir("test.txt"), utils.AbsoluteFilePath("test.txt")) 206 | assert.Equal(t, utils.WorkingDir("test.txt"), utils.AbsoluteFilePath("./test.txt")) 207 | assert.Equal(t, utils.WorkingDir("/a/test.txt"), utils.AbsoluteFilePath("./a/test.txt")) 208 | } 209 | 210 | func TestRunCommandInPath(t *testing.T) { 211 | _, err := utils.RunCommandInPath("ls -la", ".", true) 212 | // _, _ := output.CombinedOutput() 213 | assert.NoError(t, err) 214 | // assert.Contains(t, string(str), "utils.go") 215 | 216 | // output, err = utils.RunCommandInPath("ls -la", ".", true) 217 | // pipe, _ = output.CombinedOutput() 218 | // str = string(pipe) 219 | // assert.NoError(t, err) 220 | // assert.Contains(t, str, "utils.go") 221 | } 222 | 223 | func TestExcept(t *testing.T) { 224 | assert.Equal(t, []string{"a", "c"}, utils.Except([]string{"a", "b", "c"}, []string{"b"})) 225 | assert.Equal(t, []string{"b"}, utils.Except([]string{"a", "b", "c"}, []string{"a", "c"})) 226 | assert.Equal(t, []string{}, utils.Except([]string{"a", "b", "c"}, []string{"a", "b", "c"})) 227 | assert.Equal(t, []string{"a", "b", "c"}, utils.Except([]string{"a", "b", "c"}, []string{"d", "e"})) 228 | assert.Equal(t, []string{"a", "b", "c"}, utils.Except([]string{"a", "b", "c"}, []string{})) 229 | } 230 | 231 | func TestOnly(t *testing.T) { 232 | assert.Equal(t, []string{"a", "c"}, utils.Only([]string{"a", "b", "c"}, []string{"a", "c"})) 233 | assert.Equal(t, []string{"a", "b", "c"}, utils.Only([]string{"a", "b", "c"}, []string{"a", "b", "c"})) 234 | assert.Equal(t, []string{}, utils.Only([]string{"a", "b", "c"}, []string{"d", "e"})) 235 | assert.Equal(t, []string{}, utils.Only([]string{"a", "b", "c"}, []string{})) 236 | } 237 | -------------------------------------------------------------------------------- /lib/version/app-version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | const APP_VERSION = "1.22.1-dev.34" 4 | -------------------------------------------------------------------------------- /sweep.yaml: -------------------------------------------------------------------------------- 1 | # Sweep AI turns bugs & feature requests into code changes (https://sweep.dev) 2 | # For details on our config file, check out our docs at https://docs.sweep.dev/usage/config 3 | 4 | # This setting contains a list of rules that Sweep will check for. If any of these rules are broken in a new commit, Sweep will create an pull request to fix the broken rule. 5 | rules: 6 | - "All new business logic should have corresponding unit tests." 7 | - "Refactor large functions to be more modular." 8 | - "Add docstrings to all functions and file headers." 9 | 10 | # This is the branch that Sweep will develop from and make pull requests to. Most people use 'main' or 'master' but some users also use 'dev' or 'staging'. 11 | branch: 'main' 12 | 13 | # By default Sweep will read the logs and outputs from your existing Github Actions. To disable this, set this to false. 14 | gha_enabled: True 15 | 16 | # This is the description of your project. It will be used by sweep when creating PRs. You can tell Sweep what's unique about your project, what frameworks you use, or anything else you want. 17 | # 18 | # Example: 19 | # 20 | # description: sweepai/sweep is a python project. The main api endpoints are in sweepai/api.py. Write code that adheres to PEP8. 21 | description: '' 22 | 23 | # This sets whether to create pull requests as drafts. If this is set to True, then all pull requests will be created as drafts and GitHub Actions will not be triggered. 24 | draft: False 25 | 26 | # This is a list of directories that Sweep will not be able to edit. 27 | blocked_dirs: [] 28 | 29 | # This is a list of documentation links that Sweep will use to help it understand your code. You can add links to documentation for any packages you use here. 30 | # 31 | # Example: 32 | # 33 | # docs: 34 | # - PyGitHub: ["https://pygithub.readthedocs.io/en/latest/", "We use pygithub to interact with the GitHub API"] 35 | docs: [] 36 | -------------------------------------------------------------------------------- /templates/init.stackup.template.yaml: -------------------------------------------------------------------------------- 1 | name: my stack 2 | description: application stack 3 | version: 1.0.0 4 | 5 | settings: 6 | anonymous-statistics: false 7 | exit-on-checksum-mismatch: false 8 | dotenv: ['.env', '.env.local'] 9 | checksum-verification: true 10 | cache: 11 | ttl-minutes: 15 12 | domains: 13 | allowed: 14 | - '*.githubusercontent.com' 15 | hosts: 16 | - hostname: '*.github.com' 17 | gateway: allow 18 | headers: 19 | - 'Accept: application/vnd.github.v3+json' 20 | gateway: 21 | content-types: 22 | allowed: 23 | - '*' 24 | 25 | includes: 26 | - url: gh:permafrost-dev/stackup/main/templates/remote-includes/containers.yaml 27 | - url: gh:permafrost-dev/stackup/main/templates/remote-includes/{{.ProjectType}}.yaml 28 | 29 | # project type preconditions are loaded from included file above 30 | preconditions: 31 | 32 | startup: 33 | - task: start-containers 34 | 35 | shutdown: 36 | - task: stop-containers 37 | 38 | servers: 39 | 40 | scheduler: 41 | 42 | # tasks are loaded from included files above 43 | tasks: 44 | -------------------------------------------------------------------------------- /templates/remote-includes/checksums.sha256.txt: -------------------------------------------------------------------------------- 1 | e0548a4c4a20da76f94677a2fffe9fb5539dabf6e8c265258170bc832f96e880 containers.yaml 2 | 824d37bc6fc5f321efae52f9737178cdf1ef2261a64521fb4db430cd42a46f30 laravel.yaml 3 | 75b68a9843de344e9163e3b4f8cd82821be0fc07e027ec6a4bf93741dd9a7f01 node.yaml 4 | b5e40d295476f8747172d8dde06bce1c328acc52d44aa047e1d4ad331db85db8 php.yaml 5 | 2eae83a2695f90f94b5a710d0ee4c03e37b58b2bf39ccd03d3f29643bb17bc8a python.yaml 6 | e1b688438469fb2de23bb8c30a342f7d2449f0f0a7b663de45659828c6fd0adb shared-settings.yaml 7 | -------------------------------------------------------------------------------- /templates/remote-includes/containers.yaml: -------------------------------------------------------------------------------- 1 | name: container tasks 2 | version: 1.0.0 3 | last-modified: 2023-Jul-30 04:00 4 | author: Patrick Organ 5 | description: stackup tasks for starting and stopping containers 6 | 7 | init: | 8 | vars.Set("containerEngineBinary", "docker-compose"); 9 | 10 | if (binaryExists("podman-compose")) { 11 | vars.Set("containerEngineBinary", "podman-compose"); 12 | } 13 | 14 | app.SuccessMessage("selected " + $containerEngineBinary + " as the container engine"); 15 | 16 | preconditions: 17 | # - name: project has a docker-compose file 18 | # check: fs.Exists("docker-compose.yml") 19 | 20 | tasks: 21 | - id: start-containers 22 | command: '{{ $containerEngineBinary + " up -d" }}' 23 | if: exists("docker-compose.yml") 24 | silent: true 25 | 26 | - id: stop-containers 27 | command: '{{ $containerEngineBinary + " down" }}' 28 | if: exists("docker-compose.yml") 29 | silent: true 30 | -------------------------------------------------------------------------------- /templates/remote-includes/laravel.yaml: -------------------------------------------------------------------------------- 1 | name: laravel project tasks 2 | version: 1.0.0 3 | last-modified: 2023-Jul-31 08:40 4 | author: Patrick Organ 5 | description: stackup tasks for laravel-based projects 6 | 7 | init: | 8 | vars.Set("laravel-version", semver(outputOf("php artisan --version"))); 9 | 10 | preconditions: 11 | - name: project is a laravel project 12 | check: fs.Exists("artisan") 13 | 14 | tasks: 15 | - name: run migrations (rebuild db) 16 | id: run-migrations-fresh 17 | if: hasFlag("seed") 18 | command: php artisan migrate:fresh --seed 19 | 20 | - name: run migrations (no seeding) 21 | id: run-migrations-no-seed 22 | if: hasFlag("seed") == false 23 | command: php artisan migrate 24 | 25 | - id: httpd 26 | command: php artisan serve 27 | 28 | - id: horizon-queue 29 | if: $composer.HasDependency("laravel/horizon") 30 | command: php artisan horizon 31 | platforms: ['linux', 'darwin'] 32 | 33 | - id: run-artisan-scheduler 34 | command: php artisan schedule:run 35 | -------------------------------------------------------------------------------- /templates/remote-includes/node.yaml: -------------------------------------------------------------------------------- 1 | name: node project tasks 2 | version: 1.0.0 3 | last-modified: 2023-Jul-31 08:40 4 | author: Patrick Organ 5 | description: stackup tasks for node-based projects 6 | 7 | init: | 8 | vars.Set("node_version", semver(outputOf("node --version"))); 9 | 10 | preconditions: 11 | - name: node is installed 12 | check: binaryExists("node") 13 | 14 | - name: Node is version 16+ 15 | check: $node_version.Gte("16") 16 | 17 | tasks: 18 | #no tasks 19 | -------------------------------------------------------------------------------- /templates/remote-includes/php.yaml: -------------------------------------------------------------------------------- 1 | name: php project tasks 2 | version: 1.0.0 3 | last-modified: 2023-Jul-31 08:40 4 | author: Patrick Organ 5 | description: stackup tasks for php-based projects 6 | 7 | init: | 8 | vars.Set("php_version", semver(outputOf("php --version"))); 9 | vars.Set("composer", dev.ComposerJson($LOCAL_BACKEND_PROJECT_PATH)); 10 | 11 | preconditions: 12 | - name: php is installed 13 | check: binaryExists("php") 14 | 15 | - name: PHP is version 8+ 16 | check: $php_version.Gte("8") 17 | 18 | - name: project is a composer-based php project 19 | check: fs.Exists("composer.json") 20 | 21 | tasks: 22 | -------------------------------------------------------------------------------- /templates/remote-includes/python.yaml: -------------------------------------------------------------------------------- 1 | name: python project tasks 2 | version: 1.0.0 3 | last-modified: 2023-Aug-01 21:11 4 | author: Patrick Organ 5 | description: stackup tasks for python-based projects 6 | 7 | init: | 8 | vars.Set("pythonBin", "python"); 9 | if (binaryExists("python3")) { 10 | vars.Set("pythonBin", "python3"); 11 | } 12 | vars.Set("python_version", semver(outputOf($pythonBin + " --version"))); 13 | 14 | preconditions: 15 | - name: python is installed 16 | check: binaryExists($pythonBin) 17 | 18 | - name: python is version 3+ 19 | check: $python_version.Gte("3") 20 | 21 | - name: project is a python project 22 | check: fs.Exists("requirements.txt") 23 | 24 | tasks: 25 | -------------------------------------------------------------------------------- /templates/remote-includes/shared-settings.yaml: -------------------------------------------------------------------------------- 1 | name: example shared configuration settings 2 | version: 1.0.0 3 | last-modified: 2023-Aug-04 19:11 4 | author: Patrick Organ 5 | description: example of shared configuration settings 6 | 7 | settings: 8 | anonymous-stats: true 9 | domains: 10 | allowed: 11 | - '*.githubusercontent.com' 12 | - api.github.com 13 | - app.posthog.com 14 | 15 | init: 16 | preconditions: 17 | tasks: 18 | -------------------------------------------------------------------------------- /templates/stackup.dist.yaml: -------------------------------------------------------------------------------- 1 | name: my stack 2 | description: laravel application stack 3 | version: 1.0.0 4 | 5 | env: 6 | - MY_ENV_VAR_ONE=test1234 7 | - dotenv://vault 8 | 9 | settings: 10 | exit-on-checksum-mismatch: false 11 | dotenv: ['.env', '.env.local'] 12 | checksum-verification: false 13 | cache: 14 | ttl-minutes: 15 15 | domains: 16 | allowed: 17 | - '*.githubusercontent.com' 18 | hosts: 19 | - hostname: api.github.com 20 | gateway: allow 21 | headers: 22 | - 'Authorization: token $GITHUB_TOKEN' 23 | - 'Accept: application/vnd.github.v3+json' 24 | - hostname: '*.githubusercontent.com' 25 | gateway: allow 26 | headers: 27 | - 'Authorization: token $GITHUB_TOKEN' 28 | - 'Accept: application/vnd.github.v3+json' 29 | defaults: 30 | tasks: 31 | silent: false 32 | path: $LOCAL_BACKEND_PROJECT_PATH 33 | 34 | includes: 35 | - url: gh:permafrost-dev/stackup/main/templates/remote-includes/shared-settings.yaml 36 | - url: gh:permafrost-dev/stackup/main/templates/remote-includes/containers.yaml 37 | - url: gh:permafrost-dev/stackup/main/templates/remote-includes/laravel.yaml 38 | - url: gh:permafrost-dev/stackup/main/templates/remote-includes/php.yaml 39 | - url: gh:permafrost-dev/stackup/main/templates/remote-includes/node.yaml 40 | 41 | init: | 42 | vars.Set("run_migrations_taskId", "run-migrations-" + (hasFlag("seed") ? "fresh" : "no-seed")); 43 | vars.Set("frontend_http_cmd", (platform() == "windows" ? "npm run dev" : "node ./node_modules/.bin/next dev")); 44 | 45 | preconditions: 46 | - name: environment variables exist and are not empty 47 | check: | 48 | hasEnv("FRONTEND_PROJECT_PATH") && hasEnv("LOCAL_BACKEND_PROJECT_PATH") && 49 | env("FRONTEND_PROJECT_PATH") != "" && env("LOCAL_BACKEND_PROJECT_PATH") != "" 50 | 51 | - name: frontend project directory exists 52 | check: fs.Exists($FRONTEND_PROJECT_PATH) && fs.IsDirectory($FRONTEND_PROJECT_PATH) 53 | 54 | startup: 55 | - task: start-containers 56 | - task: '{{ $run_migrations_taskId }}' 57 | 58 | shutdown: 59 | - task: stop-containers 60 | 61 | servers: 62 | - task: frontend-httpd 63 | - task: httpd 64 | - task: horizon-queue 65 | 66 | scheduler: 67 | - task: run-artisan-scheduler 68 | cron: '* * * * *' 69 | 70 | tasks: 71 | - id: frontend-httpd 72 | path: $FRONTEND_PROJECT_PATH 73 | command: '{{ $frontend_http_cmd }}' 74 | -------------------------------------------------------------------------------- /templates/stackup.laravel.yaml: -------------------------------------------------------------------------------- 1 | name: my stack 2 | description: laravel application stack 3 | version: 1.0.0 4 | 5 | init: | 6 | vars.Set("php_version", semver(outputOf("php --version"))); 7 | vars.Set("laravel_version", semver(outputOf("php artisan --version"))); 8 | 9 | preconditions: 10 | - name: dependencies are installed 11 | check: binaryExists("php") 12 | 13 | - name: PHP is at least version 7.3 14 | check: vars.Get("php_version").Gte("7.3") 15 | 16 | - name: running Laravel v9+ 17 | check: $laravel_version.Gte("9") 18 | 19 | - name: project is a laravel application 20 | check: fs.Exists(getCwd() + "/artisan") && fs.Exists(getCwd() + "/composer.json") 21 | 22 | startup: 23 | - task: start-containers 24 | - task: run-migrations-fresh 25 | - task: run-migrations-no-seed 26 | 27 | shutdown: 28 | - task: stop-containers 29 | 30 | servers: 31 | - task: horizon-queue 32 | - task: httpd 33 | 34 | scheduler: 35 | - task: artisan-scheduler 36 | cron: '* * * * *' 37 | 38 | tasks: 39 | - id: start-containers 40 | if: fs.Exists("docker-compose.yml") && binaryExists("docker-compose") 41 | command: docker-compose up -d 42 | silent: true 43 | 44 | - id: stop-containers 45 | if: fs.Exists("docker-compose.yml") && binaryExists("docker-compose") 46 | command: docker-compose down 47 | silent: true 48 | 49 | - name: run migrations (rebuild db) 50 | id: run-migrations-fresh 51 | if: hasFlag("seed") 52 | command: php artisan migrate:fresh --seed 53 | 54 | - name: run migrations (no seeding) 55 | id: run-migrations-no-seed 56 | if: hasFlag("seed") == false 57 | command: php artisan migrate 58 | 59 | - id: run-artisan-scheduler 60 | command: php artisan schedule:run 61 | 62 | - id: horizon-queue 63 | if: dev.ComposerJson(getCwd()).HasDependency("laravel/horizon") 64 | command: php artisan horizon 65 | platforms: ['linux', 'darwin'] 66 | 67 | - id: httpd 68 | command: php artisan serve 69 | -------------------------------------------------------------------------------- /tools/generate-version-file.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/blang/semver" 11 | ) 12 | 13 | const ( 14 | VERSION = "0.0.0" 15 | TARGET_FILE = "lib/version/app-version.go" 16 | ) 17 | 18 | func getTag(match ...string) (string, *semver.PRVersion) { 19 | args := append([]string{ 20 | "describe", "--tags", 21 | }, match...) 22 | tag, err := exec.Command("git", args...).Output() 23 | if err != nil { 24 | return "", nil 25 | } 26 | tagParts := strings.Split(string(tag), "-") 27 | if len(tagParts) == 3 { 28 | if ahead, err := semver.NewPRVersion(tagParts[1]); err == nil { 29 | return tagParts[0], &ahead 30 | } 31 | } else if len(tagParts) == 4 { 32 | if ahead, err := semver.NewPRVersion(tagParts[2]); err == nil { 33 | return tagParts[0] + "-" + tagParts[1], &ahead 34 | } 35 | } 36 | 37 | return string(tag), nil 38 | } 39 | 40 | func getProjectVersion() string { 41 | if tags, err := exec.Command("git", "tag").Output(); err != nil || len(tags) == 0 { 42 | // no tags found -- fetch them 43 | exec.Command("git", "fetch", "--tags").Run() 44 | } 45 | // Find the last vX.X.X Tag and get how many builds we are ahead of it. 46 | versionStr, ahead := getTag("--match", "v*") 47 | version, err := semver.ParseTolerant(versionStr) 48 | if err != nil { 49 | // no version tag found so just return what ever we can find. 50 | return "0.0.0-unknown" 51 | } 52 | // Get the tag of the current revision. 53 | tag, _ := getTag("--exact-match") 54 | if tag == versionStr { 55 | // Seems that we are going to build a release. 56 | // So the version number should already be correct. 57 | return version.String() 58 | } 59 | 60 | // If we don't have any tag assume "dev" 61 | if tag == "" || strings.HasPrefix(tag, "nightly") { 62 | tag = "dev" 63 | } 64 | // Get the most likely next version: 65 | if !strings.Contains(version.String(), "rc") { 66 | version.Patch = version.Patch + 1 67 | } 68 | 69 | if pr, err := semver.NewPRVersion(tag); err == nil { 70 | // append the tag as pre-release name 71 | version.Pre = append(version.Pre, pr) 72 | } 73 | 74 | if ahead != nil { 75 | // if we know how many commits we are ahead of the last release, append that too. 76 | version.Pre = append(version.Pre, *ahead) 77 | } 78 | 79 | return version.String() 80 | } 81 | 82 | func RunGitCommand(args ...string) string { 83 | cmd := exec.Command("git", args...) 84 | 85 | var out bytes.Buffer 86 | var stderr bytes.Buffer 87 | cmd.Stdout = &out 88 | cmd.Stderr = &stderr 89 | 90 | err := cmd.Run() 91 | if err != nil { 92 | return "" 93 | } 94 | 95 | return strings.TrimSpace(out.String()) 96 | } 97 | 98 | func main() { 99 | result := getProjectVersion() //RunGitCommand("rev-parse", "HEAD")[0:8] 100 | 101 | file, err := os.Create(TARGET_FILE) 102 | if err != nil { 103 | fmt.Printf("Cannot create %s file: %v\n", TARGET_FILE, err) 104 | os.Exit(1) 105 | } 106 | defer file.Close() 107 | 108 | file.WriteString(fmt.Sprintf("package version\n\nconst APP_VERSION = \"%s\"\n", result)) 109 | } 110 | -------------------------------------------------------------------------------- /tools/get-version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "strings" 6 | 7 | "github.com/blang/semver" 8 | ) 9 | 10 | // func getTag(match ...string) (string, *semver.PRVersion) { 11 | // args := append([]string{ 12 | // "describe", "--tags", 13 | // }, match...) 14 | // tag, err := exec.Command("git", args...).Output() 15 | // if err != nil { 16 | // return "", nil 17 | // } 18 | // tagParts := strings.Split(string(tag), "-") 19 | // if len(tagParts) == 3 { 20 | // if ahead, err := semver.NewPRVersion(tagParts[1]); err == nil { 21 | // return tagParts[0], &ahead 22 | // } 23 | // } else if len(tagParts) == 4 { 24 | // if ahead, err := semver.NewPRVersion(tagParts[2]); err == nil { 25 | // return tagParts[0] + "-" + tagParts[1], &ahead 26 | // } 27 | // } 28 | 29 | // return string(tag), nil 30 | // } 31 | 32 | func GetProjectVersion() string { 33 | if tags, err := exec.Command("git", "tag").Output(); err != nil || len(tags) == 0 { 34 | // no tags found -- fetch them 35 | exec.Command("git", "fetch", "--tags").Run() 36 | } 37 | // Find the last vX.X.X Tag and get how many builds we are ahead of it. 38 | versionStr, ahead := getTag("--match", "v*") 39 | version, err := semver.ParseTolerant(versionStr) 40 | if err != nil { 41 | // no version tag found so just return what ever we can find. 42 | return "0.0.0-unknown" 43 | } 44 | // Get the tag of the current revision. 45 | tag, _ := getTag("--exact-match") 46 | if tag == versionStr { 47 | // Seems that we are going to build a release. 48 | // So the version number should already be correct. 49 | return version.String() 50 | } 51 | 52 | // If we don't have any tag assume "dev" 53 | if tag == "" || strings.HasPrefix(tag, "nightly") { 54 | tag = "dev" 55 | } 56 | // Get the most likely next version: 57 | if !strings.Contains(version.String(), "rc") { 58 | version.Patch = version.Patch + 1 59 | } 60 | 61 | if pr, err := semver.NewPRVersion(tag); err == nil { 62 | // append the tag as pre-release name 63 | version.Pre = append(version.Pre, pr) 64 | } 65 | 66 | if ahead != nil { 67 | // if we know how many commits we are ahead of the last release, append that too. 68 | version.Pre = append(version.Pre, *ahead) 69 | } 70 | 71 | return version.String() 72 | } 73 | --------------------------------------------------------------------------------