@pachca/cli 2026.8.0 → 2026.8.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.8.2 (12 августа 2026)
4
+
5
+ - **Добавлено** (chats list-company): Новая команда — все беседы и каналы пространства, включая закрытые, где вы не состоите. Флаг `--activity` со значением `active` или `archived` оставляет одно состояние, по умолчанию отдаются оба. Нужна роль владельца пространства, тариф «Корпорация» и право `company_chats:read`
6
+ - **Добавлено** (bots list-company): Новая команда — все боты пространства, включая недоступные вам для редактирования. У таких ботов заполнены только имя и никнейм, остальные настройки приходят пустыми. Фильтр по имени — флаг `--query`. Нужна роль владельца пространства, тариф «Корпорация» и право `company_bots:read`
7
+ - **Добавлено** (tasks list): Флаги `--status`, `--chat-ids`, `--performer-ids` и `--author-id` для фильтрации напоминаний. С `--chat-ids` выдача расширяется до всех напоминаний указанных чатов, а не только ваших
8
+ - **Добавлено** (security list): Фильтр `--event-key` принимает `company_chats_accessed` и `company_bots_accessed`
9
+ - **Исправлено**: Недостающий скоуп в ошибке `insufficient_scope` берётся из заголовка `WWW-Authenticate` — раньше его вытаскивали регуляркой из текста сообщения, и он терялся при любой смене формулировки. Разбор текста остался запасным путём
10
+
11
+ ## 2026.8.1 (7 августа 2026)
12
+
13
+ - **Добавлено** (auth login): Вход через браузер — без ручной вставки токена. CLI показывает адрес и код, открывает браузер и ждёт подтверждения. Токен такого входа живёт час и продлевается сам перед каждой командой; при параллельных процессах обновляет только один, остальные ждут результат. Флаг `--no-browser` печатает адрес и код, не открывая браузер. `--token` и `PACHCA_TOKEN` работают как раньше — это путь для CI и агентов
14
+ - **Добавлено** (auth login): Токен хранится в хранилище ключей ОС — Keychain на macOS, Secret Service на Linux, Диспетчер учётных данных на Windows; в файле конфигурации остаются только имя, почта и права. Где хранилища нет (headless-машина, контейнер), CLI сохраняет токен в файл и сообщает об этом при входе. Отказаться от хранилища: флаг `--insecure-storage` или переменная `PACHCA_SECRET_STORE=file`
15
+ - **Добавлено** (auth status): Показывает способ входа, срок жизни токена, выданные права и где хранится токен — с этого стоит начинать разбор внезапного `403`
16
+ - **Добавлено** (auth list): Колонки `auth` и `expires_at` — видно, какой профиль вошёл через браузер, а какой держит готовый токен
17
+ - **Изменено** (auth logout): Токен входа через браузер гасится на сервере — доступ закрывается в момент выхода, а не по истечении срока. Если отозвать не удалось, CLI об этом говорит и не выдаёт удаление профиля за закрытый доступ. Готовый токен продолжает работать: отозвать его можно только в интерфейсе
18
+ - **Изменено**: Отказ по правам разбирается отдельно от прочих `403`: CLI называет недостающее право и объясняет причину. В JSON-выводе — тип `PACHCA_SCOPE_ERROR` и поле `scope`
19
+ - **Изменено**: В подсказке «токен не найден» появилась переменная окружения `PACHCA_TOKEN` — раньше предлагался только флаг `--token`, хотя переменная работает и без входа
20
+ - **Добавлено** (auth status): Флаг `--remote` — спросить права у сервера вместо сохранённых при входе. Набор прав фиксируется в момент входа, и после смены роли локальный список устаревает; при расхождении CLI говорит об этом и обновляет сохранённый
21
+ - **Добавлено** (auth login): `--token -` читает токен из потока ввода: `echo $TOKEN | pachca auth login --token -` не оставляет секрет в истории оболочки и в списке процессов. Передача значением работает как раньше
22
+ - **Добавлено** (auth switch): Сразу после переключения сообщает, если секрет профиля не читается или срок токена истёк — раньше ошибка всплывала на следующей команде
23
+ - **Изменено** (doctor): Проверяет хранилище ключей ОС отдельным пунктом и не выдаёт заблокированную связку за недействительный токен. Показывает способ входа и срок жизни токена. Права файла конфигурации проверяются только когда токен действительно лежит в файле
24
+ - **Исправлено** (doctor): Проверяет тот адрес API, с которым работает CLI, а не всегда `api.pachca.com` — при изменённом адресе сервера команда рапортовала о доступности чужого хоста
25
+ - **Исправлено** (doctor): Истёкший, но обновляемый токен больше не выдаётся за недействительный: команда говорит, что он обновится при следующем вызове, вместо совета войти заново
26
+ - **Добавлено** (chats list): Флаг `--archived` — вернуть архивные чаты вместе с активными. Отличить их можно по полю `archived` в выдаче
27
+ - **Добавлено** (security list): Фильтр `--event-key` принимает `bot_oauth_client_updated`, `oauth_authorization_granted` и `oauth_authorization_revoked`
28
+ - **Добавлено** (security list): Фильтр `--event-key` принимает `bot_oauth_client_updated`, `oauth_authorization_granted` и `oauth_authorization_revoked`
29
+ - **Исправлено**: Подсказка «токен не найден» вела на несуществующую страницу `/guides/authorization` — теперь на `/api/authorization`
30
+ - **Добавлено**: `pachca --help` заканчивается ссылкой на документацию и на `pachca guide` — из терминала стало видно, куда идти за сценариями
31
+
3
32
  ## 2026.8.0 (1 августа 2026)
