Repository: Redocly/redocly-cli
Pull request #1321 (2023-11-08 15:45:49)

feat: basic push-status command implementation and extended push command 
## What/Why/How?

Now `push` command also applied for BH push. 
Syntax for push command:

```sh
REDOCLY_AUTHORIZATION=<api key> \ 
npm run cli -- push \ 
    --organization <orgId> \
    --project <projectId> \ 
    --mount-path <blue harvest mount path> \
    --branch <branch where files are pushed from> \
    --author 'Author Name <author-email@mail.com>' \
    --message <message files are committed with> \
    --files <folders or/and files>  \
    --domain 'http://localhost'
```

or

```sh
REDOCLY_AUTHORIZATION=<api key> \ 
REDOCLY_DOMAIN='http://localhost' \
npm run cli -- push  \ 
    --organization <orgId> \
    --project <projectId> \ 
    --mount-path <blue harvest mount path> \
    --branch <branch where files are pushed from> \
    --author 'Author Name <author-email@mail.com>' \
    --files <folders or/and files> \
    --message <message files are committed with> \
```

Syntax for `push-status` command: 

```sh
REDOCLY_AUTHORIZATION=<api key> \ 
npm run cli -- push-status  <pushId> \
    --organization <orgId> \
    --project <projectId> \ 
    --domain 'http://localhost' \
    --wait
```

or 


```sh
REDOCLY_AUTHORIZATION=<api key> \ 
REDOCLY_DOMAIN='http://localhost' \
npm run cli -- push-status  <pushId>
    --organization <orgId> \
    --project <projectId> \ 
    --wait
```


## Reference

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows (internal)
- [x] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 581b0749964624dcf655b291751894330e0aa6f7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/Redocly/redocly-cli/new/feat/bh-push-command?filename=.changeset/unlucky-snakes-tap.md&value=---%0A%22%40redocly%2Fcli%22%3A%20patch%0A%22%40redocly%2Fopenapi-core%22%3A%20patch%0A---%0A%0Afeat%3A%20basic%20push-bh%20command%20implementation%0A)


github-actions[bot]: | Command | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.867 ± 0.033 | 1.825 | 1.905 | 1.00 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.886 ± 0.028 | 1.834 | 1.927 | 1.01 ± 0.02 |

<!-- thollander/actions-comment-pull-request "hyperfine-comparison" -->
lornajane: We need to discuss the naming of this command, either to somehow reuse the `push` command, or to use `[product] push`, or to pick a different word altogether. Adding the suffix won't be clear to users.
roman-sainchuk: > We need to discuss the naming of this command, either to somehow reuse the `push` command, or to use `[product] push`, or to pick a different word altogether. Adding the suffix won't be clear to users.

Agree, for now it's not clear how to properly distinguish which command to run when you hit `push`. We discussed `[product] push` approach yesterday with @tatomyr, but looks like this approach will confuse users even more.

I'm also thinking about [docker approach with contexts](https://docs.docker.com/engine/context/working-with-contexts/). Example:

`redocly context create bh-eu --domain 'https://auth.beta.eu.redocly.com/' --description 'EU Region'`

And then:

`redocly context use bh-eu`
and
`redocly push ...`

@lornajane , @RomanHotsiy , @tatomyr what do you think?
roman-sainchuk: The default context will be hardcoded as `https://app.beta.redocly.com/`, but looks like it is breaking change
RomanHotsiy: Yeah, I think the suffix is a temporary solution not to get blocked. I don't have an answer now. Let me think a bit.
lornajane: It's fine to work on it with the suffix, but we can't merge until we figure out a better approach. I think we'll end up with other commands that also have the product names in, so maybe adopting that extra piece is the least confusing option.

I'm not in favour of introducing the context as it's quite complex and since the push command is mostly intended to run in CI, it should be a repeatable single command.
roman-sainchuk: > either to somehow reuse the push command

This is not an option because the syntax and options are totally different, also it will be confusing for user to understand what options should be provided to satisfy required arguments for chosen product (e.g. `push --help`)
lornajane: Perhaps we can pick a different command name, to keep things very clear and use that language consistently. How about `upload` or `commit` ? So far we have only decided on options we can't use, so I'm trying to find things that could be ruled in rather than ruled out, if that makes sense.
adamaltman: @roman-sainchuk I dislike the context proposal a lot. I like this one a lot:
```
   REDOCLY_DOMAIN='http://localhost' \
```

roman-sainchuk: @adamaltman me too, but on order to have the same command name and proper distinguishing between contexts (workflows/BH) we need to pass it even when you want to get the hint, e.g.:

```sh
REDOCLY_DOMAIN='http://localhost' redocly push --help
```

otherwise it will print  workflows version of the `push` syntax
tatomyr: I suggest moving the `redocly/cloud` (and possibly all `redocly`) folder to the CLI package from core. The intention is to use this command in CI/CD pipelines, so it looks like we can make the `openapi-core` bundle smaller if we don't include this things there. 

tatomyr: Please don't forget to add a changeset (I believe it's needed here).

Patch:
diff --git a/.changeset/new-eyes-beam.md b/.changeset/new-eyes-beam.md
new file mode 100644
index 00000000..38c5b32c
--- /dev/null
+++ b/.changeset/new-eyes-beam.md
@@ -0,0 +1,5 @@
+---
+"@redocly/cli": minor
+---
+
+Added a `push` and `push-status` command for use with future Redocly products.
diff --git a/jest.config.js b/jest.config.js
index 3f42c097..e55bf1de 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -8,7 +8,7 @@ module.exports = {
     '!packages/**/__tests__/**/*',
     '!packages/core/src/benchmark/**/*',
     '!packages/cli/src/index.ts',
-    '!packages/cli/src/assert-node-version.ts',
+    '!packages/cli/src/utils/assert-node-version.ts',
   ],
   coverageThreshold: {
     'packages/core/': {
diff --git a/package-lock.json b/package-lock.json
index 1352e0e4..34887709 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3865,9 +3865,9 @@
       "dev": true
     },
     "node_modules/@types/yargs": {
-      "version": "17.0.29",
-      "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.29.tgz",
-      "integrity": "sha512-nacjqA3ee9zRF/++a3FUY1suHTFKZeHba2n8WeDw9cCVdmzmHpIxyzOJBcpHvvEmS8E9KqWlSnWHUkOrkhWcvA==",
+      "version": "17.0.32",
+      "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz",
+      "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==",
       "dev": true,
       "dependencies": {
         "@types/yargs-parser": "*"
@@ -4268,6 +4268,17 @@
       "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
       "dev": true
     },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
     "node_modules/acorn": {
       "version": "8.10.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
@@ -4513,8 +4524,7 @@
     "node_modules/asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
-      "dev": true
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
     },
     "node_modules/available-typed-arrays": {
       "version": "1.0.5",
@@ -5123,7 +5133,6 @@
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
       "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-      "dev": true,
       "dependencies": {
         "delayed-stream": "~1.0.0"
       },
@@ -5469,7 +5478,6 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
       "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
-      "dev": true,
       "engines": {
         "node": ">=0.4.0"
       }
@@ -6058,6 +6066,14 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/eventemitter3": {
       "version": "4.0.7",
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
@@ -9150,7 +9166,6 @@
       "version": "1.52.0",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
       "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
-      "dev": true,
       "engines": {
         "node": ">= 0.6"
       }
@@ -9159,7 +9174,6 @@
       "version": "2.1.35",
       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
       "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
-      "dev": true,
       "dependencies": {
         "mime-db": "1.52.0"
       },
@@ -12854,9 +12868,11 @@
       "license": "MIT",
       "dependencies": {
         "@redocly/openapi-core": "1.7.0",
+        "abort-controller": "^3.0.0",
         "chokidar": "^3.5.1",
         "colorette": "^1.2.0",
         "core-js": "^3.32.1",
+        "form-data": "^4.0.0",
         "get-port-please": "^3.0.1",
         "glob": "^7.1.6",
         "handlebars": "^4.7.6",
@@ -12880,7 +12896,7 @@
         "@types/react": "^17.0.0 || ^18.2.21",
         "@types/react-dom": "^17.0.0 || ^18.2.7",
         "@types/semver": "^7.5.0",
-        "@types/yargs": "17.0.5",
+        "@types/yargs": "17.0.32",
         "typescript": "^5.2.2"
       },
       "engines": {
@@ -12888,13 +12904,17 @@
         "npm": ">=7.0.0"
       }
     },
-    "packages/cli/node_modules/@types/yargs": {
-      "version": "17.0.5",
-      "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.5.tgz",
-      "integrity": "sha512-4HNq144yhaVjJs+ON6A07NEoi9Hh0Rhl/jI9Nt/l/YRjt+T6St/QK3meFARWZ8IgkzoD1LC0PdTdJenlQQi2WQ==",
-      "dev": true,
+    "packages/cli/node_modules/form-data": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
       "dependencies": {
-        "@types/yargs-parser": "*"
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
       }
     },
     "packages/core": {
@@ -15524,10 +15544,12 @@
         "@types/react": "^17.0.0 || ^18.2.21",
         "@types/react-dom": "^17.0.0 || ^18.2.7",
         "@types/semver": "^7.5.0",
-        "@types/yargs": "17.0.5",
+        "@types/yargs": "17.0.32",
+        "abort-controller": "^3.0.0",
         "chokidar": "^3.5.1",
         "colorette": "^1.2.0",
         "core-js": "^3.32.1",
+        "form-data": "^4.0.0",
         "get-port-please": "^3.0.1",
         "glob": "^7.1.6",
         "handlebars": "^4.7.6",
@@ -15543,13 +15565,14 @@
         "yargs": "17.0.1"
       },
       "dependencies": {
-        "@types/yargs": {
-          "version": "17.0.5",
-          "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.5.tgz",
-          "integrity": "sha512-4HNq144yhaVjJs+ON6A07NEoi9Hh0Rhl/jI9Nt/l/YRjt+T6St/QK3meFARWZ8IgkzoD1LC0PdTdJenlQQi2WQ==",
-          "dev": true,
+        "form-data": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+          "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
           "requires": {
-            "@types/yargs-parser": "*"
+            "asynckit": "^0.4.0",
+            "combined-stream": "^1.0.8",
+            "mime-types": "^2.1.12"
           }
         }
       }
@@ -15936,9 +15959,9 @@
       "dev": true
     },
     "@types/yargs": {
-      "version": "17.0.29",
-      "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.29.tgz",
-      "integrity": "sha512-nacjqA3ee9zRF/++a3FUY1suHTFKZeHba2n8WeDw9cCVdmzmHpIxyzOJBcpHvvEmS8E9KqWlSnWHUkOrkhWcvA==",
+      "version": "17.0.32",
+      "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz",
+      "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==",
       "dev": true,
       "requires": {
         "@types/yargs-parser": "*"
@@ -16237,6 +16260,14 @@
       "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
       "dev": true
     },
+    "abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "requires": {
+        "event-target-shim": "^5.0.0"
+      }
+    },
     "acorn": {
       "version": "8.10.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
@@ -16416,8 +16447,7 @@
     "asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
-      "dev": true
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
     },
     "available-typed-arrays": {
       "version": "1.0.5",
@@ -16849,7 +16879,6 @@
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
       "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-      "dev": true,
       "requires": {
         "delayed-stream": "~1.0.0"
       }
@@ -17119,8 +17148,7 @@
     "delayed-stream": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
-      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
-      "dev": true
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
     },
     "detect-indent": {
       "version": "6.1.0",
@@ -17558,6 +17586,11 @@
       "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
       "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
     },
+    "event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
+    },
     "eventemitter3": {
       "version": "4.0.7",
       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
@@ -19866,14 +19899,12 @@
     "mime-db": {
       "version": "1.52.0",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
-      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
-      "dev": true
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
     },
     "mime-types": {
       "version": "2.1.35",
       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
       "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
-      "dev": true,
       "requires": {
         "mime-db": "1.52.0"
       }
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 34a2f894..70dd8770 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -37,9 +37,11 @@
   ],
   "dependencies": {
     "@redocly/openapi-core": "1.7.0",
+    "abort-controller": "^3.0.0",
     "chokidar": "^3.5.1",
     "colorette": "^1.2.0",
     "core-js": "^3.32.1",
+    "form-data": "^4.0.0",
     "get-port-please": "^3.0.1",
     "glob": "^7.1.6",
     "handlebars": "^4.7.6",
@@ -59,7 +61,7 @@
     "@types/react": "^17.0.0 || ^18.2.21",
     "@types/react-dom": "^17.0.0 || ^18.2.7",
     "@types/semver": "^7.5.0",
-    "@types/yargs": "17.0.5",
+    "@types/yargs": "17.0.32",
     "typescript": "^5.2.2"
   }
 }
