@pachca/cli 2026.8.0 → 2026.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.8.1 (6 августа 2026)
4
+
5
+ - **Добавлено** (auth login): Вход через браузер — без ручной вставки токена. CLI показывает адрес и код, открывает браузер и ждёт подтверждения. Токен такого входа живёт час и продлевается сам перед каждой командой; при параллельных процессах обновляет только один, остальные ждут результат. Флаг `--no-browser` печатает адрес и код, не открывая браузер. `--token` и `PACHCA_TOKEN` работают как раньше — это путь для CI и агентов
6
+ - **Добавлено** (auth login): Токен хранится в хранилище ключей ОС — Keychain на macOS, Secret Service на Linux, Диспетчер учётных данных на Windows; в файле конфигурации остаются только имя, почта и права. Где хранилища нет (headless-машина, контейнер), CLI сохраняет токен в файл и сообщает об этом при входе. Отказаться от хранилища: флаг `--insecure-storage` или переменная `PACHCA_SECRET_STORE=file`
7
+ - **Добавлено** (auth status): Показывает способ входа, срок жизни токена, выданные права и где хранится токен — с этого стоит начинать разбор внезапного `403`
8
+ - **Добавлено** (auth list): Колонки `auth` и `expires_at` — видно, какой профиль вошёл через браузер, а какой держит готовый токен
9
+ - **Изменено** (auth logout): Токен входа через браузер гасится на сервере — доступ закрывается в момент выхода, а не по истечении срока. Если отозвать не удалось, CLI об этом говорит и не выдаёт удаление профиля за закрытый доступ. Готовый токен продолжает работать: отозвать его можно только в интерфейсе
10
+ - **Изменено**: Отказ по правам разбирается отдельно от прочих `403`: CLI называет недостающее право и объясняет причину. В JSON-выводе — тип `PACHCA_SCOPE_ERROR` и поле `scope`
11
+ - **Изменено**: В подсказке «токен не найден» появилась переменная окружения `PACHCA_TOKEN` — раньше предлагался только флаг `--token`, хотя переменная работает и без входа
12
+ - **Добавлено** (auth status): Флаг `--remote` — спросить права у сервера вместо сохранённых при входе. Набор прав фиксируется в момент входа, и после смены роли локальный список устаревает; при расхождении CLI говорит об этом и обновляет сохранённый
13
+ - **Добавлено** (auth login): `--token -` читает токен из потока ввода: `echo $TOKEN | pachca auth login --token -` не оставляет секрет в истории оболочки и в списке процессов. Передача значением работает как раньше
14
+ - **Добавлено** (auth switch): Сразу после переключения сообщает, если секрет профиля не читается или срок токена истёк — раньше ошибка всплывала на следующей команде
15
+ - **Изменено** (doctor): Проверяет хранилище ключей ОС отдельным пунктом и не выдаёт заблокированную связку за недействительный токен. Показывает способ входа и срок жизни токена. Права файла конфигурации проверяются только когда токен действительно лежит в файле
16
+ - **Исправлено** (doctor): Проверяет тот адрес API, с которым работает CLI, а не всегда `api.pachca.com` — при изменённом адресе сервера команда рапортовала о доступности чужого хоста
17
+ - **Исправлено** (doctor): Истёкший, но обновляемый токен больше не выдаётся за недействительный: команда говорит, что он обновится при следующем вызове, вместо совета войти заново
18
+ - **Добавлено** (chats list): Флаг `--archived` — вернуть архивные чаты вместе с активными. Отличить их можно по полю `archived` в выдаче
19
+ - **Добавлено** (security list): Фильтр `--event-key` принимает `bot_oauth_client_updated`, `oauth_authorization_granted` и `oauth_authorization_revoked`
20
+ - **Добавлено** (security list): Фильтр `--event-key` принимает `bot_oauth_client_updated`, `oauth_authorization_granted` и `oauth_authorization_revoked`
21
+ - **Исправлено**: Подсказка «токен не найден» вела на несуществующую страницу `/guides/authorization` — теперь на `/api/authorization`
22
+ - **Добавлено**: `pachca --help` заканчивается ссылкой на документацию и на `pachca guide` — из терминала стало видно, куда идти за сценариями
23
+
3
24
  ## 2026.8.0 (1 августа 2026)