4
33
 
5
34
  - **Изменено** (api): `api POST /messages --spec` и `--docs`: у поля `files[].name` описано требование к расширению — оно должно совпадать с расширением файла в `key`
@@ -8,20 +37,20 @@
8
37
 
9
38
  ## 2026.7.2 (27 июля 2026)
10
39
 
11
- - **Удалено** (files direct-url): Файл теперь добавляется в форму последним — S3 игнорирует поля после файла, из-за чего загрузка отклонялась с 400
12
- - **Удалено**: Явный `--profile` больше не перебивается переменной `PACHCA_TOKEN` — команда с указанным профилем не уйдёт в чужое пространство
13
- - **Удалено**: Запись (`POST`/`DELETE`/`PATCH`) больше не повторяется автоматически при 503 — повтор мог создать дубль сообщения
14
- - **Удалено**: `Retry-After` в формате даты больше не игнорируется, значение ограничено разумным пределом
15
- - **Удалено** (chats download-export): Имя файла из `Content-Disposition` больше не может увести запись за пределы указанной папки
16
- - **Удалено**: Вывод `-o csv`: ячейки, начинающиеся с `=`, `+`, `-` или `@`, экранируются; объекты и массивы выводятся как JSON, а не `[object Object]`
17
- - **Удалено**: `--timeout` меньше 1 отклоняется, а не приводит к мгновенной отмене запроса
18
- - **Удалено**: Примеры в `--help` подписаны шагом сценария, а не общим заголовком (`reactions remove` больше не подписан «Добавить реакцию»)
19
- - **Удалено** (files direct-url): Команда больше не падает с TypeError при успешной загрузке: S3 отвечает 204 с пустым телом, а команда пыталась прочитать из него поле
40
+ - **Исправлено** (files direct-url): Файл теперь добавляется в форму последним — S3 игнорирует поля после файла, из-за чего загрузка отклонялась с 400
41
+ - **Исправлено**: Явный `--profile` больше не перебивается переменной `PACHCA_TOKEN` — команда с указанным профилем не уйдёт в чужое пространство
42
+ - **Исправлено**: Запись (`POST`/`DELETE`/`PATCH`) больше не повторяется автоматически при 503 — повтор мог создать дубль сообщения
43
+ - **Исправлено**: `Retry-After` в формате даты больше не игнорируется, значение ограничено разумным пределом
44
+ - **Исправлено** (chats download-export): Имя файла из `Content-Disposition` больше не может увести запись за пределы указанной папки
45
+ - **Исправлено**: Вывод `-o csv`: ячейки, начинающиеся с `=`, `+`, `-` или `@`, экранируются; объекты и массивы выводятся как JSON, а не `[object Object]`
46
+ - **Исправлено**: `--timeout` меньше 1 отклоняется, а не приводит к мгновенной отмене запроса
47
+ - **Исправлено**: Примеры в `--help` подписаны шагом сценария, а не общим заголовком (`reactions remove` больше не подписан «Добавить реакцию»)
48
+ - **Исправлено** (files direct-url): Команда больше не падает с TypeError при успешной загрузке: S3 отвечает 204 с пустым телом, а команда пыталась прочитать из него поле
20
49
 
21
50
  ## 2026.7.1 (24 июля 2026)
22
51
 
23
52
  - **Добавлено** (threads create): Новая команда — создание самостоятельного треда без привязки к сообщению
24
- - **Удалено**: Примеры в `--help` больше не смешиваются между командами с общим путём (например, create и list одного ресурса показывали примеры друг друга)
53
+ - **Исправлено**: Примеры в `--help` больше не смешиваются между командами с общим путём (например, create и list одного ресурса показывали примеры друг друга)
25
54
 
26
55
  ## 2026.7.0 (7 июля 2026)