diff --git a/packages/cli/src/cms/api/api-client.ts b/packages/cli/src/cms/api/api-client.ts
new file mode 100644
index 00000000..671da6c2
--- /dev/null
+++ b/packages/cli/src/cms/api/api-client.ts
@@ -0,0 +1,199 @@
+import fetchWithTimeout from '../../utils/fetch-with-timeout';
+import fetch from 'node-fetch';
+import * as FormData from 'form-data';
+
+import type { Response } from 'node-fetch';
+import type { ReadStream } from 'fs';
+import type {
+  ListRemotesResponse,
+  ProjectSourceResponse,
+  PushResponse,
+  UpsertRemoteResponse,
+} from './types';
+
+class RemotesApiClient {
+  constructor(private readonly domain: string, private readonly apiKey: string) {}
+
+  private async getParsedResponse<T>(response: Response): Promise<T> {
+    const responseBody = await response.json();
+
+    if (response.ok) {
+      return responseBody as T;
+    }
+
+    throw new Error(responseBody.title || response.statusText);
+  }
+
+  async getDefaultBranch(organizationId: string, projectId: string) {
+    const response = await fetch(
+      `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/source`,
+      {
+        method: 'GET',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: `Bearer ${this.apiKey}`,
+        },
+      }
+    );
+
+    try {
+      const source = await this.getParsedResponse<ProjectSourceResponse>(response);
+
+      return source.branchName;
+    } catch (err) {
+      throw new Error(`Failed to fetch default branch: ${err.message || 'Unknown error'}`);
+    }
+  }
+
+  async upsert(
+    organizationId: string,
+    projectId: string,
+    remote: {
+      mountPath: string;
+      mountBranchName: string;
+    }
+  ): Promise<UpsertRemoteResponse> {
+    const response = await fetch(
+      `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes`,
+      {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: `Bearer ${this.apiKey}`,
+        },
+        body: JSON.stringify({
+          mountPath: remote.mountPath,
+          mountBranchName: remote.mountBranchName,
+          type: 'CICD',
+          autoMerge: true,
+        }),
+      }
+    );
+
+    try {
+      return await this.getParsedResponse<UpsertRemoteResponse>(response);
+    } catch (err) {
+      throw new Error(`Failed to upsert remote: ${err.message || 'Unknown error'}`);
+    }
+  }
+
+  async push(
+    organizationId: string,
+    projectId: string,
+    payload: PushPayload,
+    files: { path: string; stream: ReadStream | Buffer }[]
+  ): Promise<PushResponse> {
+    const formData = new FormData();
+
+    formData.append('remoteId', payload.remoteId);
+    formData.append('commit[message]', payload.commit.message);
+    formData.append('commit[author][name]', payload.commit.author.name);
+    formData.append('commit[author][email]', payload.commit.author.email);
+    formData.append('commit[branchName]', payload.commit.branchName);
+    payload.commit.url && formData.append('commit[url]', payload.commit.url);
+    payload.commit.namespace && formData.append('commit[namespaceId]', payload.commit.namespace);
+    payload.commit.sha && formData.append('commit[sha]', payload.commit.sha);
+    payload.commit.repository && formData.append('commit[repositoryId]', payload.commit.repository);
+    payload.commit.createdAt && formData.append('commit[createdAt]', payload.commit.createdAt);
+
+    for (const file of files) {
+      formData.append(`files[${file.path}]`, file.stream);
+    }
+
+    payload.isMainBranch && formData.append('isMainBranch', 'true');
+
+    const response = await fetch(
+      `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes`,
+      {
+        method: 'POST',
+        headers: {
+          Authorization: `Bearer ${this.apiKey}`,
+        },
+        body: formData,
+      }
+    );
+
+    try {
+      return await this.getParsedResponse<PushResponse>(response);
+    } catch (err) {
+      throw new Error(`Failed to push: ${err.message || 'Unknown error'}`);
+    }
+  }
+
+  async getRemotesList(organizationId: string, projectId: string, mountPath: string) {
+    const response = await fetch(
+      `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/remotes?filter=mountPath:/${mountPath}/`,
+      {
+        method: 'GET',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: `Bearer ${this.apiKey}`,
+        },
+      }
+    );
+
+    try {
+      return await this.getParsedResponse<ListRemotesResponse>(response);
+    } catch (err) {
+      throw new Error(`Failed to get remote list: ${err.message || 'Unknown error'}`);
+    }
+  }
+
+  async getPush({
+    organizationId,
+    projectId,
+    pushId,
+  }: {
+    organizationId: string;
+    projectId: string;
+    pushId: string;
+  }) {
+    const response = await fetchWithTimeout(
+      `${this.domain}/api/orgs/${organizationId}/projects/${projectId}/pushes/${pushId}`,
+      {
+        method: 'GET',
+        headers: {
+          'Content-Type': 'application/json',
+          Authorization: `Bearer ${this.apiKey}`,
+        },
+      }
+    );
+
+    if (!response) {
+      throw new Error(`Failed to get push status: Time is up`);
+    }
+
+    try {
+      return await this.getParsedResponse<PushResponse>(response);
+    } catch (err) {
+      throw new Error(`Failed to get push status: ${err.message || 'Unknown error'}`);
+    }
+  }
+}
+
+export class ReuniteApiClient {
+  remotes: RemotesApiClient;
+
+  constructor(public domain: string, private readonly apiKey: string) {
+    this.remotes = new RemotesApiClient(this.domain, this.apiKey);
+  }
+}
+
+export type PushPayload = {
+  remoteId: string;
+  commit: {
+    message: string;
+    branchName: string;
+    sha?: string;
+    url?: string;
+    createdAt?: string;
+    namespace?: string;
+    repository?: string;
+    author: {
+      name: string;
+      email: string;
+      image?: string;
+    };
+  };
+  isMainBranch?: boolean;
+};
diff --git a/packages/cli/src/cms/api/api-keys.ts b/packages/cli/src/cms/api/api-keys.ts
new file mode 100644
index 00000000..79bf7762
--- /dev/null
+++ b/packages/cli/src/cms/api/api-keys.ts
@@ -0,0 +1,26 @@
+import { resolve } from 'path';
+import { homedir } from 'os';
+import { existsSync, readFileSync } from 'fs';
+import { isNotEmptyObject } from '@redocly/openapi-core/lib/utils';
+import { TOKEN_FILENAME } from '@redocly/openapi-core/lib/redocly';
+
+function readCredentialsFile(credentialsPath: string) {
+  return existsSync(credentialsPath) ? JSON.parse(readFileSync(credentialsPath, 'utf-8')) : {};
+}
+
+export function getApiKeys(domain: string) {
+  const apiKey = process.env.REDOCLY_AUTHORIZATION;
+
+  if (apiKey) {
+    return apiKey;
+  }
+
+  const credentialsPath = resolve(homedir(), TOKEN_FILENAME);
+  const credentials = readCredentialsFile(credentialsPath);
+
+  if (isNotEmptyObject(credentials) && credentials[domain]) {
+    return credentials[domain];
+  }
+
+  throw new Error('No api key provided, please use environment variable REDOCLY_AUTHORIZATION.');
+}
diff --git a/packages/cli/src/cms/api/domains.ts b/packages/cli/src/cms/api/domains.ts
new file mode 100644
index 00000000..16e0b1aa
--- /dev/null
+++ b/packages/cli/src/cms/api/domains.ts
@@ -0,0 +1,11 @@
+const DEFAULT_DOMAIN = 'https://app.cloud.redocly.com';
+
+export function getDomain(): string {
+  const domain = process.env.REDOCLY_DOMAIN;
+
+  if (domain) {
+    return domain;
+  }
+
+  return DEFAULT_DOMAIN;
+}
diff --git a/packages/cli/src/cms/api/index.ts b/packages/cli/src/cms/api/index.ts
new file mode 100644
index 00000000..a21875a3
--- /dev/null
+++ b/packages/cli/src/cms/api/index.ts
@@ -0,0 +1,3 @@
+export * from './api-client';
+export * from './domains';
+export * from './api-keys';
diff --git a/packages/cli/src/cms/api/types.ts b/packages/cli/src/cms/api/types.ts
new file mode 100644
index 00000000..e49c5c02
--- /dev/null
+++ b/packages/cli/src/cms/api/types.ts
@@ -0,0 +1,101 @@
+export type ProjectSourceResponse = {
+  branchName: string;
+  contentPath: string;
+  isInternal: boolean;
+};
+
+export type UpsertRemoteResponse = {
+  id: string;
+  type: 'CICD';
+  mountPath: string;
+  mountBranchName: string;
+  organizationId: string;
+  projectId: string;
+};
+
+export type ListRemotesResponse = {
+  object: 'list';
+  page: {
+    endCursor: string;
+    startCursor: string;
+    haxNextPage: boolean;
+    hasPrevPage: boolean;
+    limit: number;
+    total: number;
+  };
+  items: Remote[];
+};
+
+export type Remote = {
+  mountPath: string;
+  type: string;
+  autoSync: boolean;
+  autoMerge: boolean;
+  createdAt: string;
+  updatedAt: string;
+  providerType: string;
+  namespaceId: string;
+  repositoryId: string;
+  projectId: string;
+  mountBranchName: string;
+  contentPath: string;
+  credentialId: string;
+  branchName: string;
+  contentType: string;
+  id: string;
+};
+
+export type PushResponse = {
+  id: string;
+  remoteId: string;
+  commit: {
+    message: string;
+    branchName: string;
+    sha: string | null;
+    url: string | null;
+    createdAt: string | null;
+    namespace: string | null;
+    repository: string | null;
+    author: {
+      name: string;
+      email: string;
+      image: string | null;
+    };
+  };
+  remote: {
+    commits: {
+      branchName: string;
+      sha: string;
+    }[];
+  };
+  hasChanges: boolean;
+  isOutdated: boolean;
+  isMainBranch: boolean;
+  status: PushStatusResponse;
+};
+
+type DeploymentStatusResponse = {
+  deploy: {
+    url: string | null;
+    status: DeploymentStatus;
+  };
+  scorecard: ScorecardItem[];
+};
+
+export type PushStatusResponse = {
+  preview: DeploymentStatusResponse;
+  production: DeploymentStatusResponse;
+};
+
+export type ScorecardItem = {
+  name: string;
+  status: PushStatusBase;
+  description: string;
+  url: string;
+};
+
+export type PushStatusBase = 'pending' | 'success' | 'running' | 'failed';
+
+// export type BuildStatus = PushStatusBase | 'NOT_STARTED' | 'QUEUED';
+
+export type DeploymentStatus = 'skipped' | PushStatusBase;
diff --git a/packages/cli/src/cms/commands/push-status.ts b/packages/cli/src/cms/commands/push-status.ts
new file mode 100644
index 00000000..f072974e
--- /dev/null
+++ b/packages/cli/src/cms/commands/push-status.ts
@@ -0,0 +1,203 @@
+import * as colors from 'colorette';
+import { Config } from '@redocly/openapi-core';
+import { exitWithError, printExecutionTime } from '../../utils/miscellaneous';
+import { Spinner } from '../../utils/spinner';
+import { DeploymentError } from '../utils';
+import { yellow } from 'colorette';
+import { ReuniteApiClient, getApiKeys, getDomain } from '../api';
+
+import type { DeploymentStatus, PushResponse, ScorecardItem } from '../api/types';
+
+const INTERVAL = 5000;
+
+export type PushStatusOptions = {
+  organization: string;
+  project: string;
+  pushId: string;
+  domain?: string;
+  config?: string;
+  format?: 'stylish' | 'json';
+  wait?: boolean;
+  'max-execution-time': number;
+};
+
+export async function handlePushStatus(argv: PushStatusOptions, config: Config) {
+  const startedAt = performance.now();
+  const spinner = new Spinner();
+
+  const { organization, project: projectId, pushId, wait } = argv;
+
+  const orgId = organization || config.organization;
+
+  if (!orgId) {
+    return exitWithError(
+      `No organization provided, please use --organization option or specify the 'organization' field in the config file.`
+    );
+  }
+
+  const domain = argv.domain || getDomain();
+
+  if (!domain) {
+    return exitWithError(
+      `No domain provided, please use --domain option or environment variable REDOCLY_DOMAIN.`
+    );
+  }
+
+  const maxExecutionTime = argv['max-execution-time'] || 600;
+
+  try {
+    const apiKey = getApiKeys(domain);
+    const client = new ReuniteApiClient(domain, apiKey);
+
+    if (wait) {
+      const push = await waitForDeployment(client, 'preview');
+
+      if (push.isMainBranch && push.status.preview.deploy.status === 'success') {
+        await waitForDeployment(client, 'production');
+      }
+
+      printPushStatusInfo();
+      return;
+    }
+
+    const pushPreview = await getAndPrintPushStatus(client, 'preview');
+    printScorecard(pushPreview.status.preview.scorecard);
+
+    if (pushPreview.isMainBranch) {
+      await getAndPrintPushStatus(client, 'production');
+      printScorecard(pushPreview.status.production.scorecard);
+    }
+
+    printPushStatusInfo();
+  } catch (err) {
+    const message =
+      err instanceof DeploymentError
+        ? err.message
+        : `✗ Failed to get push status. Reason: ${err.message}\n`;
+    exitWithError(message);
+  }
+
+  function printPushStatusInfo() {
+    process.stderr.write(
+      `\nProcessed push-status for ${colors.yellow(orgId!)}, ${colors.yellow(
+        projectId
+      )} and pushID ${colors.yellow(pushId)}.\n`
+    );
+    printExecutionTime('push-status', startedAt, 'Finished');
+  }
+
+  async function waitForDeployment(
+    client: ReuniteApiClient,
+    buildType: 'preview' | 'production'
+  ): Promise<PushResponse> {
+    return new Promise((resolve, reject) => {
+      if (performance.now() - startedAt > maxExecutionTime * 1000) {
+        spinner.stop();
+        reject(new Error(`Time limit exceeded.`));
+      }
+
+      getAndPrintPushStatus(client, buildType)
+        .then((push) => {
+          if (!['pending', 'running'].includes(push.status[buildType].deploy.status)) {
+            printScorecard(push.status[buildType].scorecard);
+            resolve(push);
+            return;
+          }
+
+          setTimeout(async () => {
+            try {
+              const pushResponse = await waitForDeployment(client, buildType);
+              resolve(pushResponse);
+            } catch (e) {
+              reject(e);
+            }
+          }, INTERVAL);
+        })
+        .catch(reject);
+    });
+  }
+
+  async function getAndPrintPushStatus(
+    client: ReuniteApiClient,
+    buildType: 'preview' | 'production'
+  ) {
+    const push = await client.remotes.getPush({
+      organizationId: orgId!,
+      projectId,
+      pushId,
+    });
+
+    if (push.isOutdated || !push.hasChanges) {
+      process.stderr.write(
+        yellow(`Files not uploaded. Reason: ${push.isOutdated ? 'outdated' : 'no changes'}.\n`)
+      );
+    } else {
+      displayDeploymentAndBuildStatus({
+        status: push.status[buildType].deploy.status,
+        previewUrl: push.status[buildType].deploy.url,
+        buildType,
+        spinner,
+        wait,
+      });
+    }
+
+    return push;
+  }
+}
+
+function printScorecard(scorecard: ScorecardItem[]) {
+  if (!scorecard.length) {
+    return;
+  }
+  process.stdout.write(`\n${colors.magenta('Scorecard')}:`);
+  for (const scorecardItem of scorecard) {
+    process.stdout.write(`
+    ${colors.magenta('Name')}: ${scorecardItem.name}
+    ${colors.magenta('Status')}: ${scorecardItem.status}
+    ${colors.magenta('URL')}: ${colors.cyan(scorecardItem.url)}
+    ${colors.magenta('Description')}: ${scorecardItem.description}\n`);
+  }
+  process.stdout.write(`\n`);
+}
+
+function displayDeploymentAndBuildStatus({
+  status,
+  previewUrl,
+  spinner,
+  buildType,
+  wait,
+}: {
+  status: DeploymentStatus;
+  previewUrl: string | null;
+  spinner: Spinner;
+  buildType: 'preview' | 'production';
+  wait?: boolean;
+}) {
+  switch (status) {
+    case 'success':
+      spinner.stop();
+      return process.stdout.write(
+        `${colors.green(
+          `🚀 ${buildType.toLocaleUpperCase()} deployment succeeded.`
+        )}\n${colors.magenta('Preview URL')}: ${colors.cyan(previewUrl!)}\n`
+      );
+    case 'failed':
+      spinner.stop();
+      throw new DeploymentError(
+        `${colors.red(`❌ ${buildType.toLocaleUpperCase()} deployment failed.`)}\n${colors.magenta(
+          'Preview URL'
+        )}: ${colors.cyan(previewUrl!)}`
+      );
+    case 'pending':
+      return wait
+        ? spinner.start(`${colors.yellow(`Pending ${buildType}`)}`)
+        : process.stdout.write(`Status: ${colors.yellow(`Pending ${buildType}`)}\n`);
+    case 'skipped':
+      spinner.stop();
+      return process.stdout.write(`${colors.yellow(`Skipped ${buildType}`)}\n`);
+    case 'running':
+      return wait
+        ? spinner.start(`${colors.yellow(`Running ${buildType}`)}`)
+        : process.stdout.write(`Status: ${colors.yellow(`Running ${buildType}`)}\n`);
+  }
+}
diff --git a/packages/cli/src/cms/commands/push.ts b/packages/cli/src/cms/commands/push.ts
new file mode 100644
index 00000000..55dbea1a
--- /dev/null
+++ b/packages/cli/src/cms/commands/push.ts
@@ -0,0 +1,215 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { Config, slash } from '@redocly/openapi-core';
+import { exitWithError, HandledError, printExecutionTime } from '../../utils/miscellaneous';
+import { green, yellow } from 'colorette';
+import pluralize = require('pluralize');
+import { handlePushStatus } from './push-status';
+import { ReuniteApiClient, getDomain, getApiKeys } from '../api';
+
+export type PushOptions = {
+  apis?: string[];
+  organization?: string;
+  project: string;
+  'mount-path': string;
+
+  branch: string;
+  author: string;
+  message: string;
+  'commit-sha'?: string;
+  'commit-url'?: string;
+  namespace?: string;
+  repository?: string;
+  'created-at'?: string;
+
+  files: string[];
+
+  'default-branch': string;
+  domain?: string;
+  config?: string;
+  'wait-for-deployment'?: boolean;
+  'max-execution-time': number;
+  verbose?: boolean;
+};
+
+type FileToUpload = { name: string; path: string };
+
+export async function handlePush(argv: PushOptions, config: Config) {
+  const startedAt = performance.now();
+  const { organization, project: projectId, 'mount-path': mountPath, verbose } = argv;
+
+  const orgId = organization || config.organization;
+
+  if (!argv.message || !argv.author || !argv.branch) {
+    exitWithError('Error: message, author and branch are required for push to the CMS');
+  }
+
+  if (!orgId) {
+    return exitWithError(
+      `No organization provided, please use --organization option or specify the 'organization' field in the config file.`
+    );
+  }
+
+  const domain = argv.domain || getDomain();
+
+  if (!domain) {
+    return exitWithError(
+      `No domain provided, please use --domain option or environment variable REDOCLY_AUTHORIZATION.`
+    );
+  }
+
+  try {
+    const {
+      'commit-sha': commitSha,
+      'commit-url': commitUrl,
+      'default-branch': defaultBranch,
+      'wait-for-deployment': waitForDeployment,
+      'max-execution-time': maxExecutionTime,
+    } = argv;
+    const author = parseCommitAuthor(argv.author);
+    const apiKey = getApiKeys(domain);
+    const filesToUpload = collectFilesToPush(argv.files || argv.apis);
+
+    if (!filesToUpload.length) {
+      return printExecutionTime('push', startedAt, `No files to upload`);
+    }
+
+    const client = new ReuniteApiClient(domain, apiKey);
+    const projectDefaultBranch = await client.remotes.getDefaultBranch(orgId, projectId);
+    const remote = await client.remotes.upsert(orgId, projectId, {
+      mountBranchName: projectDefaultBranch,
+      mountPath,
+    });
+
+    process.stderr.write(
+      `Uploading to ${remote.mountPath} ${filesToUpload.length} ${pluralize(
+        'file',
+        filesToUpload.length
+      )}:\n`
+    );
+
+    const { id } = await client.remotes.push(
+      orgId,
+      projectId,
+      {
+        remoteId: remote.id,
+        commit: {
+          message: argv.message,
+          branchName: argv.branch,
+          sha: commitSha,
+          url: commitUrl,
+          createdAt: argv['created-at'],
+          namespace: argv.namespace,
+          repository: argv.repository,
+          author,
+        },
+        isMainBranch: defaultBranch === argv.branch,
+      },
+      filesToUpload.map((f) => ({ path: slash(f.name), stream: fs.createReadStream(f.path) }))
+    );
+
+    filesToUpload.forEach((f) => {
+      process.stderr.write(green(`✓ ${f.name}\n`));
+    });
+    process.stdout.write('\n');
+
+    process.stdout.write(`Push ID: ${id}\n`);
+
+    if (waitForDeployment) {
+      process.stdout.write('\n');
+
+      await handlePushStatus(
+        {
+          organization: orgId,
+          project: projectId,
+          pushId: id,
+          wait: true,
+          domain,
+          'max-execution-time': maxExecutionTime,
+        },
+        config
+      );
+    }
+    verbose &&
+      printExecutionTime(
+        'push',
+        startedAt,
+        `${pluralize(
+          'file',
+          filesToUpload.length
+        )} uploaded to organization ${orgId}, project ${projectId}. Push ID: ${id}.`
+      );
+  } catch (err) {
+    const message =
+      err instanceof HandledError ? '' : `✗ File upload failed. Reason: ${err.message}`;
+    exitWithError(message);
+  }
+}
+
+function parseCommitAuthor(author: string): { name: string; email: string } {
+  // Author Name <author@email.com>
+  const reg = /^.+\s<[^<>]+>$/;
+
+  if (!reg.test(author)) {
+    throw new Error('Invalid author format. Use "Author Name <author@email.com>"');
+  }
+
+  const [name, email] = author.split('<');
+
+  return {
+    name: name.trim(),
+    email: email.replace('>', '').trim(),
+  };
+}
+
+function collectFilesToPush(files: string[]): FileToUpload[] {
+  const collectedFiles: Record<string, string> = {};
+
+  for (const file of files) {
+    if (fs.statSync(file).isDirectory()) {
+      const dir = file;
+      const fileList = getFilesList(dir, []);
+
+      fileList.forEach((f) => addFile(f, dir));
+    } else {
+      addFile(file, path.dirname(file));
+    }
+  }
+
+  function addFile(filePath: string, fileDir: string) {
+    const fileName = path.relative(fileDir, filePath);
+
+    if (collectedFiles[fileName]) {
+      process.stdout.write(
+        yellow(`File ${collectedFiles[fileName]} is overwritten by ${filePath}\n`)
+      );
+    }
+
+    collectedFiles[fileName] = filePath;
+  }
+
+  return Object.entries(collectedFiles).map(([name, filePath]) => getFileEntry(name, filePath));
+}
+
+function getFileEntry(name: string, filePath: string): FileToUpload {
+  return {
+    name,
+    path: path.resolve(filePath),
+  };
+}
+
+function getFilesList(dir: string, files: string[]): string[] {
+  const filesAndDirs = fs.readdirSync(dir);
+
+  for (const name of filesAndDirs) {
+    const currentPath = path.join(dir, name);
+
+    if (fs.statSync(currentPath).isDirectory()) {
+      files = getFilesList(currentPath, files);
+    } else {
+      files.push(currentPath);
+    }
+  }
+
+  return files;
+}
diff --git a/packages/cli/src/cms/utils.ts b/packages/cli/src/cms/utils.ts
new file mode 100644
index 00000000..aa7d0495
--- /dev/null
+++ b/packages/cli/src/cms/utils.ts
@@ -0,0 +1,1 @@
+export class DeploymentError extends Error {}
diff --git a/packages/cli/src/commands/build-docs/index.ts b/packages/cli/src/commands/build-docs/index.ts
index fdf3a3a7..e837d44d 100644
--- a/packages/cli/src/commands/build-docs/index.ts
+++ b/packages/cli/src/commands/build-docs/index.ts
@@ -6,7 +6,7 @@ import { performance } from 'perf_hooks';
 import { getObjectOrJSON, getPageHTML } from './utils';
 import type { BuildDocsArgv } from './types';
 import { Config, getMergedConfig, isAbsoluteUrl } from '@redocly/openapi-core';