4
25
 
5
26
  - **Изменено** (api): `api POST /messages --spec` и `--docs`: у поля `files[].name` описано требование к расширению — оно должно совпадать с расширением файла в `key`
@@ -8,20 +29,20 @@
8
29
 
9
30
  ## 2026.7.2 (27 июля 2026)
10
31
 
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 с пустым телом, а команда пыталась прочитать из него поле
32
+ - **Исправлено** (files direct-url): Файл теперь добавляется в форму последним — S3 игнорирует поля после файла, из-за чего загрузка отклонялась с 400
33
+ - **Исправлено**: Явный `--profile` больше не перебивается переменной `PACHCA_TOKEN` — команда с указанным профилем не уйдёт в чужое пространство
34
+ - **Исправлено**: Запись (`POST`/`DELETE`/`PATCH`) больше не повторяется автоматически при 503 — повтор мог создать дубль сообщения
35
+ - **Исправлено**: `Retry-After` в формате даты больше не игнорируется, значение ограничено разумным пределом
36
+ - **Исправлено** (chats download-export): Имя файла из `Content-Disposition` больше не может увести запись за пределы указанной папки
37
+ - **Исправлено**: Вывод `-o csv`: ячейки, начинающиеся с `=`, `+`, `-` или `@`, экранируются; объекты и массивы выводятся как JSON, а не `[object Object]`
38
+ - **Исправлено**: `--timeout` меньше 1 отклоняется, а не приводит к мгновенной отмене запроса
39
+ - **Исправлено**: Примеры в `--help` подписаны шагом сценария, а не общим заголовком (`reactions remove` больше не подписан «Добавить реакцию»)
40
+ - **Исправлено** (files direct-url): Команда больше не падает с TypeError при успешной загрузке: S3 отвечает 204 с пустым телом, а команда пыталась прочитать из него поле
20
41
 
21
42
  ## 2026.7.1 (24 июля 2026)
22
43
 
23
44
  - **Добавлено** (threads create): Новая команда — создание самостоятельного треда без привязки к сообщению
24
- - **Удалено**: Примеры в `--help` больше не смешиваются между командами с общим путём (например, create и list одного ресурса показывали примеры друг друга)
45
+ - **Исправлено**: Примеры в `--help` больше не смешиваются между командами с общим путём (например, create и list одного ресурса показывали примеры друг друга)
25
46
 
26
47
  ## 2026.7.0 (7 июля 2026)
27
48
 
@@ -107,13 +128,13 @@
107
128
 
108
129
  - **Изменено** (all): Убрана клиентская валидация скоупов — проверка разрешений теперь только на стороне API (403)
109
130
  - **Изменено** (auth login): Профиль больше не сохраняет список скоупов — только тип, имя и email
110
- - **Удалено** (auth refresh): Команда удалена — кеширование скоупов больше не используется
131
+ - **Исправлено** (auth refresh): Команда удалена — кеширование скоупов больше не используется
111
132
 
112
133
  ## 2026.3.8 (17 марта 2026)
113
134
 
114
135
  - **Добавлено** (upgrade): Команда pachca upgrade — обновление CLI до последней версии
115
- - **Удалено** (init hook): Ошибки авторизации теперь выводятся в JSON при -o json (раньше формат игнорировался)
116
- - **Удалено** (update banner): Баннер обновления теперь показывается даже при ошибках команд
136
+ - **Исправлено** (init hook): Ошибки авторизации теперь выводятся в JSON при -o json (раньше формат игнорировался)
137
+ - **Исправлено** (update banner): Баннер обновления теперь показывается даже при ошибках команд
117
138
 
118
139
  ## 2026.3.7 (16 марта 2026)
119
140
 
@@ -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
  }
@@ -229,6 +229,21 @@ export async function request(opts, clientFlags) {
229
229
  });
230
230
  }
