@statuser/cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +448 -2
- package/dist/client.js +163 -0
- package/dist/client.js.map +1 -0
- package/dist/commander-i18n.js +52 -0
- package/dist/commander-i18n.js.map +1 -0
- package/dist/commands/api.js +104 -0
- package/dist/commands/api.js.map +1 -0
- package/dist/commands/auth.js +104 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/catalog.js +72 -0
- package/dist/commands/catalog.js.map +1 -0
- package/dist/commands/config.js +302 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/heartbeat.js +199 -0
- package/dist/commands/heartbeat.js.map +1 -0
- package/dist/commands/incidents.js +265 -0
- package/dist/commands/incidents.js.map +1 -0
- package/dist/commands/maintenance.js +211 -0
- package/dist/commands/maintenance.js.map +1 -0
- package/dist/commands/reports.js +170 -0
- package/dist/commands/reports.js.map +1 -0
- package/dist/commands/scenarios.js +361 -0
- package/dist/commands/scenarios.js.map +1 -0
- package/dist/commands/servers-add.js +152 -0
- package/dist/commands/servers-add.js.map +1 -0
- package/dist/commands/servers-checks.js +56 -0
- package/dist/commands/servers-checks.js.map +1 -0
- package/dist/commands/servers.js +424 -0
- package/dist/commands/servers.js.map +1 -0
- package/dist/config.js +107 -0
- package/dist/config.js.map +1 -0
- package/dist/confirm.js +34 -0
- package/dist/confirm.js.map +1 -0
- package/dist/context.js +27 -0
- package/dist/context.js.map +1 -0
- package/dist/duration.js +20 -0
- package/dist/duration.js.map +1 -0
- package/dist/errors.js +73 -0
- package/dist/errors.js.map +1 -0
- package/dist/files.js +36 -0
- package/dist/files.js.map +1 -0
- package/dist/generated/openapi.js +6 -0
- package/dist/generated/openapi.js.map +1 -0
- package/dist/i18n.js +757 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.js +58 -0
- package/dist/index.js.map +1 -0
- package/dist/json-error.js +10 -0
- package/dist/json-error.js.map +1 -0
- package/dist/output.js +43 -0
- package/dist/output.js.map +1 -0
- package/dist/scenarios/config.js +228 -0
- package/dist/scenarios/config.js.map +1 -0
- package/dist/scenarios/plan.js +159 -0
- package/dist/scenarios/plan.js.map +1 -0
- package/dist/servers-config/export.js +72 -0
- package/dist/servers-config/export.js.map +1 -0
- package/dist/servers-config/fields.js +172 -0
- package/dist/servers-config/fields.js.map +1 -0
- package/dist/servers-config/forbidden-host.js +75 -0
- package/dist/servers-config/forbidden-host.js.map +1 -0
- package/dist/servers-config/host-pattern.js +6 -0
- package/dist/servers-config/host-pattern.js.map +1 -0
- package/dist/servers-config/parse.js +260 -0
- package/dist/servers-config/parse.js.map +1 -0
- package/dist/servers-config/plan.js +492 -0
- package/dist/servers-config/plan.js.map +1 -0
- package/dist/servers-update.js +157 -0
- package/dist/servers-update.js.map +1 -0
- package/dist/status-page.js +15 -0
- package/dist/status-page.js.map +1 -0
- package/dist/text-length.js +8 -0
- package/dist/text-length.js.map +1 -0
- package/dist/write-failure.js +37 -0
- package/dist/write-failure.js.map +1 -0
- package/package.json +70 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Statuser
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,449 @@
|
|
|
1
|
-
#
|
|
1
|
+
# CLI Statuser
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@statuser/cli)
|
|
4
|
+
[](https://github.com/statuser-cloud/cli/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Утилита командной строки для [Statuser](https://statuser.cloud). Добавляет и настраивает серверы, описывает серверы и сценарные проверки как код, ставит проверки на паузу на время деплоя, ведёт плановые работы и отчёты на странице статуса, отправляет heartbeat из крон-задач. Работает поверх [публичного API Statuser](https://statuser.cloud/api-reference) с авторизацией по API-ключу.
|
|
8
|
+
|
|
9
|
+
```console
|
|
10
|
+
$ statuser scenarios test checks/checkout.spec.ts
|
|
11
|
+
Прогоняю сценарий…
|
|
12
|
+
Сценарий не прошёл.
|
|
13
|
+
Тест: оформление заказа
|
|
14
|
+
Ошибка: expect(locator).toBeVisible() failed
|
|
15
|
+
Locator: getByRole('heading', { name: 'Мои заказы' })
|
|
16
|
+
Скриншот: https://…/screenshot.png
|
|
17
|
+
Видео: https://…/video.webm
|
|
18
|
+
Трейс: https://…/trace.zip
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Содержание
|
|
22
|
+
|
|
23
|
+
- [Установка](#установка)
|
|
24
|
+
- [Быстрый старт](#быстрый-старт)
|
|
25
|
+
- [Серверы](#серверы)
|
|
26
|
+
- [Серверы как код](#серверы-как-код)
|
|
27
|
+
- [Сценарии](#сценарии)
|
|
28
|
+
- [Деплой и плановые работы](#деплой-и-плановые-работы)
|
|
29
|
+
- [Heartbeat](#heartbeat)
|
|
30
|
+
- [Инциденты](#инциденты)
|
|
31
|
+
- [Отчёты на странице статуса](#отчёты-на-странице-статуса)
|
|
32
|
+
- [Готовые файлы CI](#готовые-файлы-ci)
|
|
33
|
+
- [Справка](#справка)
|
|
34
|
+
|
|
35
|
+
## Установка
|
|
36
|
+
|
|
37
|
+
Через npm, если на машине есть Node.js 20.19 или новее:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm install -g @statuser/cli
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Пакет называется `@statuser/cli`, команда после установки — `statuser`.
|
|
44
|
+
|
|
45
|
+
В CI устанавливать не обязательно, достаточно `npx`:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx -y @statuser/cli scenarios deploy
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Без Node — один исполняемый файл со страницы [релизов](https://github.com/statuser-cloud/cli/releases/latest):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
curl -fsSL https://github.com/statuser-cloud/cli/releases/latest/download/statuser-linux-x64.tar.gz | tar -xz
|
|
55
|
+
sudo mv statuser /usr/local/bin/
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Для других систем замените `linux-x64` на `linux-arm64`, `macos-arm64` или `macos-x64`. Для Windows скачайте `statuser-windows-x64.zip`. Контрольные суммы лежат в файле `SHA256SUMS` того же релиза. Если на macOS файл скачан браузером, система не даст его запустить, пока не снять отметку: `xattr -d com.apple.quarantine statuser`.
|
|
59
|
+
|
|
60
|
+
Справка и сообщения выводятся на языке системы: на русском, если `LANG` начинается с `ru`, иначе на английском. Справка по любой команде: `statuser <команда> --help`.
|
|
61
|
+
|
|
62
|
+
## Быстрый старт
|
|
63
|
+
|
|
64
|
+
1. Создайте ключ в [панели Statuser](https://statuser.cloud/my/account/api-keys): «Настройки аккаунта → API и MCP».
|
|
65
|
+
2. Сохраните его на этой машине:
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
$ statuser login
|
|
69
|
+
API-ключ (панель Statuser → Настройки аккаунта → API и MCP):
|
|
70
|
+
Готово: you@example.com. Ключ сохранён в /home/you/.config/statuser/config.json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
3. Посмотрите серверы аккаунта:
|
|
74
|
+
|
|
75
|
+
```console
|
|
76
|
+
$ statuser servers list
|
|
77
|
+
ID Имя Протокол Статус Хост Проект
|
|
78
|
+
1874 Сайт магазина http online https://shop.example.com 100000
|
|
79
|
+
2041 Вход в кабинет scenario online scenario://2041 100000
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Файл с ключом доступен только владельцу. В CI передавайте ключ переменной `STATUSER_API_KEY`, а не флагом: аргументы команды видны в истории и в списке процессов.
|
|
83
|
+
|
|
84
|
+
## Серверы
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
statuser servers add https://shop.example.com --name "Магазин" --project 100000
|
|
88
|
+
statuser servers add 203.0.113.10 --name "Шлюз"
|
|
89
|
+
statuser servers add db.example.com --protocol tcp --port 5432
|
|
90
|
+
statuser servers add --protocol heartbeat --name "Бэкап" --interval 86400
|
|
91
|
+
|
|
92
|
+
statuser servers list --project 100000 --protocol http
|
|
93
|
+
statuser servers get 101
|
|
94
|
+
statuser servers checks 101 --since 7d
|
|
95
|
+
statuser servers test-notify 101
|
|
96
|
+
statuser servers update 101 --name "Магазин (прод)" --host https://shop.example.ru
|
|
97
|
+
statuser servers delete 101
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Протокол определяется по адресу: для URL это HTTP-проверка, для IP — ping. У домена без схемы укажите `--protocol`. Остальные настройки получают значения по умолчанию, как в панели. Если API откажет в создании, CLI скажет об этом до записи. Без `--project` сервер попадает в самый старый проект аккаунта.
|
|
101
|
+
|
|
102
|
+
- `checks` показывает проверки за период, по умолчанию за сутки: статус и задержку в каждой локации.
|
|
103
|
+
- `test-notify` отправляет тестовый алёрт в те же каналы, куда ушёл бы настоящий.
|
|
104
|
+
- `update` с `--name`, `--host` или `--description` меняет один сервер.
|
|
105
|
+
- `delete` удаляет сервер вместе с историей проверок и инцидентами, поэтому сначала спрашивает подтверждение. В CI спросить некого, там нужен `--yes`.
|
|
106
|
+
|
|
107
|
+
### Массовые правки
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
statuser servers update --project 100000 --interval 60 --regions msk-1,spb-1 --dry-run
|
|
111
|
+
statuser servers update --project 100000 --protocol http --ssl --domain
|
|
112
|
+
statuser servers update 101 102 --latency-alert 800
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Флаги: `--interval`, `--timeout`, `--regions`, `--confirm-delay`, `--ssl` / `--no-ssl`, `--domain` / `--no-domain`, `--blocklist` / `--no-blocklist`, `--latency-alert <мс>` / `--no-latency-alert`. Отправляются только поля, которые действительно меняются. Если флаг не относится к протоколу сервера, например `--ssl` у ping, сервер пропускается с объяснением. Ошибка на одном сервере не останавливает остальные, `--json` возвращает `done`, `skipped` и `failed`.
|
|
116
|
+
|
|
117
|
+
## Серверы как код
|
|
118
|
+
|
|
119
|
+
Серверы проекта описываются в файле `statuser.json`, в секции `servers`: HTTP, keyword, ping, TCP, DNS, heartbeat и LLM.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
statuser export --project 100000 -o statuser.json # выгрузить, что есть сейчас
|
|
123
|
+
statuser diff # что изменит apply
|
|
124
|
+
statuser apply # привести проект к файлу
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"project_id": 100000,
|
|
130
|
+
"servers": [
|
|
131
|
+
{
|
|
132
|
+
"id": 1874,
|
|
133
|
+
"name": "Сайт магазина",
|
|
134
|
+
"protocol": "http",
|
|
135
|
+
"host": "https://shop.example.com",
|
|
136
|
+
"check_interval": 60,
|
|
137
|
+
"locations": ["msk-1", "spb-1"],
|
|
138
|
+
"is_ssl_check": true,
|
|
139
|
+
"headers": [{ "key": "Authorization", "value": "$SHOP_API_TOKEN" }]
|
|
140
|
+
},
|
|
141
|
+
{ "name": "Бэкап базы", "protocol": "heartbeat", "check_interval": 86400 }
|
|
142
|
+
],
|
|
143
|
+
"scenarios": []
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- Поля называются так же, как в [API](https://statuser.cloud/api-reference). Сервер узнаётся по `id`, а без него — по имени внутри проекта.
|
|
148
|
+
- Секреты API не возвращает, поэтому в файле они задаются ссылкой на переменную окружения: `"http_auth_password": "$SHOP_PASSWORD"`. Значения секретных заголовков `export` заменяет такими же ссылками и называет переменные. Строку, которая сама начинается с `$`, пишите как `$$…`. Адрес, тело запроса и логин выгружаются как есть, поэтому проверьте файл перед коммитом.
|
|
149
|
+
- Ошибки в файле `apply` показывает до первой записи. Если запись всё же остановилась на ошибке, повторный `apply` продолжит с этого места.
|
|
150
|
+
- `apply` не удаляет серверы и не меняет их протокол. Серверы, которых нет в файле, только показываются в плане.
|
|
151
|
+
|
|
152
|
+
## Сценарии
|
|
153
|
+
|
|
154
|
+
Сценарий — это Playwright-тест, который Statuser прогоняет по расписанию в настоящем браузере: вход в кабинет, корзина, цепочка запросов к API. С CLI тесты лежат в вашем репозитории рядом с кодом сайта и выкатываются вместе с ним.
|
|
155
|
+
|
|
156
|
+
### Тест
|
|
157
|
+
|
|
158
|
+
`checks/checkout.spec.ts`:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { test, expect } from '@playwright/test';
|
|
162
|
+
|
|
163
|
+
test('оформление заказа', async ({ page }) => {
|
|
164
|
+
await page.goto('https://shop.example.com/login');
|
|
165
|
+
await page.getByLabel('Email').fill(process.env.SHOP_LOGIN!);
|
|
166
|
+
await page.getByLabel('Пароль').fill(process.env.SHOP_PASSWORD!);
|
|
167
|
+
await page.getByRole('button', { name: 'Войти' }).click();
|
|
168
|
+
await expect(page.getByRole('heading', { name: 'Мои заказы' })).toBeVisible();
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Один файл до 64 КБ. Из пакетов доступен `@playwright/test`, свои вспомогательные файлы подключить нельзя. Логины и пароли передаются только через `process.env`.
|
|
173
|
+
|
|
174
|
+
### Прогнать до выкладки
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
statuser scenarios test checks/checkout.spec.ts \
|
|
178
|
+
--location msk-1 --env SHOP_LOGIN='$SHOP_LOGIN' --env SHOP_PASSWORD='$SHOP_PASSWORD'
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Сервер при этом не создаётся, история проверок и инциденты не меняются. Значение вида `$NAME` берётся из окружения, поэтому пароль не попадает в историю команд. Ссылки на скриншот, видео и трейс действуют час.
|
|
182
|
+
|
|
183
|
+
### Выкатить
|
|
184
|
+
|
|
185
|
+
`statuser.json` в корне репозитория:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"project_id": 100000,
|
|
190
|
+
"scenarios": [
|
|
191
|
+
{
|
|
192
|
+
"name": "Оформление заказа",
|
|
193
|
+
"file": "checks/checkout.spec.ts",
|
|
194
|
+
"interval": 300,
|
|
195
|
+
"timeout": 60,
|
|
196
|
+
"location": "msk-1",
|
|
197
|
+
"env": { "SHOP_LOGIN": "$SHOP_LOGIN", "SHOP_PASSWORD": "$SHOP_PASSWORD" }
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```console
|
|
204
|
+
$ statuser scenarios deploy --dry-run
|
|
205
|
+
План для проекта #100000:
|
|
206
|
+
+ создать «Оформление заказа»
|
|
207
|
+
~ изменить «Вход в кабинет» (#2041): interval
|
|
208
|
+
Это план (--dry-run), ничего не изменено.
|
|
209
|
+
|
|
210
|
+
$ statuser scenarios deploy
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
| Поле | Что значит |
|
|
214
|
+
| ------------ | ------------------------------------------------------------------------------------------------ |
|
|
215
|
+
| `project_id` | Проект, в котором живут сценарии. Id показывает `statuser projects list` |
|
|
216
|
+
| `name` | Имя сервера в Statuser. По нему сценарий узнаётся внутри проекта |
|
|
217
|
+
| `id` | Необязательно. Нужен, чтобы переименовать сценарий: без него новое имя заведёт новый сервер |
|
|
218
|
+
| `file` | Путь к тесту относительно `statuser.json` |
|
|
219
|
+
| `interval` | Как часто проверять, секунд: от 60 до 86 400 и не меньше таймаута. По умолчанию 300 |
|
|
220
|
+
| `timeout` | Сколько ждать прогон, секунд: 15, 30, 45, 60 или 120. По умолчанию 30 |
|
|
221
|
+
| `location` | Откуда проверять: `msk-1`, `spb-1`, `ala-1`, `nyc-1` или `ams-1`. По умолчанию `msk-1` |
|
|
222
|
+
| `env` | Переменные окружения теста. Имена — заглавными буквами, значение `$NAME` берётся из окружения CI |
|
|
223
|
+
|
|
224
|
+
- Весь файл проверяется до первой записи: ошибка в пятом сценарии не оставит первые четыре выкаченными.
|
|
225
|
+
- `env` задаёт полный набор переменных: имя, которое убрали из файла, удаляется и на сервере. Если поля `env` нет, переменные на сервере остаются как есть.
|
|
226
|
+
- Сценарии проекта, которых нет в файле, не удаляются, а только показываются в плане.
|
|
227
|
+
- Число сценариев, минимальный интервал и максимальный таймаут зависят от тарифа.
|
|
228
|
+
|
|
229
|
+
### Проверить после выкладки
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
statuser scenarios run 2041
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Сохранённый сценарий прогоняется сразу, с теми же последствиями, что по расписанию: результат попадает в историю, а сбой открывает инцидент. Значения переменных сценария API не возвращает. CLI скрывает в выводе те из них, что лежат в окружении под теми же именами, и предупреждает об остальных.
|
|
236
|
+
|
|
237
|
+
Код выхода у `scenarios test` и `scenarios run`: 0 — сценарий прошёл, 4 — не прошёл, 1 — сбой песочницы или API.
|
|
238
|
+
|
|
239
|
+
## Деплой и плановые работы
|
|
240
|
+
|
|
241
|
+
Пауза проверок избавляет команду от ложных алёртов на время деплоя, а плановые работы предупреждают клиентов о простое, который они заметят.
|
|
242
|
+
|
|
243
|
+
### Пауза проверок на время деплоя
|
|
244
|
+
|
|
245
|
+
Если сервис во время деплоя отвечает с ошибками, проверки поднимут команду ложными алёртами. Их можно поставить на паузу и вернуть после:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
statuser servers pause --project 100000 --json > paused.json
|
|
249
|
+
|
|
250
|
+
# … деплой …
|
|
251
|
+
|
|
252
|
+
statuser servers resume --from paused.json
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`resume --from` возобновляет только то, что поставил на паузу шаг `pause`: серверы, которые уже стояли на паузе, такими и останутся.
|
|
256
|
+
|
|
257
|
+
### Плановые работы на странице статуса
|
|
258
|
+
|
|
259
|
+
Когда простой заметят клиенты, например при переезде базы или обновлении на полчаса, их предупреждают плановыми работами: окно появляется на странице статуса, подписчики получают письмо. Для короткого деплоя это лишнее, иначе рассылка уходила бы на каждый релиз.
|
|
260
|
+
|
|
261
|
+
Объявить заранее:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
statuser maintenance schedule --page 12 --all-servers --at 2026-11-20T03:00+03:00 --for 1h \
|
|
265
|
+
--title "Переезд базы данных" --description "Магазин будет недоступен до 30 минут"
|
|
266
|
+
statuser maintenance list --page 12
|
|
267
|
+
statuser maintenance delete 77 --page 12
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Открыть и закрыть, когда работы идут сейчас:
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
statuser maintenance start --page 12 --all-servers \
|
|
274
|
+
--title "Переезд базы данных" --description "Магазин будет недоступен до 30 минут" \
|
|
275
|
+
--for 30m --json > maintenance.json
|
|
276
|
+
|
|
277
|
+
# … работы …
|
|
278
|
+
|
|
279
|
+
statuser maintenance end --page 12 "$(jq .id maintenance.json)" --message "Работы завершены"
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
- `--servers 101,102` выбирает серверы страницы статуса, `--all-servers` берёт все. `--for` задаёт длительность окна: `30m`, `2h`, `1h30m`. `--at` задаёт начало в будущем.
|
|
283
|
+
- `maintenance end` без `--message` только переносит время окончания. С `--message` в таймлайне работ появляется запись, и подписчики получают уведомление. Повторный `end` дату не сдвигает и то же сообщение второй раз не публикует, поэтому шаг можно безопасно перезапустить.
|
|
284
|
+
- Плановые работы алёрты не глушат. Если проверки на это время не нужны, добавьте паузу.
|
|
285
|
+
|
|
286
|
+
## Heartbeat
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
STATUSER_HEARTBEAT_TOKEN=… statuser run -- ./backup.sh
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Если команда завершилась с кодом 0, Statuser получает сигнал «работает», с другим кодом — «ошибка», и инцидент открывается сразу. Код выхода `run` — код самой команды: если сигнал не дошёл из-за сети, успешный бэкап не превратится в упавший. Если команду убил сигнал, код равен 128 + номер сигнала, как в shell.
|
|
293
|
+
|
|
294
|
+
Строка для crontab:
|
|
295
|
+
|
|
296
|
+
```cron
|
|
297
|
+
0 3 * * * STATUSER_HEARTBEAT_TOKEN=… /usr/local/bin/statuser run -- /opt/scripts/backup.sh
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
У крона короткий `PATH`, поэтому путь к `statuser` указан полностью. Его покажет `which statuser`.
|
|
301
|
+
|
|
302
|
+
- Токен — последняя часть адреса heartbeat-сервера: `https://hb.statuser.cloud/<токен>`. API-ключ на машине с кроном не нужен. Передавайте токен переменной, а не флагом `--heartbeat`: аргументы видны в списке процессов, пока идёт команда.
|
|
303
|
+
- Если на машине есть API-ключ, токен можно не копировать: `statuser run --server 101 -- ./backup.sh`.
|
|
304
|
+
- Пока не пришёл первый сигнал, Statuser не знает, что задача запущена, и пропуски не считает. Интервал и допустимую задержку heartbeat-сервера задайте с запасом на самую долгую задачу.
|
|
305
|
+
- Разовый сигнал без команды: `statuser heartbeat ping "$TOKEN"`, сигнал ошибки — с `--fail`.
|
|
306
|
+
|
|
307
|
+
## Инциденты
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
statuser incidents list --open
|
|
311
|
+
statuser incidents list --since 7d --project 100000
|
|
312
|
+
statuser incidents get 5021
|
|
313
|
+
statuser incidents ai 5021 # разбор Statuser AI
|
|
314
|
+
statuser incidents pdf 5021 -o incident-5021.pdf
|
|
315
|
+
statuser incidents comment 5021 -m "Выкатили исправление"
|
|
316
|
+
statuser incidents delete 5021
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`--since` и `--until` фильтруют по времени начала инцидента: `24h`, `7d` или дата. Диагностика и разбор Statuser AI есть на платных тарифах. Если данных для разбора нет, он так и скажет. PDF сохраняется с доступом только для владельца, существующий файл перезаписывается только с `--force`. `delete` спрашивает подтверждение, как и удаление серверов.
|
|
320
|
+
|
|
321
|
+
## Отчёты на странице статуса
|
|
322
|
+
|
|
323
|
+
Публичные отчёты об инцидентах видят посетители страницы статуса, а подписчики получают их письмом.
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
statuser reports create --page 12 --all-servers --status degraded \
|
|
327
|
+
--title "Медленная оплата" --message "Разбираемся с задержками платежей"
|
|
328
|
+
statuser reports update 88 --page 12 --message "Нашли причину, выкатываем исправление"
|
|
329
|
+
statuser reports resolve 88 --page 12 --message "Платежи проходят штатно"
|
|
330
|
+
statuser reports list --page 12
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Сообщение обязательно в каждой команде: оно уходит подписчикам на языке страницы, поэтому CLI своих текстов не подставляет. `resolve` уже закрытого отчёта ничего не отправляет, так что шаг CI можно безопасно перезапустить.
|
|
334
|
+
|
|
335
|
+
## Готовые файлы CI
|
|
336
|
+
|
|
337
|
+
Примеры можно скопировать в репозиторий как есть, поменяв id проекта и имена переменных.
|
|
338
|
+
|
|
339
|
+
### GitHub Actions
|
|
340
|
+
|
|
341
|
+
Прогнать тесты и выкатить сценарии при изменении тестов:
|
|
342
|
+
|
|
343
|
+
```yaml
|
|
344
|
+
name: Сценарии Statuser
|
|
345
|
+
on:
|
|
346
|
+
push:
|
|
347
|
+
branches: [main]
|
|
348
|
+
paths: ['checks/**', 'statuser.json']
|
|
349
|
+
|
|
350
|
+
jobs:
|
|
351
|
+
deploy:
|
|
352
|
+
runs-on: ubuntu-latest
|
|
353
|
+
env:
|
|
354
|
+
STATUSER_API_KEY: ${{ secrets.STATUSER_API_KEY }}
|
|
355
|
+
SHOP_LOGIN: ${{ secrets.SHOP_LOGIN }}
|
|
356
|
+
SHOP_PASSWORD: ${{ secrets.SHOP_PASSWORD }}
|
|
357
|
+
steps:
|
|
358
|
+
- uses: actions/checkout@v4
|
|
359
|
+
- uses: actions/setup-node@v4
|
|
360
|
+
with:
|
|
361
|
+
node-version: 22
|
|
362
|
+
- name: Прогнать тесты
|
|
363
|
+
run: |
|
|
364
|
+
for file in checks/*.spec.ts; do
|
|
365
|
+
npx -y @statuser/cli scenarios test "$file" \
|
|
366
|
+
--env SHOP_LOGIN='$SHOP_LOGIN' --env SHOP_PASSWORD='$SHOP_PASSWORD'
|
|
367
|
+
done
|
|
368
|
+
- name: Выкатить
|
|
369
|
+
run: npx -y @statuser/cli scenarios deploy
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### GitLab CI
|
|
373
|
+
|
|
374
|
+
Пауза проверок вокруг деплоя. `after_script` выполняется и при упавшем деплое, поэтому проверки возобновятся в любом случае:
|
|
375
|
+
|
|
376
|
+
```yaml
|
|
377
|
+
deploy:
|
|
378
|
+
image: node:22
|
|
379
|
+
variables:
|
|
380
|
+
STATUSER_PROJECT: '100000'
|
|
381
|
+
before_script:
|
|
382
|
+
- npx -y @statuser/cli servers pause --project "$STATUSER_PROJECT" --json > paused.json
|
|
383
|
+
script:
|
|
384
|
+
- ./deploy.sh
|
|
385
|
+
after_script:
|
|
386
|
+
- npx -y @statuser/cli servers resume --from paused.json
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`STATUSER_API_KEY` задайте в настройках CI/CD проекта как замаскированную переменную.
|
|
390
|
+
|
|
391
|
+
## Справка
|
|
392
|
+
|
|
393
|
+
Команды, которые нужны реже, и общие правила: переменные окружения, коды выхода, работа с ключом и повтор запросов.
|
|
394
|
+
|
|
395
|
+
### Остальные команды
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
statuser projects list
|
|
399
|
+
statuser pages list
|
|
400
|
+
statuser api /v1/servers/101 -X PATCH -F check_interval=120
|
|
401
|
+
statuser whoami
|
|
402
|
+
statuser logout
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
`projects list` и `pages list` показывают id для `--project` и `--page`.
|
|
406
|
+
|
|
407
|
+
`statuser api` отправляет запрос к любому методу [API](https://statuser.cloud/api-reference), как `gh api`. Поля из `-F` для GET и DELETE уходят в query, для остальных методов — в тело запроса. `-F` разбирает значение как JSON (`-F on=true` передаёт логическое значение), `-f` всегда передаёт строку. Тело целиком: `--input file.json` или `--input -` из stdin.
|
|
408
|
+
|
|
409
|
+
С флагом `--json` любая команда печатает машиночитаемый ответ в stdout, а сообщения о ходе работы уходят в stderr. `--debug` печатает запросы, ключ и токены при этом скрыты.
|
|
410
|
+
|
|
411
|
+
### Переменные окружения
|
|
412
|
+
|
|
413
|
+
| Переменная | Что задаёт |
|
|
414
|
+
| -------------------------- | ------------------------------------------------------ |
|
|
415
|
+
| `STATUSER_API_KEY` | API-ключ. Главнее ключа, сохранённого `statuser login` |
|
|
416
|
+
| `STATUSER_HEARTBEAT_TOKEN` | Токен heartbeat для `statuser run` и `heartbeat ping` |
|
|
417
|
+
| `STATUSER_API_URL` | Адрес API, по умолчанию `https://api.statuser.cloud` |
|
|
418
|
+
| `LANG`, `LC_ALL` | Язык сообщений: `ru…` — русский, иначе английский |
|
|
419
|
+
|
|
420
|
+
### Коды выхода
|
|
421
|
+
|
|
422
|
+
| Код | Когда |
|
|
423
|
+
| --- | ------------------------------------------------------ |
|
|
424
|
+
| 0 | Успех |
|
|
425
|
+
| 1 | Ошибка API или сети, сбой песочницы сценариев |
|
|
426
|
+
| 2 | Неверные аргументы или файл конфигурации |
|
|
427
|
+
| 3 | Нет ключа или ключ не принят |
|
|
428
|
+
| 4 | Сценарий не прошёл (`scenarios test`, `scenarios run`) |
|
|
429
|
+
|
|
430
|
+
### Как это устроено
|
|
431
|
+
|
|
432
|
+
- **Сценарии выполняются не на вашей машине**, а в изолированной песочнице Statuser, из выбранного города, так же, как прогон по расписанию. Поэтому `scenarios test` показывает то, что увидит мониторинг, а не то, что видно из офиса.
|
|
433
|
+
- **Ключ уходит только на адрес API.** CLI не идёт за редиректами и не отправляет ключ на другой адрес. Ключ, сохранённый для одного адреса API, не используется для другого.
|
|
434
|
+
- **CLI ничего не удаляет сам.** `apply` и `deploy` только перечисляют серверы, которых нет в файле. Удаляют явные команды `delete`, и только после подтверждения.
|
|
435
|
+
- **Запрос повторяется, только когда он точно не ушёл**: если соединение не установилось, CLI делает две повторные попытки, после ответа 429 — ждёт паузу из `Retry-After`. Если запись ушла, но ответа нет (обрыв, таймаут, 5xx), `apply` и `deploy` останавливаются и сообщают, что исход неизвестен. Перед повтором запустите `statuser diff`.
|
|
436
|
+
|
|
437
|
+
## Смотрите также
|
|
438
|
+
|
|
439
|
+
[MCP-сервер Statuser](https://github.com/statuser-cloud/mcp) даёт то же API ИИ-клиентам: Claude Code, Cursor, VS Code.
|
|
440
|
+
|
|
441
|
+
## Обратная связь
|
|
442
|
+
|
|
443
|
+
Ошибки и предложения — в [issues](https://github.com/statuser-cloud/cli/issues). Об уязвимостях сообщайте приватно — см. [SECURITY.md](SECURITY.md).
|
|
444
|
+
|
|
445
|
+
Общие вопросы по API — в [документации Statuser](https://statuser.cloud/api-reference) или на [info@statuser.cloud](mailto:info@statuser.cloud).
|
|
446
|
+
|
|
447
|
+
## Лицензия
|
|
448
|
+
|
|
449
|
+
MIT — см. [LICENSE](LICENSE).
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { CliError, ExitCode, NotSentError, parseApiErrorBody, } from './errors.js';
|
|
3
|
+
import { t } from './i18n.js';
|
|
4
|
+
export const CLI_VERSION = typeof __STATUSER_VERSION__ === 'string'
|
|
5
|
+
? __STATUSER_VERSION__
|
|
6
|
+
: createRequire(import.meta.url)('../package.json').version;
|
|
7
|
+
const USER_AGENT = `statuser-cli/${CLI_VERSION}`;
|
|
8
|
+
const MAX_429_RETRIES = 2;
|
|
9
|
+
const MAX_429_WAIT_SECONDS = 30;
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
11
|
+
const MAX_CONNECT_RETRIES = 2;
|
|
12
|
+
const CONNECT_RETRY_DELAY_MS = 1_000;
|
|
13
|
+
// The connection was never set up, so the request did not leave: repeating
|
|
14
|
+
// it cannot apply a write twice. A reset or timeout after the request went
|
|
15
|
+
// out is not here — the API may already have acted on it.
|
|
16
|
+
const NOT_SENT_CODES = new Set([
|
|
17
|
+
'ECONNREFUSED',
|
|
18
|
+
'EHOSTUNREACH',
|
|
19
|
+
'ENETUNREACH',
|
|
20
|
+
'EAI_AGAIN',
|
|
21
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
22
|
+
]);
|
|
23
|
+
export class StatuserClient {
|
|
24
|
+
options;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
async call(opts) {
|
|
29
|
+
return (await this.execute(opts, false));
|
|
30
|
+
}
|
|
31
|
+
/** For files (PDF reports): the body is returned as bytes, never parsed. */
|
|
32
|
+
async callBinary(opts) {
|
|
33
|
+
return (await this.execute(opts, true));
|
|
34
|
+
}
|
|
35
|
+
async execute(opts, binary) {
|
|
36
|
+
const url = this.buildUrl(opts.path, opts.query);
|
|
37
|
+
const headers = {
|
|
38
|
+
'user-agent': USER_AGENT,
|
|
39
|
+
accept: binary ? '*/*' : 'application/json',
|
|
40
|
+
};
|
|
41
|
+
if (!opts.anonymous) {
|
|
42
|
+
if (!this.options.apiKey)
|
|
43
|
+
throw new CliError(t.noApiKey, ExitCode.Auth);
|
|
44
|
+
// A newline or space inside the key makes fetch throw an error that
|
|
45
|
+
// quotes the whole header — the key would land in stderr and CI logs.
|
|
46
|
+
if (!/^[\x21-\x7e]+$/.test(this.options.apiKey)) {
|
|
47
|
+
throw new CliError(t.apiKeyMalformed, ExitCode.Auth);
|
|
48
|
+
}
|
|
49
|
+
headers.authorization = `Bearer ${this.options.apiKey}`;
|
|
50
|
+
}
|
|
51
|
+
let body;
|
|
52
|
+
if (opts.body !== undefined) {
|
|
53
|
+
body = JSON.stringify(opts.body);
|
|
54
|
+
headers['content-type'] = 'application/json';
|
|
55
|
+
}
|
|
56
|
+
let connectRetries = 0;
|
|
57
|
+
let rateLimitRetries = 0;
|
|
58
|
+
for (;;) {
|
|
59
|
+
this.debug(`→ ${opts.method} ${redactUrl(url)}`);
|
|
60
|
+
let res;
|
|
61
|
+
try {
|
|
62
|
+
res = await fetch(url, {
|
|
63
|
+
method: opts.method,
|
|
64
|
+
headers,
|
|
65
|
+
body,
|
|
66
|
+
// Never follow redirects: the key must not travel to another origin.
|
|
67
|
+
redirect: 'manual',
|
|
68
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
const reason = describeFetchError(error);
|
|
73
|
+
if (NOT_SENT_CODES.has(reason)) {
|
|
74
|
+
if (connectRetries < MAX_CONNECT_RETRIES) {
|
|
75
|
+
connectRetries += 1;
|
|
76
|
+
this.debug(t.debugRetry(reason));
|
|
77
|
+
await sleep(CONNECT_RETRY_DELAY_MS * connectRetries);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
throw new NotSentError(t.networkError(reason));
|
|
81
|
+
}
|
|
82
|
+
throw new CliError(t.networkError(reason));
|
|
83
|
+
}
|
|
84
|
+
this.debug(`← ${res.status}`);
|
|
85
|
+
if (res.status === 429 && rateLimitRetries < MAX_429_RETRIES) {
|
|
86
|
+
const wait = parseRetryAfter(res.headers.get('retry-after'));
|
|
87
|
+
if (wait !== null && wait <= MAX_429_WAIT_SECONDS) {
|
|
88
|
+
rateLimitRetries += 1;
|
|
89
|
+
await res.body?.cancel();
|
|
90
|
+
await sleep(wait * 1000);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (binary && res.status >= 200 && res.status < 300) {
|
|
95
|
+
return {
|
|
96
|
+
contentType: res.headers.get('content-type') ?? '',
|
|
97
|
+
bytes: Buffer.from(await res.arrayBuffer()),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const text = await res.text();
|
|
101
|
+
if (res.status >= 200 && res.status < 300) {
|
|
102
|
+
if (!text)
|
|
103
|
+
return null;
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(text);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return text;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw parseApiErrorBody(res.status, text);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
buildUrl(path, query) {
|
|
115
|
+
if (!path.startsWith('/')) {
|
|
116
|
+
throw new CliError(t.apiPathInvalid, ExitCode.Usage);
|
|
117
|
+
}
|
|
118
|
+
const base = new URL(this.options.apiUrl);
|
|
119
|
+
const url = new URL(`${this.options.apiUrl}${path}`);
|
|
120
|
+
// `//evil.example` or encoded tricks in the path must not change the host.
|
|
121
|
+
if (url.origin !== base.origin) {
|
|
122
|
+
throw new CliError(t.foreignOrigin(url.toString()), ExitCode.Usage);
|
|
123
|
+
}
|
|
124
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
125
|
+
if (value !== undefined)
|
|
126
|
+
url.searchParams.append(key, String(value));
|
|
127
|
+
}
|
|
128
|
+
return url.toString();
|
|
129
|
+
}
|
|
130
|
+
debug(line) {
|
|
131
|
+
if (this.options.debug)
|
|
132
|
+
process.stderr.write(`${line}\n`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Heartbeat tokens live in the path; they are credentials, so debug output hides them. */
|
|
136
|
+
export function redactUrl(url) {
|
|
137
|
+
return url.replace(/(\/heartbeat\/)[^/?#]+/, '$1***');
|
|
138
|
+
}
|
|
139
|
+
function describeFetchError(error) {
|
|
140
|
+
if (error instanceof Error) {
|
|
141
|
+
if (error.name === 'TimeoutError')
|
|
142
|
+
return 'timeout';
|
|
143
|
+
const cause = error
|
|
144
|
+
.cause;
|
|
145
|
+
return cause?.code ?? cause?.message ?? error.message;
|
|
146
|
+
}
|
|
147
|
+
return String(error);
|
|
148
|
+
}
|
|
149
|
+
function parseRetryAfter(value) {
|
|
150
|
+
if (!value)
|
|
151
|
+
return null;
|
|
152
|
+
const seconds = Number(value);
|
|
153
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
154
|
+
return seconds;
|
|
155
|
+
const date = Date.parse(value);
|
|
156
|
+
if (Number.isNaN(date))
|
|
157
|
+
return null;
|
|
158
|
+
return Math.max(0, Math.ceil((date - Date.now()) / 1000));
|
|
159
|
+
}
|
|
160
|
+
export function sleep(ms) {
|
|
161
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|