├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── release.yml │ └── test.yaml ├── .gitignore ├── .prettierrc ├── .yarnrc.yml ├── CHANGELOG.json ├── CHANGELOG.md ├── README.md ├── bin ├── dev.js ├── pkg-build.sh ├── pkg-compress.sh ├── run.cmd └── run.js ├── package.json ├── src ├── api │ ├── api.ts │ ├── deploy.ts │ ├── gateways.ts │ ├── index.ts │ ├── profile.ts │ ├── schema.d.ts │ ├── secrets.ts │ ├── squids.ts │ ├── types.ts │ └── upload.ts ├── command.ts ├── commands │ ├── auth.ts │ ├── deploy.ts │ ├── deploy.unit.spec.ts │ ├── docs.ts │ ├── explorer.ts │ ├── gateways │ │ └── list.ts │ ├── init.ts │ ├── list.ts │ ├── logs.ts │ ├── prod.ts │ ├── remove.ts │ ├── restart.ts │ ├── run.ts │ ├── secrets │ │ ├── list.ts │ │ ├── remove.ts │ │ └── set.ts │ ├── tags │ │ ├── add.ts │ │ └── remove.ts │ ├── view.ts │ └── whoami.ts ├── config │ ├── config.ts │ ├── config.unit.spec.ts │ └── index.ts ├── deploy-command.ts ├── flags │ ├── fullname.ts │ ├── index.ts │ ├── name.ts │ ├── org.ts │ ├── slot.ts │ └── tag.ts ├── help.ts ├── hooks │ └── command_not_found.ts ├── logs │ ├── index.ts │ └── printer.ts ├── manifest │ ├── index.ts │ └── manifest.ts ├── tty.ts ├── types.d.ts ├── ui │ ├── components │ │ ├── Loader.ts │ │ ├── SquidList.ts │ │ ├── Tabs.ts │ │ ├── VersionDbAccessTab.ts │ │ ├── VersionDeployTab.ts │ │ ├── VersionLogsTab.ts │ │ ├── VersionManager.ts │ │ ├── VersionSummaryTab.ts │ │ ├── VersionView.ts │ │ └── types.ts │ └── theme │ │ └── index.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | # /node_modules/* in the project root is ignored by default 2 | # build artefacts 3 | lib/* 4 | # data definition files 5 | **/*.d.ts 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin', 'prettier'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'plugin:import/errors', 13 | 'plugin:import/warnings', 14 | 'plugin:import/typescript' 15 | ], 16 | root: true, 17 | env: { 18 | node: true, 19 | jest: true, 20 | }, 21 | rules: { 22 | 'max-len': ['error', { code: 120, ignoreComments: true, ignoreStrings: true, ignoreTemplateLiterals: true }], 23 | 'prettier/prettier': ['error', { printWidth: 120 }], 24 | 'no-implicit-coercion': ['error', { allow: ['!!'] }], 25 | 'import/order': [ 26 | 'error', 27 | { 28 | 29 | pathGroups: [ 30 | ], 31 | 'newlines-between': 'always', 32 | alphabetize: { 33 | order: 'asc' /* sort in ascending order. Options: ['ignore', 'asc', 'desc'] */, 34 | caseInsensitive: true /* ignore case. Options: [true, false] */, 35 | }, 36 | pathGroupsExcludedImportTypes: ['builtin'], 37 | groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object'], 38 | }, 39 | ], 40 | 'import/no-unresolved': 'off', 41 | 'no-negated-condition': 'off', 42 | '@typescript-eslint/explicit-module-boundary-types': 'off', 43 | '@typescript-eslint/ban-ts-ignore': 'off', 44 | '@typescript-eslint/interface-name-prefix': 'off', 45 | '@typescript-eslint/explicit-function-return-type': 'off', 46 | '@typescript-eslint/no-explicit-any': 'off', 47 | '@typescript-eslint/no-empty-function': 'off', 48 | '@typescript-eslint/camelcase': 'off', 49 | '@typescript-eslint/no-unused-vars': 'off', 50 | '@typescript-eslint/no-unsafe-declaration-merging': 'off' 51 | }, 52 | }; 53 | 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | tag: 6 | type: choice 7 | description: Tag 8 | required: true 9 | options: 10 | - beta 11 | - latest 12 | - alpha 13 | 14 | # pull_request: 15 | # branches: 16 | # - develop 17 | # paths: 18 | # - .github/workflows/release.yaml 19 | # - src/** 20 | # - bin/** 21 | # - yarn.lock 22 | 23 | jobs: 24 | build-publish: 25 | name: Build & publish 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Cancel previous runs 29 | uses: styfle/cancel-workflow-action@0.5.0 30 | with: 31 | access_token: ${{ github.token }} 32 | 33 | - name: Checkout 34 | uses: actions/checkout@v3 35 | 36 | - name: Install node 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: 18 40 | 41 | - name: Install Yarn 42 | run: corepack enable 43 | 44 | # Yarn dependencies cannot be cached until yarn is installed 45 | # WORKAROUND: https://github.com/actions/setup-node/issues/531 46 | - name: Extract cached dependencies 47 | uses: actions/setup-node@v4 48 | with: 49 | cache: yarn 50 | 51 | - name: Write npm credentials 52 | run: | 53 | echo "//registry.npmjs.org/:_authToken=$NPM_AUTH_TOKEN" >> .npmrc 54 | npm whoami 55 | env: 56 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 57 | 58 | 59 | - name: Install 60 | run: yarn install --immutable 61 | 62 | - name: Build 63 | run: yarn build 64 | 65 | - name: Release 66 | run: npm publish --tag ${{ github.event.inputs.tag }} --access public 67 | env: 68 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 69 | 70 | # - name: Build packages 71 | # run: | 72 | # yarn pkg:build 73 | # yarn pkg:compress 74 | # 75 | # - name: Get version 76 | # id: package-version 77 | # uses: martinbeentjes/npm-get-version-action@main 78 | # 79 | # - name: Tag release 80 | # uses: tvdias/github-tagger@v0.0.1 81 | # with: 82 | # repo-token: ${{ github.token }} 83 | # tag: v${{ steps.package-version.outputs.current-version }} 84 | # 85 | # - name: Create release page 86 | # uses: softprops/action-gh-release@v1 87 | # with: 88 | # files: 'package/*' 89 | # tag_name: v${{ steps.package-version.outputs.current-version }} 90 | # 91 | # - name: Checkout subsquid/homebrew-cli 92 | # uses: actions/checkout@v3 93 | # with: 94 | # repository: subsquid/homebrew-cli 95 | # path: homebrew-cli 96 | # token: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} 97 | # 98 | # - name: Gen Formula 99 | # run: | 100 | # echo pkg_macos_shasum=$(shasum -a256 ./package/subsquid-cli-$npm_package_version-macos-x64.tar.gz | cut -f 1 -d " ") >> $GITHUB_ENV 101 | # echo pkg_linux_shasum=$(shasum -a256 ./package/subsquid-cli-$npm_package_version-linux-x64.tar.gz | cut -f 1 -d " ") >> $GITHUB_ENV 102 | # source ./homebrew-cli/gen-formula.sh 103 | # cp sqd@$npm_package_version.rb ./homebrew-cli/Formula 104 | # cp sqd@$version_tag.rb ./homebrew-cli/Formula 105 | # if [ "$version_tag" = "latest" ]; then cp sqd.rb ./homebrew-cli/Formula; fi 106 | # env: 107 | # npm_package_version: ${{ steps.package-version.outputs.current-version }} 108 | # version_tag: ${{ github.event.inputs.tag }} 109 | # 110 | # - name: Pushes to another repository 111 | # uses: cpina/github-action-push-to-another-repository@main 112 | # env: 113 | # API_TOKEN_GITHUB: ${{ secrets.HOMEBREW_GITHUB_TOKEN }} 114 | # with: 115 | # source-directory: 'homebrew-cli' 116 | # destination-github-username: 'subsquid' 117 | # destination-repository-name: 'homebrew-cli' 118 | # user-name: 'github-actions' 119 | # user-email: 'github-actions@github.com' 120 | # target-branch: master 121 | # commit-message: 'release: v${{ steps.package-version.outputs.current-version }}' 122 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - develop 6 | - main 7 | paths: 8 | - .github/workflows/test.yaml 9 | - src/** 10 | - bin/** 11 | - yarn.lock 12 | 13 | jobs: 14 | setup-build-publish-deploy: 15 | name: Run tests 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Cancel previous runs 20 | uses: styfle/cancel-workflow-action@0.5.0 21 | with: 22 | access_token: ${{ github.token }} 23 | 24 | - name: Checkout 25 | uses: actions/checkout@v3 26 | 27 | - name: Install node 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 18 31 | 32 | - name: Install Yarn 33 | run: corepack enable 34 | 35 | # Yarn dependencies cannot be cached until yarn is installed 36 | # WORKAROUND: https://github.com/actions/setup-node/issues/531 37 | - name: Extract cached dependencies 38 | uses: actions/setup-node@v4 39 | with: 40 | cache: yarn 41 | 42 | - name: Install 43 | run: yarn install 44 | 45 | - name: Typescript check 46 | run: yarn tsc 47 | 48 | - name: Unit tests 49 | run: yarn test:unit 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # Tooling logs 5 | *.log 6 | 7 | # Our temprorary files (e.g. ad-hoc scripts) 8 | *.temp.* 9 | 10 | # IDE 11 | .idea 12 | 13 | # Built js libs 14 | lib/ 15 | 16 | # Build files 17 | package/ 18 | dist/ 19 | 20 | # Oclif 21 | oclif.manifest.json 22 | 23 | # Yarn v2 24 | .yarn/cache 25 | .yarn/unplugged 26 | .yarn/build-state.yml 27 | .yarn/install-state.gz 28 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /CHANGELOG.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@subsquid/cli", 3 | "entries": [ 4 | { 5 | "version": "0.6.0", 6 | "tag": "@subsquid/cli_v0.6.0", 7 | "date": "Sat, 13 Aug 2022 13:18:27 GMT", 8 | "comments": { 9 | "minor": [ 10 | { 11 | "comment": "deploy flow improvements" 12 | } 13 | ] 14 | } 15 | }, 16 | { 17 | "version": "0.5.1", 18 | "tag": "@subsquid/cli_v0.5.1", 19 | "date": "Tue, 28 Jun 2022 18:37:46 GMT", 20 | "comments": { 21 | "patch": [ 22 | { 23 | "comment": "meaningful error messages and command naming" 24 | } 25 | ] 26 | } 27 | }, 28 | { 29 | "version": "0.5.0", 30 | "tag": "@subsquid/cli_v0.5.0", 31 | "date": "Mon, 27 Jun 2022 20:38:17 GMT", 32 | "comments": { 33 | "minor": [ 34 | { 35 | "comment": "add `squid:logs` command" 36 | } 37 | ] 38 | } 39 | }, 40 | { 41 | "version": "0.4.1", 42 | "tag": "@subsquid/cli_v0.4.1", 43 | "date": "Thu, 12 May 2022 15:07:10 GMT", 44 | "comments": { 45 | "dependency": [ 46 | { 47 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.7.2` to `^0.8.0`" 48 | } 49 | ] 50 | } 51 | }, 52 | { 53 | "version": "0.4.0", 54 | "tag": "@subsquid/cli_v0.4.0", 55 | "date": "Thu, 05 May 2022 20:47:14 GMT", 56 | "comments": { 57 | "minor": [ 58 | { 59 | "comment": "codegen: support JSON scalars in typeorm data models" 60 | } 61 | ], 62 | "patch": [ 63 | { 64 | "comment": "codegen: map `Int` scalar to `int4` (instead of `integer`) for cockroach compatibility" 65 | } 66 | ], 67 | "dependency": [ 68 | { 69 | "comment": "Updating dependency \"@subsquid/openreader\" from `^0.6.0` to `^0.7.0`" 70 | }, 71 | { 72 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.7.1` to `^0.7.2`" 73 | } 74 | ] 75 | } 76 | }, 77 | { 78 | "version": "0.3.0", 79 | "tag": "@subsquid/cli_v0.3.0", 80 | "date": "Wed, 20 Apr 2022 22:55:27 GMT", 81 | "comments": { 82 | "minor": [ 83 | { 84 | "comment": "introduce archive deployment commands" 85 | } 86 | ], 87 | "dependency": [ 88 | { 89 | "comment": "Updating dependency \"@subsquid/openreader\" from `^0.5.1` to `^0.6.0`" 90 | }, 91 | { 92 | "comment": "Updating dependency \"@subsquid/typeorm-config\" from `^0.0.4` to `^0.0.5`" 93 | }, 94 | { 95 | "comment": "Updating dependency \"@subsquid/util\" from `^0.0.4` to `^0.0.5`" 96 | }, 97 | { 98 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.7.0` to `^0.7.1`" 99 | } 100 | ] 101 | } 102 | }, 103 | { 104 | "version": "0.2.3", 105 | "tag": "@subsquid/cli_v0.2.3", 106 | "date": "Fri, 08 Apr 2022 10:44:56 GMT", 107 | "comments": { 108 | "patch": [ 109 | { 110 | "comment": "don't use deprecated cli-ux package" 111 | } 112 | ] 113 | } 114 | }, 115 | { 116 | "version": "0.2.2", 117 | "tag": "@subsquid/cli_v0.2.2", 118 | "date": "Fri, 08 Apr 2022 10:15:29 GMT", 119 | "comments": { 120 | "dependency": [ 121 | { 122 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.6.1` to `^0.7.0`" 123 | } 124 | ] 125 | } 126 | }, 127 | { 128 | "version": "0.2.1", 129 | "tag": "@subsquid/cli_v0.2.1", 130 | "date": "Sun, 27 Mar 2022 11:00:39 GMT", 131 | "comments": { 132 | "patch": [ 133 | { 134 | "comment": "fix code generation for self referencing models" 135 | } 136 | ], 137 | "dependency": [ 138 | { 139 | "comment": "Updating dependency \"@subsquid/openreader\" from `^0.5.0` to `^0.5.1`" 140 | } 141 | ] 142 | } 143 | }, 144 | { 145 | "version": "0.2.0", 146 | "tag": "@subsquid/cli_v0.2.0", 147 | "date": "Mon, 14 Mar 2022 18:47:21 GMT", 148 | "comments": { 149 | "minor": [ 150 | { 151 | "comment": "dot config format improvements" 152 | } 153 | ], 154 | "dependency": [ 155 | { 156 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.5.0` to `^0.6.0`" 157 | } 158 | ] 159 | } 160 | }, 161 | { 162 | "version": "0.1.5", 163 | "tag": "@subsquid/cli_v0.1.5", 164 | "date": "Fri, 11 Mar 2022 07:38:31 GMT", 165 | "comments": { 166 | "dependency": [ 167 | { 168 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.4.1` to `^0.5.0`" 169 | } 170 | ] 171 | } 172 | }, 173 | { 174 | "version": "0.1.4", 175 | "tag": "@subsquid/cli_v0.1.4", 176 | "date": "Wed, 02 Mar 2022 18:11:28 GMT", 177 | "comments": { 178 | "patch": [ 179 | { 180 | "comment": "improve cli messages" 181 | } 182 | ], 183 | "dependency": [ 184 | { 185 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.3.0` to `^0.4.0`" 186 | } 187 | ] 188 | } 189 | }, 190 | { 191 | "version": "0.1.3", 192 | "tag": "@subsquid/cli_v0.1.3", 193 | "date": "Wed, 23 Feb 2022 11:18:26 GMT", 194 | "comments": { 195 | "dependency": [ 196 | { 197 | "comment": "Updating dependency \"@subsquid/openreader\" from `^0.4.1` to `^0.5.0`" 198 | } 199 | ] 200 | } 201 | }, 202 | { 203 | "version": "0.1.2", 204 | "tag": "@subsquid/cli_v0.1.2", 205 | "date": "Mon, 07 Feb 2022 15:16:41 GMT", 206 | "comments": { 207 | "dependency": [ 208 | { 209 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.2.6` to `^0.3.0`" 210 | } 211 | ] 212 | } 213 | }, 214 | { 215 | "version": "0.1.1", 216 | "tag": "@subsquid/cli_v0.1.1", 217 | "date": "Wed, 02 Feb 2022 11:01:32 GMT", 218 | "comments": { 219 | "patch": [ 220 | { 221 | "comment": "upgrade dependencies" 222 | } 223 | ], 224 | "dependency": [ 225 | { 226 | "comment": "Updating dependency \"@subsquid/openreader\" from `^0.4.0` to `^0.4.1`" 227 | }, 228 | { 229 | "comment": "Updating dependency \"@subsquid/typeorm-config\" from `^0.0.3` to `^0.0.4`" 230 | }, 231 | { 232 | "comment": "Updating dependency \"@subsquid/util\" from `^0.0.3` to `^0.0.4`" 233 | }, 234 | { 235 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.2.5` to `^0.2.6`" 236 | } 237 | ] 238 | } 239 | }, 240 | { 241 | "version": "0.1.0", 242 | "tag": "@subsquid/cli_v0.1.0", 243 | "date": "Tue, 25 Jan 2022 12:44:12 GMT", 244 | "comments": { 245 | "minor": [ 246 | { 247 | "comment": "codegen: add support for `@index` directives" 248 | } 249 | ], 250 | "dependency": [ 251 | { 252 | "comment": "Updating dependency \"@subsquid/openreader\" from `^0.3.3` to `^0.4.0`" 253 | }, 254 | { 255 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.2.3` to `^0.2.4`" 256 | } 257 | ] 258 | } 259 | }, 260 | { 261 | "version": "0.0.6", 262 | "tag": "@subsquid/cli_v0.0.6", 263 | "date": "Thu, 20 Jan 2022 08:42:53 GMT", 264 | "comments": { 265 | "patch": [ 266 | { 267 | "comment": "include src files into npm package" 268 | } 269 | ], 270 | "dependency": [ 271 | { 272 | "comment": "Updating dependency \"@subsquid/openreader\" from `^0.3.2` to `^0.3.3`" 273 | }, 274 | { 275 | "comment": "Updating dependency \"@subsquid/typeorm-config\" from `^0.0.2` to `^0.0.3`" 276 | }, 277 | { 278 | "comment": "Updating dependency \"@subsquid/util\" from `^0.0.2` to `^0.0.3`" 279 | }, 280 | { 281 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.2.2` to `^0.2.3`" 282 | } 283 | ] 284 | } 285 | }, 286 | { 287 | "version": "0.0.5", 288 | "tag": "@subsquid/cli_v0.0.5", 289 | "date": "Tue, 18 Jan 2022 09:31:27 GMT", 290 | "comments": { 291 | "patch": [ 292 | { 293 | "comment": "change license to GPL3" 294 | } 295 | ], 296 | "dependency": [ 297 | { 298 | "comment": "Updating dependency \"@subsquid/openreader\" from `^0.3.1` to `^0.3.2`" 299 | }, 300 | { 301 | "comment": "Updating dependency \"@subsquid/typeorm-config\" from `^0.0.1` to `^0.0.2`" 302 | }, 303 | { 304 | "comment": "Updating dependency \"@subsquid/util\" from `^0.0.1` to `^0.0.2`" 305 | }, 306 | { 307 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.2.1` to `^0.2.2`" 308 | } 309 | ] 310 | } 311 | }, 312 | { 313 | "version": "0.0.4", 314 | "tag": "@subsquid/cli_v0.0.4", 315 | "date": "Thu, 13 Jan 2022 16:05:36 GMT", 316 | "comments": { 317 | "patch": [ 318 | { 319 | "comment": "codegen: fix model generation for entities with Float fields" 320 | } 321 | ], 322 | "dependency": [ 323 | { 324 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.1.1` to `^0.2.0`" 325 | } 326 | ] 327 | } 328 | }, 329 | { 330 | "version": "0.0.3", 331 | "tag": "@subsquid/cli_v0.0.3", 332 | "date": "Sat, 08 Jan 2022 13:00:12 GMT", 333 | "comments": { 334 | "dependency": [ 335 | { 336 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.0.1` to `^0.1.0`" 337 | } 338 | ] 339 | } 340 | }, 341 | { 342 | "version": "0.0.2", 343 | "tag": "@subsquid/cli_v0.0.2", 344 | "date": "Mon, 03 Jan 2022 16:07:32 GMT", 345 | "comments": { 346 | "patch": [ 347 | { 348 | "comment": "set `publishConfig.access` to `public`" 349 | } 350 | ], 351 | "dependency": [ 352 | { 353 | "comment": "Updating dependency \"@subsquid/openreader\" from `^0.3.0` to `^0.3.1`" 354 | }, 355 | { 356 | "comment": "Updating dependency \"@subsquid/typeorm-config\" from `^0.0.0` to `^0.0.1`" 357 | }, 358 | { 359 | "comment": "Updating dependency \"@subsquid/util\" from `^0.0.0` to `^0.0.1`" 360 | }, 361 | { 362 | "comment": "Updating dependency \"@subsquid/substrate-processor\" from `^0.0.0` to `^0.0.1`" 363 | } 364 | ] 365 | } 366 | }, 367 | { 368 | "version": "0.0.1", 369 | "tag": "@subsquid/cli_v0.0.1", 370 | "date": "Mon, 03 Jan 2022 12:24:26 GMT", 371 | "comments": { 372 | "dependency": [ 373 | { 374 | "comment": "Updating dependency \"@subsquid/openreader\" from `^0.2.1` to `^0.3.0`" 375 | } 376 | ] 377 | } 378 | } 379 | ] 380 | } 381 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log - @subsquid/cli 2 | 3 | This log was last generated on Sat, 13 Aug 2022 13:18:27 GMT and should not be manually modified. 4 | 5 | ## 0.6.0 6 | Sat, 13 Aug 2022 13:18:27 GMT 7 | 8 | ### Minor changes 9 | 10 | - deploy flow improvements 11 | 12 | ## 0.5.1 13 | Tue, 28 Jun 2022 18:37:46 GMT 14 | 15 | ### Patches 16 | 17 | - meaningful error messages and command naming 18 | 19 | ## 0.5.0 20 | Mon, 27 Jun 2022 20:38:17 GMT 21 | 22 | ### Minor changes 23 | 24 | - add `squid:logs` command 25 | 26 | ## 0.4.1 27 | Thu, 12 May 2022 15:07:10 GMT 28 | 29 | _Version update only_ 30 | 31 | ## 0.4.0 32 | Thu, 05 May 2022 20:47:14 GMT 33 | 34 | ### Minor changes 35 | 36 | - codegen: support JSON scalars in typeorm data models 37 | 38 | ### Patches 39 | 40 | - codegen: map `Int` scalar to `int4` (instead of `integer`) for cockroach compatibility 41 | 42 | ## 0.3.0 43 | Wed, 20 Apr 2022 22:55:27 GMT 44 | 45 | ### Minor changes 46 | 47 | - introduce archive deployment commands 48 | 49 | ## 0.2.3 50 | Fri, 08 Apr 2022 10:44:56 GMT 51 | 52 | ### Patches 53 | 54 | - don't use deprecated cli-ux package 55 | 56 | ## 0.2.2 57 | Fri, 08 Apr 2022 10:15:29 GMT 58 | 59 | _Version update only_ 60 | 61 | ## 0.2.1 62 | Sun, 27 Mar 2022 11:00:39 GMT 63 | 64 | ### Patches 65 | 66 | - fix code generation for self referencing models 67 | 68 | ## 0.2.0 69 | Mon, 14 Mar 2022 18:47:21 GMT 70 | 71 | ### Minor changes 72 | 73 | - dot config format improvements 74 | 75 | ## 0.1.5 76 | Fri, 11 Mar 2022 07:38:31 GMT 77 | 78 | _Version update only_ 79 | 80 | ## 0.1.4 81 | Wed, 02 Mar 2022 18:11:28 GMT 82 | 83 | ### Patches 84 | 85 | - improve cli messages 86 | 87 | ## 0.1.3 88 | Wed, 23 Feb 2022 11:18:26 GMT 89 | 90 | _Version update only_ 91 | 92 | ## 0.1.2 93 | Mon, 07 Feb 2022 15:16:41 GMT 94 | 95 | _Version update only_ 96 | 97 | ## 0.1.1 98 | Wed, 02 Feb 2022 11:01:32 GMT 99 | 100 | ### Patches 101 | 102 | - upgrade dependencies 103 | 104 | ## 0.1.0 105 | Tue, 25 Jan 2022 12:44:12 GMT 106 | 107 | ### Minor changes 108 | 109 | - codegen: add support for `@index` directives 110 | 111 | ## 0.0.6 112 | Thu, 20 Jan 2022 08:42:53 GMT 113 | 114 | ### Patches 115 | 116 | - include src files into npm package 117 | 118 | ## 0.0.5 119 | Tue, 18 Jan 2022 09:31:27 GMT 120 | 121 | ### Patches 122 | 123 | - change license to GPL3 124 | 125 | ## 0.0.4 126 | Thu, 13 Jan 2022 16:05:36 GMT 127 | 128 | ### Patches 129 | 130 | - codegen: fix model generation for entities with Float fields 131 | 132 | ## 0.0.3 133 | Sat, 08 Jan 2022 13:00:12 GMT 134 | 135 | _Version update only_ 136 | 137 | ## 0.0.2 138 | Mon, 03 Jan 2022 16:07:32 GMT 139 | 140 | ### Patches 141 | 142 | - set `publishConfig.access` to `public` 143 | 144 | ## 0.0.1 145 | Mon, 03 Jan 2022 12:24:26 GMT 146 | 147 | _Initial release_ 148 | 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @subsquid/cli 2 | 3 | [![npm version](https://badge.fury.io/js/@subsquid%2Fcli.svg)](https://badge.fury.io/js/@subsquid%2Fcli) 4 | 5 | `sqd(1)` tool for [squid project](https://docs.subsquid.io) management. 6 | 7 | ## Installation 8 | 9 | We recommend installing squid CLI globally: 10 | 11 | `npm i -g @subsquid/cli` 12 | 13 | For a full `sqd` command reference, see the [Doc page](https://docs.subsquid.io/squid-cli/) 14 | -------------------------------------------------------------------------------- /bin/dev.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node_modules/.bin/ts-node 2 | // eslint-disable-next-line node/shebang, unicorn/prefer-top-level-await 3 | ;(async () => { 4 | const oclif = await import('@oclif/core') 5 | await oclif.execute({development: true, dir: __dirname}) 6 | })() -------------------------------------------------------------------------------- /bin/pkg-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -rf dist 4 | npx pkg -t ${TARGET_NODE:-node18}-macos-x64,${TARGET_NODE:-node18}-linux-x64,${TARGET_NODE:-node18}-win-x64 \ 5 | --compress GZip \ 6 | -o dist/sqd \ 7 | package.json -------------------------------------------------------------------------------- /bin/pkg-compress.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd dist 4 | mv sqd-macos sqd && tar -czf subsquid-cli-${npm_package_version}-macos-x64.tar.gz sqd && rm sqd 5 | mv sqd-linux sqd && tar -czf subsquid-cli-${npm_package_version}-linux-x64.tar.gz sqd && rm sqd 6 | mv sqd-win.exe sqd.exe -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* -------------------------------------------------------------------------------- /bin/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // eslint-disable-next-line unicorn/prefer-top-level-await 4 | (async () => { 5 | const oclif = await import('@oclif/core') 6 | await oclif.execute({development: false, dir: __dirname}) 7 | })() -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@subsquid/cli", 3 | "description": "squid cli tool", 4 | "version": "3.2.1", 5 | "license": "GPL-3.0-or-later", 6 | "repository": "git@github.com:subsquid/squid-cli.git", 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "bin": { 11 | "sqd": "./bin/run.js" 12 | }, 13 | "files": [ 14 | "bin", 15 | "lib" 16 | ], 17 | "pkg": { 18 | "scripts": "./lib/**/*.js", 19 | "assets": "./node_modules/**/*" 20 | }, 21 | "oclif": { 22 | "commands": { 23 | "strategy": "pattern", 24 | "target": "./lib/commands" 25 | }, 26 | "helpClass": "./lib/help", 27 | "bin": "sqd", 28 | "repositoryPrefix": "<%- repo %>/tree/master/<%- commandPath %>", 29 | "topicSeparator": " ", 30 | "plugins": [ 31 | "@oclif/plugin-autocomplete", 32 | "@oclif/plugin-warn-if-update-available" 33 | ], 34 | "warn-if-update-available": { 35 | "timeoutInDays": 1, 36 | "message": "\n<%= chalk.yellow('=========================================================') %>\n<%= config.name %> update is available from <%= chalk.greenBright(config.version) %> to <%= chalk.greenBright(latest) %>\n\nPlease run <%= chalk.bold(`npm i -g @subsquid/cli`) %>\n<%= chalk.yellow('=========================================================') %>" 37 | }, 38 | "topics": { 39 | "gateways": { 40 | "description": "Explore data sources for a squid" 41 | }, 42 | "secrets": { 43 | "description": "Manage organization secrets" 44 | } 45 | }, 46 | "hooks": { 47 | "command_not_found": "./lib/hooks/command_not_found" 48 | } 49 | }, 50 | "homepage": "https://www.subsquid.io/", 51 | "scripts": { 52 | "build": "rm -rf lib && tsc", 53 | "dev": "./bin/dev.js", 54 | "sqd": "./bin/run.js", 55 | "bl": "node ./lib/blessed.js", 56 | "lint": "eslint --fix src/**/*", 57 | "test:unit": "NODE_ENV=test jest --bail --testRegex=.unit.spec.ts\\$", 58 | "tsc": "tsc --noEmit", 59 | "pkg:build": "./bin/pkg-build.sh", 60 | "pkg:compress": "./bin/pkg-compress.sh", 61 | "upg": "yarn upgrade-interactive", 62 | "codegen": "npx openapi-typescript http://localhost:3001/docs/swagger.json -o ./src/api/schema.d.ts --enum --empty-objects-unknown" 63 | }, 64 | "jest": { 65 | "moduleFileExtensions": [ 66 | "js", 67 | "json", 68 | "ts" 69 | ], 70 | "rootDir": ".", 71 | "transform": { 72 | "^.+\\.(t|j)s$": "ts-jest" 73 | }, 74 | "testEnvironment": "node" 75 | }, 76 | "dependencies": { 77 | "@oclif/core": "3.27.0", 78 | "@oclif/plugin-autocomplete": "3.2.2", 79 | "@oclif/plugin-warn-if-update-available": "^3.1.13", 80 | "@subsquid/commands": "^2.3.1", 81 | "@subsquid/manifest": "^2.0.0-beta.18", 82 | "@subsquid/manifest-expr": "^0.0.1", 83 | "@types/fast-levenshtein": "^0.0.4", 84 | "@types/lodash": "^4.17.7", 85 | "@types/targz": "^1.0.4", 86 | "async-retry": "^1.3.3", 87 | "axios": "^1.7.5", 88 | "axios-retry": "^4.5.0", 89 | "blessed-contrib": "^4.11.0", 90 | "chalk": "^4.1.2", 91 | "cli-diff": "^1.0.0", 92 | "cli-select": "^1.1.2", 93 | "cross-spawn": "^7.0.3", 94 | "date-fns": "^3.6.0", 95 | "dotenv": "^16.4.5", 96 | "fast-levenshtein": "^3.0.0", 97 | "figlet": "^1.7.0", 98 | "form-data": "^4.0.0", 99 | "glob": "^10.4.5", 100 | "ignore": "^5.3.2", 101 | "inquirer": "^8.2.6", 102 | "joi": "^17.13.3", 103 | "js-yaml": "^4.1.0", 104 | "lodash": "^4.17.21", 105 | "ms": "^2.1.3", 106 | "neo-blessed": "^0.2.0", 107 | "open": "^8.1.0", 108 | "pretty-bytes": "^5.6.0", 109 | "qs": "^6.13.0", 110 | "reblessed": "^0.2.1", 111 | "simple-git": "^3.25.0", 112 | "split2": "^4.2.0", 113 | "targz": "^1.0.1", 114 | "tree-kill": "^1.2.2" 115 | }, 116 | "devDependencies": { 117 | "@oclif/dev-cli": "^1.26.10", 118 | "@oclif/help": "^1.0.15", 119 | "@types/async-retry": "^1.4.8", 120 | "@types/blessed": "^0.1.25", 121 | "@types/cross-spawn": "^6.0.6", 122 | "@types/figlet": "^1.5.8", 123 | "@types/inquirer": "^8.2.10", 124 | "@types/jest": "^29.5.12", 125 | "@types/js-yaml": "^4.0.9", 126 | "@types/ms": "^0.7.34", 127 | "@types/node": "^20.16.2", 128 | "@types/qs": "^6.9.15", 129 | "@types/split2": "^3.2.1", 130 | "@typescript-eslint/eslint-plugin": "^7.18.0", 131 | "@typescript-eslint/eslint-plugin-tslint": "^7.0.2", 132 | "@typescript-eslint/parser": "^7.18.0", 133 | "eslint": "8.57.0", 134 | "eslint-config-prettier": "^9.1.0", 135 | "eslint-config-standard": "^17.1.0", 136 | "eslint-plugin-import": "^2.29.1", 137 | "eslint-plugin-node": "^11.1.0", 138 | "eslint-plugin-prettier": "^5.1.3", 139 | "eslint-plugin-promise": "^6.1.1", 140 | "eslint-plugin-standard": "^4.1.0", 141 | "jest": "^29.7.0", 142 | "openapi-typescript": "^7.3.0", 143 | "pkg": "^5.8.1", 144 | "prettier": "^3.3.3", 145 | "ts-jest": "^29.2.5", 146 | "ts-node": "^10.9.2", 147 | "type-fest": "^4.26.0", 148 | "typescript": "~5.5.4" 149 | }, 150 | "packageManager": "yarn@4.1.1" 151 | } 152 | -------------------------------------------------------------------------------- /src/api/api.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import axios, { Method } from 'axios'; 4 | import axiosRetry, { IAxiosRetryConfig, isNetworkOrIdempotentRequestError } from 'axios-retry'; 5 | import chalk from 'chalk'; 6 | import { isEmpty, pickBy } from 'lodash'; 7 | import ms from 'ms'; 8 | import qs from 'qs'; 9 | 10 | import { getConfig } from '../config'; 11 | 12 | const API_DEBUG = process.env.API_DEBUG === 'true'; 13 | const delayFactor = 10; 14 | const DEFAULT_RETRY: IAxiosRetryConfig = { 15 | retries: 10, 16 | retryDelay: (retryCount, error) => axiosRetry.exponentialDelay(retryCount, error, delayFactor), 17 | retryCondition: isNetworkOrIdempotentRequestError, 18 | onRetry: (retryCount, error) => { 19 | if (!error.response) { 20 | if (retryCount === 1) { 21 | console.log(chalk.dim(`There appears to be trouble with your network connection. Retrying...`)); 22 | } else if (retryCount > 6) { 23 | const next = ms(Math.round(axiosRetry.exponentialDelay(retryCount, error, delayFactor))); 24 | console.log(chalk.dim(`There appears to be trouble with your network connection. Retrying in ${next}...`)); 25 | } 26 | } 27 | }, 28 | }; 29 | 30 | axiosRetry(axios, DEFAULT_RETRY); 31 | 32 | let CLI_VERSION = 'unknown'; 33 | try { 34 | // eslint-disable-next-line @typescript-eslint/no-var-requires 35 | CLI_VERSION = require(path.resolve(__dirname, '../../package.json')).version; 36 | } catch (e) {} 37 | 38 | export class ApiError extends Error { 39 | constructor( 40 | public request: { status: number; method: string; url: string }, 41 | public body: { 42 | error: string; 43 | message?: string; 44 | invalidFields?: { path: string[]; message: string; type: string }[]; 45 | }, 46 | ) { 47 | super(); 48 | 49 | if (body?.message) { 50 | this.message = body.message; 51 | } 52 | } 53 | } 54 | 55 | export function debugLog(...args: any[]) { 56 | if (!API_DEBUG) return; 57 | 58 | console.log(chalk.dim(new Date().toISOString()), chalk.cyan`[DEBUG]`, ...args); 59 | } 60 | 61 | export async function api({ 62 | version = 'v1', 63 | method, 64 | path, 65 | data, 66 | query = {}, 67 | headers = {}, 68 | auth, 69 | responseType = 'json', 70 | abortController, 71 | retry, 72 | }: { 73 | version?: 'v1'; 74 | method: Method; 75 | path: string; 76 | query?: Record; 77 | data?: unknown; 78 | headers?: Record; 79 | auth?: { apiUrl: string; credentials: string }; 80 | responseType?: 'json' | 'stream'; 81 | abortController?: AbortController; 82 | retry?: number; 83 | }): Promise<{ body: T }> { 84 | const config = auth || getConfig(); 85 | 86 | const started = Date.now(); 87 | // add the API_URL to the path if it's not a full url 88 | const url = !path.startsWith('https') ? `${config.apiUrl}/${version}${path}` : path; 89 | 90 | const finalHeaders = { 91 | authorization: url.startsWith(config.apiUrl) ? `token ${config.credentials}` : null, 92 | 'X-CLI-Version': CLI_VERSION, 93 | ...headers, 94 | }; 95 | 96 | const response = await axios(url, { 97 | method, 98 | headers: finalHeaders, 99 | data, 100 | timeout: responseType === 'stream' ? 0 : undefined, 101 | responseType, 102 | params: pickBy(query, (v) => v), 103 | signal: abortController ? (abortController.signal as any) : undefined, 104 | validateStatus: () => true, 105 | 'axios-retry': retry 106 | ? { 107 | ...DEFAULT_RETRY, 108 | retries: retry, 109 | } 110 | : undefined, 111 | }); 112 | 113 | if (API_DEBUG) { 114 | console.log( 115 | chalk.dim(new Date().toISOString()), 116 | chalk.cyan`[${method.toUpperCase()}]`, 117 | `${response.config.url}${!isEmpty(query) ? `?${qs.stringify(query)}` : ``}`, 118 | chalk.cyan(response.status), 119 | ms(Date.now() - started), 120 | chalk.dim(JSON.stringify({ headers: response.headers })), 121 | ); 122 | if (response.data && responseType === 'json') { 123 | console.log(chalk.dim(JSON.stringify(response.data))); 124 | } 125 | } 126 | 127 | switch (response.status) { 128 | case 200: 129 | case 201: 130 | case 204: 131 | return { body: response.data }; 132 | default: 133 | throw new ApiError( 134 | { 135 | method: method.toUpperCase(), 136 | url: response.config.url || 'Unknown URL', 137 | status: response.status, 138 | }, 139 | response.data, 140 | ); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/api/deploy.ts: -------------------------------------------------------------------------------- 1 | import { api } from './api'; 2 | import { Deployment, DeployRequest, HttpResponse, OrganizationRequest } from './types'; 3 | 4 | export async function getDeploy({ organization, deploy }: DeployRequest): Promise { 5 | const { body } = await api>({ 6 | method: 'get', 7 | path: `/orgs/${organization.code}/deployments/${deploy.id}`, 8 | }); 9 | 10 | return body.payload; 11 | } 12 | 13 | export async function getDeploys({ organization }: OrganizationRequest): Promise { 14 | const { body } = await api>({ 15 | method: 'get', 16 | path: `/orgs/${organization.code}/deployments`, 17 | }); 18 | 19 | return body.payload; 20 | } 21 | -------------------------------------------------------------------------------- /src/api/gateways.ts: -------------------------------------------------------------------------------- 1 | import { api } from './api'; 2 | 3 | export type Provider = { 4 | provider: string; 5 | dataSourceUrl: string; 6 | release: string; 7 | }; 8 | 9 | export type Gateway = { 10 | network: string; 11 | chainName: string; 12 | chainId?: number; 13 | chainSS58Prefix?: number; 14 | providers: Provider[]; 15 | }; 16 | 17 | export type GatewaysResponse = { 18 | archives: Gateway[]; 19 | }; 20 | 21 | export async function getSubstrateGateways() { 22 | const { body } = await api({ 23 | method: 'get', 24 | path: 'https://cdn.subsquid.io/archives/substrate.json', 25 | }); 26 | 27 | return body.archives; 28 | } 29 | 30 | export async function getEvmGateways() { 31 | const { body } = await api({ 32 | method: 'get', 33 | path: 'https://cdn.subsquid.io/archives/evm.json', 34 | }); 35 | 36 | return body.archives; 37 | } 38 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api'; 2 | export * from './squids'; 3 | export * from './deploy'; 4 | export * from './secrets'; 5 | export * from './types'; 6 | export * from './upload'; 7 | export * from './profile'; 8 | -------------------------------------------------------------------------------- /src/api/profile.ts: -------------------------------------------------------------------------------- 1 | import { api, ApiError } from './api'; 2 | import { HttpResponse, Organization, OrganizationRequest, Squid } from './types'; 3 | 4 | export type Profile = { 5 | username?: string; 6 | email: string; 7 | organizations?: { 8 | code: string; 9 | name: string; 10 | }[]; 11 | }; 12 | 13 | export async function profile({ 14 | auth, 15 | }: { 16 | auth?: { apiUrl: string; credentials: string }; 17 | } = {}): Promise { 18 | const { body } = await api>({ 19 | method: 'get', 20 | auth, 21 | path: `/user`, 22 | }); 23 | 24 | if (!body.payload) { 25 | throw new ApiError( 26 | { 27 | status: 401, 28 | method: 'get', 29 | url: '/user', 30 | }, 31 | { error: 'Credentials are missing or invalid' }, 32 | ); 33 | } 34 | 35 | return body.payload; 36 | } 37 | 38 | export async function listOrganizations() { 39 | const { body } = await api>({ 40 | method: 'get', 41 | path: `/orgs`, 42 | }); 43 | 44 | return body.payload; 45 | } 46 | 47 | export async function getOrganization({ organization }: OrganizationRequest) { 48 | const { body } = await api>({ 49 | method: 'get', 50 | path: `/orgs/${organization.code}`, 51 | }); 52 | 53 | return body.payload; 54 | } 55 | 56 | export async function listUserSquids({ name }: { name?: string }) { 57 | const { body } = await api>({ 58 | method: 'get', 59 | path: `/user/squids`, 60 | query: { 61 | name: name, 62 | }, 63 | }); 64 | 65 | return body.payload; 66 | } 67 | -------------------------------------------------------------------------------- /src/api/secrets.ts: -------------------------------------------------------------------------------- 1 | import { api } from './api'; 2 | import { HttpResponse, OrganizationRequest, SecretsListResponse } from './types'; 3 | 4 | export async function listSecrets({ organization }: OrganizationRequest): Promise { 5 | const { body } = await api>({ 6 | method: 'get', 7 | path: `/orgs/${organization.code}/secrets`, 8 | }); 9 | return body.payload; 10 | } 11 | 12 | export async function removeSecret({ 13 | organization, 14 | name, 15 | }: OrganizationRequest & { name: string }): Promise { 16 | const { body } = await api>({ 17 | method: 'put', 18 | path: `/orgs/${organization.code}/secrets`, 19 | data: { 20 | secrets: [{ action: 'DELETE', name }], 21 | }, 22 | }); 23 | 24 | return body.payload; 25 | } 26 | 27 | export async function setSecret({ 28 | organization, 29 | name, 30 | value, 31 | }: OrganizationRequest & { 32 | name: string; 33 | value: string; 34 | }): Promise { 35 | const { body } = await api>({ 36 | method: 'put', 37 | path: `/orgs/${organization.code}/secrets`, 38 | data: { 39 | secrets: [{ action: 'UPDATE', name, value }], 40 | }, 41 | }); 42 | 43 | return body.payload; 44 | } 45 | -------------------------------------------------------------------------------- /src/api/squids.ts: -------------------------------------------------------------------------------- 1 | import split2 from 'split2'; 2 | 3 | import { pretty } from '../logs'; 4 | import { formatSquidReference } from '../utils'; 5 | 6 | import { api, debugLog } from './api'; 7 | import { 8 | Deployment, 9 | HttpResponse, 10 | LogEntry, 11 | LogsResponse, 12 | OrganizationRequest, 13 | Squid, 14 | SquidRequest, 15 | UploadUrl, 16 | } from './types'; 17 | 18 | export async function listSquids({ organization, name }: OrganizationRequest & { name?: string }): Promise { 19 | const { body } = await api>({ 20 | method: 'get', 21 | path: `/orgs/${organization.code}/squids`, 22 | query: { name }, 23 | }); 24 | 25 | return body.payload.sort((a, b) => a.name.localeCompare(b.name)); 26 | } 27 | 28 | export async function getSquid({ organization, squid }: SquidRequest): Promise { 29 | const { body } = await api>({ 30 | method: 'get', 31 | path: `/orgs/${organization.code}/squids/${formatSquidReference(squid)}`, 32 | }); 33 | 34 | return body.payload; 35 | } 36 | 37 | export async function squidHistoryLogs({ 38 | organization, 39 | squid, 40 | query, 41 | abortController, 42 | }: SquidRequest & { 43 | query: { 44 | limit: number; 45 | from: Date; 46 | nextPage?: string; 47 | orderBy?: string; 48 | container?: string[]; 49 | level?: string[]; 50 | search?: string; 51 | }; 52 | abortController?: AbortController; 53 | }): Promise { 54 | const { body } = await api>({ 55 | method: 'get', 56 | path: `/orgs/${organization.code}/squids/${formatSquidReference(squid)}/logs/history`, 57 | query: { 58 | ...query, 59 | from: query.from.toISOString(), 60 | level: query.level?.map((l) => l.toUpperCase()), 61 | }, 62 | abortController, 63 | }); 64 | 65 | const payload = body?.payload; 66 | 67 | return { logs: payload?.logs ?? [], nextPage: payload?.nextPage ?? null }; 68 | } 69 | 70 | export async function squidLogsFollow({ 71 | organization, 72 | squid, 73 | query, 74 | abortController, 75 | }: SquidRequest & { 76 | query: { container?: string[]; level?: string[]; search?: string }; 77 | abortController?: AbortController; 78 | }) { 79 | const { body } = await api({ 80 | method: 'get', 81 | path: `/orgs/${organization.code}/squids/${formatSquidReference(squid)}/logs/follow`, 82 | query, 83 | responseType: 'stream', 84 | abortController: abortController, 85 | }); 86 | 87 | return body; 88 | } 89 | 90 | export async function streamSquidLogs({ 91 | organization, 92 | squid, 93 | abortController, 94 | query = {}, 95 | onLog, 96 | }: SquidRequest & { 97 | onLog: (log: string) => unknown; 98 | query?: { container?: string[]; level?: string[]; search?: string }; 99 | abortController?: AbortController; 100 | }) { 101 | let attempt = 0; 102 | let stream: NodeJS.ReadableStream; 103 | 104 | while (true) { 105 | debugLog(`streaming logs`); 106 | const retry = await new Promise(async (resolve) => { 107 | try { 108 | stream = await squidLogsFollow({ 109 | organization, 110 | squid, 111 | query, 112 | abortController, 113 | }); 114 | } catch (e: any) { 115 | /** 116 | * 524 status means timeout 117 | */ 118 | if (e.status === 524) { 119 | debugLog(`streaming logs timeout occurred`); 120 | return resolve(true); 121 | } 122 | 123 | debugLog(`streaming logs error thrown: ${e.status} ${e.message}`); 124 | 125 | return resolve(false); 126 | } 127 | 128 | stream 129 | .pipe(split2()) 130 | .on('error', async (e: any) => { 131 | debugLog(`streaming logs error received: ${e.message}`); 132 | 133 | resolve(true); 134 | }) 135 | .on('data', (line: string) => { 136 | if (line.length === 0) return; 137 | 138 | try { 139 | const entries: LogEntry[] = JSON.parse(line); 140 | pretty(entries).forEach((l) => { 141 | onLog(l); 142 | }); 143 | } catch (e) { 144 | onLog(line); 145 | } 146 | }) 147 | .on('close', async () => { 148 | debugLog(`streaming logs stream closed`); 149 | 150 | resolve(true); 151 | }) 152 | .on('end', async () => { 153 | debugLog(`streaming logs stream ended`); 154 | 155 | resolve(true); 156 | }); 157 | }); 158 | 159 | if (!retry) { 160 | debugLog(`streaming logs exited`); 161 | return; 162 | } 163 | 164 | attempt++; 165 | debugLog(`streaming logs retrying, ${attempt} attempt...`); 166 | } 167 | } 168 | 169 | export async function deploySquid({ 170 | organization, 171 | data, 172 | }: OrganizationRequest & { 173 | data: { 174 | artifactUrl: string; 175 | manifestPath: string; 176 | options: { 177 | overrideSlot?: string; 178 | overrideName?: string; 179 | tag?: string; 180 | hardReset?: boolean; 181 | }; 182 | }; 183 | }): Promise { 184 | const { body } = await api>({ 185 | method: 'post', 186 | path: `/orgs/${organization.code}/squids/deploy`, 187 | data, 188 | }); 189 | 190 | return body.payload; 191 | } 192 | 193 | export async function getUploadUrl({ organization }: OrganizationRequest): Promise { 194 | const { body } = await api>({ 195 | method: 'post', 196 | path: `/orgs/${organization.code}/deployments/upload-url`, 197 | }); 198 | 199 | return body.payload; 200 | } 201 | 202 | export async function restartSquid({ organization, squid }: SquidRequest): Promise { 203 | const { body } = await api>({ 204 | method: 'post', 205 | path: `/orgs/${organization.code}/squids/${formatSquidReference(squid)}/restart`, 206 | }); 207 | 208 | return body.payload; 209 | } 210 | 211 | export async function deleteSquid({ organization, squid }: SquidRequest): Promise { 212 | const { body } = await api>({ 213 | method: 'delete', 214 | path: `/orgs/${organization.code}/squids/${formatSquidReference(squid)}`, 215 | }); 216 | 217 | return body.payload; 218 | } 219 | 220 | export async function addSquidTag({ organization, squid, tag }: SquidRequest & { tag: string }): Promise { 221 | const { body } = await api>({ 222 | method: 'PUT', 223 | path: `/orgs/${organization.code}/squids/${formatSquidReference(squid)}/tags/${tag}`, 224 | }); 225 | 226 | return body.payload; 227 | } 228 | 229 | export async function removeSquidTag({ 230 | organization, 231 | squid, 232 | tag, 233 | }: SquidRequest & { tag: string }): Promise { 234 | const { body } = await api>({ 235 | method: 'DELETE', 236 | path: `/orgs/${organization.code}/squids/${formatSquidReference(squid)}/tags/${tag}`, 237 | }); 238 | 239 | return body.payload; 240 | } 241 | -------------------------------------------------------------------------------- /src/api/types.ts: -------------------------------------------------------------------------------- 1 | import { PickDeep } from 'type-fest'; 2 | 3 | import { components } from './schema'; 4 | 5 | export type HttpResponse = { 6 | payload: T; 7 | }; 8 | 9 | export type Organization = components['schemas']['OrganizationResponse']; 10 | 11 | export type Deployment = components['schemas']['DeploymentResponse']; 12 | 13 | export type UploadUrl = components['schemas']['UploadUrlResponse']; 14 | 15 | export type SquidProcessor = components['schemas']['SquidProcessorResponse']; 16 | 17 | export type SquidApi = components['schemas']['SquidApiResponse']; 18 | 19 | export type SquidAddonsNeon = components['schemas']['SquidAddonsNeonResponse']; 20 | 21 | export type SquidAddonsPostgres = components['schemas']['SquidAddonsPostgresResponse']; 22 | 23 | export type Squid = components['schemas']['SquidResponse']; 24 | 25 | export type SecretsListResponse = { 26 | secrets: Record; 27 | }; 28 | 29 | export enum LogLevel { 30 | Error = 'ERROR', 31 | Debug = 'DEBUG', 32 | Info = 'INFO', 33 | Notice = 'NOTICE', 34 | Warning = 'WARNING', 35 | Critical = 'CRITICAL', 36 | Fatal = 'FATAL', 37 | } 38 | export type LogPayload = string | Record; 39 | 40 | export type LogEntry = { 41 | timestamp: string; 42 | container: string; 43 | level: LogLevel; 44 | payload: LogPayload; 45 | }; 46 | 47 | export type LogsResponse = { 48 | logs: LogEntry[]; 49 | nextPage: string | null; 50 | }; 51 | 52 | export type OrganizationRequest = { organization: PickDeep }; 53 | 54 | export type SquidRequest = OrganizationRequest & { 55 | squid: ({ name: string } & { tag?: string; slot?: string }) | string; 56 | }; 57 | 58 | export type DeployRequest = OrganizationRequest & { deploy: PickDeep }; 59 | -------------------------------------------------------------------------------- /src/api/upload.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import FormData from 'form-data'; 4 | 5 | import { api } from './api'; 6 | import { getUploadUrl } from './squids'; 7 | import { OrganizationRequest } from './types'; 8 | 9 | export async function uploadFile({ organization, path }: OrganizationRequest & { path: string }): Promise<{ 10 | error: string | null; 11 | fileUrl?: string; 12 | }> { 13 | const { uploadFields, uploadUrl, maxUploadBytes, fileUrl } = await getUploadUrl({ organization }); 14 | 15 | const fileStream = fs.createReadStream(path); 16 | const { size } = fs.statSync(path); 17 | 18 | if (size > maxUploadBytes) { 19 | return { 20 | error: `The squid archive size is too large (${size} bytes), exceeding the limit of ${Math.round( 21 | maxUploadBytes / 1_000_000, 22 | ).toFixed(1)}M.`, 23 | }; 24 | } 25 | 26 | const body = new FormData(); 27 | Object.entries(uploadFields).forEach(([k, v]) => { 28 | body.append(k, v); 29 | }); 30 | 31 | body.append('file', fileStream, { knownLength: size }); 32 | 33 | await api({ 34 | path: uploadUrl, 35 | method: 'post', 36 | headers: { 37 | ...body.getHeaders(), 38 | }, 39 | data: body, 40 | }); 41 | 42 | return { error: null, fileUrl }; 43 | } 44 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from '@oclif/core'; 2 | import { FailedFlagValidationError } from '@oclif/core/lib/parser/errors'; 3 | import chalk from 'chalk'; 4 | import inquirer from 'inquirer'; 5 | import { isNil, uniqBy } from 'lodash'; 6 | 7 | import { ApiError, getOrganization, getSquid, listOrganizations, listUserSquids, SquidRequest } from './api'; 8 | import { getTTY } from './tty'; 9 | import { formatSquidReference, printSquid } from './utils'; 10 | 11 | export const SUCCESS_CHECK_MARK = chalk.green('✓'); 12 | 13 | export abstract class CliCommand extends Command { 14 | static baseFlags = { 15 | interactive: Flags.boolean({ 16 | description: 'Disable interactive mode', 17 | required: false, 18 | default: true, 19 | allowNo: true, 20 | }), 21 | }; 22 | 23 | logSuccess(message: string) { 24 | this.log(SUCCESS_CHECK_MARK + message); 25 | } 26 | 27 | logQuestion(message: string) { 28 | this.log(chalk.green(`? `) + message); 29 | } 30 | 31 | logDimmed(message: string) { 32 | this.log(chalk.dim(message)); 33 | } 34 | 35 | // Haven't find a way to do it with native settings 36 | validateSquidNameFlags(flags: { reference?: any; name?: any }) { 37 | if (flags.reference || flags.name) return; 38 | 39 | throw new FailedFlagValidationError({ 40 | failed: [ 41 | { 42 | name: 'squid name', 43 | validationFn: 'validateSquidName', 44 | reason: 'One of the following must be provided: --reference, --name', 45 | status: 'failed', 46 | }, 47 | ], 48 | parse: {}, 49 | }); 50 | } 51 | 52 | async catch(error: any) { 53 | if (error instanceof ApiError) { 54 | const { request, body } = error; 55 | 56 | switch (request.status) { 57 | case 401: 58 | return this.error( 59 | `Authentication failure. Please obtain a new deployment key at https://app.subsquid.io and follow the instructions`, 60 | ); 61 | case 400: 62 | if (body?.invalidFields) { 63 | const messages = body.invalidFields.map(function (obj: any, index: number) { 64 | return `${index + 1}) ${chalk.bold('"' + obj.path.join('.') + '"')} — ${obj.message}`; 65 | }); 66 | return this.error(`Validation error:\n${messages.join('\n')}`); 67 | } 68 | return this.error(body?.error || body?.message || `Validation error ${body}`); 69 | case 404: 70 | const defaultErrorStart = `cannot ${request.method.toLowerCase()}`; 71 | 72 | if ( 73 | body.error.toLowerCase().startsWith(defaultErrorStart) || 74 | body.message?.toLowerCase().startsWith(defaultErrorStart) 75 | ) { 76 | const url = `${chalk.bold(request.method)} ${chalk.bold(request.url)}`; 77 | 78 | return this.error( 79 | `Unknown API endpoint ${url}. Check that your are using the latest version of the Squid CLI. If the problem persists, please contact support.`, 80 | ); 81 | } else { 82 | return this.error(body.error); 83 | } 84 | case 405: 85 | return this.error(body?.error || body?.message || 'Method not allowed'); 86 | case 502: 87 | case 503: 88 | case 504: 89 | return this.error('The API is currently unavailable. Please try again later'); 90 | default: 91 | return this.error( 92 | [ 93 | `Unknown network error occurred`, 94 | `==================`, 95 | `Status: ${request.status}`, 96 | `Body:\n${JSON.stringify(body)}`, 97 | ].join('\n'), 98 | ); 99 | } 100 | } 101 | 102 | throw error; 103 | } 104 | 105 | async findSquid(req: SquidRequest) { 106 | try { 107 | return await getSquid(req); 108 | } catch (e) { 109 | if (e instanceof ApiError && e.request.status === 404) { 110 | return null; 111 | } 112 | 113 | throw e; 114 | } 115 | } 116 | 117 | async findOrThrowSquid({ organization, squid }: SquidRequest) { 118 | const res = await this.findSquid({ organization, squid }); 119 | if (!res) { 120 | throw new Error( 121 | `The squid ${formatSquidReference(typeof squid === 'string' ? squid : squid, { colored: true })} is not found`, 122 | ); 123 | } 124 | 125 | return res; 126 | } 127 | 128 | async promptOrganization( 129 | code: string | null | undefined, 130 | { using, interactive }: { using?: string; interactive?: boolean } = {}, 131 | ) { 132 | if (code) { 133 | return await getOrganization({ organization: { code } }); 134 | } 135 | 136 | const organizations = await listOrganizations(); 137 | 138 | return await this.getOrganizationPrompt(organizations, { using, interactive }); 139 | } 140 | 141 | async promptSquidOrganization( 142 | code: string | null | undefined, 143 | name: string, 144 | { 145 | using, 146 | interactive, 147 | }: { 148 | using?: string; 149 | interactive?: boolean; 150 | } = {}, 151 | ) { 152 | if (code) { 153 | return await getOrganization({ organization: { code } }); 154 | } 155 | 156 | const squids = await listUserSquids({ name }); 157 | 158 | let organizations = squids.map((s) => s.organization).filter((o) => !isNil(o)); 159 | organizations = uniqBy(organizations, (o) => o.code); 160 | 161 | if (organizations.length === 0) { 162 | return this.error(`You have no organizations with squid "${name}".`); 163 | } 164 | 165 | return await this.getOrganizationPrompt(organizations, { using, interactive }); 166 | } 167 | 168 | private async getOrganizationPrompt( 169 | organizations: T[], 170 | { 171 | using = 'using "--org" flag', 172 | interactive, 173 | }: { 174 | using?: string; 175 | interactive?: boolean; 176 | }, 177 | ): Promise { 178 | if (organizations.length === 0) { 179 | return this.error(`You have no organizations. Please create organization first.`); 180 | } else if (organizations.length === 1) { 181 | return organizations[0]; 182 | } 183 | 184 | const { stdin, stdout } = getTTY(); 185 | if (!stdin || !stdout || !interactive) { 186 | return this.error( 187 | [ 188 | `You have ${organizations.length} organizations:`, 189 | ...organizations.map((o) => `${chalk.dim(' - ')}${chalk.dim(o.code)}`), 190 | `Please specify one of them explicitly ${using}`, 191 | ].join('\n'), 192 | ); 193 | } 194 | 195 | const prompt = inquirer.createPromptModule({ input: stdin, output: stdout }); 196 | const { organization } = await prompt([ 197 | { 198 | name: 'organization', 199 | type: 'list', 200 | message: `Please choose an organization:`, 201 | choices: organizations.map((o) => { 202 | return { 203 | name: o.name ? `${o.name} (${o.code})` : o.code, 204 | value: o, 205 | }; 206 | }), 207 | }, 208 | ]); 209 | 210 | // Hack to prevent opened descriptors to block event loop before exit 211 | stdin.destroy(); 212 | stdout.destroy(); 213 | 214 | return organization; 215 | } 216 | } 217 | 218 | export * as SqdFlags from './flags'; 219 | -------------------------------------------------------------------------------- /src/commands/auth.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | 3 | import { profile } from '../api/profile'; 4 | import { CliCommand } from '../command'; 5 | import { DEFAULT_API_URL, setConfig } from '../config'; 6 | 7 | export default class Auth extends CliCommand { 8 | static description = `Log in to the Cloud`; 9 | 10 | static flags = { 11 | key: Flags.string({ 12 | char: 'k', 13 | description: 'Cloud auth key. Log in to https://app.subsquid.io to create or update your key.', 14 | required: true, 15 | }), 16 | host: Flags.string({ 17 | char: 'h', 18 | hidden: true, 19 | default: DEFAULT_API_URL, 20 | required: false, 21 | }), 22 | }; 23 | 24 | async run(): Promise { 25 | const { 26 | flags: { key, host }, 27 | } = await this.parse(Auth); 28 | 29 | const { username, email } = await profile({ 30 | auth: { 31 | apiUrl: host, 32 | credentials: key, 33 | }, 34 | }); 35 | 36 | setConfig(key, host); 37 | 38 | this.log(`Successfully logged as ${email || username}`); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/deploy.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { promisify } from 'util'; 4 | 5 | import { Args, Flags, ux as CliUx } from '@oclif/core'; 6 | import { Manifest } from '@subsquid/manifest'; 7 | import chalk from 'chalk'; 8 | import diff from 'cli-diff'; 9 | import { globSync } from 'glob'; 10 | import ignore from 'ignore'; 11 | import inquirer from 'inquirer'; 12 | import { entries, get, pick } from 'lodash'; 13 | import prettyBytes from 'pretty-bytes'; 14 | import targz from 'targz'; 15 | 16 | import { deploySquid, OrganizationRequest, Squid, uploadFile } from '../api'; 17 | import { SqdFlags, SUCCESS_CHECK_MARK } from '../command'; 18 | import { DeployCommand } from '../deploy-command'; 19 | import { loadManifestFile } from '../manifest'; 20 | import { formatSquidReference, ParsedSquidReference, printSquid } from '../utils'; 21 | 22 | const compressAsync = promisify(targz.compress); 23 | 24 | const SQUID_WORKDIR_DESC = [ 25 | `Squid working directory. Could be:`, 26 | ` - a relative or absolute path to a local folder (e.g. ".")`, 27 | ` - a URL to a .tar.gz archive`, 28 | ` - a github URL to a git repo with a branch or commit tag`, 29 | ]; 30 | 31 | export const UPDATE_COLOR = 'cyan'; 32 | export const CREATE_COLOR = 'green'; 33 | export const DELETE_COLOR = 'red'; 34 | 35 | export function resolveManifest( 36 | localPath: string, 37 | manifestPath: string, 38 | ): { error: string } | { buildDir: string; squidDir: string; manifest: Manifest; manifestRaw: string } { 39 | try { 40 | const { squidDir, manifest, manifestRaw } = loadManifestFile(localPath, manifestPath); 41 | 42 | const buildDir = path.join(squidDir, 'builds'); 43 | fs.mkdirSync(buildDir, { recursive: true, mode: 0o777 }); 44 | 45 | return { 46 | squidDir, 47 | buildDir, 48 | manifest, 49 | manifestRaw, 50 | }; 51 | } catch (e: any) { 52 | return { error: e.message }; 53 | } 54 | } 55 | 56 | function example(command: string, description: string) { 57 | // return [chalk.dim(`// ${description}`), command].join('\r\n'); 58 | return `${command} ${chalk.dim(`// ${description}`)}`; 59 | } 60 | 61 | export default class Deploy extends DeployCommand { 62 | static description = 'Deploy new or update an existing squid in the Cloud'; 63 | static examples = [ 64 | example('sqd deploy .', 'Create a new squid with name provided in the manifest file'), 65 | example( 66 | 'sqd deploy . -n my-squid-override', 67 | 'Create a new squid deployment and override it\'s name to "my-squid-override"', 68 | ), 69 | example('sqd deploy . -n my-squid -s asmzf5', 'Update the "my-squid" squid with slot "asmzf5"'), 70 | example( 71 | 'sqd deploy ./path-to-the-squid -m squid.prod.yaml', 72 | 'Use a manifest file located in ./path-to-the-squid/squid.prod.yaml', 73 | ), 74 | example( 75 | 'sqd deploy /Users/dev/path-to-the-squid -m /Users/dev/path-to-the-squid/squid.prod.yaml', 76 | 'Full paths are also fine', 77 | ), 78 | ]; 79 | 80 | static help = 'If squid flags are not specified, the they will be retrieved from the manifest or prompted.'; 81 | 82 | static args = { 83 | source: Args.directory({ 84 | description: [ 85 | `Squid source. Could be:`, 86 | ` - a relative or absolute path to a local folder (e.g. ".")`, 87 | ` - a URL to a .tar.gz archive`, 88 | ` - a github URL to a git repo with a branch or commit tag`, 89 | ].join('\n'), 90 | required: true, 91 | default: '.', 92 | }), 93 | }; 94 | 95 | static flags = { 96 | org: SqdFlags.org({ 97 | required: false, 98 | }), 99 | name: SqdFlags.name({ 100 | required: false, 101 | relationships: [], 102 | }), 103 | tag: SqdFlags.tag({ 104 | required: false, 105 | dependsOn: [], 106 | }), 107 | slot: SqdFlags.slot({ 108 | required: false, 109 | dependsOn: [], 110 | }), 111 | reference: SqdFlags.reference({ 112 | required: false, 113 | }), 114 | manifest: Flags.file({ 115 | char: 'm', 116 | description: 'Specify the relative local path to a squid manifest file in the squid working directory', 117 | required: false, 118 | default: 'squid.yaml', 119 | helpValue: '', 120 | }), 121 | 'hard-reset': Flags.boolean({ 122 | description: 123 | 'Perform a hard reset before deploying. This will drop and re-create all squid resources, including the database, causing a short API downtime', 124 | required: false, 125 | default: false, 126 | }), 127 | 'stream-logs': Flags.boolean({ 128 | description: 'Attach and stream squid logs after the deployment', 129 | required: false, 130 | default: true, 131 | allowNo: true, 132 | }), 133 | 'add-tag': Flags.string({ 134 | description: 'Add a tag to the deployed squid', 135 | required: false, 136 | }), 137 | 'allow-update': Flags.boolean({ 138 | description: 'Allow updating an existing squid', 139 | required: false, 140 | default: false, 141 | }), 142 | 'allow-tag-reassign': Flags.boolean({ 143 | description: 'Allow reassigning an existing tag', 144 | required: false, 145 | default: false, 146 | }), 147 | 'allow-manifest-override': Flags.boolean({ 148 | description: 'Allow overriding the manifest during deployment', 149 | required: false, 150 | default: false, 151 | }), 152 | }; 153 | 154 | async run(): Promise { 155 | const { 156 | args: { source }, 157 | flags: { 158 | interactive, 159 | manifest: manifestPath, 160 | 'hard-reset': hardReset, 161 | 'stream-logs': streamLogs, 162 | 'add-tag': addTag, 163 | reference, 164 | ...flags 165 | }, 166 | } = await this.parse(Deploy); 167 | 168 | const isUrl = source.startsWith('http://') || source.startsWith('https://'); 169 | if (isUrl) { 170 | this.log(`🦑 Releasing the squid from remote`); 171 | return this.error('Not implemented yet'); 172 | } 173 | 174 | if (interactive && hardReset) { 175 | const { confirm } = await inquirer.prompt([ 176 | { 177 | name: 'confirm', 178 | type: 'confirm', 179 | message: `Are you sure?`, 180 | prefix: `Your squid will be reset, which may potentially result in data loss.`, 181 | }, 182 | ]); 183 | if (!confirm) return; 184 | } 185 | 186 | this.log(`🦑 Releasing the squid from local folder`); 187 | 188 | const res = resolveManifest(source, manifestPath); 189 | if ('error' in res) return this.showError(res.error, 'MANIFEST_VALIDATION_FAILED'); 190 | 191 | const { buildDir, squidDir } = res; 192 | 193 | const overrides = reference || (pick(flags, 'slot', 'name', 'tag', 'org') as Partial); 194 | 195 | let manifest = res.manifest; 196 | // FIXME: it is not possible to override org atm 197 | if (entries(overrides).some(([k, v]) => k !== 'org' && get(manifest, k) !== v)) { 198 | // we need to do it to keep formatting the same 199 | const manifestRaw = Manifest.replace(res.manifestRaw, {}); 200 | const newManifestRaw = Manifest.replace(manifestRaw, overrides); 201 | 202 | if (!flags['allow-manifest-override']) { 203 | const confirm = await this.promptOverrideConflict(manifestRaw, newManifestRaw, { interactive }); 204 | if (!confirm) return; 205 | } 206 | 207 | const newRes = Manifest.parse(newManifestRaw); 208 | if (newRes.error) return this.showError(newRes.error.message, 'MANIFEST_VALIDATION_FAILED'); 209 | 210 | manifest = newRes.value; 211 | } 212 | 213 | const organization = await this.promptOrganization(overrides.org, { interactive }); 214 | 215 | const name = await this.promptSquidName(manifest.squidName(), { interactive }); 216 | const slot = manifest.slotName(); 217 | const tag = manifest.tag; 218 | 219 | this.log(chalk.dim(`Squid directory: ${squidDir}`)); 220 | this.log(chalk.dim(`Build directory: ${buildDir}`)); 221 | this.log(chalk.dim(`Manifest: ${manifestPath}`)); 222 | this.log(chalk.cyan(`-----------------------------`)); 223 | this.log(chalk.cyan(`Organization: ${organization.code}`)); 224 | this.log(chalk.cyan(`Squid name: ${name}`)); 225 | if (slot) { 226 | this.log(chalk.cyan(`Squid slot: ${slot}`)); 227 | } else if (tag) { 228 | this.log(chalk.cyan(`Squid tag: ${tag}`)); 229 | } 230 | this.log(chalk.cyan(`-----------------------------`)); 231 | 232 | let target: Squid | null = null; 233 | if (slot) { 234 | target = await this.findSquid({ 235 | organization, 236 | squid: { name, slot }, 237 | }); 238 | } else if (tag) { 239 | target = await this.findOrThrowSquid({ 240 | organization, 241 | squid: { name, tag }, 242 | }); 243 | } 244 | 245 | /** 246 | * Squid exists we should check running deploys 247 | */ 248 | if (target) { 249 | const attached = await this.promptAttachToDeploy(target, { interactive }); 250 | if (attached) return; 251 | } 252 | 253 | /** 254 | * Squid exists we should ask for update 255 | */ 256 | if (target && !flags['allow-update']) { 257 | const update = await this.promptUpdateSquid(target, { interactive, tag }); 258 | if (!update) return; 259 | } 260 | 261 | /** 262 | * Squid exists we should check if tag belongs to another squid 263 | */ 264 | const hasTag = !!target?.tags.find((t) => t.name === addTag) || tag === addTag; 265 | if (addTag && !flags['allow-tag-reassign'] && !hasTag) { 266 | const add = await this.promptAddTag({ organization, name, tag: addTag }, { interactive }); 267 | if (!add) return; 268 | } 269 | 270 | const archiveName = `${slot || tag ? formatSquidReference({ name, slot, tag }) : name}.tar.gz`; 271 | const artifactPath = await this.pack({ buildDir, squidDir, archiveName }); 272 | const artifactUrl = await this.upload({ organization, artifactPath }); 273 | 274 | const deploy = await deploySquid({ 275 | organization, 276 | data: { 277 | artifactUrl, 278 | manifestPath, 279 | options: { 280 | hardReset, 281 | overrideName: name, 282 | overrideSlot: target?.slot || slot, 283 | tag: addTag, 284 | }, 285 | }, 286 | }); 287 | 288 | const deployment = await this.pollDeploy({ organization, deploy }); 289 | if (!deployment || !deployment.squid) return; 290 | 291 | if (target) { 292 | this.logDeployResult(UPDATE_COLOR, `The squid ${printSquid(target)} has been successfully updated`); 293 | } else { 294 | this.logDeployResult( 295 | CREATE_COLOR, 296 | `A new squid ${printSquid({ ...deployment.squid, organization: deployment.organization })} has been successfully created`, 297 | ); 298 | } 299 | 300 | if (streamLogs) { 301 | await this.streamLogs({ organization: deployment.organization, squid: deployment.squid }); 302 | } 303 | } 304 | 305 | private async promptUpdateSquid( 306 | squid: Squid, 307 | { 308 | using = 'using "--allow-update" flag', 309 | interactive, 310 | tag, 311 | }: { 312 | using?: string; 313 | interactive?: boolean; 314 | tag?: string; 315 | } = {}, 316 | ) { 317 | const hasOtherTags = (!tag && squid.tags.length > 0) || squid.tags.some((t) => t.name !== tag); 318 | const warning = [ 319 | `The squid ${printSquid(squid)} already exists${hasOtherTags ? ` and has one or more tags assigned to it:` : ``}`, 320 | ]; 321 | if (hasOtherTags) { 322 | warning.push(...squid.tags.map((t) => chalk.dim(` - ${t.name}`))); 323 | } 324 | 325 | if (interactive) { 326 | this.warn(warning.join('\n')); 327 | } else { 328 | this.error([...warning, `Please do it explicitly ${using}`].join('\n')); 329 | } 330 | 331 | const { confirm } = await inquirer.prompt([ 332 | { 333 | name: 'confirm', 334 | type: 'confirm', 335 | message: 'Are you sure?', 336 | prefix: `The squid ${printSquid(squid)} will be updated.`, 337 | }, 338 | ]); 339 | 340 | return !!confirm; 341 | } 342 | 343 | private async promptOverrideConflict( 344 | dest: string, 345 | src: string, 346 | { using = 'using "--allow--manifest-override" flag', interactive }: { using?: string; interactive?: boolean } = {}, 347 | ) { 348 | const warning = [ 349 | 'Conflict detected!', 350 | `A manifest values do not match with specified ones.`, 351 | ``, 352 | diff({ content: dest }, { content: src }), 353 | ``, 354 | ].join('\n'); 355 | 356 | if (interactive) { 357 | this.warn(warning); 358 | } else { 359 | this.error([warning, `Please do it explicitly ${using}`].join('\n')); 360 | } 361 | 362 | this.log( 363 | `If it is intended and you'd like to override them, just skip this message and confirm, the manifest name will be overridden automatically in the Cloud during the deploy.`, 364 | ); 365 | 366 | const { confirm } = await inquirer.prompt([ 367 | { 368 | name: 'confirm', 369 | type: 'confirm', 370 | message: chalk.reset(`Manifest values will be overridden. ${chalk.bold('Are you sure?')}`), 371 | }, 372 | ]); 373 | 374 | return !!confirm; 375 | } 376 | 377 | private async promptSquidName( 378 | name?: string | null | undefined, 379 | { using = 'using "--name" flag', interactive }: { using?: string; interactive?: boolean } = {}, 380 | ) { 381 | if (name) return name; 382 | 383 | const warning = `The squid name is not defined either in the manifest or via CLI command.`; 384 | 385 | if (interactive) { 386 | this.warn(warning); 387 | } else { 388 | this.error([warning, `Please specify it explicitly ${using}`].join('\n')); 389 | } 390 | 391 | const { input } = await inquirer.prompt([ 392 | { 393 | name: 'input', 394 | type: 'input', 395 | message: `Please enter the name of the squid:`, 396 | }, 397 | ]); 398 | 399 | return input as string; 400 | } 401 | 402 | private async pack({ buildDir, squidDir, archiveName }: { buildDir: string; squidDir: string; archiveName: string }) { 403 | CliUx.ux.action.start(`◷ Compressing the squid to ${archiveName} `); 404 | 405 | const squidIgnore = createSquidIgnore(squidDir); 406 | const squidArtifact = path.join(buildDir, archiveName); 407 | 408 | let filesCount = 0; 409 | await compressAsync({ 410 | src: squidDir, 411 | dest: squidArtifact, 412 | tar: { 413 | ignore: (name) => { 414 | const relativePath = path.relative(path.resolve(squidDir), path.resolve(name)); 415 | 416 | if (squidIgnore.ignores(relativePath)) { 417 | this.log(chalk.dim(`-- ignoring ${relativePath}`)); 418 | return true; 419 | } else { 420 | this.log(chalk.dim(`adding ${relativePath}`)); 421 | filesCount++; 422 | return false; 423 | } 424 | }, 425 | }, 426 | }); 427 | 428 | if (filesCount === 0) { 429 | return this.showError( 430 | `0 files were found in ${squidDir}. Please check the squid source, looks like it is empty`, 431 | 'PACKING_FAILED', 432 | ); 433 | } 434 | 435 | const squidArtifactStats = fs.statSync(squidArtifact); 436 | 437 | CliUx.ux.action.stop(`${filesCount} files, ${prettyBytes(squidArtifactStats.size)} ${SUCCESS_CHECK_MARK}`); 438 | 439 | return squidArtifact; 440 | } 441 | 442 | private async upload({ organization, artifactPath }: OrganizationRequest & { artifactPath: string }) { 443 | CliUx.ux.action.start(`◷ Uploading ${path.basename(artifactPath)}`); 444 | 445 | const { error, fileUrl: artifactUrl } = await uploadFile({ organization, path: artifactPath }); 446 | if (error) { 447 | return this.showError(error); 448 | } else if (!artifactUrl) { 449 | return this.showError('The artifact URL is missing', 'UPLOAD_FAILED'); 450 | } 451 | 452 | CliUx.ux.action.stop(SUCCESS_CHECK_MARK); 453 | 454 | return artifactUrl; 455 | } 456 | } 457 | 458 | export function createSquidIgnore(squidDir: string) { 459 | const ig = ignore().add( 460 | // default ignore patterns 461 | ['node_modules', '.git'], 462 | ); 463 | 464 | const ignoreFilePaths = globSync(['.squidignore', '**/.squidignore'], { 465 | cwd: squidDir, 466 | nodir: true, 467 | posix: true, 468 | }); 469 | 470 | if (!ignoreFilePaths.length) { 471 | return ig.add([ 472 | // squid uploaded archives directory 473 | '/builds', 474 | // squid built files 475 | '/lib', 476 | // IDE files 477 | '.idea', 478 | '.vscode', 479 | ]); 480 | } 481 | 482 | for (const ignoreFilePath of ignoreFilePaths) { 483 | const raw = fs.readFileSync(path.resolve(squidDir, ignoreFilePath)).toString(); 484 | 485 | const ignoreDir = path.dirname(ignoreFilePath); 486 | const patterns = getIgnorePatterns(ignoreDir, raw); 487 | 488 | ig.add(patterns); 489 | } 490 | 491 | return ig; 492 | } 493 | 494 | export function getIgnorePatterns(ignoreDir: string, raw: string) { 495 | const lines = raw.split('\n'); 496 | 497 | const patterns: string[] = []; 498 | for (let line of lines) { 499 | line = line.trim(); 500 | 501 | if (line.length === 0) continue; 502 | if (line.startsWith('#')) continue; 503 | 504 | let pattern = line.startsWith('/') || line.startsWith('*/') || line.startsWith('**/') ? line : `**/${line}`; 505 | pattern = ignoreDir === '.' ? pattern : `${toRootPattern(ignoreDir)}${toRootPattern(pattern)}`; 506 | 507 | patterns.push(pattern); 508 | } 509 | 510 | return patterns; 511 | } 512 | 513 | function toRootPattern(pattern: string) { 514 | return pattern.startsWith('/') ? pattern : `/${pattern}`; 515 | } 516 | -------------------------------------------------------------------------------- /src/commands/deploy.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { getIgnorePatterns } from './deploy'; 2 | 3 | describe('Deploy', () => { 4 | describe('get squid ignore paths', () => { 5 | const squidignore = [ 6 | '/.git', 7 | '/builds', 8 | '#comment', 9 | ' /abi ', 10 | 'test', 11 | ' ', 12 | '.env', 13 | '', 14 | '# another comment', 15 | '**/foo', 16 | ].join('\n'); 17 | 18 | it('root', () => { 19 | const patterns = getIgnorePatterns('.', squidignore); 20 | expect(patterns).toEqual(['/.git', '/builds', '/abi', '**/test', '**/.env', '**/foo']); 21 | }); 22 | 23 | it('dir', () => { 24 | const patterns = getIgnorePatterns('dir', squidignore); 25 | expect(patterns).toEqual(['/dir/.git', '/dir/builds', '/dir/abi', '/dir/**/test', '/dir/**/.env', '/dir/**/foo']); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/commands/docs.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@oclif/core'; 2 | import open from 'open'; 3 | 4 | export default class Docs extends Command { 5 | static description = 'Open the docs in a browser'; 6 | 7 | async run(): Promise { 8 | await this.parse(Docs); 9 | 10 | void open('https://docs.sqd.dev/'); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/commands/explorer.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import blessed from 'reblessed'; 3 | 4 | import { CliCommand } from '../command'; 5 | import { Loader } from '../ui/components/Loader'; 6 | import { VersionManager } from '../ui/components/VersionManager'; 7 | 8 | export default class Explorer extends CliCommand { 9 | static hidden = true; 10 | 11 | static description = 'Open a visual explorer for the Cloud deployments'; 12 | // static hidden = true; 13 | static flags = { 14 | org: Flags.string({ 15 | char: 'o', 16 | description: 'Organization', 17 | required: false, 18 | }), 19 | }; 20 | 21 | async run(): Promise { 22 | const { 23 | flags: { org, interactive }, 24 | } = await this.parse(Explorer); 25 | 26 | const organization = await this.promptOrganization(org, { interactive }); 27 | const screen = blessed.screen({ 28 | smartCSR: true, 29 | fastCSR: true, 30 | // dockBorders: true, 31 | debug: true, 32 | // autoPadding: true, 33 | fullUnicode: true, 34 | }); 35 | 36 | const manager = new VersionManager(organization, { 37 | top: '0', 38 | left: '0', 39 | width: '100%', 40 | height: '100%', 41 | }); 42 | 43 | const loader = new Loader({ 44 | top: '50%', 45 | left: '50%', 46 | }); 47 | 48 | manager.hide(); 49 | 50 | screen.append(loader); 51 | screen.append(manager); 52 | 53 | await manager.load(); 54 | await loader.destroyWithTimeout(); 55 | 56 | manager.show(); 57 | 58 | screen.key(['C-c'], () => { 59 | return process.exit(0); 60 | }); 61 | 62 | // screen.program.disableMouse(); 63 | 64 | manager.focus(); 65 | screen.render(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/commands/gateways/list.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import chalk from 'chalk'; 3 | import Table from 'cli-table3'; 4 | import { maxBy } from 'lodash'; 5 | 6 | import { Gateway, getEvmGateways, getSubstrateGateways } from '../../api/gateways'; 7 | import { CliCommand } from '../../command'; 8 | 9 | export default class Ls extends CliCommand { 10 | static aliases = ['gateways ls']; 11 | 12 | static description = 'List available gateways'; 13 | 14 | static flags = { 15 | type: Flags.string({ 16 | char: 't', 17 | description: 'Filter by network type', 18 | options: ['evm', 'substrate'], 19 | helpValue: '', 20 | required: false, 21 | }), 22 | name: Flags.string({ 23 | char: 'n', 24 | description: 'Filter by network name', 25 | helpValue: '', 26 | required: false, 27 | }), 28 | chain: Flags.string({ 29 | char: 'c', 30 | description: 'Filter by chain ID or SS58 prefix', 31 | helpValue: '', 32 | required: false, 33 | }), 34 | }; 35 | 36 | async run(): Promise { 37 | const { 38 | flags: { type, name, chain: chainId }, 39 | } = await this.parse(Ls); 40 | 41 | const [evm, substrate] = await Promise.all([ 42 | !type || type === 'evm' ? getEvmGateways() : [], 43 | !type || type === 'substrate' ? getSubstrateGateways() : [], 44 | ]); 45 | 46 | const maxNameLength = maxBy([...evm, ...substrate], (g) => g.chainName.length)?.chainName.length; 47 | 48 | switch (type) { 49 | case 'evm': 50 | this.processGateways(evm, { type, name, chainId, summary: 'EVM', maxNameLength }); 51 | break; 52 | case 'substrate': 53 | this.processGateways(substrate, { type, name, chainId, summary: 'Substrate', maxNameLength }); 54 | break; 55 | default: 56 | this.processGateways(evm, { type: 'evm', name, chainId, summary: 'EVM', maxNameLength }); 57 | this.log(); 58 | this.processGateways(substrate, { type: 'substrate', name, chainId, summary: 'Substrate', maxNameLength }); 59 | } 60 | } 61 | 62 | processGateways( 63 | gateways: Gateway[], 64 | { 65 | type, 66 | name, 67 | chainId, 68 | summary, 69 | maxNameLength, 70 | }: { type: 'evm' | 'substrate'; name?: string; chainId?: string; summary?: string; maxNameLength?: number }, 71 | ) { 72 | if (summary) { 73 | this.log(chalk.bold(summary)); 74 | } 75 | 76 | gateways = name ? gateways.filter((g) => g.network.match(new RegExp(name, 'i'))) : gateways; 77 | 78 | gateways = chainId 79 | ? gateways.filter((g) => (type === 'evm' ? String(g.chainId) === chainId : String(g.chainSS58Prefix) === chainId)) 80 | : gateways; 81 | 82 | if (!gateways.length) { 83 | return this.log('No gateways found'); 84 | } 85 | 86 | const headRow = ['Network', type === 'evm' ? 'Chain ID' : 'SS58 prefix', 'Gateway URL']; 87 | const table = new Table({ 88 | wordWrap: true, 89 | colWidths: [maxNameLength ? maxNameLength + 2 : null], 90 | head: headRow, 91 | wrapOnWordBoundary: false, 92 | 93 | style: { 94 | head: ['bold'], 95 | border: ['gray'], 96 | compact: true, 97 | }, 98 | }); 99 | 100 | gateways.map(({ chainName, chainId, chainSS58Prefix, providers }) => { 101 | const row = [chainName, chalk.dim(chainId || chainSS58Prefix || '-'), providers[0].dataSourceUrl]; 102 | table.push(row); 103 | }); 104 | 105 | this.log(table.toString()); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import { promises as asyncFs } from 'fs'; 2 | import path from 'path'; 3 | 4 | import { Args, Flags, ux as CliUx } from '@oclif/core'; 5 | import { Manifest } from '@subsquid/manifest'; 6 | import chalk from 'chalk'; 7 | import inquirer from 'inquirer'; 8 | import { simpleGit } from 'simple-git'; 9 | 10 | import { CliCommand, SUCCESS_CHECK_MARK } from '../command'; 11 | import { readManifest, saveManifest } from '../manifest'; 12 | 13 | const SQUID_NAME_DESC = [ 14 | `The squid name. It must contain only alphanumeric or dash ("-") symbols and must not start with "-".`, 15 | ]; 16 | 17 | const TEMPLATE_ALIASES: Record = { 18 | evm: { 19 | url: 'https://github.com/subsquid/squid-evm-template', 20 | description: `A minimal squid template for indexing EVM data.`, 21 | }, 22 | abi: { 23 | url: 'https://github.com/subsquid/squid-abi-template', 24 | description: `A template to auto-generate a squid indexing events and txs from a contract ABI`, 25 | }, 26 | multichain: { 27 | url: 'https://github.com/subsquid-labs/squid-multichain-template', 28 | description: `A template for indexing data from multiple chains`, 29 | }, 30 | gravatar: { 31 | url: 'https://github.com/subsquid/gravatar-squid', 32 | description: 'A sample EVM squid indexing the Gravatar smart contract on Ethereum.', 33 | }, 34 | substrate: { 35 | url: 'https://github.com/subsquid/squid-substrate-template', 36 | description: 'A template squid for indexing Substrate-based chains.', 37 | }, 38 | ink: { 39 | url: 'https://github.com/subsquid/squid-wasm-template', 40 | description: `A template for indexing Ink! smart contracts`, 41 | }, 42 | 'ink-abi': { 43 | url: 'https://github.com/subsquid-labs/squid-ink-abi-template', 44 | description: `A template to auto-generate a squid from an ink! contract ABI`, 45 | }, 46 | 'frontier-evm': { 47 | url: 'https://github.com/subsquid/squid-frontier-evm-template', 48 | description: 'A template for indexing Frontier EVM chains, like Moonbeam and Astar.', 49 | }, 50 | }; 51 | 52 | const git = simpleGit({ 53 | baseDir: process.cwd(), 54 | binary: 'git', 55 | }); 56 | 57 | async function directoryIsEmpty(path: string) { 58 | try { 59 | const directory = await asyncFs.opendir(path); 60 | const entry = await directory.read(); 61 | await directory.close(); 62 | 63 | return entry === null; 64 | } catch (e: any) { 65 | if (e.code === 'ENOENT') return true; 66 | 67 | return false; 68 | } 69 | } 70 | 71 | const SQUID_TEMPLATE_DESC = [ 72 | `A template for the squid. Accepts: `, 73 | `- a ${chalk.bold('github repository URL')} containing a valid ${chalk.italic( 74 | 'squid.yaml', 75 | )} manifest in the root folder `, 76 | ` or one of the pre-defined aliases:`, 77 | ...Object.entries(TEMPLATE_ALIASES).map(([alias, { description }]) => ` - ${chalk.bold(alias)} ${description}`), 78 | ]; 79 | 80 | export default class Init extends CliCommand { 81 | static description = 'Setup a new squid project from a template or github repo'; 82 | 83 | static args = { 84 | name: Args.string({ description: SQUID_NAME_DESC.join('\n'), required: true }), 85 | }; 86 | 87 | static flags = { 88 | template: Flags.string({ 89 | char: 't', 90 | description: SQUID_TEMPLATE_DESC.join('\n'), 91 | required: false, 92 | }), 93 | dir: Flags.string({ 94 | char: 'd', 95 | description: 'The target location for the squid. If omitted, a new folder NAME is created.', 96 | required: false, 97 | }), 98 | remove: Flags.boolean({ 99 | char: 'r', 100 | description: 'Clean up the target directory if it exists', 101 | required: false, 102 | }), 103 | }; 104 | 105 | async run() { 106 | const { 107 | args: { name }, 108 | flags: { template, dir, remove }, 109 | } = await this.parse(Init); 110 | 111 | const localDir = path.resolve(dir || name); 112 | const isEmptyDir = await directoryIsEmpty(localDir); 113 | if (!isEmptyDir) { 114 | if (!remove) { 115 | return this.error( 116 | `The folder "${localDir}" already exists. Use the "-r" flag to init the squid at the existing path (will clean the folder first).`, 117 | ); 118 | } 119 | 120 | await asyncFs.rm(localDir, { recursive: true }); 121 | } 122 | 123 | let resolvedTemplate = template || ''; 124 | if (!template) { 125 | const { alias } = await inquirer.prompt({ 126 | name: 'alias', 127 | message: `Please select one of the templates for your "${name}" squid:`, 128 | type: 'list', 129 | 130 | choices: Object.entries(TEMPLATE_ALIASES).map(([name, { description }]) => { 131 | return { 132 | name: `${name}. ${chalk.dim(description)}`, 133 | value: name, 134 | }; 135 | }), 136 | }); 137 | 138 | resolvedTemplate = alias; 139 | } 140 | 141 | const githubRepository = TEMPLATE_ALIASES[resolvedTemplate] 142 | ? TEMPLATE_ALIASES[resolvedTemplate].url 143 | : resolvedTemplate; 144 | 145 | CliUx.ux.action.start(`◷ Downloading the template: ${githubRepository}... `); 146 | try { 147 | // TODO: support branches? 148 | await git.clone(githubRepository, localDir, {}); 149 | } catch (e: any) { 150 | return this.error(e); 151 | } 152 | CliUx.ux.action.stop(SUCCESS_CHECK_MARK); 153 | 154 | /** Clean up template **/ 155 | await asyncFs.rm(path.resolve(localDir, '.git'), { recursive: true }); 156 | 157 | /** Remove deprecated files from repositories **/ 158 | try { 159 | await asyncFs.rm(path.resolve(localDir, 'Dockerfile')); 160 | } catch (e) {} 161 | 162 | const manifestPath = path.resolve(localDir, 'squid.yaml'); 163 | try { 164 | const manifest = Manifest.replace(readManifest(manifestPath), { name }); 165 | 166 | saveManifest(manifestPath, manifest); 167 | } catch (e: any) { 168 | return this.error(e); 169 | } 170 | 171 | this.log(`The squid is created in ${localDir}`); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/commands/list.ts: -------------------------------------------------------------------------------- 1 | import { ux as CliUx, Flags } from '@oclif/core'; 2 | 3 | import { listSquids } from '../api'; 4 | import { CliCommand, SqdFlags } from '../command'; 5 | import { printSquid } from '../utils'; 6 | 7 | export default class List extends CliCommand { 8 | static aliases = ['ls']; 9 | 10 | static description = 'List squids deployed to the Cloud'; 11 | 12 | static flags = { 13 | org: SqdFlags.org({ 14 | required: false, 15 | }), 16 | name: SqdFlags.name({ 17 | required: false, 18 | relationships: [], 19 | }), 20 | tag: SqdFlags.tag({ 21 | required: false, 22 | dependsOn: [], 23 | }), 24 | slot: SqdFlags.slot({ 25 | required: false, 26 | dependsOn: [], 27 | }), 28 | reference: SqdFlags.reference({ 29 | required: false, 30 | }), 31 | truncate: Flags.boolean({ 32 | description: 'Truncate data in columns: false by default', 33 | required: false, 34 | default: false, 35 | allowNo: true, 36 | }), 37 | }; 38 | 39 | async run(): Promise { 40 | const { 41 | flags: { truncate, reference, interactive, ...flags }, 42 | } = await this.parse(List); 43 | 44 | const { org, name, slot, tag } = reference ? reference : (flags as any); 45 | 46 | const organization = name 47 | ? await this.promptSquidOrganization(org, name, { interactive }) 48 | : await this.promptOrganization(org, { interactive }); 49 | 50 | let squids = await listSquids({ organization, name }); 51 | if (tag || slot) { 52 | squids = squids.filter((s) => s.slot === slot || s.tags.some((t) => t.name === tag)); 53 | } 54 | if (squids.length) { 55 | CliUx.ux.table( 56 | squids, 57 | { 58 | name: { 59 | header: 'Squid', 60 | get: (s) => `${printSquid(s)}`, 61 | }, 62 | tags: { 63 | header: 'Tags', 64 | get: (s) => 65 | s.tags 66 | .map((t) => t.name) 67 | .sort() 68 | .join(', '), 69 | }, 70 | status: { 71 | header: 'Status', 72 | get: (s) => s.status?.toUpperCase(), 73 | }, 74 | deployedAt: { 75 | header: 'Deployed', 76 | get: (s) => (s.deployedAt ? new Date(s.deployedAt).toUTCString() : `-`), 77 | }, 78 | }, 79 | { 'no-truncate': !truncate }, 80 | ); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/commands/logs.ts: -------------------------------------------------------------------------------- 1 | import { Flags, ux as CliUx } from '@oclif/core'; 2 | import { isNil, omitBy } from 'lodash'; 3 | import ms from 'ms'; 4 | 5 | import { debugLog, squidHistoryLogs, SquidRequest, streamSquidLogs } from '../api'; 6 | import { CliCommand, SqdFlags } from '../command'; 7 | import { pretty } from '../logs'; 8 | import { formatSquidReference } from '../utils'; 9 | 10 | type LogResult = { 11 | hasLogs: boolean; 12 | nextPage: string | null; 13 | }; 14 | 15 | function parseDate(str: string): Date { 16 | const date = Date.parse(str); 17 | if (!isNaN(date)) { 18 | return new Date(date); 19 | } 20 | 21 | return new Date(Date.now() - ms(str)); 22 | } 23 | 24 | export default class Logs extends CliCommand { 25 | static description = 'Fetch logs from a squid deployed to the Cloud'; 26 | 27 | static flags = { 28 | org: SqdFlags.org({ 29 | required: false, 30 | }), 31 | name: SqdFlags.name({ 32 | required: false, 33 | }), 34 | slot: SqdFlags.slot({ 35 | required: false, 36 | }), 37 | tag: SqdFlags.tag({ 38 | required: false, 39 | }), 40 | reference: SqdFlags.reference({ 41 | required: false, 42 | }), 43 | container: Flags.string({ 44 | char: 'c', 45 | summary: `Container name`, 46 | required: false, 47 | multiple: true, 48 | options: ['processor', 'query-node', 'api', 'db-migrate', 'db'], 49 | }), 50 | pageSize: Flags.integer({ 51 | char: 'p', 52 | summary: 'Logs page size', 53 | required: false, 54 | default: 100, 55 | }), 56 | level: Flags.string({ 57 | char: 'l', 58 | summary: 'Log level', 59 | required: false, 60 | multiple: true, 61 | options: ['error', 'debug', 'info', 'warning'], 62 | }), 63 | since: Flags.string({ 64 | summary: 'Filter by date/interval', 65 | required: false, 66 | default: '1d', 67 | }), 68 | search: Flags.string({ 69 | summary: 'Filter by content', 70 | required: false, 71 | }), 72 | follow: Flags.boolean({ 73 | char: 'f', 74 | summary: 'Follow', 75 | required: false, 76 | default: false, 77 | exclusive: ['fromDate', 'pageSize'], 78 | }), 79 | }; 80 | 81 | async run(): Promise { 82 | const { 83 | flags: { follow, pageSize, container, level, since, search, reference, interactive, ...flags }, 84 | } = await this.parse(Logs); 85 | 86 | this.validateSquidNameFlags({ reference, ...flags }); 87 | 88 | const { org, name, tag, slot } = reference ? reference : (flags as any); 89 | 90 | const organization = await this.promptSquidOrganization(org, name, { interactive }); 91 | const squid = await this.findOrThrowSquid({ organization, squid: { name, slot, tag } }); 92 | if (!squid) return; 93 | 94 | const fromDate = parseDate(since); 95 | this.log(`Fetching logs from ${fromDate.toISOString()}...`); 96 | if (follow) { 97 | await this.fetchLogs({ 98 | organization, 99 | squid, 100 | reverse: true, 101 | query: { 102 | limit: 30, 103 | from: fromDate, 104 | container, 105 | level, 106 | search, 107 | }, 108 | }); 109 | await streamSquidLogs({ 110 | organization, 111 | squid, 112 | onLog: (l) => this.log(l), 113 | query: { container, level, search }, 114 | }); 115 | debugLog(`done`); 116 | return; 117 | } 118 | let cursor = undefined; 119 | do { 120 | const { hasLogs, nextPage }: LogResult = await this.fetchLogs({ 121 | organization, 122 | squid, 123 | query: { 124 | limit: pageSize, 125 | from: fromDate, 126 | nextPage: cursor, 127 | container, 128 | level, 129 | search, 130 | }, 131 | }); 132 | if (!hasLogs) { 133 | this.log('No logs found'); 134 | return; 135 | } 136 | if (nextPage) { 137 | const more = await CliUx.ux.prompt(`type "it" to fetch more logs...`); 138 | if (more !== 'it') { 139 | return; 140 | } 141 | } 142 | cursor = nextPage; 143 | } while (cursor); 144 | } 145 | 146 | async fetchLogs({ 147 | organization, 148 | squid, 149 | query, 150 | reverse, 151 | }: SquidRequest & { 152 | reverse?: boolean; 153 | query: { 154 | limit: number; 155 | from: Date; 156 | container?: string[]; 157 | nextPage?: string; 158 | orderBy?: string; 159 | level?: string[]; 160 | search?: string; 161 | }; 162 | }): Promise { 163 | // eslint-disable-next-line prefer-const 164 | let { logs, nextPage } = await squidHistoryLogs({ organization, squid, query }); 165 | 166 | if (reverse) { 167 | logs = logs.reverse(); 168 | } 169 | pretty(logs).forEach((l) => { 170 | this.log(l); 171 | }); 172 | 173 | return { hasLogs: logs.length > 0, nextPage }; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/commands/prod.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@oclif/core'; 2 | import chalk from 'chalk'; 3 | 4 | export default class Prod extends Command { 5 | static hidden = true; 6 | 7 | static description = 'Assign the canonical production API alias for a squid deployed to the Cloud'; 8 | 9 | async run(): Promise { 10 | await this.parse(Prod); 11 | 12 | // TODO write description 13 | this.log( 14 | [ 15 | chalk.yellow('*******************************************************'), 16 | chalk.yellow('* *'), 17 | chalk.yellow('* WARNING! This command has been deprecated *'), 18 | chalk.yellow('* Please check the release notes *'), 19 | chalk.yellow('* https://docs.sqd.dev/deployments-two-release-notes/ *'), 20 | chalk.yellow('* *'), 21 | chalk.yellow('*******************************************************'), 22 | ].join('\n'), 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/commands/remove.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import chalk from 'chalk'; 3 | import inquirer from 'inquirer'; 4 | 5 | import { deleteSquid } from '../api'; 6 | import { SqdFlags } from '../command'; 7 | import { DeployCommand } from '../deploy-command'; 8 | import { ParsedSquidReference, printSquid } from '../utils'; 9 | 10 | import { DELETE_COLOR } from './deploy'; 11 | 12 | export default class Remove extends DeployCommand { 13 | static description = 'Remove a squid deployed to the Cloud'; 14 | 15 | static aliases = ['rm']; 16 | 17 | static flags = { 18 | org: SqdFlags.org({ 19 | required: false, 20 | }), 21 | name: SqdFlags.name({ 22 | required: false, 23 | }), 24 | slot: SqdFlags.slot({ 25 | required: false, 26 | }), 27 | tag: SqdFlags.tag({ 28 | required: false, 29 | }), 30 | reference: SqdFlags.reference({ 31 | required: false, 32 | }), 33 | force: Flags.boolean({ 34 | char: 'f', 35 | description: 'Does not prompt before removing a squid or its version', 36 | required: false, 37 | }), 38 | }; 39 | 40 | async run(): Promise { 41 | const { 42 | flags: { interactive, force, reference, ...flags }, 43 | } = await this.parse(Remove); 44 | 45 | this.validateSquidNameFlags({ reference, ...flags }); 46 | 47 | const { org, name, tag, slot } = reference || (flags as ParsedSquidReference); 48 | 49 | const organization = await this.promptSquidOrganization(org, name, { interactive }); 50 | const squid = await this.findOrThrowSquid({ organization, squid: { name, slot, tag } }); 51 | 52 | const attached = await this.promptAttachToDeploy(squid, { interactive }); 53 | if (attached) return; 54 | 55 | const hasOtherTags = (!tag && squid.tags.length > 0) || squid.tags.some((t) => t.name !== tag); 56 | if (hasOtherTags) { 57 | const warning = [ 58 | `The squid ${printSquid(squid)} has one or more tags assigned to it:`, 59 | ...squid.tags.map((t) => chalk.dim(` - ${t.name}`)), 60 | ]; 61 | 62 | if (!interactive && !force) { 63 | this.error([...warning, 'Please do it explicitly using --force flag'].join('\n')); 64 | } else { 65 | this.warn(warning.join('\n')); 66 | } 67 | } 68 | 69 | if (!force && interactive) { 70 | const { confirm } = await inquirer.prompt([ 71 | { 72 | name: 'confirm', 73 | type: 'confirm', 74 | message: `Are you sure?`, 75 | prefix: `The squid ${printSquid(squid)} will be completely removed. This action can not be undone.`, 76 | }, 77 | ]); 78 | if (!confirm) return; 79 | } 80 | 81 | const deployment = await deleteSquid({ organization, squid }); 82 | await this.pollDeploy({ organization, deploy: deployment }); 83 | if (!deployment || !deployment.squid) return; 84 | 85 | this.logDeployResult(DELETE_COLOR, `The squid ${printSquid(squid)} was successfully deleted`); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/commands/restart.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import { isNil, omitBy } from 'lodash'; 3 | 4 | import { restartSquid } from '../api'; 5 | import { SqdFlags } from '../command'; 6 | import { DeployCommand } from '../deploy-command'; 7 | import { formatSquidReference as formatSquidReference, printSquid } from '../utils'; 8 | 9 | import { UPDATE_COLOR } from './deploy'; 10 | 11 | export default class Restart extends DeployCommand { 12 | static description = 'Restart a squid deployed to the Cloud'; 13 | 14 | static flags = { 15 | org: SqdFlags.org({ 16 | required: false, 17 | }), 18 | name: SqdFlags.name({ 19 | required: false, 20 | }), 21 | slot: SqdFlags.slot({ 22 | required: false, 23 | }), 24 | tag: SqdFlags.tag({ 25 | required: false, 26 | }), 27 | reference: SqdFlags.reference({ 28 | required: false, 29 | }), 30 | }; 31 | 32 | async run(): Promise { 33 | const { 34 | flags: { reference, interactive, ...flags }, 35 | } = await this.parse(Restart); 36 | 37 | this.validateSquidNameFlags({ reference, ...flags }); 38 | 39 | const { org, name, tag, slot } = reference ? reference : (flags as any); 40 | 41 | const organization = await this.promptSquidOrganization(org, name, { interactive }); 42 | const squid = await this.findOrThrowSquid({ organization, squid: { name, tag, slot } }); 43 | 44 | const attached = await this.promptAttachToDeploy(squid, { interactive }); 45 | if (attached) return; 46 | 47 | const deployment = await restartSquid({ organization, squid }); 48 | await this.pollDeploy({ organization, deploy: deployment }); 49 | if (!deployment || !deployment.squid) return; 50 | 51 | this.logDeployResult(UPDATE_COLOR, `The squid ${printSquid(squid)} has been successfully restarted`); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/commands/run.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { ChildProcess } from 'child_process'; 3 | import path from 'path'; 4 | import * as readline from 'readline'; 5 | 6 | import { Args, Flags } from '@oclif/core'; 7 | import retryAsync from 'async-retry'; 8 | import chalk from 'chalk'; 9 | import crossSpawn from 'cross-spawn'; 10 | import dotenv from 'dotenv'; 11 | import { defaults, omit } from 'lodash'; 12 | import treeKill from 'tree-kill'; 13 | 14 | import { CliCommand } from '../command'; 15 | import { evalManifestEnv, loadManifestFile } from '../manifest'; 16 | 17 | const chalkColors = [chalk.green, chalk.yellow, chalk.blue, chalk.magenta, chalk.cyan]; 18 | 19 | const chalkColorGenerator = (() => { 20 | let n = 0; 21 | return () => chalkColors[n++ % chalkColors.length]; 22 | })(); 23 | 24 | type SquidProcessOptions = { 25 | env?: Record; 26 | cwd?: string; 27 | stdin: NodeJS.ReadableStream; 28 | stdout: NodeJS.WritableStream; 29 | stderr: NodeJS.WritableStream; 30 | }; 31 | 32 | class SquidProcess { 33 | private readonly prefix: string; 34 | private child?: ChildProcess; 35 | private options: SquidProcessOptions; 36 | 37 | constructor( 38 | readonly name: string, 39 | private cmd: string[], 40 | options: Partial, 41 | ) { 42 | this.prefix = chalkColorGenerator()(`[${name}]`); 43 | this.options = defaults(options, { 44 | stdin: process.stdin, 45 | stdout: process.stdout, 46 | stderr: process.stderr, 47 | }); 48 | } 49 | 50 | async run({ retries }: { retries: number }) { 51 | await retryAsync( 52 | async () => { 53 | const result = await this.spawn(); 54 | if (result.code && result.code > 0) { 55 | throw new Error(`Process ${this.prefix} failed with exit code: ${result.code}`); 56 | } else { 57 | return; 58 | } 59 | }, 60 | { 61 | forever: retries < 0, 62 | retries, 63 | factor: 1, 64 | onRetry: (e) => { 65 | console.log(e.message); 66 | console.log(`Process ${this.prefix} restarting...`); 67 | }, 68 | }, 69 | ); 70 | } 71 | 72 | kill(signal?: string | number) { 73 | if (this.child?.pid) { 74 | treeKill(this.child.pid, signal); 75 | this.child = undefined; 76 | } 77 | } 78 | 79 | private spawn() { 80 | assert(!this.child, `process "${this.name}" has been already started`); 81 | 82 | const [command, ...args] = this.cmd; 83 | 84 | return new Promise<{ code: number | null; signal: string | null }>((resolve, reject) => { 85 | const child = crossSpawn(command, args, { 86 | cwd: this.options.cwd, 87 | env: this.options.env, 88 | }); 89 | this.child = child; 90 | 91 | child.once('error', (error: Error) => { 92 | this.child = undefined; 93 | reject(error); 94 | }); 95 | 96 | child.once('close', (code: number | null, signal: string | null) => { 97 | this.child = undefined; 98 | resolve({ code, signal }); 99 | }); 100 | 101 | if (this.child.stderr) { 102 | this.pipe(this.child.stderr, this.options.stderr); 103 | } 104 | if (this.child.stdout) { 105 | this.pipe(this.child.stdout, this.options.stdout); 106 | } 107 | }); 108 | } 109 | 110 | private pipe(input: NodeJS.ReadableStream, output: NodeJS.WritableStream) { 111 | readline.createInterface({ input }).on('line', (line) => { 112 | output.write(`${this.prefix} ${line}\n`); 113 | }); 114 | } 115 | } 116 | 117 | function isSkipped({ include, exclude }: { include?: string[]; exclude?: string[] }, haystack: string) { 118 | if (exclude?.length && exclude.includes(haystack)) return true; 119 | else if (include?.length && !include.includes(haystack)) return true; 120 | 121 | return false; 122 | } 123 | 124 | export default class Run extends CliCommand { 125 | static description = 'Run a squid project locally'; 126 | 127 | static flags = { 128 | manifest: Flags.string({ 129 | char: 'm', 130 | description: 'Relative path to a squid manifest file', 131 | required: false, 132 | default: 'squid.yaml', 133 | }), 134 | envFile: Flags.string({ 135 | char: 'f', 136 | description: 'Relative path to an additional environment file', 137 | required: false, 138 | default: '.env', 139 | }), 140 | exclude: Flags.string({ char: 'e', description: 'Do not run specified services', required: false, multiple: true }), 141 | include: Flags.string({ 142 | char: 'i', 143 | description: 'Run only specified services', 144 | required: false, 145 | multiple: true, 146 | exclusive: ['exclude'], 147 | }), 148 | retries: Flags.integer({ 149 | char: 'r', 150 | description: 'Attempts to restart failed or stopped services', 151 | required: false, 152 | default: 5, 153 | }), 154 | }; 155 | 156 | static args = { 157 | path: Args.string({ 158 | hidden: false, 159 | required: true, 160 | default: '.', 161 | }), 162 | }; 163 | async run(): Promise { 164 | try { 165 | const { 166 | flags: { manifest: manifestPath, envFile, exclude, include, retries }, 167 | args: { path: squidPath }, 168 | } = await this.parse(Run); 169 | 170 | const { squidDir, manifest } = loadManifestFile(squidPath, manifestPath); 171 | const children: SquidProcess[] = []; 172 | 173 | if (envFile) { 174 | const { error } = dotenv.config({ 175 | path: path.isAbsolute(envFile) ? envFile : path.join(squidDir, envFile), 176 | }); 177 | if (error) { 178 | return this.error(error); 179 | } 180 | } 181 | 182 | const context = { secrets: process.env }; 183 | 184 | const processEnv = omit(process.env, ['PROCESSOR_PROMETHEUS_PORT']); 185 | const env = { 186 | FORCE_COLOR: 'true', 187 | FORCE_PRETTY_LOGGER: 'true', 188 | ...processEnv, 189 | ...evalManifestEnv(manifest.deploy?.env ?? {}, context), 190 | }; 191 | 192 | const init = manifest.deploy?.init; 193 | if (init && init.cmd && !isSkipped({ include, exclude }, 'init')) { 194 | const p = new SquidProcess('init', init.cmd, { 195 | env: { 196 | ...env, 197 | ...evalManifestEnv(init.env ?? {}, context), 198 | }, 199 | cwd: squidDir, 200 | }); 201 | 202 | await p.run({ retries }); 203 | } 204 | 205 | const api = manifest.deploy?.api; 206 | if (api && api.cmd && !isSkipped({ include, exclude }, 'api')) { 207 | children.push( 208 | new SquidProcess('api', api.cmd, { 209 | env: { 210 | ...env, 211 | ...evalManifestEnv(api.env ?? {}, context), 212 | }, 213 | cwd: squidDir, 214 | }), 215 | ); 216 | } 217 | 218 | if (manifest.deploy?.processor) { 219 | const processors = Array.isArray(manifest.deploy.processor) 220 | ? manifest.deploy.processor 221 | : [manifest.deploy.processor]; 222 | 223 | for (const processor of processors) { 224 | if (!processor.cmd || isSkipped({ include, exclude }, processor.name)) continue; 225 | 226 | children.push( 227 | new SquidProcess(processor.name, processor.cmd, { 228 | env: { 229 | ...env, 230 | ...evalManifestEnv(processor.env ?? {}, context), 231 | }, 232 | cwd: squidDir, 233 | }), 234 | ); 235 | } 236 | } 237 | 238 | let error: Error | undefined; 239 | const abort = (e: Error) => { 240 | if (error) return; 241 | error = e; 242 | 243 | children.map((c) => c.kill()); 244 | }; 245 | 246 | await Promise.allSettled(children.map((c) => c.run({ retries }).catch(abort))); 247 | 248 | if (error) this.error(error); 249 | } catch (e: any) { 250 | this.error(e instanceof Error ? e.message : e); 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/commands/secrets/list.ts: -------------------------------------------------------------------------------- 1 | import { ux as CliUx, Flags } from '@oclif/core'; 2 | 3 | import { listSecrets } from '../../api'; 4 | import { CliCommand } from '../../command'; 5 | 6 | export default class Ls extends CliCommand { 7 | static aliases = ['secrets ls']; 8 | 9 | static description = 'List organization secrets in the Cloud'; 10 | 11 | static flags = { 12 | org: Flags.string({ 13 | char: 'o', 14 | description: 'Organization', 15 | required: false, 16 | }), 17 | }; 18 | 19 | async run(): Promise { 20 | const { 21 | flags: { org, interactive }, 22 | args: {}, 23 | } = await this.parse(Ls); 24 | 25 | const organization = await this.promptOrganization(org, { interactive }); 26 | const response = await listSecrets({ organization }); 27 | 28 | if (!Object.keys(response.secrets).length) { 29 | return this.log('There are no secrets'); 30 | } 31 | 32 | const values: { name: string; value: string }[] = []; 33 | for (const secret in response.secrets) { 34 | values.push({ name: secret, value: response.secrets[secret] }); 35 | } 36 | CliUx.ux.table( 37 | values, 38 | { 39 | name: {}, 40 | value: {}, 41 | }, 42 | { 'no-truncate': false }, 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/secrets/remove.ts: -------------------------------------------------------------------------------- 1 | import { Flags, Args } from '@oclif/core'; 2 | 3 | import { removeSecret } from '../../api'; 4 | import { CliCommand } from '../../command'; 5 | 6 | export default class Rm extends CliCommand { 7 | static aliases = ['secrets rm']; 8 | 9 | static description = 'Delete an organization secret in the Cloud'; 10 | static args = { 11 | name: Args.string({ 12 | description: 'The secret name', 13 | required: true, 14 | }), 15 | }; 16 | 17 | static flags = { 18 | org: Flags.string({ 19 | char: 'o', 20 | description: 'Organization', 21 | required: false, 22 | }), 23 | }; 24 | 25 | async run(): Promise { 26 | const { 27 | flags: { org, interactive }, 28 | args: { name }, 29 | } = await this.parse(Rm); 30 | 31 | const organization = await this.promptOrganization(org, { interactive }); 32 | await removeSecret({ organization, name }); 33 | 34 | this.log(`Secret '${name}' removed`); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/commands/secrets/set.ts: -------------------------------------------------------------------------------- 1 | import { Args, Flags } from '@oclif/core'; 2 | 3 | import { setSecret } from '../../api'; 4 | import { CliCommand } from '../../command'; 5 | 6 | // TODO move to new API using put method 7 | 8 | export default class Set extends CliCommand { 9 | static description = [ 10 | 'Add or update an organization secret in the Cloud. If value is not specified, it reads from standard input.', 11 | `The secret will be exposed as an environment variable with the given name to all the squids.`, 12 | `NOTE: The changes take affect only after a squid is restarted or updated.`, 13 | ].join('\n'); 14 | 15 | static args = { 16 | name: Args.string({ 17 | description: 'The secret name', 18 | required: true, 19 | }), 20 | value: Args.string({ 21 | description: 'The secret value', 22 | required: true, 23 | }), 24 | }; 25 | 26 | static flags = { 27 | org: Flags.string({ 28 | char: 'o', 29 | description: 'Organization', 30 | required: false, 31 | }), 32 | }; 33 | 34 | async run(): Promise { 35 | const { 36 | flags: { org, interactive }, 37 | args: { name, value }, 38 | } = await this.parse(Set); 39 | 40 | const organization = await this.promptOrganization(org, { interactive }); 41 | 42 | let secretValue = value; 43 | if (!secretValue) { 44 | this.logQuestion('Reading plaintext input from stdin.'); 45 | this.logDimmed('Use ctrl-d to end input, twice if secret does not have a newline. Ctrl+c to cancel'); 46 | secretValue = await readFromStdin(); 47 | } 48 | 49 | await setSecret({ name, value: secretValue, organization }); 50 | 51 | this.logSuccess(`Set secret ${name} for organization ${organization.code}`); 52 | } 53 | } 54 | 55 | async function readFromStdin(): Promise { 56 | let res = ''; 57 | return await new Promise((resolve, reject) => { 58 | process.stdin.on('data', (data) => { 59 | res += data.toString('utf-8'); 60 | }); 61 | process.stdin.on('end', () => { 62 | resolve(res); 63 | }); 64 | process.stdin.on('error', reject); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /src/commands/tags/add.ts: -------------------------------------------------------------------------------- 1 | import { Args, Flags } from '@oclif/core'; 2 | import chalk from 'chalk'; 3 | import inquirer from 'inquirer'; 4 | 5 | import { addSquidTag } from '../../api'; 6 | import { SqdFlags } from '../../command'; 7 | import { DeployCommand } from '../../deploy-command'; 8 | import { formatSquidReference, printSquid } from '../../utils'; 9 | import { UPDATE_COLOR } from '../deploy'; 10 | 11 | export default class Add extends DeployCommand { 12 | static description = 'Add a tag to a squid'; 13 | 14 | static args = { 15 | tag: Args.string({ 16 | description: `New tag to assign`, 17 | required: true, 18 | }), 19 | }; 20 | 21 | static flags = { 22 | org: SqdFlags.org({ 23 | required: false, 24 | }), 25 | name: SqdFlags.name({ 26 | required: false, 27 | }), 28 | slot: SqdFlags.slot({ 29 | required: false, 30 | }), 31 | tag: SqdFlags.tag({ 32 | required: false, 33 | }), 34 | reference: SqdFlags.reference({ 35 | required: false, 36 | }), 37 | 'allow-tag-reassign': Flags.boolean({ 38 | description: 'Allow reassigning an existing tag', 39 | required: false, 40 | default: false, 41 | }), 42 | }; 43 | 44 | async run(): Promise { 45 | const { 46 | args: { tag: tagName }, 47 | flags: { reference, interactive, ...flags }, 48 | } = await this.parse(Add); 49 | 50 | this.validateSquidNameFlags({ reference, ...flags }); 51 | 52 | const { org, name, tag, slot } = reference ? reference : (flags as any); 53 | 54 | const organization = await this.promptSquidOrganization(org, name, { interactive }); 55 | const squid = await this.findOrThrowSquid({ organization, squid: { name, slot, tag } }); 56 | 57 | if (squid.tags.find((t) => t.name === tagName)) { 58 | return this.log(`Tag "${tagName}" is already assigned to the squid ${printSquid(squid)}`); 59 | } 60 | 61 | if (!flags['allow-tag-reassign']) { 62 | const confirm = await this.promptAddTag( 63 | { 64 | organization, 65 | name, 66 | tag: tagName, 67 | }, 68 | { interactive }, 69 | ); 70 | if (!confirm) return; 71 | } 72 | 73 | const attached = await this.promptAttachToDeploy(squid, { interactive }); 74 | if (attached) return; 75 | 76 | const deployment = await addSquidTag({ 77 | organization, 78 | squid, 79 | tag: tagName, 80 | }); 81 | await this.pollDeploy({ organization, deploy: deployment }); 82 | if (!deployment || !deployment.squid) return; 83 | 84 | this.logDeployResult(UPDATE_COLOR, `The squid ${printSquid(squid)} has been successfully updated`); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/commands/tags/remove.ts: -------------------------------------------------------------------------------- 1 | import { Args } from '@oclif/core'; 2 | 3 | import { removeSquidTag } from '../../api'; 4 | import { SqdFlags } from '../../command'; 5 | import { DeployCommand } from '../../deploy-command'; 6 | import { formatSquidReference, printSquid } from '../../utils'; 7 | import { UPDATE_COLOR } from '../deploy'; 8 | 9 | export default class Remove extends DeployCommand { 10 | static description = 'Remove a tag from a squid'; 11 | 12 | static args = { 13 | tag: Args.string({ 14 | description: `New tag to assign`, 15 | required: true, 16 | }), 17 | }; 18 | 19 | static flags = { 20 | org: SqdFlags.org({ 21 | required: false, 22 | }), 23 | name: SqdFlags.name({ 24 | required: false, 25 | }), 26 | slot: SqdFlags.slot({ 27 | required: false, 28 | }), 29 | tag: SqdFlags.tag({ 30 | required: false, 31 | }), 32 | reference: SqdFlags.reference({ 33 | required: false, 34 | }), 35 | }; 36 | 37 | async run(): Promise { 38 | const { 39 | args: { tag: tagName }, 40 | flags: { reference, interactive, ...flags }, 41 | } = await this.parse(Remove); 42 | 43 | this.validateSquidNameFlags({ reference, ...flags }); 44 | 45 | const { org, name, tag, slot } = reference ? reference : (flags as any); 46 | 47 | const organization = await this.promptSquidOrganization(org, name, { interactive }); 48 | const squid = await this.findOrThrowSquid({ organization, squid: { name, tag, slot } }); 49 | 50 | if (!squid.tags.some((t) => t.name === tagName)) { 51 | return this.log(`Tag "${tagName}" is not assigned to the squid ${printSquid(squid)}`); 52 | } 53 | 54 | const attached = await this.promptAttachToDeploy(squid, { interactive }); 55 | if (attached) return; 56 | 57 | const deployment = await removeSquidTag({ 58 | organization, 59 | squid, 60 | tag: tagName, 61 | }); 62 | await this.pollDeploy({ organization, deploy: deployment }); 63 | if (!deployment || !deployment.squid) return; 64 | 65 | this.logDeployResult(UPDATE_COLOR, `The squid ${printSquid(squid)} has been successfully updated`); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/commands/view.ts: -------------------------------------------------------------------------------- 1 | import { json } from 'stream/consumers'; 2 | 3 | import { ux as CliUx, Flags } from '@oclif/core'; 4 | import { Manifest, ManifestValue } from '@subsquid/manifest'; 5 | import chalk from 'chalk'; 6 | import { func } from 'joi'; 7 | import { startCase, toUpper } from 'lodash'; 8 | import prettyBytes from 'pretty-bytes'; 9 | 10 | import { getSquid, Squid, SquidAddonsPostgres } from '../api'; 11 | import { 12 | SquidAddonsHasuraResponseStatus, 13 | SquidApiResponseStatus, 14 | SquidDiskResponseUsageStatus, 15 | SquidProcessorResponseStatus, 16 | SquidResponseStatus, 17 | } from '../api/schema'; 18 | import { CliCommand, SqdFlags } from '../command'; 19 | import { printSquid } from '../utils'; 20 | 21 | export default class View extends CliCommand { 22 | static description = 'View information about a squid'; 23 | 24 | static flags = { 25 | org: SqdFlags.org({ 26 | required: false, 27 | }), 28 | name: SqdFlags.name({ 29 | required: false, 30 | }), 31 | slot: SqdFlags.slot({ 32 | required: false, 33 | }), 34 | tag: SqdFlags.tag({ 35 | required: false, 36 | }), 37 | reference: SqdFlags.reference({ 38 | required: false, 39 | }), 40 | json: Flags.boolean({ 41 | description: 'Output in JSON format', 42 | }), 43 | }; 44 | 45 | async run(): Promise { 46 | const { 47 | flags: { reference, interactive, json, ...flags }, 48 | } = await this.parse(View); 49 | 50 | this.validateSquidNameFlags({ reference, ...flags }); 51 | 52 | const { org, name, slot, tag } = reference ? reference : (flags as any); 53 | 54 | const organization = name 55 | ? await this.promptSquidOrganization(org, name, { interactive }) 56 | : await this.promptOrganization(org, { interactive }); 57 | 58 | const squid = await getSquid({ organization, squid: { name, tag, slot } }); 59 | 60 | if (json) { 61 | return this.log(JSON.stringify(squid, null, 2)); 62 | } 63 | 64 | this.log(`${chalk.bold('SQUID:')} ${printSquid(squid)} (${squid.tags.map((t) => t.name).join(', ')})`); 65 | this.printSquidInfo(squid); 66 | this.log(); 67 | this.log(`View this squid in Cloud: ${chalk.underline(squid.links.cloudUrl)}`); 68 | } 69 | 70 | printSquidInfo(squid: Squid) { 71 | this.printHeader('General'); 72 | printInfoTable([ 73 | { 74 | name: 'Status', 75 | value: formatSquidStatus(squid.status), 76 | }, 77 | { 78 | name: 'Description', 79 | value: squid.description, 80 | }, 81 | { 82 | name: 'Hibernated', 83 | value: squid.hibernatedAt && new Date(squid.hibernatedAt).toUTCString(), 84 | }, 85 | { 86 | name: 'Deployed', 87 | value: squid.deployedAt && new Date(squid.deployedAt).toUTCString(), 88 | }, 89 | { 90 | name: 'Created', 91 | value: squid.createdAt && new Date(squid.createdAt).toUTCString(), 92 | }, 93 | ]); 94 | if (squid.status !== 'HIBERNATED') { 95 | if (squid.api) { 96 | this.printHeader('API'); 97 | printInfoTable([ 98 | { 99 | name: 'Status', 100 | value: formatApiStatus(squid.api?.status), 101 | }, 102 | { 103 | name: 'URL', 104 | value: squid.api?.urls?.map((u) => chalk.underline(u.url)).join('\n'), 105 | }, 106 | { 107 | name: 'Profile', 108 | value: startCase(getManifest(squid).scale?.api?.profile), 109 | }, 110 | { 111 | name: 'Replicas', 112 | value: getManifest(squid).scale?.api?.replicas, 113 | }, 114 | ]); 115 | } 116 | for (const processor of squid.processors || []) { 117 | this.printHeader(`Processor (${processor.name})`); 118 | printInfoTable([ 119 | { 120 | name: 'Status', 121 | value: formatProcessorStatus(processor.status), 122 | }, 123 | { 124 | name: 'Progress', 125 | value: 126 | `${formatNumber(processor.syncState.currentBlock)}/${formatNumber(processor.syncState.totalBlocks)} ` + 127 | `(${Math.round((processor.syncState.currentBlock / processor.syncState.totalBlocks) * 100)}%)`, 128 | }, 129 | { 130 | name: 'Profile', 131 | value: startCase(getManifest(squid).scale?.processor?.profile), 132 | }, 133 | ]); 134 | } 135 | if (squid.addons?.postgres) { 136 | this.printHeader('Addon (Postgres)'); 137 | printInfoTable([ 138 | { 139 | name: 'Usage', 140 | value: formatPostgresStatus(squid.addons?.postgres?.disk.usageStatus), 141 | }, 142 | { 143 | name: 'Disk', 144 | value: 145 | `${prettyBytes(squid.addons?.postgres?.disk.usedBytes)}/${prettyBytes(squid.addons?.postgres?.disk.totalBytes)} ` + 146 | `(${Math.round((squid.addons?.postgres?.disk.usedBytes / squid.addons?.postgres?.disk.totalBytes) * 100)}%)`, 147 | }, 148 | { 149 | name: 'Connection', 150 | value: squid.addons?.postgres?.connections?.map((c) => c.uri).join('\n'), 151 | }, 152 | { 153 | name: 'Profile', 154 | value: startCase(getManifest(squid).scale?.addons?.postgres?.profile), 155 | }, 156 | ]); 157 | } 158 | if (squid.addons?.neon) { 159 | this.printHeader('Addon (Neon)'); 160 | printInfoTable([ 161 | { 162 | name: 'Connection', 163 | value: squid.addons?.neon?.connections?.map((c) => c.uri), 164 | }, 165 | ]); 166 | } 167 | if (squid.addons?.hasura) { 168 | this.printHeader('Addon (Hasura)'); 169 | printInfoTable([ 170 | { 171 | name: 'Status', 172 | value: formatApiStatus(squid.addons?.hasura?.status), 173 | }, 174 | { 175 | name: 'URL', 176 | value: squid.addons?.hasura?.urls?.map((u) => chalk.underline(u.url)).join('\n'), 177 | }, 178 | { 179 | name: 'Profile', 180 | value: startCase(squid.addons?.hasura?.profile), 181 | }, 182 | { 183 | name: 'Replicas', 184 | value: squid.addons?.hasura?.replicas, 185 | }, 186 | ]); 187 | } 188 | } 189 | } 190 | 191 | printHeader(value: string) { 192 | this.log(); 193 | this.log(`${chalk.dim('===')} ${chalk.bold(value)}`); 194 | } 195 | } 196 | 197 | function printInfoTable( 198 | data: { 199 | name: string; 200 | value: any; 201 | }[], 202 | ) { 203 | CliUx.ux.table( 204 | data, 205 | { 206 | name: { 207 | get: (v) => chalk.dim(v.name), 208 | minWidth: 14, 209 | }, 210 | value: { 211 | get: (v) => v.value ?? '-', 212 | }, 213 | }, 214 | { 'no-header': true, 'no-truncate': true }, 215 | ); 216 | } 217 | 218 | function formatSquidStatus(status?: SquidResponseStatus) { 219 | switch (status) { 220 | case 'HIBERNATED': 221 | return chalk.gray(status); 222 | case 'DEPLOYED': 223 | return chalk.green(status); 224 | case 'DEPLOYING': 225 | return chalk.blue(status); 226 | case 'DEPLOY_ERROR': 227 | return chalk.red(status); 228 | default: 229 | return status; 230 | } 231 | } 232 | 233 | function formatApiStatus(status?: SquidApiResponseStatus | SquidAddonsHasuraResponseStatus) { 234 | switch (status) { 235 | case 'AVAILABLE': 236 | return chalk.green(status); 237 | case 'NOT_AVAILABLE': 238 | return chalk.red(status); 239 | default: 240 | return status; 241 | } 242 | } 243 | 244 | function formatProcessorStatus(status?: SquidProcessorResponseStatus) { 245 | switch (status) { 246 | case 'STARTING': 247 | return chalk.blue(status); 248 | case 'SYNCING': 249 | return chalk.yellow(status); 250 | case 'SYNCED': 251 | return chalk.green(status); 252 | default: 253 | return status; 254 | } 255 | } 256 | 257 | function getManifest(squid: Squid): ManifestValue { 258 | return squid.manifest.current as ManifestValue; 259 | } 260 | 261 | function formatPostgresStatus(status?: SquidDiskResponseUsageStatus): any { 262 | switch (status) { 263 | case 'LOW': 264 | return chalk.green(status); 265 | case 'NORMAL': 266 | return chalk.green(status); 267 | case 'WARNING': 268 | return chalk.yellow(status); 269 | case 'CRITICAL': 270 | return chalk.red(status); 271 | default: 272 | return status; 273 | } 274 | } 275 | 276 | export function formatNumber(value: number) { 277 | return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value); 278 | } 279 | -------------------------------------------------------------------------------- /src/commands/whoami.ts: -------------------------------------------------------------------------------- 1 | import { profile } from '../api/profile'; 2 | import { CliCommand } from '../command'; 3 | import { getConfig } from '../config'; 4 | 5 | export default class Whoami extends CliCommand { 6 | static description = `Show the user details for the current Cloud account`; 7 | 8 | async run(): Promise { 9 | await this.parse(Whoami); 10 | 11 | const { username, email } = await profile(); 12 | const { apiUrl, credentials } = getConfig(); 13 | 14 | if (email) { 15 | this.log(`Email: ${email}`); 16 | } 17 | if (username) { 18 | this.log(`Username: ${username}`); 19 | } 20 | this.log(`API URL: ${apiUrl}`); 21 | this.log(`Token: ${credentials}`); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/config/config.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; 2 | import { homedir } from 'os'; 3 | import { resolve, dirname } from 'path'; 4 | 5 | export const DEFAULT_API_URL = process.env.SUBSQUID_DEFAULT_API_URL || 'https://cloud.sqd.dev/api'; 6 | 7 | export type Config = { 8 | apiUrl: string; 9 | credentials: string; 10 | }; 11 | 12 | function defaultConfig(apiUrl: string = DEFAULT_API_URL) { 13 | return { 14 | apiUrl, 15 | credentials: 'empty', 16 | }; 17 | } 18 | 19 | export function getConfigFilePath() { 20 | return process.env.SUBSQUID_CLI_CONFIG_DIR || resolve(homedir(), '.hydra-cli', 'config.json'); 21 | } 22 | 23 | function writeConfig(config: Config) { 24 | const path = getConfigFilePath(); 25 | const dir = dirname(path); 26 | 27 | if (!existsSync(path)) { 28 | if (!existsSync(dir)) { 29 | mkdirSync(dir); 30 | } 31 | } 32 | 33 | writeFileSync(path, JSON.stringify(config), { 34 | flag: 'w', 35 | encoding: 'utf8', 36 | }); 37 | } 38 | 39 | export function getConfig(): Config { 40 | try { 41 | const config = JSON.parse(readFileSync(getConfigFilePath(), 'utf8')); 42 | 43 | // Migrate old config API URL 44 | if (config.apiUrl === 'https://app.subsquid.io/api') { 45 | config.apiUrl = DEFAULT_API_URL; 46 | writeConfig(config); 47 | } 48 | 49 | return config; 50 | } catch (e) { 51 | return defaultConfig(); 52 | } 53 | } 54 | 55 | export function setConfig(creds: string, host: string) { 56 | const config = { 57 | ...getConfig(), 58 | apiUrl: host, 59 | credentials: creds, 60 | }; 61 | 62 | writeConfig(config); 63 | 64 | return config; 65 | } 66 | 67 | /** 68 | * @deprecated Use getConfig() 69 | */ 70 | export function getCreds(): string { 71 | return getConfig().credentials; 72 | } 73 | /** 74 | * @deprecated Use getConfig() 75 | */ 76 | export function getConfigField(name: 'apiUrl' | 'credentials'): any { 77 | return getConfig()[name]; 78 | } 79 | -------------------------------------------------------------------------------- /src/config/config.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { unlinkSync } from 'fs'; 2 | import { homedir } from 'os'; 3 | 4 | import { DEFAULT_API_URL, getConfig, getConfigFilePath, setConfig } from './config'; 5 | 6 | describe('Config', () => { 7 | describe('getConfigOption', () => { 8 | afterAll(() => { 9 | process.env.SUBSQUID_CLI_CONFIG_DIR = undefined; 10 | }); 11 | it('should return default config path', () => { 12 | expect(getConfigFilePath()).toEqual(`${homedir()}/.hydra-cli/config.json`); 13 | }); 14 | 15 | it('should return override config path via env', () => { 16 | process.env.SUBSQUID_CLI_CONFIG_DIR = `${__dirname}/config.json`; 17 | expect(getConfigFilePath()).toEqual(process.env.SUBSQUID_CLI_CONFIG_DIR); 18 | }); 19 | }); 20 | 21 | describe('getConfig', () => { 22 | afterAll(() => { 23 | if (!process.env.SUBSQUID_CLI_CONFIG_DIR) return; 24 | 25 | unlinkSync(process.env.SUBSQUID_CLI_CONFIG_DIR); 26 | process.env.SUBSQUID_CLI_CONFIG_DIR = undefined; 27 | }); 28 | 29 | it('should return default config if config did not exists', () => { 30 | process.env.SUBSQUID_CLI_CONFIG_DIR = `${__dirname}/test-stubs/config1.json`; 31 | expect(getConfig()).toMatchObject({ apiUrl: DEFAULT_API_URL, credentials: 'empty' }); 32 | }); 33 | 34 | it('should set and get same config', () => { 35 | process.env.SUBSQUID_CLI_CONFIG_DIR = `${__dirname}/test-stubs/config2.json`; 36 | expect(setConfig('testToken', 'test.ru')).toMatchObject({ apiUrl: 'test.ru', credentials: 'testToken' }); 37 | expect(getConfig()).toMatchObject({ apiUrl: 'test.ru', credentials: 'testToken' }); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config'; 2 | -------------------------------------------------------------------------------- /src/deploy-command.ts: -------------------------------------------------------------------------------- 1 | import { ux as CliUx } from '@oclif/core'; 2 | import chalk, { ForegroundColor } from 'chalk'; 3 | import inquirer from 'inquirer'; 4 | 5 | import { Deployment, DeployRequest, getDeploy, Organization, Squid, SquidRequest, streamSquidLogs } from './api'; 6 | import { CliCommand, SUCCESS_CHECK_MARK } from './command'; 7 | import { doUntil, formatSquidReference, printSquid } from './utils'; 8 | 9 | export abstract class DeployCommand extends CliCommand { 10 | deploy: Deployment | undefined; 11 | logsPrinted = 0; 12 | 13 | async promptAttachToDeploy(squid: Squid, { interactive }: { interactive?: boolean } = {}) { 14 | if (!squid.lastDeploy) return false; 15 | if (squid.status !== 'DEPLOYING') return false; 16 | 17 | const warning = `Squid ${printSquid(squid)} is being deploying. 18 | You can not run deploys on the same squid in parallel`; 19 | 20 | if (!interactive) { 21 | this.error(warning); 22 | } 23 | 24 | this.warn(warning); 25 | 26 | switch (squid.lastDeploy.type) { 27 | // we should react only for running deploy 28 | case 'DEPLOY': 29 | const { confirm } = await inquirer.prompt([ 30 | { 31 | name: 'confirm', 32 | type: 'confirm', 33 | message: `Do you want to attach to the running deploy process?`, 34 | }, 35 | ]); 36 | if (!confirm) return false; 37 | 38 | if (squid.organization) { 39 | await this.pollDeploy({ 40 | organization: squid.organization, 41 | deploy: squid.lastDeploy, 42 | streamLogs: true, 43 | }); 44 | } 45 | 46 | return true; 47 | } 48 | } 49 | 50 | async promptAddTag( 51 | { organization, name, tag }: { organization: Pick; name: string; tag: string }, 52 | { using = 'using "--allow-tag-reassign" flag', interactive }: { using?: string; interactive?: boolean } = {}, 53 | ) { 54 | const oldSquid = await this.findSquid({ 55 | organization, 56 | squid: { name, tag }, 57 | }); 58 | if (!oldSquid) return true; 59 | 60 | const warning = `The tag "${tag}" has already been assigned to ${printSquid(oldSquid)}.`; 61 | 62 | if (!interactive) { 63 | this.error([warning, `Please do it explicitly ${using}`].join('\n')); 64 | } 65 | 66 | this.warn(warning); 67 | 68 | const { confirm } = await inquirer.prompt([ 69 | { 70 | name: 'confirm', 71 | type: 'confirm', 72 | message: 'Are you sure?', 73 | prefix: `The tag will be reassigned.`, 74 | }, 75 | ]); 76 | 77 | return !!confirm; 78 | } 79 | 80 | async pollDeploy({ 81 | deploy, 82 | organization, 83 | }: DeployRequest & { streamLogs?: boolean }): Promise { 84 | let lastStatus: string; 85 | let validatedPrinted = false; 86 | 87 | await doUntil( 88 | async () => { 89 | this.deploy = await getDeploy({ deploy, organization }); 90 | 91 | if (!this.deploy) return true; 92 | 93 | this.printDebug(); 94 | 95 | if (this.isFailed()) return this.showError(`An error occurred while deploying the squid`); 96 | if (this.deploy.status === lastStatus) return false; 97 | lastStatus = this.deploy.status; 98 | CliUx.ux.action.stop(SUCCESS_CHECK_MARK); 99 | 100 | switch (this.deploy.status) { 101 | case 'UNPACKING': 102 | CliUx.ux.action.start('◷ Preparing the squid'); 103 | 104 | return false; 105 | case 'RESETTING': 106 | CliUx.ux.action.start('◷ Resetting the squid'); 107 | 108 | return false; 109 | case 'IMAGE_BUILDING': 110 | CliUx.ux.action.start('◷ Building the squid'); 111 | 112 | if (!validatedPrinted) { 113 | this.log( 114 | '◷ You may now detach from the build process by pressing Ctrl + C. The Squid deployment will continue uninterrupted.', 115 | ); 116 | this.log('◷ The new squid will be available as soon as the deployment is complete.'); 117 | validatedPrinted = true; 118 | } 119 | 120 | return false; 121 | case 'SQUID_DELETING': 122 | CliUx.ux.action.start('◷ Deleting the squid'); 123 | 124 | return false; 125 | case 'ADDONS_DELETING': 126 | CliUx.ux.action.start('◷ Deleting the squid addons'); 127 | 128 | return false; 129 | case 'DEPLOYING': 130 | case 'SQUID_SYNCING': 131 | CliUx.ux.action.start('◷ Syncing the squid'); 132 | 133 | return false; 134 | case 'ADDONS_SYNCING': 135 | CliUx.ux.action.start('◷ Syncing the squid addons'); 136 | 137 | return false; 138 | case 'ADDING_INGRESS': 139 | case 'REMOVING_INGRESS': 140 | CliUx.ux.action.start('◷ Configuring ingress'); 141 | 142 | return false; 143 | case 'OK': 144 | this.log(`Done! ${SUCCESS_CHECK_MARK}`); 145 | 146 | return true; 147 | default: 148 | /** 149 | * Just wait if some unexpected status has been received. 150 | * This behavior is more safe for forward compatibility 151 | */ 152 | return false; 153 | } 154 | }, 155 | { pause: 3000 }, 156 | ); 157 | 158 | return this.deploy; 159 | } 160 | 161 | async streamLogs({ organization, squid }: SquidRequest) { 162 | CliUx.ux.action.start(`Streaming logs from the squid`); 163 | 164 | await streamSquidLogs({ 165 | organization, 166 | squid, 167 | onLog: (l) => this.log(l), 168 | }); 169 | } 170 | 171 | printDebug = () => { 172 | if (!this.deploy) return; 173 | 174 | const logs = this.deploy.logs.slice(this.logsPrinted); 175 | if (logs.length === 0) return; 176 | 177 | this.logsPrinted += logs.length; 178 | 179 | logs 180 | .filter((v) => v) 181 | .forEach(({ severity, message }) => { 182 | switch (severity) { 183 | case 'info': 184 | this.log(chalk.cyan(message)); 185 | return; 186 | case 'warn': 187 | this.log(chalk.yellow(message)); 188 | return; 189 | case 'error': 190 | this.log(chalk.red(message)); 191 | return; 192 | default: 193 | this.log(chalk.dim(message)); 194 | } 195 | }); 196 | }; 197 | 198 | showError(text: string, reason?: string): never { 199 | CliUx.ux.action.stop('❌'); 200 | 201 | reason = reason || this.deploy?.failed || 'UNEXPECTED'; 202 | const errors: (string | null)[] = [text]; 203 | if (reason === 'UNEXPECTED') { 204 | errors.push( 205 | `------`, 206 | 'Please report to Discord https://discord.gg/KRvRcBdhEE or SquidDevs https://t.me/HydraDevs', 207 | `${chalk.dim('Deploy:')} ${this.deploy?.id}`, 208 | ); 209 | 210 | if (this.deploy?.squid) { 211 | errors.push(`${chalk.dim('Squid:')} ${formatSquidReference(this.deploy.squid)}`); 212 | } 213 | } 214 | 215 | // FIXME: maybe we should send an error report ourselves here with more details? 216 | this.error(errors.filter(Boolean).join('\n')); 217 | } 218 | 219 | isFailed() { 220 | if (!this.deploy) return true; 221 | 222 | return this.deploy.failed !== 'NO'; 223 | } 224 | 225 | logDeployResult(color: typeof ForegroundColor, message: string) { 226 | this.log( 227 | [ 228 | '', 229 | chalk[color](`=================================================`), 230 | message, 231 | chalk[color](`=================================================`), 232 | '', 233 | ].join('\n'), 234 | ); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/flags/fullname.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import Joi from 'joi'; 3 | 4 | import { JoiSquidReference, ParsedSquidReference, parseSquidReference, SQUID_FULLNAME_REGEXP } from '../utils'; 5 | 6 | export const reference = Flags.custom({ 7 | char: 'r', 8 | helpGroup: 'SQUID', 9 | name: 'reference', 10 | aliases: ['ref'], 11 | description: `Fully qualified reference of the squid. It can include the organization, name, slot, or tag`, 12 | helpValue: '[/](@|:)', 13 | required: false, 14 | exclusive: ['org', 'name', 'slot', 'tag'], 15 | parse: async (input) => { 16 | const res = parseSquidReference(input); 17 | return await JoiSquidReference.validateAsync(res); 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/flags/index.ts: -------------------------------------------------------------------------------- 1 | export { reference } from './fullname'; 2 | export { name } from './name'; 3 | export { org } from './org'; 4 | export { slot } from './slot'; 5 | export { tag } from './tag'; 6 | -------------------------------------------------------------------------------- /src/flags/name.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import { JoiSquidName } from '@subsquid/manifest'; 3 | 4 | export const name = Flags.custom({ 5 | helpGroup: 'SQUID', 6 | char: 'n', 7 | name: 'name', 8 | description: 'Name of the squid', 9 | helpValue: '', 10 | required: false, 11 | parse: async (input) => { 12 | return await JoiSquidName.validateAsync(input); 13 | }, 14 | relationships: [ 15 | { 16 | type: 'some', 17 | flags: [ 18 | { name: 'slot', when: async (flags) => !flags['tag'] }, 19 | { name: 'tag', when: async (flags) => !flags['slot'] }, 20 | ], 21 | }, 22 | ], 23 | }); 24 | -------------------------------------------------------------------------------- /src/flags/org.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | 3 | export const org = Flags.custom({ 4 | helpGroup: 'ORG', 5 | char: 'o', 6 | name: 'org', 7 | description: 'Code of the organization', 8 | helpValue: '', 9 | required: false, 10 | parse: async (input) => { 11 | return input.toLowerCase(); 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/flags/slot.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import { JoiSquidSlot } from '@subsquid/manifest'; 3 | 4 | export const slot = Flags.custom({ 5 | helpGroup: 'SQUID', 6 | char: 's', 7 | name: 'slot', 8 | description: 'Slot of the squid', 9 | helpValue: '', 10 | parse: async (input) => { 11 | return await JoiSquidSlot.validateAsync(input); 12 | }, 13 | required: false, 14 | dependsOn: ['name'], 15 | exclusive: ['tag'], 16 | }); 17 | -------------------------------------------------------------------------------- /src/flags/tag.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from '@oclif/core'; 2 | import { JoiSquidTag } from '@subsquid/manifest'; 3 | 4 | export const tag = Flags.custom({ 5 | helpGroup: 'SQUID', 6 | char: 't', 7 | name: 'tag', 8 | description: 'Tag of the squid', 9 | helpValue: '', 10 | required: false, 11 | parse: async (input) => { 12 | return await JoiSquidTag.validateAsync(input); 13 | }, 14 | dependsOn: ['name'], 15 | exclusive: ['slot'], 16 | }); 17 | -------------------------------------------------------------------------------- /src/help.ts: -------------------------------------------------------------------------------- 1 | import { Help as OclifHelp, ux as CliUx } from '@oclif/core'; 2 | import * as Interfaces from '@oclif/core/lib/interfaces'; 3 | import chalk from 'chalk'; 4 | 5 | import { getSquidCommands } from './utils'; 6 | 7 | const TABLE_OPTIONS = { 8 | 'no-header': true, 9 | 'no-truncate': true, 10 | }; 11 | 12 | const TOOLS_COMMANDS = ['docs', 'autocomplete', 'init']; 13 | 14 | function capitalizeFirstLetter(val: string | undefined) { 15 | if (!val) return null; 16 | 17 | return val.charAt(0).toUpperCase() + val.slice(1); 18 | } 19 | 20 | const COMMANDS_FORMAT = { 21 | name: { 22 | minWidth: 13, 23 | get: (c: any) => `${c.name}`, // ${c.aliases ? '\n' + c.aliases.join(', ') : ''} 24 | }, 25 | description: { 26 | get: (c: any) => capitalizeFirstLetter(c.description), 27 | }, 28 | }; 29 | 30 | export default class Help extends OclifHelp { 31 | async showRootHelp() { 32 | this.log('Subsquid CLI tool'); 33 | 34 | this.log(); 35 | this.helpHeader('VERSION'); 36 | this.log(this.config.pjson.name, chalk.dim(`(${this.config.pjson.version})`)); 37 | 38 | this.log(); 39 | this.helpHeader('CLOUD COMMANDS'); 40 | 41 | const commands = Help.getVisibleCloudCommands(this.config); 42 | CliUx.ux.table( 43 | commands.filter((c) => !TOOLS_COMMANDS.includes(c.name)), 44 | COMMANDS_FORMAT, 45 | TABLE_OPTIONS, 46 | ); 47 | 48 | const squidCommands = await Help.getVisibleSquidCommands(); 49 | if (squidCommands.length !== 0) { 50 | this.log(); 51 | this.helpHeader('SQUID COMMANDS'); 52 | 53 | CliUx.ux.table(squidCommands, COMMANDS_FORMAT, TABLE_OPTIONS); 54 | } 55 | 56 | this.log(); 57 | this.helpHeader('TOOLS'); 58 | 59 | CliUx.ux.table( 60 | commands.filter((c) => TOOLS_COMMANDS.includes(c.name)), 61 | COMMANDS_FORMAT, 62 | TABLE_OPTIONS, 63 | ); 64 | } 65 | 66 | static async getVisibleSquidCommands(): Promise<{ name: string; description?: string }[]> { 67 | const config = await getSquidCommands(); 68 | if (!config) return []; 69 | 70 | return Object.entries(config.commands || {}) 71 | .filter(([, cmd]) => !cmd.hidden) 72 | .map(([name, cmd]) => ({ 73 | name, 74 | description: cmd.description, 75 | })); 76 | } 77 | 78 | static getVisibleCloudCommands( 79 | config: Interfaces.Config, 80 | ): { name: string; description?: string; aliases: string[] }[] { 81 | const aliases = new Set(); 82 | 83 | return config.commands 84 | .filter((c) => !c.hidden) 85 | .map((c) => { 86 | c.aliases.forEach((a) => { 87 | aliases.add(a); 88 | }); 89 | 90 | const [description] = (c.summary || c.description || '').split('\n'); 91 | 92 | return { 93 | name: c.id.replace(':', ' '), 94 | aliases: c.aliases, 95 | description: description, 96 | }; 97 | }) 98 | .filter((c) => !aliases.has(c.name)); 99 | } 100 | 101 | helpHeader(str: string) { 102 | this.log(chalk.bold(str.toUpperCase())); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/hooks/command_not_found.ts: -------------------------------------------------------------------------------- 1 | import { Hook, toConfiguredId } from '@oclif/core'; 2 | import { run as squidCommandRun } from '@subsquid/commands/lib/run'; 3 | import chalk from 'chalk'; 4 | import Levenshtein from 'fast-levenshtein'; 5 | import { minBy } from 'lodash'; 6 | 7 | import Help from '../help'; 8 | import { getSquidCommands } from '../utils'; 9 | 10 | function closestCommand(cmd: string, commands: string[]) { 11 | return minBy(commands, (c) => Levenshtein.get(cmd, c))!; 12 | } 13 | 14 | const hook: Hook<'command_not_found'> = async function ({ id, argv, config }) { 15 | const squidCmdConfig = await getSquidCommands(); 16 | if (squidCmdConfig?.commands?.[id]) { 17 | process.exit(await squidCommandRun(squidCmdConfig, id, (argv || []).slice(1))); 18 | } 19 | 20 | const squidCommands = await Help.getVisibleSquidCommands(); 21 | const suggestion = closestCommand(id, [ 22 | ...Help.getVisibleCloudCommands(config).map(({ name }) => name), 23 | ...squidCommands.map(({ name }) => name), 24 | ]); 25 | const readableSuggestion = toConfiguredId(suggestion, config); 26 | 27 | this.log(`"${id}" is not a ${config.bin} command.`); 28 | this.log(`Did you mean "${readableSuggestion}"?`); 29 | this.log(chalk.dim(`Run "${config.bin} help" for a list of available commands.`)); 30 | }; 31 | 32 | export default hook; 33 | -------------------------------------------------------------------------------- /src/logs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './printer'; 2 | -------------------------------------------------------------------------------- /src/logs/printer.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | import { LogEntry, LogLevel, LogPayload } from '../api'; 4 | 5 | function getLevel(level: LogLevel) { 6 | switch (level) { 7 | case LogLevel.Debug: 8 | return chalk.dim(level); 9 | case LogLevel.Info: 10 | case LogLevel.Notice: 11 | return chalk.cyan(level); 12 | case LogLevel.Warning: 13 | return chalk.yellow(level); 14 | case LogLevel.Error: 15 | case LogLevel.Critical: 16 | case LogLevel.Fatal: 17 | return chalk.red(level); 18 | default: 19 | return chalk.dim(level); 20 | } 21 | } 22 | 23 | function getPayload(container: string, payload: LogPayload) { 24 | if (typeof payload === 'string') { 25 | return payload || ''; 26 | } 27 | 28 | const { msg, ns, err, level, ...rest } = payload; 29 | const res = [ns ? chalk.cyan(ns) : null, msg]; 30 | 31 | if (container === 'db' && rest.statement) { 32 | res.push(rest.statement); 33 | delete rest.statement; 34 | } 35 | 36 | // log if message is empty or some additional data exists 37 | if (!msg || Object.keys(rest).length !== 0) { 38 | res.push(chalk.dim(JSON.stringify(rest))); 39 | } 40 | 41 | return res.filter((v) => Boolean(v)).join(' '); 42 | } 43 | 44 | export function pretty(logs: LogEntry[]) { 45 | return logs.map(({ container, timestamp, level, payload }) => { 46 | return `${container ? chalk.magentaBright(container) + ' ' : ''}${chalk.dim(timestamp)} ${getLevel( 47 | level, 48 | )} ${getPayload(container, payload)}`; 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/manifest/index.ts: -------------------------------------------------------------------------------- 1 | export * from './manifest'; 2 | -------------------------------------------------------------------------------- /src/manifest/manifest.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | import { Manifest } from '@subsquid/manifest'; 5 | import { Expression, Parser } from '@subsquid/manifest-expr'; 6 | import { mapValues } from 'lodash'; 7 | 8 | export function readManifest(path: string) { 9 | return fs.readFileSync(path).toString(); 10 | } 11 | 12 | export function saveManifest(path: string, manifest: string) { 13 | fs.writeFileSync(path, manifest); 14 | } 15 | 16 | export function evalManifestEnv(env: Record, context: Record) { 17 | const parsed = parseManifestEnv(env); 18 | 19 | return mapValues(parsed, (value) => (value instanceof Expression ? value.eval(context) : value)); 20 | } 21 | 22 | export function parseManifestEnv(env: Record) { 23 | const parser = new Parser(); 24 | 25 | return mapValues(env, (value) => (typeof value === 'string' ? parser.parse(value) : value)); 26 | } 27 | 28 | export function loadManifestFile( 29 | localPath: string, 30 | manifestPath: string, 31 | ): { squidDir: string; manifest: Manifest; manifestRaw: string } { 32 | const squidDir = path.resolve(localPath); 33 | 34 | if (!fs.statSync(squidDir).isDirectory()) { 35 | throw new Error( 36 | [ 37 | `The provided path is not a directory`, 38 | ``, 39 | `Squid directory ${squidDir}`, 40 | ``, 41 | `Please provide a path to the root of a squid directory`, 42 | ``, 43 | ].join('\n'), 44 | ); 45 | } 46 | 47 | const manifestFullPath = path.isAbsolute(manifestPath) 48 | ? manifestPath 49 | : path.resolve(path.join(localPath, manifestPath)); 50 | 51 | if (!fs.existsSync(manifestFullPath)) { 52 | throw new Error( 53 | [ 54 | `The manifest file is not found`, 55 | ``, 56 | `Manifest path ${manifestFullPath}`, 57 | ``, 58 | `Please provide a path to a valid manifest inside the squid directory using "-m" flag`, 59 | ``, 60 | ].join('\n'), 61 | ); 62 | } 63 | 64 | if (!manifestFullPath.startsWith(squidDir)) { 65 | throw new Error( 66 | [ 67 | `The manifest is located outside the squid directory.`, 68 | ``, 69 | `Squid directory ${squidDir}`, 70 | `Manifest ${manifestFullPath}`, 71 | ``, 72 | `To fix the problem, please`, 73 | ` — check the squid directory is correct`, 74 | ` — move manifest inside into the squid directory`, 75 | ``, 76 | ].join('\n'), 77 | ); 78 | } 79 | 80 | if (fs.statSync(manifestFullPath).isDirectory()) { 81 | throw new Error( 82 | [ 83 | `The path ${manifestFullPath} is a directory, not a manifest file`, 84 | `Please provide a path to a valid manifest inside squid directory using -m flag `, 85 | ].join('\n'), 86 | ); 87 | } 88 | 89 | let manifest: Manifest; 90 | let manifestRaw: string; 91 | try { 92 | manifestRaw = fs.readFileSync(manifestFullPath).toString(); 93 | const { value, error } = Manifest.parse(manifestRaw, { validation: { allowUnknown: true } }); 94 | if (error) { 95 | throw error; 96 | } 97 | manifest = value; 98 | } catch (e: any) { 99 | throw new Error( 100 | `The manifest file on ${manifestFullPath} can not be parsed: ${e instanceof Error ? e.message : e}`, 101 | ); 102 | } 103 | return { 104 | squidDir, 105 | manifest, 106 | manifestRaw, 107 | }; 108 | } 109 | -------------------------------------------------------------------------------- /src/tty.ts: -------------------------------------------------------------------------------- 1 | import { OpenMode, openSync } from 'fs'; 2 | import tty from 'tty'; 3 | 4 | function tryOpenSync(path: string, flags: OpenMode) { 5 | try { 6 | return openSync(path, flags); 7 | } catch (e) { 8 | return; 9 | } 10 | } 11 | 12 | // https://github.com/postgres/postgres/blob/741fb0056eda5e7bd03deb0719121f88b4b9e34a/src/common/sprompt.c#L67 13 | function openTTY() { 14 | if (process.platform == 'win32') { 15 | return { 16 | ttyFdIn: tryOpenSync('CONIN$', 'w+'), 17 | ttyFdOut: tryOpenSync('CONOUT$', 'w+'), 18 | }; 19 | } 20 | return { 21 | ttyFdIn: tryOpenSync('/dev/tty', 'r'), 22 | ttyFdOut: tryOpenSync('/dev/tty', 'w'), 23 | }; 24 | } 25 | 26 | export function getTTY() { 27 | const { ttyFdIn, ttyFdOut } = openTTY(); 28 | let stdin: tty.ReadStream | undefined = undefined; 29 | let stdout: tty.WriteStream | undefined = undefined; 30 | 31 | if (ttyFdIn && tty.isatty(ttyFdIn)) { 32 | stdin = new tty.ReadStream(ttyFdIn); 33 | } 34 | 35 | if (ttyFdOut && tty.isatty(ttyFdOut)) { 36 | stdout = new tty.WriteStream(ttyFdOut); 37 | } 38 | 39 | return { stdin, stdout }; 40 | } 41 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Widgets } from 'blessed'; 2 | 3 | declare module 'reblessed' { 4 | export * from 'blessed' 5 | 6 | export class Element extends Widgets.BoxElement {} 7 | export class TextElement extends Widgets.TextElement {} 8 | export class ListTable extends Widgets.ListTableElement {} 9 | export class List extends Widgets.ListElement {} 10 | export class Log extends Widgets.Log {} 11 | export class BigText extends Widgets.BoxElement {} 12 | 13 | export function image(options?: Widgets.BoxOptions): Widgets.BoxElement; 14 | }; -------------------------------------------------------------------------------- /src/ui/components/Loader.ts: -------------------------------------------------------------------------------- 1 | import { defaultsDeep } from 'lodash'; 2 | import blessed, { Widgets } from 'reblessed'; 3 | 4 | import { mainColor } from '../theme'; 5 | 6 | const frames = ['▰▱▱▱▱▱▱', '▰▰▱▱▱▱▱', '▰▰▰▱▱▱▱', '▰▰▰▰▱▱▱', '▰▰▰▰▰▱▱', '▰▰▰▰▰▰▱', '▰▰▰▰▰▰▰', '▰▱▱▱▱▱▱']; // arr of symbols to form loader 7 | 8 | const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 9 | 10 | export class Loader extends blessed.Element { 11 | step = 0; 12 | renderedAt = Date.now(); 13 | interval; 14 | 15 | constructor(options: Widgets.BoxOptions = {}) { 16 | options = defaultsDeep(options, { 17 | top: '50%', 18 | left: '50%', 19 | style: { 20 | fg: mainColor, 21 | }, 22 | content: frames[0], 23 | }); 24 | 25 | if (options.top?.toString().includes('%') && !options.top?.toString().includes('%-')) { 26 | options.top += '-2'; 27 | } 28 | if (options.left?.toString().includes('%') && !options.left?.toString().includes('%-')) { 29 | options.left += '-5'; 30 | } 31 | 32 | super(options); 33 | 34 | this.interval = setInterval(() => { 35 | this.step = (this.step + 1) % frames.length; 36 | 37 | this.setContent(frames[this.step]); 38 | 39 | this.screen.render(); 40 | }, 100); 41 | } 42 | 43 | async destroyWithTimeout(minTimeout = 0) { 44 | if (minTimeout > 0) { 45 | const sleepTime = Date.now() - this.renderedAt; 46 | 47 | if (sleepTime > 0) { 48 | await sleep(sleepTime); 49 | } 50 | } 51 | 52 | if (!this.parent) { 53 | return false; 54 | } 55 | 56 | this.parent.remove(this); 57 | this.destroy(); 58 | clearInterval(this.interval); 59 | return true; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ui/components/SquidList.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | import { defaultsDeep } from 'lodash'; 3 | import blessed, { List, TextElement, Widgets } from 'reblessed'; 4 | 5 | import { SquidAddonsPostgres, SquidApi } from '../../api'; 6 | import { mainColor } from '../theme'; 7 | 8 | import { Squid } from './types'; 9 | 10 | function unicodeLength(str: string) { 11 | return [...str.replace(/{[^}]+}/g, '')].length; 12 | } 13 | 14 | function unicodePadEnd(str: string, need: number) { 15 | const length = unicodeLength(str); 16 | 17 | if (length >= need) { 18 | return str; 19 | } 20 | 21 | return str + new Array(need - length).fill(' ').join(''); 22 | } 23 | 24 | function apiStatus(status?: SquidApi['status']) { 25 | switch (status) { 26 | case 'AVAILABLE': 27 | return '✓'; 28 | case 'NOT_AVAILABLE': 29 | return 'x'; 30 | default: 31 | return status || ''; 32 | } 33 | } 34 | 35 | function processorStatus(squid: Squid) { 36 | const processor = squid.processors?.[0]; 37 | if (!processor) return ''; 38 | 39 | switch (processor.status) { 40 | case 'SYNCING': 41 | case 'SYNCED': 42 | const percent = (100 * processor.syncState.currentBlock) / processor.syncState.totalBlocks; 43 | return `${percent.toFixed(2)}%`; 44 | default: 45 | return processor.status; 46 | } 47 | } 48 | 49 | function dbUsage(status?: SquidAddonsPostgres['disk']['usageStatus']) { 50 | switch (status) { 51 | case 'LOW': 52 | return ' ▰▱▱▱ '; 53 | case 'NORMAL': 54 | return ' ▰▰▱▱ '; 55 | case 'WARNING': 56 | return ' ▰▰▰▱ '; 57 | case 'CRITICAL': 58 | return ' ▰▰▰▰ '; 59 | case 'UNKNOWN': 60 | return ' ---- '; 61 | default: 62 | return status || ''; 63 | } 64 | } 65 | 66 | function versionStatus(status: Squid['status']) { 67 | switch (status) { 68 | case undefined: 69 | return 'UNKNOWN'; 70 | case 'HIBERNATED': 71 | return status; 72 | case 'DEPLOYED': 73 | return '✓'; 74 | case 'DEPLOYING': 75 | return '...'; 76 | default: 77 | return status; 78 | } 79 | } 80 | 81 | export class SquidList extends List { 82 | rows: List; 83 | text: TextElement; 84 | squids: Squid[] = []; 85 | 86 | constructor(options: Widgets.BoxOptions) { 87 | super( 88 | defaultsDeep(options, { 89 | vi: true, 90 | keys: true, 91 | mouse: true, 92 | label: 'Squids', 93 | padding: { 94 | left: 0, 95 | right: 0, 96 | }, 97 | border: { 98 | type: 'line', 99 | }, 100 | style: { 101 | border: { 102 | fg: mainColor, 103 | }, 104 | }, 105 | }), 106 | ); 107 | 108 | this.rows = blessed.list({ 109 | vi: true, 110 | keys: true, 111 | tags: true, 112 | mouse: true, 113 | top: 1, 114 | width: '100%-3', 115 | height: '100%-3', 116 | style: { 117 | fg: mainColor, 118 | selected: { 119 | fg: 'white', 120 | bg: mainColor, 121 | }, 122 | }, 123 | }); 124 | this.text = blessed.text({ 125 | top: 0, 126 | }); 127 | 128 | this.rows.on('select item', (item: Widgets.BlessedElement) => { 129 | const index = this.rows.getItemIndex(item); 130 | 131 | this.emit('select', index); 132 | }); 133 | 134 | this.append(this.rows); 135 | this.append(this.text); 136 | 137 | this.screen.on('resize', () => { 138 | this.screen.debug(`resize ${this.screen.width}`); 139 | 140 | this.recalculateTable(this.squids); 141 | this.screen.render(); 142 | }); 143 | } 144 | 145 | calculateRows(headers: string[], data: string[][], maxWidth = Infinity) { 146 | const max: number[] = []; 147 | headers.forEach((v, i) => { 148 | max[i] = Math.max(unicodeLength(v) + 1, max[i] || 0); 149 | }); 150 | 151 | data.forEach((row) => { 152 | row.forEach((v, i) => { 153 | max[i] = Math.max(unicodeLength(v) + 1, max[i] || 0); 154 | }); 155 | }); 156 | 157 | let lastIndex = 0; 158 | let width = 0; 159 | max.forEach((m, i) => { 160 | width += m; 161 | if (width < maxWidth) { 162 | lastIndex = i; 163 | } 164 | }); 165 | 166 | return { 167 | header: headers.slice(0, lastIndex + 1).reduce((res, v, i) => res + unicodePadEnd(v, max[i]), ''), 168 | rows: data.map((row) => row.slice(0, lastIndex + 1).reduce((res, v, i) => res + unicodePadEnd(v, max[i]), '')), 169 | }; 170 | } 171 | 172 | recalculateTable(squids: Squid[]) { 173 | this.screen.debug('recalculate table'); 174 | 175 | const data: string[][] = squids.map((s) => { 176 | return [ 177 | s.name, 178 | versionStatus(s.status), 179 | !s.isHibernated() ? apiStatus(s.api?.status) : '', 180 | !s.isHibernated() ? processorStatus(s) : '', 181 | !s.isHibernated() ? dbUsage(s.addons?.postgres?.disk.usageStatus) : '', 182 | s.deployedAt ? format(new Date(s.deployedAt), 'dd.MM.yy') : '', 183 | ]; 184 | }); 185 | 186 | const width = typeof this.width === 'string' ? parseInt(this.width) : this.width; 187 | 188 | this.screen.debug(`manager resize ${width}`); 189 | 190 | const { header, rows } = this.calculateRows( 191 | ['Name', 'Deploy', 'API', 'Processor', ' DB ', 'Deployed'].map((s) => s.toUpperCase()), 192 | data, 193 | width, 194 | ); 195 | 196 | this.setLabel(`Squids (${squids.length})`); 197 | this.text.setContent(header); 198 | this.rows.setItems(rows.map((r, i) => this.colorize(r, squids[i]))); 199 | 200 | this.squids = squids; 201 | } 202 | 203 | colorize(data: string, squid: Squid) { 204 | const color = squid.getColor(); 205 | 206 | if (!color) return data; 207 | 208 | return `{${color}-fg}${data}{/${color}-fg}`; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/ui/components/Tabs.ts: -------------------------------------------------------------------------------- 1 | import blessed, { Element, Widgets } from 'reblessed'; 2 | 3 | import { mainColor } from '../theme'; 4 | 5 | import { Squid } from './types'; 6 | 7 | interface VersionTabConstructor { 8 | new (): VersionTab; 9 | } 10 | 11 | export type Cancellable = void | (() => void) | undefined; 12 | 13 | export interface VersionTab { 14 | append(holder: Element, squid: Squid): Promise; 15 | } 16 | 17 | export type Tab = { 18 | name: string; 19 | keys: string[]; 20 | renderer: VersionTabConstructor; 21 | }; 22 | 23 | export class Tabs extends Element { 24 | menu: any; 25 | squid: Squid | undefined; 26 | selectedTab = 0; 27 | wrapper: Element | undefined; 28 | cancel: Cancellable | undefined; 29 | 30 | constructor(tabs: Tab[], options: Widgets.BoxOptions = {}) { 31 | super(options); 32 | 33 | const commands = tabs.reduce((res, tab, currentIndex) => { 34 | return { 35 | ...res, 36 | [tab.name]: { 37 | keys: tab.keys, 38 | callback: async () => { 39 | // if (this.selectedTab === currentIndex) return; 40 | if (!this.squid) return; 41 | if (this.squid?.isHibernated()) { 42 | return; 43 | } 44 | 45 | if (typeof this.cancel === 'function') { 46 | this.cancel(); 47 | } 48 | 49 | this.selectedTab = currentIndex; 50 | this.wrapper?.destroy(); 51 | this.wrapper = blessed.box({ 52 | top: 2, 53 | left: '15', 54 | }); 55 | 56 | this.append(this.wrapper); 57 | 58 | const renderer = new tab.renderer(); 59 | 60 | try { 61 | this.cancel = await renderer.append(this.wrapper, this.squid); 62 | } catch (e) {} 63 | }, 64 | }, 65 | }; 66 | }, {}); 67 | 68 | this.menu = blessed.listbar({ 69 | top: '0', 70 | left: '0', 71 | width: '100%-10', 72 | 73 | autoCommandKeys: false, 74 | keys: true, 75 | mouse: true, 76 | 77 | style: { 78 | item: { 79 | fg: 'white', 80 | bg: 'black', 81 | border: mainColor, 82 | }, 83 | selected: { 84 | fg: 'white', 85 | bg: mainColor, 86 | }, 87 | }, 88 | commands, 89 | } as any); 90 | 91 | this.append(this.menu); 92 | 93 | // this.menu.selectTab(this.selectedTab); 94 | } 95 | 96 | setVersion(squid: Squid) { 97 | // if (squid === this.squid) return; 98 | this.screen.debug('set version'); 99 | this.squid = squid; 100 | 101 | if (squid.isHibernated()) { 102 | this.wrapper?.destroy(); 103 | this.wrapper = blessed.box({ 104 | top: '40', 105 | left: '15', 106 | content: `The squid is hibernated due to inactivity. Redeploy it to activate`, 107 | }); 108 | this.append(this.wrapper); 109 | this.menu.hide(); 110 | } else { 111 | this.menu.show(); 112 | this.menu.selectTab(this.selectedTab); 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/ui/components/VersionDbAccessTab.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import blessed, { Element } from 'reblessed'; 3 | 4 | import { chalkMainColor, scrollBarTheme } from '../theme'; 5 | 6 | import { VersionTab } from './Tabs'; 7 | import { Squid } from './types'; 8 | 9 | export class VersionDbAccessTab implements VersionTab { 10 | async append(parent: Element, squid: Squid) { 11 | const lines = []; 12 | 13 | const db = squid.addons?.postgres || squid.addons?.neon; 14 | 15 | const connection = db?.connections?.[0]; 16 | if (connection) { 17 | lines.push(chalkMainColor(`URL`)); 18 | lines.push(connection.params.host); 19 | lines.push(''); 20 | 21 | lines.push(chalkMainColor(`DB`)); 22 | lines.push(connection.params.database); 23 | lines.push(''); 24 | 25 | lines.push(chalkMainColor(`User`)); 26 | lines.push(connection.params.user); 27 | lines.push(''); 28 | 29 | lines.push(chalkMainColor(`Password`)); 30 | lines.push(connection.params.password); 31 | lines.push(''); 32 | lines.push(''); 33 | 34 | lines.push(chalkMainColor(`PSQL command`)); 35 | lines.push( 36 | chalk.bgBlackBright( 37 | `PGPASSWORD=${connection.params.password} psql -h ${connection.params.host} -d ${connection.params.database} -U ${connection.params.user}`, 38 | ), 39 | ); 40 | lines.push(''); 41 | } 42 | 43 | parent.append( 44 | blessed.box({ 45 | content: lines.join('\n'), 46 | scrollable: true, 47 | mouse: true, 48 | scrollbar: scrollBarTheme, 49 | }), 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/ui/components/VersionDeployTab.ts: -------------------------------------------------------------------------------- 1 | import blessed, { Element } from 'reblessed'; 2 | 3 | import { getDeploys } from '../../api'; 4 | import { mainColor } from '../theme'; 5 | 6 | import { Loader } from './Loader'; 7 | import { VersionTab } from './Tabs'; 8 | import { Squid } from './types'; 9 | 10 | export class SquidDeployTab implements VersionTab { 11 | async append(parent: Element, squid: Squid) { 12 | const list = blessed.listtable({ 13 | top: 0, 14 | left: 0, 15 | // tags: true, 16 | style: { 17 | header: { 18 | align: 'left', 19 | fg: 'white', 20 | }, 21 | selected: { 22 | bg: mainColor, 23 | }, 24 | fg: mainColor, 25 | item: { 26 | align: 'left', 27 | fg: 'white', 28 | }, 29 | }, 30 | mouse: true, 31 | }); 32 | list.hide(); 33 | const loader = new Loader(); 34 | parent.append(list); 35 | parent.append(loader); 36 | 37 | const deploys = squid.organization ? await getDeploys({ organization: squid.organization }) : []; 38 | 39 | list.setRows([ 40 | ['ID', 'Status', 'Failed', 'Logs', 'Created'], 41 | ...deploys.map((deploy) => { 42 | return [deploy.id.toString(), deploy.status, deploy.failed, deploy.logs.length.toString(), deploy.createdAt]; 43 | }), 44 | ]); 45 | 46 | await loader.destroyWithTimeout(); 47 | list.show(); 48 | list.screen.render(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/ui/components/VersionLogsTab.ts: -------------------------------------------------------------------------------- 1 | import { addMinutes } from 'date-fns'; 2 | import blessed, { Element } from 'reblessed'; 3 | 4 | import { squidHistoryLogs, streamSquidLogs } from '../../api'; 5 | import { pretty } from '../../logs'; 6 | import { mainColor, scrollBarTheme } from '../theme'; 7 | 8 | import { Loader } from './Loader'; 9 | import { VersionTab } from './Tabs'; 10 | import { Squid } from './types'; 11 | 12 | export class VersionLogTab implements VersionTab { 13 | async append(parent: Element, squid: Squid) { 14 | const logsBox = blessed.log({ 15 | top: 0, 16 | left: 0, 17 | width: '100%', 18 | height: '100%', 19 | scrollable: true, 20 | scrollbar: scrollBarTheme, 21 | alwaysScroll: true, 22 | mouse: true, 23 | style: { 24 | scrollbar: { 25 | bg: mainColor, 26 | fg: 'white', 27 | }, 28 | }, 29 | } as any); 30 | logsBox.hide(); 31 | 32 | const loader = new Loader(); 33 | parent.append(logsBox); 34 | parent.append(loader); 35 | 36 | const abortController = new AbortController(); 37 | 38 | process.nextTick(async () => { 39 | try { 40 | const { logs } = await squidHistoryLogs({ 41 | organization: squid.organization, 42 | squid, 43 | query: { 44 | limit: 100, 45 | from: addMinutes(new Date(), -30), 46 | }, 47 | abortController, 48 | }); 49 | 50 | pretty(logs.reverse()).forEach((line) => { 51 | logsBox.add(line); 52 | }); 53 | } catch (e: any) { 54 | if (e?.type === 'aborted') return; 55 | 56 | throw e; 57 | } 58 | await loader.destroyWithTimeout(); 59 | logsBox.show(); 60 | logsBox.screen.render(); 61 | 62 | streamSquidLogs({ 63 | organization: squid.organization, 64 | squid, 65 | onLog: (line) => { 66 | logsBox.add(line); 67 | }, 68 | abortController, 69 | }); 70 | }); 71 | 72 | return () => { 73 | abortController.abort(); 74 | }; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/ui/components/VersionManager.ts: -------------------------------------------------------------------------------- 1 | import { defaultsDeep, flatten } from 'lodash'; 2 | import { List, Widgets } from 'reblessed'; 3 | 4 | import { listSquids, Organization } from '../../api'; 5 | 6 | import { SquidList } from './SquidList'; 7 | import { Squid } from './types'; 8 | import { VersionView } from './VersionView'; 9 | 10 | export class VersionManager extends List { 11 | list: SquidList; 12 | view: VersionView; 13 | squids: Squid[] = []; 14 | currentIndex?: number; 15 | 16 | constructor( 17 | public organization: Organization, 18 | options: Widgets.BoxOptions, 19 | ) { 20 | super( 21 | defaultsDeep(options, { 22 | vi: true, 23 | keys: true, 24 | mouse: true, 25 | }), 26 | ); 27 | 28 | this.list = new SquidList({ 29 | top: 0, 30 | left: 0, 31 | width: '30%', 32 | height: '100%', 33 | }); 34 | 35 | this.view = new VersionView({ 36 | top: '0', 37 | left: '30%', 38 | width: '70%', 39 | height: '100%', 40 | }); 41 | 42 | this.list.on('select', (index: number) => { 43 | this.updateCurrentSquidByIndex(index); 44 | }); 45 | 46 | this.key(['up', 'down'], (ch, key) => { 47 | this.list.rows.emit('keypress', ch, key); 48 | }); 49 | 50 | this.append(this.list); 51 | this.append(this.view); 52 | } 53 | 54 | async load() { 55 | const squids = await listSquids({ organization: this.organization }); 56 | 57 | // this.squids = flatten( 58 | // squids.map((squid) => 59 | // squid.versions.map((v) => { 60 | // return new SquidVersion(squid, v); 61 | // }), 62 | // ), 63 | // ).sort((a, b) => a.name.localeCompare(b.name)); 64 | 65 | await this.list.recalculateTable(this.squids); 66 | 67 | if (this.currentIndex === undefined) { 68 | await this.updateCurrentSquidByIndex(0); 69 | } 70 | 71 | setTimeout(() => this.load(), 10000); 72 | } 73 | 74 | async updateCurrentSquidByIndex(index: number) { 75 | const squid = this.squids[index]; 76 | if (!squid) return; 77 | 78 | await this.view.setSquid(squid); 79 | 80 | this.currentIndex = index; 81 | 82 | this.screen.render(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/ui/components/VersionSummaryTab.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import Table from 'cli-table3'; 3 | import bytes from 'pretty-bytes'; 4 | import blessed, { Element } from 'reblessed'; 5 | 6 | import { SquidProcessor } from '../../api'; 7 | import { chalkMainColor, mainColor, scrollBarTheme } from '../theme'; 8 | 9 | import { VersionTab } from './Tabs'; 10 | import { Squid } from './types'; 11 | 12 | export function numberWithSpaces(n: number) { 13 | return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' '); 14 | } 15 | 16 | export function getProcessors(processor: SquidProcessor) { 17 | return [`${chalkMainColor(`PROCESSOR`)} ${processor.name} ${chalkMainColor(processor.status)}`]; 18 | } 19 | 20 | export class VersionSummaryTab implements VersionTab { 21 | async append(parent: Element, squid: Squid) { 22 | const lines = []; 23 | 24 | if (squid.description) { 25 | lines.push(squid.description); 26 | lines.push(''); 27 | } 28 | 29 | lines.push(`${chalkMainColor(`API`)} ${chalkMainColor(squid.api?.status)}`); 30 | for (const url of squid.api?.urls || []) { 31 | lines.push(`${url.url}`); 32 | } 33 | 34 | lines.push(''); 35 | 36 | const table = new Table({ 37 | head: ['Processor', 'Status', 'Sync rate'], 38 | wordWrap: true, 39 | wrapOnWordBoundary: false, 40 | style: { 41 | head: [mainColor], 42 | border: [mainColor], 43 | }, 44 | }); 45 | 46 | const addonPostgres = squid.addons?.postgres; 47 | if (addonPostgres) { 48 | const usedBytes = addonPostgres.disk.usedBytes || 0; 49 | const totalBytes = addonPostgres.disk.totalBytes || 0; 50 | 51 | const dbUsedPercent = totalBytes > 0 ? (usedBytes * 100) / totalBytes : 0; 52 | const dbState = `Used ${dbUsedPercent.toFixed(2)}% ${bytes(usedBytes)} / ${bytes(totalBytes)}`; 53 | 54 | lines.push(`${chalkMainColor(`DB`)} ${chalkMainColor(addonPostgres.disk.usageStatus)}`); 55 | lines.push(dbState); 56 | lines.push(''); 57 | } 58 | 59 | if (squid.processors) { 60 | // table is an Array, so you can `push`, `unshift`, `splice` and friends 61 | for (const processor of squid.processors) { 62 | const processorPercent = (processor.syncState.currentBlock * 100) / processor.syncState.totalBlocks; 63 | const processorState = `${processorPercent.toFixed(2)}%\n${numberWithSpaces( 64 | processor.syncState.currentBlock, 65 | )} / ${numberWithSpaces(processor.syncState.totalBlocks)}`; 66 | 67 | table.push([processor.name, processor.status, chalk.dim(processorState)]); 68 | } 69 | } 70 | 71 | lines.push(table.toString()); 72 | 73 | parent.append( 74 | blessed.box({ 75 | content: lines.join('\n'), 76 | scrollable: true, 77 | mouse: true, 78 | scrollbar: scrollBarTheme, 79 | }), 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/ui/components/VersionView.ts: -------------------------------------------------------------------------------- 1 | import figlet from 'figlet'; 2 | import { defaultsDeep } from 'lodash'; 3 | import blessed, { Element, List, Widgets } from 'reblessed'; 4 | 5 | import { defaultBoxTheme, mainColor } from '../theme'; 6 | 7 | import { Tabs } from './Tabs'; 8 | import { Squid } from './types'; 9 | import { VersionDbAccessTab } from './VersionDbAccessTab'; 10 | import { SquidDeployTab } from './VersionDeployTab'; 11 | import { VersionLogTab } from './VersionLogsTab'; 12 | import { VersionSummaryTab } from './VersionSummaryTab'; 13 | 14 | const figletAsync = (text: string, options?: figlet.Options) => { 15 | return new Promise((resolve, reject) => { 16 | figlet(text, options, (error, result) => { 17 | if (error || !result) { 18 | reject(error); 19 | return; 20 | } 21 | 22 | resolve(result); 23 | }); 24 | }); 25 | }; 26 | 27 | export class VersionView extends List { 28 | header: Element; 29 | tabs: Tabs; 30 | 31 | constructor(options: Widgets.BoxOptions) { 32 | super( 33 | defaultsDeep(options, defaultBoxTheme, { 34 | tags: true, 35 | content: '', 36 | }), 37 | ); 38 | 39 | this.header = blessed.box({ 40 | top: '0', 41 | left: '0', 42 | width: '100%-3', 43 | style: { 44 | fg: mainColor, 45 | }, 46 | }); 47 | 48 | this.tabs = new Tabs( 49 | [ 50 | { 51 | name: 'Summary', 52 | keys: ['1'], 53 | renderer: VersionSummaryTab, 54 | }, 55 | { 56 | name: 'Logs', 57 | keys: ['2'], 58 | renderer: VersionLogTab, 59 | }, 60 | { 61 | name: 'DB Access', 62 | keys: ['3'], 63 | renderer: VersionDbAccessTab, 64 | }, 65 | // { 66 | // name: 'Deploys', 67 | // keys: ['4'], 68 | // renderer: VersionDeployTab, 69 | // }, 70 | ], 71 | { 72 | left: 2, 73 | top: 7, 74 | }, 75 | ); 76 | 77 | this.append(this.header); 78 | this.append(this.tabs); 79 | } 80 | 81 | async setSquid(squid: Squid) { 82 | const width = typeof this.width === 'string' ? parseInt(this.width) : this.width; 83 | 84 | const title = await figletAsync(squid.name, { width: width - 3, whitespaceBreak: true }); 85 | const lines = title.split('\n'); 86 | 87 | this.tabs.position.top = lines.length + 2; 88 | 89 | this.header.setContent(title); 90 | this.tabs.setVersion(squid); 91 | this.setLabel(squid.name); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/ui/components/types.ts: -------------------------------------------------------------------------------- 1 | import { Squid as ApiSquid } from '../../api'; 2 | import { formatSquidReference } from '../../utils'; 3 | 4 | export interface Squid extends ApiSquid {} 5 | export class Squid { 6 | readonly displayName: string; 7 | 8 | constructor(squid: ApiSquid) { 9 | Object.assign(this, squid); 10 | 11 | this.displayName = formatSquidReference({ name: this.name, slot: this.slot }); 12 | 13 | if (this.tags.length) { 14 | this.displayName += ` (${this.tags.map((a) => a.name).join(', ')})`; 15 | } 16 | } 17 | 18 | isHibernated() { 19 | return this.status === 'HIBERNATED'; 20 | } 21 | 22 | getColor(): string | null { 23 | if (this.isHibernated()) { 24 | return 'bright-black'; 25 | } else if (this.api?.status === 'NOT_AVAILABLE') { 26 | return 'red'; 27 | } else if ( 28 | this.addons?.postgres?.disk.usageStatus === 'CRITICAL' || 29 | this.addons?.postgres?.disk.usageStatus === 'WARNING' 30 | ) { 31 | return 'yellow'; 32 | } 33 | 34 | return null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/theme/index.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export const mainColor = 'blue'; 4 | export const chalkMainColor = chalk.blue; 5 | export const mainLightColor = 'bright-blue'; 6 | 7 | export const defaultBoxTheme = { 8 | tags: true, 9 | border: { 10 | type: 'line', 11 | }, 12 | style: { 13 | border: { 14 | fg: mainColor, 15 | }, 16 | focus: { 17 | border: { 18 | fg: mainColor, 19 | }, 20 | }, 21 | }, 22 | }; 23 | 24 | export const scrollBarTheme = { 25 | style: { 26 | bg: mainLightColor, 27 | }, 28 | track: { 29 | bg: mainColor, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { ConfigNotFound, getConfig } from '@subsquid/commands'; 2 | import { 3 | JoiSquidName, 4 | JoiSquidSlot, 5 | JoiSquidTag, 6 | SQUID_NAME_PATTERN, 7 | SQUID_SLOT_PATTERN, 8 | SQUID_TAG_PATTERN, 9 | } from '@subsquid/manifest'; 10 | import chalk from 'chalk'; 11 | import Joi from 'joi'; 12 | import { PickDeep } from 'type-fest'; 13 | 14 | import { Squid } from './api'; 15 | import { org } from './flags'; 16 | 17 | export async function getSquidCommands() { 18 | try { 19 | return await getConfig(); 20 | } catch (e) { 21 | if (e instanceof ConfigNotFound) { 22 | return null; 23 | } 24 | 25 | throw e; 26 | } 27 | } 28 | 29 | export async function doUntil(fn: () => Promise, { pause }: { pause: number }) { 30 | while (true) { 31 | const done = await fn(); 32 | if (done) { 33 | return; 34 | } 35 | await new Promise((resolve) => setTimeout(resolve, pause)); 36 | } 37 | } 38 | 39 | export type ParsedSquidReference = { org?: string; name: string } & ( 40 | | { slot: string; tag?: never } 41 | | { slot?: never; tag: string } 42 | ); 43 | 44 | export function formatSquidReference( 45 | reference: { org?: string; name: string; slot?: string; tag?: string } | string, 46 | { colored }: { colored?: boolean } = {}, 47 | ) { 48 | const { org, name, slot, tag } = typeof reference === 'string' ? parseSquidReference(reference) : reference; 49 | if (!tag && !slot) { 50 | throw new Error('At least one of slot or tag has to be defined'); 51 | } 52 | 53 | const prefix = org ? `${org}/` : ``; 54 | const suffix = slot ? `@${slot}` : `:${tag}`; 55 | 56 | return colored ? chalk`{bold {green ${prefix}}{green ${name}}{blue ${suffix}}}` : `${prefix}${name}${suffix}`; 57 | } 58 | 59 | export const SQUID_FULLNAME_REGEXP = /^((.+)\/)?(.+)([@:])(.+)$/; 60 | 61 | export const JoiSquidReference = Joi.object({ 62 | org: JoiSquidName, 63 | name: JoiSquidName.required(), 64 | slot: JoiSquidSlot, 65 | tag: JoiSquidTag, 66 | }).xor('slot', 'tag'); 67 | 68 | export function parseSquidReference(reference: string): ParsedSquidReference { 69 | const parsed = SQUID_FULLNAME_REGEXP.exec(reference); 70 | if (!parsed) { 71 | throw new Error(`The squid reference "${reference}" is invalid.`); 72 | } 73 | 74 | const [, , org, name, type, tagOrSlot] = parsed; 75 | 76 | // the last case should never happen, used only for flag validation 77 | return { org, name, ...(type === ':' ? { tag: tagOrSlot } : type === '@' ? { slot: tagOrSlot } : ({} as any)) }; 78 | } 79 | 80 | export function printSquid(squid: PickDeep) { 81 | return formatSquidReference({ org: squid.organization.code, name: squid.name, slot: squid.slot }, { colored: true }); 82 | } 83 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2020", 5 | "outDir": "lib", 6 | "rootDir": "src", 7 | "allowJs": true, 8 | "strict": true, 9 | "declaration": true, 10 | "declarationMap": true, 11 | "sourceMap": true, 12 | "esModuleInterop": true, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "skipLibCheck": true 16 | }, 17 | "include": ["types","src"], 18 | "exclude": [ 19 | "node_modules", 20 | "resource", 21 | "bin" 22 | ], 23 | } 24 | --------------------------------------------------------------------------------