-import { exitWithError, getExecutionTime, getFallbackApisOrExit } from '../../utils';
+import { exitWithError, getExecutionTime, getFallbackApisOrExit } from '../../utils/miscellaneous';
 
 export const handlerBuildCommand = async (argv: BuildDocsArgv, configFromFile: Config) => {
   const startedAt = performance.now();
diff --git a/packages/cli/src/commands/build-docs/utils.ts b/packages/cli/src/commands/build-docs/utils.ts
index 02eb7aa9..568664b6 100644
--- a/packages/cli/src/commands/build-docs/utils.ts
+++ b/packages/cli/src/commands/build-docs/utils.ts
@@ -10,7 +10,7 @@ import { existsSync, lstatSync, readFileSync } from 'fs';
 
 import type { BuildDocsOptions } from './types';
 import { red } from 'colorette';
-import { exitWithError } from '../../utils';
+import { exitWithError } from '../../utils/miscellaneous';
 
 export function getObjectOrJSON(
   openapiOptions: string | Record<string, unknown>,
diff --git a/packages/cli/src/commands/bundle.ts b/packages/cli/src/commands/bundle.ts
index aade0ab8..b55d708b 100644
--- a/packages/cli/src/commands/bundle.ts
+++ b/packages/cli/src/commands/bundle.ts
@@ -18,12 +18,12 @@ import {
   printLintTotals,
   checkIfRulesetExist,
   sortTopLevelKeysForOas,
-} from '../utils';
+} from '../utils/miscellaneous';
 import type { OutputExtensions, Skips, Totals } from '../types';
 import { performance } from 'perf_hooks';
 import { blue, gray, green, yellow } from 'colorette';
 import { writeFileSync } from 'fs';
-import { checkForDeprecatedOptions } from '../utils';
+import { checkForDeprecatedOptions } from '../utils/miscellaneous';
 
 export type BundleOptions = {
   apis?: string[];
diff --git a/packages/cli/src/commands/join.ts b/packages/cli/src/commands/join.ts
index e7b6981d..0432f83d 100644
--- a/packages/cli/src/commands/join.ts
+++ b/packages/cli/src/commands/join.ts
@@ -30,8 +30,8 @@ import {
   getAndValidateFileExtension,
   writeToFileByExtension,
   checkForDeprecatedOptions,
-} from '../utils';
-import { isObject, isString, keysOf } from '../js-utils';
+} from '../utils/miscellaneous';
+import { isObject, isString, keysOf } from '../utils/js-utils';
 import {
   Oas3Parameter,
   Oas3PathItem,
diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts
index ee7313bb..bb15ffe1 100644
--- a/packages/cli/src/commands/lint.ts
+++ b/packages/cli/src/commands/lint.ts
@@ -17,7 +17,7 @@ import {
   printConfigLintTotals,
   printLintTotals,
   printUnusedWarnings,
-} from '../utils';
+} from '../utils/miscellaneous';
 import { blue, gray } from 'colorette';
 import { performance } from 'perf_hooks';
 
diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts
index 21215b1d..14d1c1d7 100644
--- a/packages/cli/src/commands/login.ts
+++ b/packages/cli/src/commands/login.ts
@@ -1,6 +1,6 @@
 import { Region, RedoclyClient, Config } from '@redocly/openapi-core';
 import { blue, green, gray } from 'colorette';
-import { promptUser } from '../utils';
+import { promptUser } from '../utils/miscellaneous';
 
 export function promptClientToken(domain: string) {
   return promptUser(
diff --git a/packages/cli/src/commands/preview-docs/index.ts b/packages/cli/src/commands/preview-docs/index.ts
index e905aced..896e12cc 100644
--- a/packages/cli/src/commands/preview-docs/index.ts
+++ b/packages/cli/src/commands/preview-docs/index.ts
@@ -1,7 +1,11 @@
 import * as colorette from 'colorette';
 import * as chockidar from 'chokidar';
 import { bundle, RedoclyClient, getTotals, getMergedConfig, Config } from '@redocly/openapi-core';
-import { getFallbackApisOrExit, handleError, loadConfigAndHandleErrors } from '../../utils';
+import {
+  getFallbackApisOrExit,
+  handleError,
+  loadConfigAndHandleErrors,
+} from '../../utils/miscellaneous';
 import startPreviewServer from './preview-server/preview-server';
 import type { Skips } from '../../types';
 
diff --git a/packages/cli/src/commands/preview-docs/preview-server/preview-server.ts b/packages/cli/src/commands/preview-docs/preview-server/preview-server.ts
index 958efcf4..aafccc9b 100644
--- a/packages/cli/src/commands/preview-docs/preview-server/preview-server.ts
+++ b/packages/cli/src/commands/preview-docs/preview-server/preview-server.ts
@@ -6,7 +6,7 @@ import * as path from 'path';
 
 import { startHttpServer, startWsServer, respondWithGzip, mimeTypes } from './server';
 import type { IncomingMessage } from 'http';
-import { isSubdir } from '../../../utils';
+import { isSubdir } from '../../../utils/miscellaneous';
 
 function getPageHTML(
   htmlTemplate: string,
diff --git a/packages/cli/src/commands/preview-project/types.ts b/packages/cli/src/commands/preview-project/types.ts
index 31314253..0e88bb01 100644
--- a/packages/cli/src/commands/preview-project/types.ts
+++ b/packages/cli/src/commands/preview-project/types.ts
@@ -8,5 +8,5 @@ export type PreviewProjectOptions = {
   plan: ProductPlan | string;
   port?: number;
   'source-dir': string;
-  config: string | undefined;
+  config?: string;
 };
diff --git a/packages/cli/src/commands/push.ts b/packages/cli/src/commands/push.ts
index b2f3a029..0b9bb5b3 100644
--- a/packages/cli/src/commands/push.ts
+++ b/packages/cli/src/commands/push.ts
@@ -21,8 +21,9 @@ import {
   getFallbackApisOrExit,
   pluralize,
   dumpBundle,
-} from '../utils';
+} from '../utils/miscellaneous';
 import { promptClientToken } from './login';
+import { handlePush as handleCMSPush } from '../cms/commands/push';
 
 const DEFAULT_VERSION = 'latest';
 
@@ -44,6 +45,19 @@ export type PushOptions = {
   config?: string;
 };
 
+export function commonPushHandler({
+  project,
+  'mount-path': mountPath,
+}: {
+  project?: string;
+  'mount-path'?: string;
+}) {
+  if (project && mountPath) {
+    return handleCMSPush;
+  }
+  return handlePush;
+}
+
 export async function handlePush(argv: PushOptions, config: Config): Promise<void> {
   const client = new RedoclyClient(config.region);
   const isAuthorized = await client.isAuthorizedWithRedoclyByRegion();
diff --git a/packages/cli/src/commands/split/index.ts b/packages/cli/src/commands/split/index.ts
index 2dd54e0b..a5d0aa19 100644
--- a/packages/cli/src/commands/split/index.ts
+++ b/packages/cli/src/commands/split/index.ts
@@ -15,8 +15,8 @@ import {
   langToExt,
   writeToFileByExtension,
   getAndValidateFileExtension,
-} from '../../utils';
-import { isString, isObject, isEmptyObject } from '../../js-utils';
+} from '../../utils/miscellaneous';
+import { isString, isObject, isEmptyObject } from '../../utils/js-utils';
 import {
   Definition,
   Oas2Definition,
diff --git a/packages/cli/src/commands/stats.ts b/packages/cli/src/commands/stats.ts
index 27104f3d..e37f66c7 100755
--- a/packages/cli/src/commands/stats.ts
+++ b/packages/cli/src/commands/stats.ts
@@ -13,8 +13,8 @@ import {
   Stats,
   bundle,
 } from '@redocly/openapi-core';
-import { getFallbackApisOrExit } from '../utils';
-import { printExecutionTime } from '../utils';
+import { getFallbackApisOrExit } from '../utils/miscellaneous';
+import { printExecutionTime } from '../utils/miscellaneous';
 import type { StatsAccumulator, StatsName, WalkContext, OutputFormat } from '@redocly/openapi-core';
 
 const statsAccumulator: StatsAccumulator = {
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index a4d9e874..828fe0c0 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -1,26 +1,27 @@
 #!/usr/bin/env node
 
-import './assert-node-version';
+import './utils/assert-node-version';
 import * as yargs from 'yargs';
-import { outputExtensions, regionChoices } from './types';
+import { outputExtensions, PushArguments, regionChoices } from './types';
 import { RedoclyClient } from '@redocly/openapi-core';
 import { previewDocs } from './commands/preview-docs';
 import { handleStats } from './commands/stats';
 import { handleSplit } from './commands/split';
 import { handleJoin } from './commands/join';
-import { handlePush, transformPush } from './commands/push';
+import { handlePushStatus, PushStatusOptions } from './cms/commands/push-status';
 import { handleLint } from './commands/lint';
 import { handleBundle } from './commands/bundle';
 import { handleLogin } from './commands/login';
 import { handlerBuildCommand } from './commands/build-docs';
-import { cacheLatestVersion, notifyUpdateCliVersion } from './update-version-notifier';
+import { cacheLatestVersion, notifyUpdateCliVersion } from './utils/update-version-notifier';
 import { commandWrapper } from './wrapper';
-import { version } from './update-version-notifier';
+import { version } from './utils/update-version-notifier';
 import type { Arguments } from 'yargs';
 import type { OutputFormat, RuleSeverity } from '@redocly/openapi-core';
 import type { BuildDocsArgv } from './commands/build-docs/types';
 import { previewProject } from './commands/preview-project';
 import { PRODUCT_PLANS } from './commands/preview-project/constants';
+import { commonPushHandler } from './commands/push';
 
 if (!('replaceAll' in String.prototype)) {
   require('core-js/actual/string/replace-all');
@@ -146,23 +147,75 @@ yargs
       commandWrapper(handleJoin)(argv);
     }
   )
-
   .command(
-    'push [api] [maybeDestination] [maybeBranchName]',
-    'Push an API description to the Redocly API registry.',
+    'push-status [pushId]',
+    false,
     (yargs) =>
       yargs
-        .usage('push [api]')
-        .positional('api', { type: 'string' })
-        .positional('maybeDestination', { type: 'string' })
-        .hide('maybeDestination')
-        .hide('maybeBranchName')
+        .positional('pushId', {
+          description: 'Push id.',
+          type: 'string',
+          required: true,
+        })
+        .implies('max-execution-time', 'wait')
         .option({
           organization: {
             description: 'Name of the organization to push to.',
             type: 'string',
             alias: 'o',
           },
+          project: {
+            description: 'Name of the project to push to.',
+            type: 'string',
+            alias: 'p',
+          },
+          domain: { description: 'Specify a domain.', alias: 'd', type: 'string' },
+          wait: {
+            description: 'Wait for build to finish.',
+            type: 'boolean',
+            default: false,
+          },
+          'max-execution-time': {
+            description: 'Maximum execution time in seconds.',
+            type: 'number',
+          },
+        }),
+    (argv) => {
+      process.env.REDOCLY_CLI_COMMAND = 'push-status';
+      commandWrapper(handlePushStatus)(argv as Arguments<PushStatusOptions>);
+    }
+  )
+  .command(
+    'push [apis...]',
+    'Push an API description to the Redocly API registry.',
+    (yargs) =>
+      yargs
+        .positional('apis', {
+          type: 'string',
+          array: true,
+          required: true,
+          default: [],
+        })
+        .hide('project')
+        .hide('domain')
+        .hide('mount-path')
+        .hide('author')
+        .hide('message')
+        .hide('default-branch')
+        .hide('verbose')
+        .hide('commit-sha')
+        .hide('commit-url')
+        .hide('namespace')
+        .hide('repository')
+        .hide('wait-for-deployment')
+        .hide('created-at')
+        .hide('max-execution-time')
+        .deprecateOption('batch-id', 'use --job-id')
+        .implies('job-id', 'batch-size')
+        .implies('batch-id', 'batch-size')
+        .implies('batch-size', 'job-id')
+        .implies('max-execution-time', 'wait-for-deployment')
+        .option({
           destination: {
             description: 'API name and version in the format `name@version`.',
             type: 'string',
@@ -217,15 +270,80 @@ yargs
             choices: ['warn', 'error', 'off'] as ReadonlyArray<RuleSeverity>,
             default: 'warn' as RuleSeverity,
           },
-        })
-        .deprecateOption('batch-id', 'use --job-id')
-        .deprecateOption('maybeDestination')
-        .implies('job-id', 'batch-size')
-        .implies('batch-id', 'batch-size')
-        .implies('batch-size', 'job-id'),
+          organization: {
+            description: 'Name of the organization to push to.',
+            type: 'string',
+            alias: 'o',
+          },
+          project: {
+            description: 'Name of the project to push to.',
+            type: 'string',
+            alias: 'p',
+          },
+          'mount-path': {
+            description: 'The path files should be pushed to.',
+            type: 'string',
+            alias: 'mp',
+          },
+          author: {
+            description: 'Author of the commit.',
+            type: 'string',
+            alias: 'a',
+          },
+          message: {
+            description: 'Commit message.',
+            type: 'string',
+            alias: 'm',
+          },
+          'commit-sha': {
+            description: 'Commit SHA.',
+            type: 'string',
+            alias: 'sha',
+          },
+          'commit-url': {
+            description: 'Commit URL.',
+            type: 'string',
+            alias: 'url',
+          },
+          namespace: {
+            description: 'Repository namespace.',
+            type: 'string',
+          },
+          repository: {
+            description: 'Repository name.',
+            type: 'string',
+          },
+          'created-at': {
+            description: 'Commit creation date.',
+            type: 'string',
+          },
+          domain: { description: 'Specify a domain.', alias: 'd', type: 'string' },
+          config: {
+            description: 'Path to the config file.',
+            requiresArg: true,
+            type: 'string',
+          },
+          'default-branch': {
+            type: 'string',
+            default: 'main',
+          },
+          'max-execution-time': {
+            description: 'Maximum execution time in seconds.',
+            type: 'number',
+          },
+          'wait-for-deployment': {
+            description: 'Wait for build to finish.',
+            type: 'boolean',
+            default: false,
+          },
+          verbose: {
+            type: 'boolean',
+            default: false,
+          },
+        }),
     (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'push';
-      commandWrapper(transformPush(handlePush))(argv);
+      commandWrapper(commonPushHandler(argv))(argv as PushArguments);
     }
   )
   .command(
diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts
index c9313f23..7d20912f 100644
--- a/packages/cli/src/types.ts
+++ b/packages/cli/src/types.ts
@@ -1,4 +1,5 @@
 import type { BundleOutputFormat, Region, Config } from '@redocly/openapi-core';
+import type { ArgumentsCamelCase } from 'yargs';
 import type { LintOptions } from './commands/lint';
 import type { BundleOptions } from './commands/bundle';
 import type { JoinOptions } from './commands/join';
@@ -8,6 +9,9 @@ import type { StatsOptions } from './commands/stats';
 import type { SplitOptions } from './commands/split';
 import type { PreviewDocsOptions } from './commands/preview-docs';
 import type { BuildDocsArgv } from './commands/build-docs/types';
+import type { PushOptions as PushBhOptions } from './cms/commands/push';
+import type { PushStatusOptions } from './cms/commands/push-status';
+import type { PushOptions as CMSPushOptions } from './cms/commands/push';
 import type { PreviewProjectOptions } from './commands/preview-project/types';
 
 export type Totals = {
@@ -27,11 +31,13 @@ export type CommandOptions =
   | SplitOptions
   | JoinOptions
   | PushOptions
+  | PushBhOptions
   | LintOptions
   | BundleOptions
   | LoginOptions
   | PreviewDocsOptions
   | BuildDocsArgv
+  | PushStatusOptions
   | PreviewProjectOptions;
 
 export type Skips = {
@@ -41,3 +47,5 @@ export type Skips = {
 };
 
 export type ConfigApis = Pick<Config, 'apis' | 'configFile'>;
+
+export type PushArguments = ArgumentsCamelCase<PushOptions & CMSPushOptions & { apis: string[] }>;
diff --git a/packages/cli/src/__mocks__/utils.ts b/packages/cli/src/utils/__mocks__/miscellaneous.ts
similarity index 93%
rename from packages/cli/src/__mocks__/utils.ts
rename to packages/cli/src/utils/__mocks__/miscellaneous.ts
index 48bf562a..1298db94 100644
--- a/packages/cli/src/__mocks__/utils.ts
+++ b/packages/cli/src/utils/__mocks__/miscellaneous.ts
@@ -1,4 +1,4 @@
-import { ConfigFixture } from '../__tests__/fixtures/config';
+import { ConfigFixture } from '../../__tests__/fixtures/config';
 
 export const getFallbackApisOrExit = jest.fn((entrypoints) =>
   entrypoints.map((path: string) => ({ path }))
diff --git a/packages/cli/src/assert-node-version.ts b/packages/cli/src/utils/assert-node-version.ts
similarity index 100%
rename from packages/cli/src/assert-node-version.ts
rename to packages/cli/src/utils/assert-node-version.ts
diff --git a/packages/cli/src/fetch-with-timeout.ts b/packages/cli/src/utils/fetch-with-timeout.ts
similarity index 60%
rename from packages/cli/src/fetch-with-timeout.ts
rename to packages/cli/src/utils/fetch-with-timeout.ts
index 2a8738dd..9a373e33 100644
--- a/packages/cli/src/fetch-with-timeout.ts
+++ b/packages/cli/src/utils/fetch-with-timeout.ts
@@ -1,20 +1,15 @@
 import nodeFetch from 'node-fetch';
+import AbortController from 'abort-controller';
 
 const TIMEOUT = 3000;
 
 export default async (url: string, options = {}) => {
   try {
-    if (!global.AbortController) {
-      return nodeFetch(url, options);
-    }
     const controller = new AbortController();
     const timeout = setTimeout(() => {
       controller.abort();
     }, TIMEOUT);
 
-    // FIXME: fix this (possibly along with this issue: https://github.com/Redocly/redocly-cli/issues/1260)
-    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-    // @ts-ignore
     const res = await nodeFetch(url, { signal: controller.signal, ...options });
     clearTimeout(timeout);
     return res;
diff --git a/packages/cli/src/js-utils.ts b/packages/cli/src/utils/js-utils.ts
similarity index 100%
rename from packages/cli/src/js-utils.ts
rename to packages/cli/src/utils/js-utils.ts
diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils/miscellaneous.ts
similarity index 99%
rename from packages/cli/src/utils.ts
rename to packages/cli/src/utils/miscellaneous.ts
index ef8bce1f..495ab526 100644
--- a/packages/cli/src/utils.ts
+++ b/packages/cli/src/utils/miscellaneous.ts
@@ -30,11 +30,11 @@ import {
   ConfigApis,
   CommandOptions,
   OutputExtensions,
-} from './types';
+} from '../types';
 import { isEmptyObject } from '@redocly/openapi-core/lib/utils';
 import { Arguments } from 'yargs';
 import { version } from './update-version-notifier';
-import { DESTINATION_REGEX } from './commands/push';
+import { DESTINATION_REGEX } from '../commands/push';
 import { ConfigValidationError } from '@redocly/openapi-core/lib/config';
 
 import type { RawConfigProcessor } from '@redocly/openapi-core/lib/config';
diff --git a/packages/cli/src/utils/spinner.ts b/packages/cli/src/utils/spinner.ts
new file mode 100644
index 00000000..d7a7fb8a
--- /dev/null
+++ b/packages/cli/src/utils/spinner.ts
@@ -0,0 +1,50 @@
+import * as process from 'process';
+
+const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
+
+export class Spinner {
+  private readonly frames: string[];
+  private currentFrame: number;
+  private intervalId: NodeJS.Timeout | null;
+  private message: string;
+
+  constructor() {
+    this.frames = SPINNER_FRAMES;
+    this.currentFrame = 0;
+    this.intervalId = null;
+    this.message = '';
+  }
+
+  private showFrame() {
+    process.stdout.write('\r' + this.frames[this.currentFrame] + ' ' + this.message);
+    this.currentFrame = (this.currentFrame + 1) % this.frames.length;
+  }
+
+  start(message: string) {
+    if (this.message === message) {
+      return;
+    }
+
+    this.message = message;
+    // If we're not in a TTY, don't display the spinner.
+    if (!process.stdout.isTTY) {
+      process.stdout.write(`${message}...\n`);
+      return;
+    }
+
+    if (this.intervalId === null) {
+      this.intervalId = setInterval(() => {
+        this.showFrame();
+      }, 100);
+    }
+  }
+
+  stop() {
+    if (this.intervalId !== null) {
+      clearInterval(this.intervalId);
+      this.intervalId = null;
+      process.stdout.write('\r');
+    }
+    this.message = '';
+  }
+}
diff --git a/packages/cli/src/update-version-notifier.ts b/packages/cli/src/utils/update-version-notifier.ts
similarity index 96%
rename from packages/cli/src/update-version-notifier.ts
rename to packages/cli/src/utils/update-version-notifier.ts
index e6163681..4b92012e 100644
--- a/packages/cli/src/update-version-notifier.ts
+++ b/packages/cli/src/utils/update-version-notifier.ts
@@ -4,9 +4,9 @@ import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
 import { compare } from 'semver';
 import fetch from './fetch-with-timeout';
 import { cyan, green, yellow } from 'colorette';
-import { cleanColors } from './utils';
+import { cleanColors } from './miscellaneous';
 
-export const { version, name } = require('../package.json');
+export const { version, name } = require('../../package.json');
 
 const VERSION_CACHE_FILE = 'redocly-cli-version';
 const SPACE_TO_BORDER = 4;
diff --git a/packages/cli/src/wrapper.ts b/packages/cli/src/wrapper.ts
index 2e1fa85d..4816e503 100644
--- a/packages/cli/src/wrapper.ts
+++ b/packages/cli/src/wrapper.ts
@@ -1,7 +1,12 @@
 import { Config, Region, doesYamlFileExist } from '@redocly/openapi-core';
 import type { Arguments } from 'yargs';
-import { version } from './update-version-notifier';
-import { ExitCode, exitWithError, loadConfigAndHandleErrors, sendTelemetry } from './utils';
+import { version } from './utils/update-version-notifier';
+import {
+  ExitCode,
+  exitWithError,
+  loadConfigAndHandleErrors,
+  sendTelemetry,
+} from './utils/miscellaneous';
 import { lintConfigCallback } from './commands/lint';
 import type { CommandOptions } from './types';
 
diff --git a/packages/core/src/redocly/index.ts b/packages/core/src/redocly/index.ts
index b0571ff5..4b1de8b6 100644
--- a/packages/core/src/redocly/index.ts
+++ b/packages/core/src/redocly/index.ts
@@ -10,7 +10,7 @@ import { colorize } from '../logger';
 
 import type { AccessTokens, Region } from '../config/types';
 
-const TOKEN_FILENAME = '.redocly-config.json';
+export const TOKEN_FILENAME = '.redocly-config.json';
 
 export class RedoclyClient {
   private accessTokens: AccessTokens = {};


Test patch:
diff --git a/packages/cli/src/__tests__/commands/build-docs.test.ts b/packages/cli/src/__tests__/commands/build-docs.test.ts
index 16fab291..39583b98 100644
--- a/packages/cli/src/__tests__/commands/build-docs.test.ts
+++ b/packages/cli/src/__tests__/commands/build-docs.test.ts
@@ -3,11 +3,11 @@ import { renderToString } from 'react-dom/server';
 import { handlerBuildCommand } from '../../commands/build-docs';
 import { BuildDocsArgv } from '../../commands/build-docs/types';
 import { getPageHTML } from '../../commands/build-docs/utils';
-import { getFallbackApisOrExit } from '../../utils';
+import { getFallbackApisOrExit } from '../../utils/miscellaneous';
 
 jest.mock('redoc');
 jest.mock('fs');
-jest.mock('../../utils');
+jest.mock('../../utils/miscellaneous');
 
 const config = {
   output: '',
diff --git a/packages/cli/src/__tests__/commands/bundle.test.ts b/packages/cli/src/__tests__/commands/bundle.test.ts
index 2cc40086..f22d9937 100644
--- a/packages/cli/src/__tests__/commands/bundle.test.ts
+++ b/packages/cli/src/__tests__/commands/bundle.test.ts
@@ -1,13 +1,13 @@
 import { lint, bundle, getTotals, getMergedConfig } from '@redocly/openapi-core';
 
 import { BundleOptions, handleBundle } from '../../commands/bundle';
-import { handleError } from '../../utils';
+import { handleError } from '../../utils/miscellaneous';
 import { commandWrapper } from '../../wrapper';
 import SpyInstance = jest.SpyInstance;
 import { Arguments } from 'yargs';
 
 jest.mock('@redocly/openapi-core');
-jest.mock('../../utils');
+jest.mock('../../utils/miscellaneous');
 
 (getMergedConfig as jest.Mock).mockImplementation((config) => config);
 
diff --git a/packages/cli/src/__tests__/commands/join.test.ts b/packages/cli/src/__tests__/commands/join.test.ts
index e9f995fc..a82f9edd 100644
--- a/packages/cli/src/__tests__/commands/join.test.ts
+++ b/packages/cli/src/__tests__/commands/join.test.ts
@@ -1,11 +1,11 @@
 import { handleJoin } from '../../commands/join';
-import { exitWithError, writeToFileByExtension, writeYaml } from '../../utils';
+import { exitWithError, writeToFileByExtension, writeYaml } from '../../utils/miscellaneous';
 import { yellow } from 'colorette';
 import { detectSpec } from '@redocly/openapi-core';
 import { loadConfig } from '../../__mocks__/@redocly/openapi-core';
 import { ConfigFixture } from '../fixtures/config';
 
-jest.mock('../../utils');
+jest.mock('../../utils/miscellaneous');
 
 jest.mock('colorette');
 
diff --git a/packages/cli/src/__tests__/commands/lint.test.ts b/packages/cli/src/__tests__/commands/lint.test.ts
index 3198bc9f..4b4b15d4 100644
--- a/packages/cli/src/__tests__/commands/lint.test.ts
+++ b/packages/cli/src/__tests__/commands/lint.test.ts
@@ -14,7 +14,7 @@ import {
   exitWithError,
   loadConfigAndHandleErrors,
   checkIfRulesetExist,
-} from '../../utils';
+} from '../../utils/miscellaneous';
 import { ConfigFixture } from '../fixtures/config';
 import { performance } from 'perf_hooks';
 import { commandWrapper } from '../../wrapper';
@@ -22,10 +22,10 @@ import { Arguments } from 'yargs';
 import { blue } from 'colorette';
 
 jest.mock('@redocly/openapi-core');
-jest.mock('../../utils');
+jest.mock('../../utils/miscellaneous');
 jest.mock('perf_hooks');
 
-jest.mock('../../update-version-notifier', () => ({
+jest.mock('../../utils/update-version-notifier', () => ({
   version: '1.0.0',
 }));
 
diff --git a/packages/cli/src/__tests__/commands/push-region.test.ts b/packages/cli/src/__tests__/commands/push-region.test.ts
index 3217f900..8dd66631 100644
--- a/packages/cli/src/__tests__/commands/push-region.test.ts
+++ b/packages/cli/src/__tests__/commands/push-region.test.ts
@@ -12,7 +12,7 @@ jest.mock('node-fetch', () => ({
 }));
 jest.mock('@redocly/openapi-core');
 jest.mock('../../commands/login');
-jest.mock('../../utils');
+jest.mock('../../utils/miscellaneous');
 
 (getMergedConfig as jest.Mock).mockImplementation((config) => config);
 
diff --git a/packages/cli/src/__tests__/commands/push.test.ts b/packages/cli/src/__tests__/commands/push.test.ts
index a10c3b46..8926a771 100644
--- a/packages/cli/src/__tests__/commands/push.test.ts
+++ b/packages/cli/src/__tests__/commands/push.test.ts
@@ -1,6 +1,6 @@
 import * as fs from 'fs';
 import { Config, getMergedConfig } from '@redocly/openapi-core';
-import { exitWithError } from '../../utils';
+import { exitWithError } from '../../utils/miscellaneous';
 import { getApiRoot, getDestinationProps, handlePush, transformPush } from '../../commands/push';
 import { ConfigFixture } from '../fixtures/config';
 import { yellow } from 'colorette';
@@ -13,7 +13,7 @@ jest.mock('node-fetch', () => ({
   })),
 }));
 jest.mock('@redocly/openapi-core');
-jest.mock('../../utils');
+jest.mock('../../utils/miscellaneous');
 
 (getMergedConfig as jest.Mock).mockImplementation((config) => config);
 
diff --git a/packages/cli/src/__tests__/fetch-with-timeout.test.ts b/packages/cli/src/__tests__/fetch-with-timeout.test.ts
index 598fc82e..e3db223d 100644
--- a/packages/cli/src/__tests__/fetch-with-timeout.test.ts
+++ b/packages/cli/src/__tests__/fetch-with-timeout.test.ts
@@ -1,4 +1,5 @@
-import fetchWithTimeout from '../fetch-with-timeout';
+import AbortController from 'abort-controller';
+import fetchWithTimeout from '../utils/fetch-with-timeout';
 import nodeFetch from 'node-fetch';
 
 jest.mock('node-fetch');
@@ -8,20 +9,7 @@ describe('fetchWithTimeout', () => {
     jest.clearAllMocks();
   });
 
-  it('should use bare node-fetch if AbortController is not available', async () => {
-    // @ts-ignore
-    global.AbortController = undefined;
-    // @ts-ignore
-    global.setTimeout = jest.fn();
-    await fetchWithTimeout('url', { method: 'GET' });
-
-    expect(nodeFetch).toHaveBeenCalledWith('url', { method: 'GET' });
-
-    expect(global.setTimeout).toHaveBeenCalledTimes(0);
-  });
-
-  it('should call node-fetch with signal if AbortController is  available', async () => {
-    global.AbortController = jest.fn().mockImplementation(() => ({ signal: 'something' }));
+  it('should call node-fetch with signal', async () => {
     // @ts-ignore
     global.setTimeout = jest.fn();
 
@@ -29,7 +17,7 @@ describe('fetchWithTimeout', () => {
     await fetchWithTimeout('url');
 
     expect(global.setTimeout).toHaveBeenCalledTimes(1);
-    expect(nodeFetch).toHaveBeenCalledWith('url', { signal: 'something' });
+    expect(nodeFetch).toHaveBeenCalledWith('url', { signal: new AbortController().signal });
     expect(global.clearTimeout).toHaveBeenCalledTimes(1);
   });
 });
diff --git a/packages/cli/src/__tests__/spinner.test.ts b/packages/cli/src/__tests__/spinner.test.ts
new file mode 100644
index 00000000..3b8be2fa
--- /dev/null
+++ b/packages/cli/src/__tests__/spinner.test.ts
@@ -0,0 +1,51 @@
+import { Spinner } from '../utils/spinner';
+import * as process from 'process';
+
+jest.useFakeTimers();
+
+describe('Spinner', () => {
+  const IS_TTY = process.stdout.isTTY;
+
+  let writeMock: jest.SpyInstance;
+  let spinner: Spinner;
+
+  beforeEach(() => {
+    process.stdout.isTTY = true;
+    writeMock = jest.spyOn(process.stdout, 'write').mockImplementation(jest.fn());
+    spinner = new Spinner();
+  });
+
+  afterEach(() => {
+    writeMock.mockRestore();
+    jest.clearAllTimers();
+  });
+
+  afterAll(() => {
+    process.stdout.isTTY = IS_TTY;
+  });
+
+  it('starts the spinner', () => {
+    spinner.start('Loading');
+    jest.advanceTimersByTime(100);
+    expect(writeMock).toHaveBeenCalledWith('\r⠋ Loading');
+  });
+
+  it('stops the spinner', () => {
+    spinner.start('Loading');
+    spinner.stop();
+    expect(writeMock).toHaveBeenCalledWith('\r');
+  });
+
+  it('should write 3 frames', () => {
+    spinner.start('Loading');
+    jest.advanceTimersByTime(300);
+    expect(writeMock).toHaveBeenCalledTimes(3);
+  });
+
+  it('should call write 1 times if CI set to true', () => {
+    process.stdout.isTTY = false;
+    spinner.start('Loading');
+    jest.advanceTimersByTime(300);
+    expect(writeMock).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/packages/cli/src/__tests__/utils.test.ts b/packages/cli/src/__tests__/utils.test.ts
index 63d3377e..af84cc5b 100644
--- a/packages/cli/src/__tests__/utils.test.ts
+++ b/packages/cli/src/__tests__/utils.test.ts
@@ -13,10 +13,8 @@ import {
   cleanArgs,
   cleanRawInput,
   getAndValidateFileExtension,
-  writeYaml,
-  writeJson,
   writeToFileByExtension,
-} from '../utils';
+} from '../utils/miscellaneous';
 import {
   ResolvedApi,
   Totals,
@@ -26,10 +24,9 @@ import {
   stringifyYaml,
 } from '@redocly/openapi-core';
 import { blue, red, yellow } from 'colorette';
-import { existsSync, statSync, writeFileSync } from 'fs';
+import { existsSync, statSync } from 'fs';
 import * as path from 'path';
 import * as process from 'process';
-import * as utils from '../utils';
 
 jest.mock('os');
 jest.mock('colorette');
diff --git a/packages/cli/src/__tests__/wrapper.test.ts b/packages/cli/src/__tests__/wrapper.test.ts
index d5ab7b78..d3554898 100644
--- a/packages/cli/src/__tests__/wrapper.test.ts
+++ b/packages/cli/src/__tests__/wrapper.test.ts
@@ -1,4 +1,4 @@
-import { loadConfigAndHandleErrors, sendTelemetry } from '../utils';
+import { loadConfigAndHandleErrors, sendTelemetry } from '../utils/miscellaneous';
 import * as process from 'process';
 import { commandWrapper } from '../wrapper';
 import { handleLint } from '../commands/lint';
@@ -6,7 +6,7 @@ import { Arguments } from 'yargs';
 import { handlePush, PushOptions } from '../commands/push';
 
 jest.mock('node-fetch');
-jest.mock('../utils', () => ({
+jest.mock('../utils/miscellaneous', () => ({
   sendTelemetry: jest.fn(),
   loadConfigAndHandleErrors: jest.fn(),
 }));
diff --git a/packages/cli/src/cms/api/__tests__/api-keys.test.ts b/packages/cli/src/cms/api/__tests__/api-keys.test.ts
new file mode 100644
index 00000000..36169e12
--- /dev/null
+++ b/packages/cli/src/cms/api/__tests__/api-keys.test.ts
@@ -0,0 +1,37 @@
+import { getApiKeys } from '../api-keys';
+import * as fs from 'fs';
+
+describe('getApiKeys()', () => {
+  afterEach(() => {
+    jest.resetAllMocks();
+  });
+
+  it('should return api key from environment variable', () => {
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+
+    expect(getApiKeys('test-domain')).toEqual('test-api-key');
+  });
+
+  it('should return api key from credentials file', () => {
+    process.env.REDOCLY_AUTHORIZATION = '';
+
+    jest.spyOn(fs, 'existsSync').mockReturnValue(true);
+    jest.spyOn(fs, 'readFileSync').mockReturnValue(
+      JSON.stringify({
+        ['test-domain']: 'test-api-key-from-credentials-file',
+      })
+    );
+
+    expect(getApiKeys('test-domain')).toEqual('test-api-key-from-credentials-file');
+  });
+
+  it('should throw an error if no api key provided', () => {
+    process.env.REDOCLY_AUTHORIZATION = '';
+
+    jest.spyOn(fs, 'existsSync').mockReturnValue(false);
+
+    expect(() => getApiKeys('test-domain')).toThrowError(
+      'No api key provided, please use environment variable REDOCLY_AUTHORIZATION.'
+    );
+  });
+});
diff --git a/packages/cli/src/cms/api/__tests__/api.client.test.ts b/packages/cli/src/cms/api/__tests__/api.client.test.ts
new file mode 100644
index 00000000..f654ad70
--- /dev/null
+++ b/packages/cli/src/cms/api/__tests__/api.client.test.ts
@@ -0,0 +1,275 @@
+import fetch, { Response } from 'node-fetch';
+import * as FormData from 'form-data';
+
+import { ReuniteApiClient, PushPayload } from '../api-client';
+
+jest.mock('node-fetch', () => ({
+  default: jest.fn(),
+}));
+
+function mockFetchResponse(response: any) {
+  (fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(response as unknown as Response);
+}
+
+describe('ApiClient', () => {
+  const testToken = 'test-token';
+  const testDomain = 'test-domain.com';
+  const testOrg = 'test-org';
+  const testProject = 'test-project';
+
+  describe('getDefaultBranch()', () => {
+    let apiClient: ReuniteApiClient;
+
+    beforeEach(() => {
+      apiClient = new ReuniteApiClient(testDomain, testToken);
+    });
+
+    it('should get default project branch', async () => {
+      mockFetchResponse({
+        ok: true,
+        json: jest.fn().mockResolvedValue({
+          branchName: 'test-branch',
+        }),
+      });
+
+      const result = await apiClient.remotes.getDefaultBranch(testOrg, testProject);
+
+      expect(fetch).toHaveBeenCalledWith(
+        `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/source`,
+        {
+          method: 'GET',
+          headers: {
+            'Content-Type': 'application/json',
+            Authorization: `Bearer ${testToken}`,
+          },
+        }
+      );
+
+      expect(result).toEqual('test-branch');
+    });
+
+    it('should throw parsed error if response is not ok', async () => {
+      mockFetchResponse({
+        ok: false,
+        json: jest.fn().mockResolvedValue({
+          type: 'about:blank',
+          title: 'Project source not found',
+          status: 404,
+          detail: 'Not Found',
+          object: 'problem',
+        }),
+      });
+
+      await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
+        new Error('Failed to fetch default branch: Project source not found')
+      );
+    });
+
+    it('should throw statusText error if response is not ok', async () => {
+      mockFetchResponse({
+        ok: false,
+        statusText: 'Not found',
+        json: jest.fn().mockResolvedValue({
+          unknownField: 'unknown-error',
+        }),
+      });
+
+      await expect(apiClient.remotes.getDefaultBranch(testOrg, testProject)).rejects.toThrow(
+        new Error('Failed to fetch default branch: Not found')
+      );
+    });
+  });
+
+  describe('upsert()', () => {
+    const remotePayload = {
+      mountBranchName: 'remote-mount-branch-name',
+      mountPath: 'remote-mount-path',
+    };
+    let apiClient: ReuniteApiClient;
+
+    beforeEach(() => {
+      apiClient = new ReuniteApiClient(testDomain, testToken);
+    });
+
+    it('should upsert remote', async () => {
+      const responseMock = {
+        id: 'remote-id',
+        type: 'CICD',
+        mountPath: 'remote-mount-path',
+        mountBranchName: 'remote-mount-branch-name',
+        organizationId: testOrg,
+        projectId: testProject,
+      };
+
+      mockFetchResponse({
+        ok: true,
+        json: jest.fn().mockResolvedValue(responseMock),
+      });
+
+      const result = await apiClient.remotes.upsert(testOrg, testProject, remotePayload);
+
+      expect(fetch).toHaveBeenCalledWith(
+        `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/remotes`,
+        {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+            Authorization: `Bearer ${testToken}`,
+          },
+          body: JSON.stringify({
+            mountPath: remotePayload.mountPath,
+            mountBranchName: remotePayload.mountBranchName,
+            type: 'CICD',
+            autoMerge: true,
+          }),
+        }
+      );
+
+      expect(result).toEqual(responseMock);
+    });
+
+    it('should throw parsed error if response is not ok', async () => {
+      mockFetchResponse({
+        ok: false,
+        json: jest.fn().mockResolvedValue({
+          type: 'about:blank',
+          title: 'Not allowed to mount remote outside of project content path: /docs',
+          status: 403,
+          detail: 'Forbidden',
+          object: 'problem',
+        }),
+      });
+
+      await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
+        new Error(
+          'Failed to upsert remote: Not allowed to mount remote outside of project content path: /docs'
+        )
+      );
+    });
+
+    it('should throw statusText error if response is not ok', async () => {
+      mockFetchResponse({
+        ok: false,
+        statusText: 'Not found',
+        json: jest.fn().mockResolvedValue({
+          unknownField: 'unknown-error',
+        }),
+      });
+
+      await expect(apiClient.remotes.upsert(testOrg, testProject, remotePayload)).rejects.toThrow(
+        new Error('Failed to upsert remote: Not found')
+      );
+    });
+  });
+
+  describe('push()', () => {
+    const testRemoteId = 'test-remote-id';
+    const pushPayload = {
+      remoteId: testRemoteId,
+      commit: {
+        message: 'test-message',
+        author: {
+          name: 'test-name',
+          email: 'test-email',
+        },
+        branchName: 'test-branch-name',
+      },
+    } as unknown as PushPayload;
+
+    const filesMock = [{ path: 'some-file.yaml', stream: Buffer.from('fefef') }];
+
+    const responseMock = {
+      branchName: 'rem/cicd/rem_01he7sr6ys2agb7w0g9t7978fn-main',
+      hasChanges: true,
+      files: [
+        {
+          type: 'file',
+          name: 'some-file.yaml',
+          path: 'docs/remotes/some-file.yaml',
+          lastModified: 1698925132394.2993,
+          mimeType: 'text/yaml',
+        },
+      ],
+      commitSha: 'bb23a2f8e012ac0b7b9961b57fb40d8686b21b43',
+      outdated: false,
+    };
+
+    let apiClient: ReuniteApiClient;
+
+    beforeEach(() => {
+      apiClient = new ReuniteApiClient(testDomain, testToken);
+    });
+
+    it('should push to remote', async () => {
+      let passedFormData = new FormData();
+
+      (fetch as jest.MockedFunction<typeof fetch>).mockImplementationOnce(
+        async (_: any, options: any): Promise<Response> => {
+          passedFormData = options.body as FormData;
+
+          return {
+            ok: true,
+            json: jest.fn().mockResolvedValue(responseMock),
+          } as unknown as Response;
+        }
+      );
+
+      const formData = new FormData();
+
+      formData.append('remoteId', testRemoteId);
+      formData.append('commit[message]', pushPayload.commit.message);
+      formData.append('commit[author][name]', pushPayload.commit.author.name);
+      formData.append('commit[author][email]', pushPayload.commit.author.email);
+      formData.append('commit[branchName]', pushPayload.commit.branchName);
+      formData.append('files[some-file.yaml]', filesMock[0].stream);
+
+      const result = await apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock);
+
+      expect(fetch).toHaveBeenCalledWith(
+        `${testDomain}/api/orgs/${testOrg}/projects/${testProject}/pushes`,
+        expect.objectContaining({
+          method: 'POST',
+          headers: {
+            Authorization: `Bearer ${testToken}`,
+          },
+        })
+      );
+
+      expect(
+        JSON.stringify(passedFormData).replace(new RegExp(passedFormData.getBoundary(), 'g'), '')
+      ).toEqual(JSON.stringify(formData).replace(new RegExp(formData.getBoundary(), 'g'), ''));
+      expect(result).toEqual(responseMock);
+    });
+
+    it('should throw parsed error if response is not ok', async () => {
+      mockFetchResponse({
+        ok: false,
+        json: jest.fn().mockResolvedValue({
+          type: 'about:blank',
+          title: 'Cannot push to remote',
+          status: 403,
+          detail: 'Forbidden',
+          object: 'problem',
+        }),
+      });
+
+      await expect(
+        apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
+      ).rejects.toThrow(new Error('Failed to push: Cannot push to remote'));
+    });
+
+    it('should throw statusText error if response is not ok', async () => {
+      mockFetchResponse({
+        ok: false,
+        statusText: 'Not found',
+        json: jest.fn().mockResolvedValue({
+          unknownField: 'unknown-error',
+        }),
+      });
+
+      await expect(
+        apiClient.remotes.push(testOrg, testProject, pushPayload, filesMock)
+      ).rejects.toThrow(new Error('Failed to push: Not found'));
+    });
+  });
+});
diff --git a/packages/cli/src/cms/api/__tests__/domains.test.ts b/packages/cli/src/cms/api/__tests__/domains.test.ts
new file mode 100644
index 00000000..261b1e7f
--- /dev/null
+++ b/packages/cli/src/cms/api/__tests__/domains.test.ts
@@ -0,0 +1,15 @@
+import { getDomain } from '../domains';
+
+describe('getDomain()', () => {
+  it('should return the domain from environment variable', () => {
+    process.env.REDOCLY_DOMAIN = 'test-domain';
+
+    expect(getDomain()).toBe('test-domain');
+  });
+
+  it('should return the default domain if no domain provided', () => {
+    process.env.REDOCLY_DOMAIN = '';
+
+    expect(getDomain()).toBe('https://app.cloud.redocly.com');
+  });
+});
diff --git a/packages/cli/src/cms/commands/__tests__/push-status.test.ts b/packages/cli/src/cms/commands/__tests__/push-status.test.ts
new file mode 100644
index 00000000..e2b8e350
--- /dev/null
+++ b/packages/cli/src/cms/commands/__tests__/push-status.test.ts
@@ -0,0 +1,212 @@
+import { handlePushStatus } from '../push-status';
+import { PushResponse } from '../../api/types';
+import { exitWithError } from '../../../utils/miscellaneous';
+
+const remotes = {
+  getPush: jest.fn(),
+  getRemotesList: jest.fn(),
+};
+
+jest.mock('../../../utils/miscellaneous');
+
+jest.mock('colorette', () => ({
+  green: (str: string) => str,
+  yellow: (str: string) => str,
+  red: (str: string) => str,
+  gray: (str: string) => str,
+  magenta: (str: string) => str,
+  cyan: (str: string) => str,
+}));
+
+jest.mock('../../api', () => ({
+  ...jest.requireActual('../../api'),
+  ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) {
+    this.remotes = remotes;
+  }),
+}));
+
+describe('handlePushStatus()', () => {
+  const mockConfig = { apis: {} } as any;
+
+  const pushResponseStub = {
+    hasChanges: true,
+    status: {
+      preview: {
+        scorecard: [],
+        deploy: {
+          url: 'https://test-url',
+          status: 'success',
+        },
+      },
+      production: {
+        scorecard: [],
+        deploy: {
+          url: 'https://test-url',
+          status: 'success',
+        },
+      },
+    },
+  } as unknown as PushResponse;
+
+  beforeEach(() => {
+    jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
+    jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should throw error if organization not provided', async () => {
+    await handlePushStatus(
+      {
+        domain: 'test-domain',
+        organization: '',
+        project: 'test-project',
+        pushId: 'test-push-id',
+        'max-execution-time': 1000,
+      },
+      mockConfig
+    );
+
+    expect(exitWithError).toHaveBeenCalledWith(
+      "No organization provided, please use --organization option or specify the 'organization' field in the config file."
+    );
+  });
+
+  it('should return success push status for preview-build', async () => {
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+    remotes.getPush.mockResolvedValueOnce(pushResponseStub);
+
+    await handlePushStatus(
+      {
+        domain: 'test-domain',
+        organization: 'test-org',
+        project: 'test-project',
+        pushId: 'test-push-id',
+        'max-execution-time': 1000,
+      },
+      mockConfig
+    );
+    expect(process.stdout.write).toHaveBeenCalledTimes(1);
+    expect(process.stdout.write).toHaveBeenCalledWith(
+      '🚀 PREVIEW deployment succeeded.\nPreview URL: https://test-url\n'
+    );
+  });
+
+  it('should return success push status for preview and production builds', async () => {
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+    remotes.getPush.mockResolvedValue({ ...pushResponseStub, isMainBranch: true });
+
+    await handlePushStatus(
+      {
+        domain: 'test-domain',
+        organization: 'test-org',
+        project: 'test-project',
+        pushId: 'test-push-id',
+        'max-execution-time': 1000,
+      },
+      mockConfig
+    );
+    expect(process.stdout.write).toHaveBeenCalledTimes(2);
+    expect(process.stdout.write).toHaveBeenCalledWith(
+      '🚀 PREVIEW deployment succeeded.\nPreview URL: https://test-url\n'
+    );
+    expect(process.stdout.write).toHaveBeenCalledWith(
+      '🚀 PRODUCTION deployment succeeded.\nPreview URL: https://test-url\n'
+    );
+  });
+
+  it('should return failed push status for preview build', async () => {
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+
+    remotes.getPush.mockResolvedValue({
+      isOutdated: false,
+      hasChanges: true,
+      status: {
+        preview: { deploy: { status: 'failed', url: 'https://test-url' }, scorecard: [] },
+      },
+    });
+
+    await handlePushStatus(
+      {
+        domain: 'test-domain',
+        organization: 'test-org',
+        project: 'test-project',
+        pushId: 'test-push-id',
+        'max-execution-time': 1000,
+      },
+      mockConfig
+    );
+    expect(exitWithError).toHaveBeenCalledWith(
+      '❌ PREVIEW deployment failed.\nPreview URL: https://test-url'
+    );
+  });
+
+  it('should return success push status for preview build and print scorecards', async () => {
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+
+    remotes.getPush.mockResolvedValue({
+      isOutdated: false,
+      hasChanges: true,
+      status: {
+        preview: {
+          deploy: { status: 'success', url: 'https://test-url' },
+          scorecard: [
+            {
+              name: 'test-name',
+              status: 'success',
+              description: 'test-description',
+              url: 'test-url',
+            },
+          ],
+        },
+      },
+    });
+
+    await handlePushStatus(
+      {
+        domain: 'test-domain',
+        organization: 'test-org',
+        project: 'test-project',
+        pushId: 'test-push-id',
+        'max-execution-time': 1000,
+      },
+      mockConfig
+    );
+    expect(process.stdout.write).toHaveBeenCalledTimes(4);
+    expect(process.stdout.write).toHaveBeenCalledWith(
+      '🚀 PREVIEW deployment succeeded.\nPreview URL: https://test-url\n'
+    );
+    expect(process.stdout.write).toHaveBeenCalledWith('\nScorecard:');
+    expect(process.stdout.write).toHaveBeenCalledWith(
+      '\n    Name: test-name\n    Status: success\n    URL: test-url\n    Description: test-description\n'
+    );
+    expect(process.stdout.write).toHaveBeenCalledWith('\n');
+  });
+
+  it('should display message if there is no changes', async () => {
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+
+    remotes.getPush.mockResolvedValueOnce({
+      isOutdated: false,
+      hasChanges: false,
+      status: {
+        preview: { deploy: { status: 'skipped', url: 'https://test-url' }, scorecard: [] },
+      },
+    });
+
+    await handlePushStatus(
+      {
+        domain: 'test-domain',
+        organization: 'test-org',
+        project: 'test-project',
+        pushId: 'test-push-id',
+        wait: true,
+        'max-execution-time': 1000,
+      },
+      mockConfig
+    );
+    expect(process.stderr.write).toHaveBeenCalledWith('Files not uploaded. Reason: no changes.\n');
+  });
+});
diff --git a/packages/cli/src/cms/commands/__tests__/push.test.ts b/packages/cli/src/cms/commands/__tests__/push.test.ts
new file mode 100644
index 00000000..ebdb1228
--- /dev/null
+++ b/packages/cli/src/cms/commands/__tests__/push.test.ts
@@ -0,0 +1,293 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import { handlePush } from '../push';
+import { ReuniteApiClient } from '../../api';
+
+const remotes = {
+  push: jest.fn(),
+  upsert: jest.fn(),
+  getDefaultBranch: jest.fn(),
+};
+
+jest.mock('@redocly/openapi-core', () => ({
+  slash: jest.fn().mockImplementation((p) => p),
+}));
+
+jest.mock('../../api', () => ({
+  ...jest.requireActual('../../api'),
+  ReuniteApiClient: jest.fn().mockImplementation(function (this: any, ...args) {
+    this.remotes = remotes;
+  }),
+}));
+
+describe('handlePush()', () => {
+  let pathResolveSpy: jest.SpyInstance;
+  let pathRelativeSpy: jest.SpyInstance;
+  let pathDirnameSpy: jest.SpyInstance;
+  let fsStatSyncSpy: jest.SpyInstance;
+  let fsReaddirSyncSpy: jest.SpyInstance;
+
+  beforeEach(() => {
+    remotes.getDefaultBranch.mockResolvedValueOnce('test-default-branch');
+    remotes.upsert.mockResolvedValueOnce({ id: 'test-remote-id' });
+    remotes.push.mockResolvedValueOnce({ branchName: 'uploaded-to-branch' });
+
+    jest.spyOn(fs, 'createReadStream').mockReturnValue('stream' as any);
+
+    pathResolveSpy = jest.spyOn(path, 'resolve');
+    pathRelativeSpy = jest.spyOn(path, 'relative');
+    pathDirnameSpy = jest.spyOn(path, 'dirname');
+    fsStatSyncSpy = jest.spyOn(fs, 'statSync');
+    fsReaddirSyncSpy = jest.spyOn(fs, 'readdirSync');
+  });
+
+  afterEach(() => {
+    pathResolveSpy.mockRestore();
+    pathRelativeSpy.mockRestore();
+    pathDirnameSpy.mockRestore();
+    fsStatSyncSpy.mockRestore();
+    fsReaddirSyncSpy.mockRestore();
+  });
+
+  it('should upload files', async () => {
+    const mockConfig = { apis: {} } as any;
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+
+    fsStatSyncSpy.mockReturnValueOnce({
+      isDirectory() {
+        return false;
+      },
+    } as any);
+
+    pathResolveSpy.mockImplementationOnce((p) => p);
+    pathRelativeSpy.mockImplementationOnce((_, p) => p);
+    pathDirnameSpy.mockImplementation((_: string) => '.');
+
+    await handlePush(
+      {
+        domain: 'test-domain',
+        'mount-path': 'test-mount-path',
+        organization: 'test-org',
+        project: 'test-project',
+        branch: 'test-branch',
+        namespace: 'test-namespace',
+        repository: 'test-repository',
+        'commit-sha': 'test-commit-sha',
+        'commit-url': 'test-commit-url',
+        'default-branch': 'test-branch',
+        'created-at': 'test-created-at',
+        author: 'TestAuthor <test-author@mail.com>',
+        message: 'Test message',
+        files: ['test-file'],
+        'max-execution-time': 10,
+      },
+      mockConfig
+    );
+
+    expect(remotes.getDefaultBranch).toHaveBeenCalledWith('test-org', 'test-project');
+    expect(remotes.upsert).toHaveBeenCalledWith('test-org', 'test-project', {
+      mountBranchName: 'test-default-branch',
+      mountPath: 'test-mount-path',
+    });
+    expect(remotes.push).toHaveBeenCalledWith(
+      'test-org',
+      'test-project',
+      {
+        isMainBranch: true,
+        remoteId: 'test-remote-id',
+        commit: {
+          message: 'Test message',
+          branchName: 'test-branch',
+          createdAt: 'test-created-at',
+          namespace: 'test-namespace',
+          repository: 'test-repository',
+          sha: 'test-commit-sha',
+          url: 'test-commit-url',
+          author: {
+            name: 'TestAuthor',
+            email: 'test-author@mail.com',
+          },
+        },
+      },
+      [
+        {
+          path: 'test-file',
+          stream: 'stream',
+        },
+      ]
+    );
+  });
+
+  it('should collect files from directory and preserve file structure', async () => {
+    const mockConfig = { apis: {} } as any;
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+
+    /*
+      ├── app
+      │   ├── index.html
+      ├── openapi.yaml
+      └── some-ref.yaml
+    */
+
+    fsStatSyncSpy.mockImplementation(
+      (filePath) =>
+        ({
+          isDirectory() {
+            return filePath === 'test-folder' || filePath === 'test-folder/app';
+          },
+        } as any)
+    );
+
+    fsReaddirSyncSpy.mockImplementation((dirPath): any => {
+      if (dirPath === 'test-folder') {
+        return ['app', 'another-ref.yaml', 'openapi.yaml'];
+      }
+
+      if (dirPath === 'test-folder/app') {
+        return ['index.html'];
+      }
+
+      throw new Error('Not a directory');
+    });
+
+    await handlePush(
+      {
+        domain: 'test-domain',
+        'mount-path': 'test-mount-path',
+        organization: 'test-org',
+        project: 'test-project',
+        branch: 'test-branch',
+        author: 'TestAuthor <test-author@mail.com>',
+        message: 'Test message',
+        'default-branch': 'main',
+        files: ['test-folder'],
+        'max-execution-time': 10,
+      },
+      mockConfig
+    );
+
+    expect(remotes.push).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.anything(),
+      expect.anything(),
+      [
+        {
+          path: 'app/index.html',
+          stream: 'stream',
+        },
+
+        {
+          path: 'another-ref.yaml',
+          stream: 'stream',
+        },
+        {
+          path: 'openapi.yaml',
+          stream: 'stream',
+        },
+      ]
+    );
+  });
+
+  it('should not upload files if no files passed', async () => {
+    const mockConfig = { apis: {} } as any;
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+
+    await handlePush(
+      {
+        domain: 'test-domain',
+        'mount-path': 'test-mount-path',
+        organization: 'test-org',
+        project: 'test-project',
+        branch: 'test-branch',
+        author: 'TestAuthor <test-author@mail.com>',
+        message: 'Test message',
+        'default-branch': 'main',
+        files: [],
+        'max-execution-time': 10,
+      },
+      mockConfig
+    );
+
+    expect(remotes.getDefaultBranch).not.toHaveBeenCalled();
+    expect(remotes.upsert).not.toHaveBeenCalled();
+    expect(remotes.push).not.toHaveBeenCalled();
+  });
+
+  it('should get organization from config if not passed', async () => {
+    const mockConfig = { organization: 'test-org-from-config', apis: {} } as any;
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+
+    fsStatSyncSpy.mockReturnValueOnce({
+      isDirectory() {
+        return false;
+      },
+    } as any);
+
+    pathResolveSpy.mockImplementationOnce((p) => p);
+    pathRelativeSpy.mockImplementationOnce((_, p) => p);
+    pathDirnameSpy.mockImplementation((_: string) => '.');
+
+    await handlePush(
+      {
+        domain: 'test-domain',
+        'mount-path': 'test-mount-path',
+        project: 'test-project',
+        branch: 'test-branch',
+        author: 'TestAuthor <test-author@mail.com>',
+        message: 'Test message',
+        files: ['test-file'],
+        'default-branch': 'main',
+        'max-execution-time': 10,
+      },
+      mockConfig
+    );
+
+    expect(remotes.getDefaultBranch).toHaveBeenCalledWith(
+      'test-org-from-config',
+      expect.anything()
+    );
+    expect(remotes.upsert).toHaveBeenCalledWith(
+      'test-org-from-config',
+      expect.anything(),
+      expect.anything()
+    );
+    expect(remotes.push).toHaveBeenCalledWith(
+      'test-org-from-config',
+      expect.anything(),
+      expect.anything(),
+      expect.anything()
+    );
+  });
+
+  it('should get domain from env if not passed', async () => {
+    const mockConfig = { organization: 'test-org-from-config', apis: {} } as any;
+    process.env.REDOCLY_AUTHORIZATION = 'test-api-key';
+    process.env.REDOCLY_DOMAIN = 'test-domain-from-env';
+
+    fsStatSyncSpy.mockReturnValueOnce({
+      isDirectory() {
+        return false;
+      },
+    } as any);
+
+    pathResolveSpy.mockImplementationOnce((p) => p);
+    pathRelativeSpy.mockImplementationOnce((_, p) => p);
+    pathDirnameSpy.mockImplementation((_: string) => '.');
+
+    await handlePush(
+      {
+        'mount-path': 'test-mount-path',
+        project: 'test-project',
+        branch: 'test-branch',
+        'default-branch': 'main',
+        author: 'TestAuthor <test-author@mail.com>',
+        message: 'Test message',
+        files: ['test-file'],
+        'max-execution-time': 10,
+      },
+      mockConfig
+    );
+
+    expect(ReuniteApiClient).toBeCalledWith('test-domain-from-env', 'test-api-key');
+  });
+});
diff --git a/packages/cli/src/commands/split/__tests__/index.test.ts b/packages/cli/src/commands/split/__tests__/index.test.ts
index beb2fe37..b29e5bc2 100644
--- a/packages/cli/src/commands/split/__tests__/index.test.ts
+++ b/packages/cli/src/commands/split/__tests__/index.test.ts
@@ -3,12 +3,11 @@ import * as path from 'path';
 import * as openapiCore from '@redocly/openapi-core';
 import { ComponentsFiles } from '../types';
 import { blue, green } from 'colorette';
-import { writeToFileByExtension } from '../../../utils';
 
-const utils = require('../../../utils');
+const utils = require('../../../utils/miscellaneous');
 
-jest.mock('../../../utils', () => ({
-  ...jest.requireActual('../../../utils'),
+jest.mock('../../../utils/miscellaneous', () => ({
+  ...jest.requireActual('../../../utils/miscellaneous'),
   writeToFileByExtension: jest.fn(),
 }));