231
231
  function createApiError(status, body, requestId) {
232
+ // Scope refusal is a distinct case from "token is bad": the token works, it
233
+ // just may not do this. Pull the missing scope out so the command can name it.
234
+ if (isRecord(body) && body.error === 'insufficient_scope') {
235
+ const description = typeof body.error_description === 'string' ? body.error_description : '';
236
+ const scope = /Required:\s*([\w:]+)/.exec(description)?.[1];
237
+ return new ApiError({
238
+ error: description || 'Insufficient scope',
239
+ code: status,
240
+ type: 'PACHCA_SCOPE_ERROR',
241
+ message: 'insufficient_scope',
242
+ ...(scope ? { scope } : {}),
243
+ request_id: requestId,
244
+ hint: 'pachca auth status',
245
+ });
246
+ }
232
247
  // OAuthError format: {error: string, error_description: string}
233
248
  if (isRecord(body) && typeof body.error === 'string' && typeof body.error_description === 'string') {
234
249
  const type = 'PACHCA_AUTH_ERROR';
@@ -284,8 +299,11 @@ function createApiError(status, body, requestId) {
284
299
  });
285
300
  }
286
301
  export function getExitCode(error) {
287
- if (error.details.type === 'PACHCA_AUTH_ERROR' || error.details.type === 'PACHCA_AUTH_LOGIN_ERROR')
302
+ if (error.details.type === 'PACHCA_AUTH_ERROR' ||
303
+ error.details.type === 'PACHCA_AUTH_LOGIN_ERROR' ||
304
+ error.details.type === 'PACHCA_SCOPE_ERROR') {
288
305
  return 3;
306
+ }
289
307
  if (error.details.code === 404)
290
308
  return 4;
291
309
  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
+ }
@@ -1,7 +1,8 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import * as clack from '@clack/prompts';
3
3
  import { BaseCommand } from '../../base-command.js';
4
- import { listProfiles, deleteProfile } from '../../profiles.js';
4
+ import { listProfiles, deleteProfile, getAuthMethod, getSecretStorage, getProfile } from '../../profiles.js';
5
+ import { revokeToken } from '../../oauth.js';
5
6
  import { outputError } from '../../output.js';
6
7
  export default class AuthLogout extends BaseCommand {
7
8
  static description = 'Удаление сохранённого профиля';
@@ -69,12 +70,45 @@ export default class AuthLogout extends BaseCommand {
69
70
  outputError({ error: `Profile "${profileName}" not found`, type: 'PACHCA_USAGE_ERROR', code: null }, format);
70
71
  this.exit(2);
71
72
  }
73
+ const authMethod = getAuthMethod(profiles[profileName]);
74
+ const storage = getSecretStorage(profiles[profileName]);
75
+ // Put the token out server-side before dropping the profile — afterwards the
76
+ // secret is gone and there is nothing left to revoke with. Only tokens this
77
+ // CLI issued can revoke themselves; a pasted one is not ours to cancel.
78
+ let revoked = false;
79
+ if (authMethod === 'oauth') {
80
+ const secret = getProfile(profileName)?.token;
81
+ if (secret)
82
+ revoked = await revokeToken(secret);
83
+ }
72
84
  deleteProfile(profileName);
73
85
  if (format === 'json') {
74
- this.output({ deleted_profile: profileName });
86
+ this.output({
87
+ deleted_profile: profileName,
88
+ auth: authMethod,
89
+ storage,
90
+ token_revoked: revoked,
91
+ });
92
+ return;
75
93
  }
76
- else {
77
- this.success(`Профиль ${profileName} удалён`);
94
+ this.success(`Профиль ${profileName} удалён`);
95
+ // Never claim access is closed when it is not: believing a logout revoked a
96
+ // token when it did not is worse than an extra line here.
97
+ if (!flags.quiet) {
98
+ if (authMethod === 'oauth') {
99
+ process.stderr.write(revoked
100
+ ? ` Токен отозван на сервере и удалён с этой машины.\n`
101
+ : ` Токен удалён с этой машины. Отозвать его не удалось — перестанет действовать по истечении срока.\n`);
102
+ }
103
+ else {
104
+ process.stderr.write(` Токен удалён с этой машины, но продолжает действовать.\n`);
105
+ process.stderr.write(` Отзовите его в настройках Пачки, если он больше не нужен.\n`);
106
+ }
107
+ // Storage is a property of the profile, not of how it was created: a pasted
108
+ // token lives in the store just the same, and its entry goes away too.
109
+ if (storage === 'keyring') {
110
+ process.stderr.write(` Запись в хранилище ключей ОС удалена.\n`);
111
+ }
78
112
  }
79
113
  }
80
114
  }