27
56
 
@@ -107,13 +136,13 @@
107
136
 
108
137
  - **Изменено** (all): Убрана клиентская валидация скоупов — проверка разрешений теперь только на стороне API (403)
109
138
  - **Изменено** (auth login): Профиль больше не сохраняет список скоупов — только тип, имя и email
110
- - **Удалено** (auth refresh): Команда удалена — кеширование скоупов больше не используется
139
+ - **Исправлено** (auth refresh): Команда удалена — кеширование скоупов больше не используется
111
140
 
112
141
  ## 2026.3.8 (17 марта 2026)
113
142
 
114
143
  - **Добавлено** (upgrade): Команда pachca upgrade — обновление CLI до последней версии
115
- - **Удалено** (init hook): Ошибки авторизации теперь выводятся в JSON при -o json (раньше формат игнорировался)
116
- - **Удалено** (update banner): Баннер обновления теперь показывается даже при ошибках команд
144
+ - **Исправлено** (init hook): Ошибки авторизации теперь выводятся в JSON при -o json (раньше формат игнорировался)
145
+ - **Исправлено** (update banner): Баннер обновления теперь показывается даже при ошибках команд
117
146
 
118
147
  ## 2026.3.7 (16 марта 2026)
119
148
 
package/README.md CHANGED
@@ -86,6 +86,7 @@ pachca guide # список всех сценариев
86
86
  | `pachca bots update` | Редактирование бота |
87
87
  | `pachca bots delete` | Удаление бота |
88
88
  | `pachca bots recreate-token` | Ротация токена бота |
89
+ | `pachca bots list-company` | Список ботов пространства |
89
90
  | `pachca bots list-events` | История событий |
90
91
  | `pachca bots remove-event` | Удаление события |
91
92
 
@@ -101,6 +102,7 @@ pachca guide # список всех сценариев
101
102
  | `pachca chats update` | Редактирование чата |
102
103
  | `pachca chats archive` | Архивация чата |
103
104
  | `pachca chats unarchive` | Разархивация чата |
105
+ | `pachca chats list-company` | Список чатов пространства |
104
106
 
105
107
  ### members
106
108
 
@@ -1,5 +1,5 @@
1
1
  import { Command, Flags } from '@oclif/core';
2
- import { resolveToken, TokenNotFoundError, ProfileNotFoundError, getDefaults, } from './profiles.js';
2
+ import { resolveToken, TokenNotFoundError, ProfileNotFoundError, getAuthMethod, getDefaults, } from './profiles.js';
3
3
  import { ApiError, getExitCode, formatDryRun, request } from './client.js';
4
4
  import { outputData, outputError, outputSuccess } from './output.js';
5
5
  import { defaultOutputFormat, isInteractive } from './utils.js';
@@ -219,10 +219,66 @@ export class BaseCommand extends Command {
219
219
  }
220
220
  outputSuccess(message, this.parsedFlags.quiet);
221
221
  }
222
+ /**
223
+ * Explain a scope refusal instead of printing a bare 403.
224
+ *
225
+ * An agent cannot walk through a consent screen — the most useful thing it can
226
+ * do is hand control back to a human with the reason spelled out. The reason
227
+ * differs by how the profile was authorised: an OAuth login already asks for
228
+ * every scope, so a refusal there can only come from the role.
229
+ */
230
+ reportScopeError(err) {
231
+ const format = this.getOutputFormat();
232
+ const scope = typeof err.details.scope === 'string' ? err.details.scope : undefined;
233
+ if (format === 'json' || !process.stderr.isTTY) {
234
+ outputError(err.details, format);
235
+ this.exit(3);
236
+ }
237
+ let profile;
238
+ try {
239
+ ({ profile } = resolveToken({
240
+ token: this.parsedFlags.token,
241
+ profile: this.parsedFlags.profile,
242
+ }));
243
+ }
244
+ catch {
245
+ // no profile resolved — the token came in from a flag or the environment
246
+ }
247
+ // Two different causes, and the fix differs. Either the scope is not in the
248
+ // token, or it is there but the role no longer allows it — the API checks
249
+ // both on every call. Which one it is shows in the token's own scope list.
250
+ //
251
+ // Naming the cause needs the scope itself, and that comes from parsing the
252
+ // server's message. When it cannot be identified, fall back to the generic
253
+ // wording: guessing a branch here would send people to fix the wrong thing.
254
+ const granted = scope ? profile?.scopes : undefined;
255
+ const hasScope = !!scope && !!granted && granted.includes(scope);
256
+ process.stderr.write(`✗ Не хватает права${scope ? ` ${scope}` : ''}.\n`);
257
+ if (granted && hasScope) {
258
+ process.stderr.write(` Право у токена есть, но его не даёт текущая роль.\n`);
259
+ process.stderr.write(` Обратитесь к администратору пространства.\n`);
260
+ }
261
+ else if (granted) {
262
+ process.stderr.write(` У токена этого права нет.\n`);
263
+ process.stderr.write(getAuthMethod(profile) === 'oauth'
264
+ ? ` Права выдаются по роли в момент входа — если роль с тех пор изменилась, войдите заново: pachca auth login\n`
265
+ : ` Выпустите токен с этим правом в интерфейсе Пачки.\n`);
266
+ }
267
+ else {
268
+ process.stderr.write(` Либо этого права нет у токена, либо его не даёт ваша роль.\n`);
269
+ process.stderr.write(profile
270
+ ? ` Проверить права токена: pachca auth status\n`
271
+ : ` Проверить права токена: pachca api GET /oauth/token/info\n`);
272
+ }
273
+ this.exit(3);
274
+ }
222
275
  /**
223
276
  * Handle errors with structured output.
224
277
  */
