@statuser/cli 0.0.0-stage → 0.1.0

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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +448 -2
  3. package/dist/client.js +163 -0
  4. package/dist/client.js.map +1 -0
  5. package/dist/commander-i18n.js +52 -0
  6. package/dist/commander-i18n.js.map +1 -0
  7. package/dist/commands/api.js +104 -0
  8. package/dist/commands/api.js.map +1 -0
  9. package/dist/commands/auth.js +104 -0
  10. package/dist/commands/auth.js.map +1 -0
  11. package/dist/commands/catalog.js +72 -0
  12. package/dist/commands/catalog.js.map +1 -0
  13. package/dist/commands/config.js +302 -0
  14. package/dist/commands/config.js.map +1 -0
  15. package/dist/commands/heartbeat.js +199 -0
  16. package/dist/commands/heartbeat.js.map +1 -0
  17. package/dist/commands/incidents.js +265 -0
  18. package/dist/commands/incidents.js.map +1 -0
  19. package/dist/commands/maintenance.js +211 -0
  20. package/dist/commands/maintenance.js.map +1 -0
  21. package/dist/commands/reports.js +170 -0
  22. package/dist/commands/reports.js.map +1 -0
  23. package/dist/commands/scenarios.js +361 -0
  24. package/dist/commands/scenarios.js.map +1 -0
  25. package/dist/commands/servers-add.js +152 -0
  26. package/dist/commands/servers-add.js.map +1 -0
  27. package/dist/commands/servers-checks.js +56 -0
  28. package/dist/commands/servers-checks.js.map +1 -0
  29. package/dist/commands/servers.js +424 -0
  30. package/dist/commands/servers.js.map +1 -0
  31. package/dist/config.js +107 -0
  32. package/dist/config.js.map +1 -0
  33. package/dist/confirm.js +34 -0
  34. package/dist/confirm.js.map +1 -0
  35. package/dist/context.js +27 -0
  36. package/dist/context.js.map +1 -0
  37. package/dist/duration.js +20 -0
  38. package/dist/duration.js.map +1 -0
  39. package/dist/errors.js +73 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/files.js +36 -0
  42. package/dist/files.js.map +1 -0
  43. package/dist/generated/openapi.js +6 -0
  44. package/dist/generated/openapi.js.map +1 -0
  45. package/dist/i18n.js +757 -0
  46. package/dist/i18n.js.map +1 -0
  47. package/dist/index.js +58 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/json-error.js +10 -0
  50. package/dist/json-error.js.map +1 -0
  51. package/dist/output.js +43 -0
  52. package/dist/output.js.map +1 -0
  53. package/dist/scenarios/config.js +228 -0
  54. package/dist/scenarios/config.js.map +1 -0
  55. package/dist/scenarios/plan.js +159 -0
  56. package/dist/scenarios/plan.js.map +1 -0
  57. package/dist/servers-config/export.js +72 -0
  58. package/dist/servers-config/export.js.map +1 -0
  59. package/dist/servers-config/fields.js +172 -0
  60. package/dist/servers-config/fields.js.map +1 -0
  61. package/dist/servers-config/forbidden-host.js +75 -0
  62. package/dist/servers-config/forbidden-host.js.map +1 -0
  63. package/dist/servers-config/host-pattern.js +6 -0
  64. package/dist/servers-config/host-pattern.js.map +1 -0
  65. package/dist/servers-config/parse.js +260 -0
  66. package/dist/servers-config/parse.js.map +1 -0
  67. package/dist/servers-config/plan.js +492 -0
  68. package/dist/servers-config/plan.js.map +1 -0
  69. package/dist/servers-update.js +157 -0
  70. package/dist/servers-update.js.map +1 -0
  71. package/dist/status-page.js +15 -0
  72. package/dist/status-page.js.map +1 -0
  73. package/dist/text-length.js +8 -0
  74. package/dist/text-length.js.map +1 -0
  75. package/dist/write-failure.js +37 -0
  76. package/dist/write-failure.js.map +1 -0
  77. 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
- # Temporary Holding Version
1
+ # CLI Statuser
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@statuser/cli.svg)](https://www.npmjs.com/package/@statuser/cli)
4
+ [![CI](https://github.com/statuser-cloud/cli/actions/workflows/ci.yml/badge.svg)](https://github.com/statuser-cloud/cli/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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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"}