225
278
  async catch(err) {
279
+ if (err instanceof ApiError && err.details.type === 'PACHCA_SCOPE_ERROR') {
280
+ this.reportScopeError(err);
281
+ }
226
282
  if (err instanceof ApiError) {
227
283
  outputError(err.details, this.getOutputFormat());
228
284
  this.exit(getExitCode(err));
@@ -230,15 +286,16 @@ export class BaseCommand extends Command {
230
286
  if (err instanceof TokenNotFoundError) {
231
287
  const format = this.getOutputFormat();
232
288
  if (format === 'json' || !process.stderr.isTTY) {
233
- outputError({ error: 'Token not found', type: 'PACHCA_AUTH_ERROR', code: null, hint: 'pachca auth login --token <your-token>' }, format);
289
+ outputError({ error: 'Token not found', type: 'PACHCA_AUTH_ERROR', code: null, hint: 'pachca auth login, or set PACHCA_TOKEN' }, format);
234
290
  }
235
291
  else {
236
292
  process.stderr.write(`✗ Токен не найден. Войдите в аккаунт:\n\n`);
237
- process.stderr.write(` Интерактивно (человек):\n`);
293
+ process.stderr.write(` Вход через браузер:\n`);
238
294
  process.stderr.write(` pachca auth login\n\n`);
239
- process.stderr.write(` Неинтерактивно (агент, CI):\n`);
240
- process.stderr.write(` pachca auth login --token <ваш токен>\n\n`);
241
- process.stderr.write(` Получить токен: https://dev.pachca.com/guides/authorization\n`);
295
+ process.stderr.write(` Готовым токеном (агент, CI):\n`);
296
+ process.stderr.write(` pachca auth login --token <ваш токен>\n`);
297
+ process.stderr.write(` либо переменная окружения PACHCA_TOKEN — без входа вообще\n\n`);
298
+ process.stderr.write(` Получить токен: https://dev.pachca.com/api/authorization\n`);
242
299
  }
243
300
  this.exit(3);
244
301
  }
package/dist/client.js CHANGED
@@ -6,7 +6,7 @@ import { spinner } from '@clack/prompts';
6
6
  import ansis from 'ansis';
7
7
  import { isInteractive } from './utils.js';
8
8
  const DEFAULT_BASE_URL = 'https://api.pachca.com/api/shared/v1';
9
- function getBaseUrl() {
9
+ export function getBaseUrl() {
10
10
  const envUrl = process.env.PACHCA_API_URL;
11
11
  if (!envUrl)
12
12
  return DEFAULT_BASE_URL;
@@ -120,7 +120,7 @@ export async function request(opts, clientFlags) {
120
120
  }
121
121
  const showSpinner = isInteractive() && !clientFlags?.quiet && !clientFlags?.['dry-run'];
122
122
  const s = showSpinner ? spinner() : null;
123
- s?.start('Загрузка...');
123
+ s?.start('Загрузка…');
124
124
  if (clientFlags?.verbose) {
125
125
  process.stderr.write(`${ansis.dim(`→ ${opts.method} ${url}`)}\n`);
126
126
  }
@@ -195,7 +195,8 @@ export async function request(opts, clientFlags) {
195
195
  if (!response.ok) {
196
196
  s?.stop('');
197
197
  const requestId = response.headers.get('x-request-id') || undefined;
198
- throw createApiError(response.status, data, requestId);
198
+ const authenticate = response.headers.get('www-authenticate') || undefined;
199
+ throw createApiError(response.status, data, requestId, authenticate);
199
200
  }
200
201
  s?.stop('');
201
202
  return { data, status: response.status, headers: response.headers };
@@ -228,7 +229,31 @@ export async function request(opts, clientFlags) {
228
229
  message: lastError?.message || 'Failed to connect to server',
229
230
  });
230
231
  }
231
- function createApiError(status, body, requestId) {
232
+ // Недостающий скоуп по RFC 6750 приходит в параметре scope заголовка
233
+ // WWW-Authenticate: Bearer error="insufficient_scope", …, scope="profile:read"
234
+ function scopeFromChallenge(header) {
235
+ if (!header)
236
+ return undefined;
237
+ return /(?:^|[\s,])scope="([^"]*)"/.exec(header)?.[1] || undefined;
238
+ }
239
+ function createApiError(status, body, requestId, authenticate) {
240
+ // Scope refusal is a distinct case from "token is bad": the token works, it
241
+ // just may not do this. Pull the missing scope out so the command can name it.
242
+ if (isRecord(body) && body.error === 'insufficient_scope') {
243
+ const description = typeof body.error_description === 'string' ? body.error_description : '';
244
+ // Заголовок — источник по умолчанию: там скоуп лежит отдельным параметром.
245
+ // Разбор error_description оставлен запасным путём для версий API без заголовка.
246
+ const scope = scopeFromChallenge(authenticate) || /Required:\s*([\w:]+)/.exec(description)?.[1];
247
+ return new ApiError({
248
+ error: description || 'Insufficient scope',
249
+ code: status,
250
+ type: 'PACHCA_SCOPE_ERROR',
251
+ message: 'insufficient_scope',
252
+ ...(scope ? { scope } : {}),
253
+ request_id: requestId,
254
+ hint: 'pachca auth status',
255
+ });
256
+ }
232
257
  // OAuthError format: {error: string, error_description: string}
233
258
  if (isRecord(body) && typeof body.error === 'string' && typeof body.error_description === 'string') {
234
259
  const type = 'PACHCA_AUTH_ERROR';
@@ -284,8 +309,11 @@ function createApiError(status, body, requestId) {
284
309
  });
285
310
  }
286
311
  export function getExitCode(error) {
287
- if (error.details.type === 'PACHCA_AUTH_ERROR' || error.details.type === 'PACHCA_AUTH_LOGIN_ERROR')
312
+ if (error.details.type === 'PACHCA_AUTH_ERROR' ||
313
+ error.details.type === 'PACHCA_AUTH_LOGIN_ERROR' ||
314
+ error.details.type === 'PACHCA_SCOPE_ERROR') {
288
315
  return 3;
316
+ }
289
317
  if (error.details.code === 404)
290
318
  return 4;
291
319
  if (error.details.type === 'PACHCA_VALIDATION_ERROR' || error.details.type === 'PACHCA_USAGE_ERROR')
@@ -1,5 +1,5 @@
1
1
  import { BaseCommand } from '../../base-command.js';
2
- import { listProfiles, getActiveProfile } from '../../profiles.js';
2
+ import { listProfiles, getActiveProfile, getAuthMethod } from '../../profiles.js';
3
3
  export default class AuthList extends BaseCommand {
4
4
  static description = 'Список сохранённых профилей';
5
5
  static examples = [
@@ -20,6 +20,8 @@ export default class AuthList extends BaseCommand {
20
20
  type: profile.type,
21
21
  user: profile.user,
22
22
  email: profile.email || null,
23
+ auth: getAuthMethod(profile),
24
+ expires_at: profile.expires_at || null,
23
25
  active: name === activeProfile,
24
26
  }));
25
27
  if (entries.length === 0) {
@@ -1,11 +1,12 @@
1
1
  import { Flags } from '@oclif/core';
2
- import * as clack from '@clack/prompts';
3
2
  import { BaseCommand } from '../../base-command.js';
4
- import { setProfile, setActiveProfile, getActiveProfile } from '../../profiles.js';
3
+ import { setProfile, setActiveProfile, getActiveProfile, getProfile, getSecretStorage, expiresAtFromNow, } from '../../profiles.js';
4
+ import { isSecretStoreAvailable } from '../../secret-store.js';
5
5
  import { ApiError, request } from '../../client.js';
6
6
  import { outputError } from '../../output.js';
7
+ import { OAuthError, copyToClipboard, openBrowser, pollForToken, requestDeviceCode, } from '../../oauth.js';
7
8
  export default class AuthLogin extends BaseCommand {
8
- static description = 'Авторизация и сохранение токена';
9
+ static description = 'Вход в аккаунт и сохранение профиля';
9
10
  static examples = [
10
11
  '<%= config.bin %> auth login',
11
12
  '<%= config.bin %> auth login --profile personal',
@@ -19,62 +20,171 @@ export default class AuthLogin extends BaseCommand {
19
20
  default: 'default',
20
21
  }),
21
22
  token: Flags.string({
22
- description: 'Токен для сохранения (без интерактивного ввода)',
23
+ description: 'Войти готовым токеном вместо входа через браузер. Значение `-` читает токен из потока ввода, не оставляя его в истории оболочки',
24
+ }),
25
+ 'no-browser': Flags.boolean({
26
+ description: 'Не открывать браузер — только напечатать адрес и код',
27
+ default: false,
28
+ }),
29
+ 'insecure-storage': Flags.boolean({
30
+ description: 'Хранить токен в файле конфигурации, а не в хранилище ключей ОС',
31
+ default: false,
23
32
  }),
24
33
  };
25
34
  async run() {
26
35
  const { flags } = await this.parse(AuthLogin);
27
36
  this.parsedFlags = flags;
28
37
  const profileName = flags.profile ?? 'default';
29
- let token = flags.token;
30
- // Interactive token prompt
31
- if (!token) {
32
- if (!this.isInteractive()) {
33
- outputError({ error: 'Token required in non-interactive mode. Use --token flag.', type: 'PACHCA_USAGE_ERROR', code: null }, this.getOutputFormat());
34
- this.exit(2);
35
- }
36
- const result = await clack.password({
37
- message: 'Вставьте API токен:',
38
- });
39
- if (clack.isCancel(result)) {
40
- process.stderr.write('Отменено.\n');
41
- this.exit(0);
42
- }
43
- token = result;
38
+ // `--token -` reads the value from stdin. A secret passed as an argument
39
+ // lands in the shell history and is visible in the process list to everyone
40
+ // on the machine — and this is the CI path, where the token is long-lived.
41
+ // Reading a terminal instead of a pipe would just hang with no output until
42
+ // the person guesses to press Ctrl-D. Say what was expected instead.
43
+ if (flags.token === '-' && process.stdin.isTTY) {
44
+ outputError({
45
+ error: 'Option --token - reads the token from stdin. Pipe it in: echo "$TOKEN" | pachca auth login --token -',
46
+ type: 'PACHCA_USAGE_ERROR',
47
+ code: null,
48
+ }, this.getOutputFormat());
49
+ this.exit(2);
50
+ }
51
+ const token = flags.token === '-' ? await readTokenFromStdin() : flags.token;
52
+ if (flags.token === '-' && !token) {
53
+ outputError({ error: 'No token on stdin', type: 'PACHCA_USAGE_ERROR', code: null }, this.getOutputFormat());
54
+ this.exit(2);
44
55
  }
45
- if (!token) {
46
- outputError({ error: 'Token is required', type: 'PACHCA_USAGE_ERROR', code: null }, this.getOutputFormat());
56
+ flags.token = token;
57
+ // Confirmation happens in a browser, so without a person at the terminal
58
+ // there is nobody to confirm. CI and scripts pass a ready token instead.
59
+ if (!flags.token && !this.isInteractive()) {
60
+ outputError({
61
+ error: 'Token required in non-interactive mode. Use --token flag or PACHCA_TOKEN.',
62
+ type: 'PACHCA_USAGE_ERROR',
63
+ code: null,
64
+ }, this.getOutputFormat());
47
65
  this.exit(2);
48
66
  }
49
- // Validate token via GET /oauth/token/info
50
- let tokenInfo;
67
+ const grant = flags.token
68
+ ? { access_token: flags.token }
69
+ : await this.deviceLogin(!flags['no-browser'], flags.quiet);
70
+ const tokenInfo = await this.verifyToken(grant.access_token);
71
+ const identity = await this.fetchIdentity(grant.access_token, tokenInfo.user_id);
72
+ const scopes = grant.scope ? grant.scope.split(' ').filter(Boolean) : tokenInfo.scopes;
73
+ const profile = {
74
+ type: identity.isBot ? 'bot' : 'user',
75
+ token: grant.access_token,
76
+ user: identity.isBot ? `${identity.name} (бот)` : identity.name,
77
+ email: identity.email,
78
+ ...(flags.token
79
+ ? { auth: 'token' }
80
+ : {
81
+ auth: 'oauth',
82
+ refresh_token: grant.refresh_token,
83
+ expires_at: grant.expires_in ? expiresAtFromNow(grant.expires_in) : undefined,
84
+ }),
85
+ ...(scopes?.length ? { scopes } : {}),
86
+ // Probed rather than assumed: on Linux the module loads even with no
87
+ // Secret Service running, and a profile marked `keyring` that cannot be
88
+ // read back would lock the user out of the session they just created.
89
+ storage: !flags['insecure-storage'] && isSecretStoreAvailable()
90
+ ? 'keyring'
91
+ : 'file',
92
+ };
93
+ setProfile(profileName, profile);
94
+ if (!getActiveProfile())
95
+ setActiveProfile(profileName);
96
+ // setProfile downgrades to the file if the write failed, so report what was
97
+ // actually stored, not what was asked for.
98
+ const saved = getProfile(profileName) ?? profile;
99
+ this.report(profileName, saved, scopes);
100
+ }
101
+ /**
102
+ * Device authorization grant: the user confirms in a browser, we poll.
103
+ *
104
+ * The address and the code are printed separately and the ready-made link with
105
+ * the code baked in is never shown — a link like that works as phishing when it
106
+ * is forwarded to someone else's device.
107
+ */
108
+ async deviceLogin(useBrowser, quiet) {
109
+ let grant;
110
+ try {
111
+ grant = await requestDeviceCode();
112
+ }
113
+ catch (error) {
114
+ this.failOAuth(error, 'Не удалось начать вход');
115
+ }
116
+ if (!quiet) {
117
+ const copied = copyToClipboard(grant.user_code);
118
+ process.stderr.write(`\n Откройте ${grant.verification_uri}\n`);
119
+ process.stderr.write(` и введите код: ${grant.user_code}\n`);
120
+ if (copied)
121
+ process.stderr.write(` (код скопирован в буфер обмена)\n`);
122
+ process.stderr.write(`\n Ожидаю подтверждения…\n`);
123
+ }
124
+ if (useBrowser)
125
+ openBrowser(grant.verification_uri);
51
126
  try {
52
- const response = await request({ method: 'GET', path: '/oauth/token/info', token }, { quiet: flags.quiet });
53
- const wrapper = response.data;
54
- tokenInfo = wrapper.data;
127
+ return await pollForToken(grant);
128
+ }
129
+ catch (error) {
130
+ this.failOAuth(error, 'Вход не завершён');
131
+ }
132
+ }
133
+ failOAuth(error, summary) {
134
+ if (!(error instanceof OAuthError))
135
+ throw error;
136
+ const explanations = {
137
+ access_denied: 'Вход отклонён в браузере',
138
+ expired_token: 'Код истёк — запустите вход заново',
139
+ network_error: 'Не удалось подключиться к серверу',
140
+ };
141
+ const message = explanations[error.code] ?? error.description ?? error.code;
142
+ outputError({
143
+ error: summary,
144
+ code: error.status ?? null,
145
+ type: 'PACHCA_AUTH_LOGIN_ERROR',
146
+ message,
147
+ oauth_error: error.code,
148
+ }, this.getOutputFormat());
149
+ this.exit(3);
150
+ }
151
+ async verifyToken(token) {
152
+ try {
153
+ const response = await request({ method: 'GET', path: '/oauth/token/info', token }, { quiet: this.parsedFlags.quiet });
154
+ return response.data.data;
55
155
  }
56
156
  catch (error) {
57
157
  if (error instanceof ApiError) {
58
158
  if (error.details.code === 401) {
59
159
  const format = this.getOutputFormat();
60
160
  if (format === 'json' || !process.stderr.isTTY) {
61
- outputError({ error: 'Invalid token', code: 401, type: 'PACHCA_AUTH_LOGIN_ERROR', message: 'Токен недействителен — сохранение отменено' }, format);
161
+ outputError({
162
+ error: 'Invalid token',
163
+ code: 401,
164
+ type: 'PACHCA_AUTH_LOGIN_ERROR',
165
+ message: 'Токен недействителен — сохранение отменено',
166
+ }, format);
62
167
  }
63
168
  else {
64
- process.stderr.write(`✗ Токен недействителен — сохранение отменено\n\n`);
169
+ process.stderr.write(`✗ Токен недействителен — сохранение отменено.\n\n`);
65
170
  process.stderr.write(` Проверьте токен и попробуйте снова:\n`);
66
171
  process.stderr.write(` pachca auth login --token <ваш токен>\n\n`);
67
- process.stderr.write(` Документация: https://dev.pachca.com/guides/authorization\n`);
172
+ process.stderr.write(` Документация: https://dev.pachca.com/api/authorization\n`);
68
173
  }
69
174
  this.exit(3);
70
175
  }
71
176
  if (error.details.type === 'PACHCA_NETWORK_ERROR') {
72
177
  const format = this.getOutputFormat();
73
178
  if (format === 'json' || !process.stderr.isTTY) {
74
- outputError({ error: 'Network error', code: null, type: 'PACHCA_NETWORK_ERROR', message: 'Не удалось подключиться к серверу — сохранение отменено' }, format);
179
+ outputError({
180
+ error: 'Network error',
181
+ code: null,
182
+ type: 'PACHCA_NETWORK_ERROR',
183
+ message: 'Не удалось подключиться к серверу — сохранение отменено',
184
+ }, format);
75
185
  }
76
186
  else {
77
- process.stderr.write(`✗ Не удалось подключиться к серверу — сохранение отменено\n\n`);
187
+ process.stderr.write(`✗ Не удалось подключиться к серверу — сохранение отменено.\n\n`);
78
188
  process.stderr.write(` Проверьте подключение к интернету и попробуйте снова:\n`);
79
189
  process.stderr.write(` pachca auth login\n`);
80
190
  }
@@ -83,49 +193,60 @@ export default class AuthLogin extends BaseCommand {
83
193
  }
84
194
  throw error;
85
195
  }
86
- // Get user info
87
- let userName = `User #${tokenInfo.user_id}`;
88
- let userEmail = null;
89
- let isBot = false;
196
+ }
197
+ async fetchIdentity(token, userId) {
90
198
  try {
91
- const profileResponse = await request({ method: 'GET', path: '/profile', token }, { quiet: true });
92
- const profileData = profileResponse.data.data;
93
- const firstName = profileData.first_name || '';
94
- const lastName = profileData.last_name || '';
95
- userName = `${firstName} ${lastName}`.trim() || userName;
96
- userEmail = profileData.email || null;
97
- isBot = profileData.bot || false;
199
+ const response = await request({ method: 'GET', path: '/profile', token }, { quiet: true });
200
+ const data = response.data.data;
201
+ const name = `${data.first_name || ''} ${data.last_name || ''}`.trim();
202
+ return {
203
+ name: name || `User #${userId}`,
204
+ email: data.email || null,
205
+ isBot: data.bot || false,
206
+ };
98
207
  }
99
208
  catch {
100
- // profile:read scope not available — use user_id from token info
101
- }
102
- const profileType = isBot ? 'bot' : 'user';
103
- // Save profile
104
- const profile = {
105
- type: profileType,
106
- token,
107
- user: isBot ? `${userName} (бот)` : userName,
108
- email: userEmail,
109
- };
110
- setProfile(profileName, profile);
111
- // Set as active if no active profile
112
- if (!getActiveProfile()) {
113
- setActiveProfile(profileName);
209
+ // profile:read scope not available — user_id from token info is enough
210
+ return { name: `User #${userId}`, email: null, isBot: false };
114
211
  }
212
+ }
213
+ report(profileName, profile, scopes) {
115
214
  const format = this.getOutputFormat();
116
215
  if (format === 'json') {
117
216
  this.output({
118
217
  profile: profileName,
119
- type: profileType,
218
+ type: profile.type,
120
219
  user: profile.user,
121
- email: userEmail,
220
+ email: profile.email ?? null,
221
+ auth: profile.auth ?? 'token',
222
+ expires_at: profile.expires_at ?? null,
223
+ scopes: scopes ?? null,
224
+ storage: getSecretStorage(profile),
122
225
  });
226
+ return;
123
227
  }
124
- else if (!flags.quiet) {
125
- const typeLabel = profileType === 'bot' ? 'bot' : 'user';
126
- const emailStr = userEmail ? ` (${userEmail})` : '';
127
- process.stderr.write(`✔ Подключён как: ${profile.user}${emailStr} [${typeLabel}]\n`);
128
- process.stderr.write(` Профиль сохранён [${profileName}]\n`);
228
+ if (this.parsedFlags.quiet)
229
+ return;
230
+ const emailStr = profile.email ? ` (${profile.email})` : '';
231
+ process.stderr.write(`✔ Подключён как: ${profile.user}${emailStr} [${profile.type}]\n`);
232
+ process.stderr.write(` Профиль сохранён [${profileName}]\n`);
233
+ if (scopes?.length)
234
+ process.stderr.write(` Права: ${scopes.length}\n`);
235
+ // Where the token landed is a security fact the user cannot check any other
236
+ // way, so it is said at login and not left to `auth status`.
237
+ if (getSecretStorage(profile) === 'keyring') {
238
+ process.stderr.write(` Токен в хранилище ключей ОС\n`);
129
239
  }
240
+ else {
241
+ process.stderr.write(` Токен в файле конфигурации (хранилище ключей недоступно)\n`);
242
+ }
243
+ process.stderr.write(` Состояние входа: pachca auth status\n`);
130
244
  }
131
245
  }
246
+ /** Read a token piped into the command, so it never appears in the shell history. */
247
+ async function readTokenFromStdin() {
248
+ const chunks = [];
249
+ for await (const chunk of process.stdin)
250
+ chunks.push(chunk);
251
+ return Buffer.concat(chunks).toString('utf8').trim();
252
+ }