@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.
@@ -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
  }
@@ -1,6 +1,22 @@
1
+ import { Flags } from '@oclif/core';
1
2
  import { BaseCommand } from '../../base-command.js';
2
- import { getActiveProfile, getProfile } from '../../profiles.js';
3
+ import { getActiveProfile, getAuthMethod, getProfile, getSecretStorage, updateProfileScopes, } from '../../profiles.js';
4
+ import { request } from '../../client.js';
3
5
  import { outputError } from '../../output.js';
6
+ /** Human-readable time left, or null when the token has no expiry at all. */
7
+ function describeExpiry(profile) {
8
+ if (getAuthMethod(profile) === 'token')
9
+ return null;
10
+ if (!profile.expires_at)
11
+ return null;
12
+ const msLeft = Date.parse(profile.expires_at) - Date.now();
13
+ if (Number.isNaN(msLeft))
14
+ return null;
15
+ if (msLeft <= 0)
16
+ return 'истёк, обновится при следующей команде';
17
+ const minutes = Math.round(msLeft / 60_000);
18
+ return minutes < 60 ? `осталось ${minutes} мин` : `осталось ${Math.round(minutes / 60)} ч`;
19
+ }
4
20
  export default class AuthStatus extends BaseCommand {
5
21
  static description = 'Статус текущего профиля';
6
22
  static examples = [
@@ -9,6 +25,10 @@ export default class AuthStatus extends BaseCommand {
9
25
  ];
10
26
  static flags = {
11
27
  ...BaseCommand.baseFlags,
28
+ remote: Flags.boolean({
29
+ description: 'Спросить права у сервера, а не показывать сохранённые при входе',
30
+ default: false,
31
+ }),
12
32
  };
13
33
  async run() {
14
34
  const { flags } = await this.parse(AuthStatus);
@@ -24,17 +44,84 @@ export default class AuthStatus extends BaseCommand {
24
44
  outputError({ error: `Profile "${profileName}" not found`, type: 'PACHCA_USAGE_ERROR', code: null }, format);
25
45
  this.exit(2);
26
46
  }
47
+ const authMethod = getAuthMethod(profile);
48
+ // The stored scope list is frozen at login. A role change since then makes it
49
+ // stale, and this is the command people are sent to when a call is refused —
50
+ // so offer to ask the server what the token actually carries.
51
+ let scopes = profile.scopes;
52
+ let drifted = false;
53
+ // Whether the answer really came from the server. `--remote` alone is not
54
+ // enough: the request can fail, and labelling the cached list as server truth
55
+ // is worse than not asking at all.
56
+ let fromServer = false;
57
+ if (flags.remote && profile.token) {
58
+ const remote = await this.fetchRemoteScopes(profile.token);
59
+ if (remote) {
60
+ fromServer = true;
61
+ drifted = JSON.stringify([...(scopes ?? [])].sort()) !== JSON.stringify([...remote].sort());
62
+ scopes = remote;
63
+ if (drifted)
64
+ updateProfileScopes(profileName, remote);
65
+ }
66
+ }
27
67
  if (format === 'json') {
28
68
  this.output({
29
69
  profile: profileName,
30
70
  type: profile.type,
31
71
  user: profile.user,
32
72
  email: profile.email || null,
73
+ auth: authMethod,
74
+ expires_at: profile.expires_at || null,
75
+ scopes: scopes || null,
76
+ scopes_source: fromServer ? 'server' : 'profile',
77
+ storage: getSecretStorage(profile),
78
+ // A keyring-backed profile with no token means the store did not answer.
79
+ // Surfaced here because this is the command people run to debug access.
80
+ secret_readable: !!profile.token,
33
81
  });
82
+ return;
83
+ }
84
+ const emailStr = profile.email ? ` (${profile.email})` : '';
85
+ process.stderr.write(` Подключён как: ${profile.user}${emailStr} [${profile.type}, profile: ${profileName}]\n`);
86
+ const expiry = describeExpiry(profile);
87
+ if (authMethod === 'oauth') {
88
+ process.stderr.write(` Вход: через браузер${expiry ? `, ${expiry}` : ''}\n`);
89
+ }
90
+ else {
91
+ process.stderr.write(` Вход: готовым токеном, без срока\n`);
92
+ }
93
+ if (getSecretStorage(profile) === 'keyring') {
94
+ process.stderr.write(profile.token
95
+ ? ` Хранение: хранилище ключей ОС\n`
96
+ : ` Хранение: хранилище ключей ОС — не читается, войдите заново\n`);
34
97
  }
35
98
  else {
36
- const emailStr = profile.email ? ` (${profile.email})` : '';
37
- process.stderr.write(` Подключён как: ${profile.user}${emailStr} [${profile.type}, profile: ${profileName}]\n`);
99
+ process.stderr.write(` Хранение: файл конфигурации\n`);
100
+ }
101
+ if (scopes?.length) {
102
+ const source = fromServer ? ' по данным сервера' : '';
103
+ process.stderr.write(` Права${source} (${scopes.length}): ${scopes.join(', ')}\n`);
104
+ }
105
+ else if (fromServer) {
106
+ // An empty set is an answer, not a missing one — and a token that carries
107
+ // no rights is exactly the failure worth naming out loud.
108
+ process.stderr.write(` Права по данным сервера: ни одного\n`);
109
+ }
110
+ if (drifted) {
111
+ process.stderr.write(` Список прав в профиле расходился с сервером и был обновлён\n`);
112
+ }
113
+ }
114
+ /** Ask the server what the token really carries. Null when it cannot be asked. */
115
+ async fetchRemoteScopes(token) {
116
+ try {
117
+ const response = await request({ method: 'GET', path: '/oauth/token/info', token }, { quiet: true });
118
+ const info = response.data.data;
119
+ return info?.scopes ?? null;
120
+ }
121
+ catch {
122
+ // Offline or a dead token: the local view is still worth printing.
123
+ process.stderr.write(` Спросить права у сервера не удалось — показаны сохранённые при входе\n`);
124
+ return null;
38
125
  }
39
126
  }
40
127
  }
@@ -1,6 +1,6 @@
1
1
  import { Args } from '@oclif/core';
2
2
  import { BaseCommand } from '../../base-command.js';
3
- import { listProfiles, setActiveProfile } from '../../profiles.js';
3
+ import { listProfiles, setActiveProfile, getProfile } from '../../profiles.js';
4
4
  import { outputError } from '../../output.js';
5
5
  export default class AuthSwitch extends BaseCommand {
6
6
  static description = 'Переключение активного профиля';
@@ -26,11 +26,25 @@ export default class AuthSwitch extends BaseCommand {
26
26
  this.exit(2);
27
27
  }
28
28
  setActiveProfile(args.profile);
29
+ // Say right here if the profile cannot work, rather than letting the next
30
+ // unrelated command fail: switching is where the user can still act on it.
31
+ const profile = getProfile(args.profile);
32
+ const secretMissing = !profile?.token;
33
+ const expired = !!profile?.expires_at && new Date(profile.expires_at) <= new Date();
29
34
  if (format === 'json') {
30
- this.output({ active_profile: args.profile });
35
+ this.output({
36
+ active_profile: args.profile,
37
+ secret_readable: !secretMissing,
38
+ expired,
39
+ });
40
+ return;
31
41
  }
32
- else {
33
- this.success(`Активный профиль: ${args.profile}`);
42
+ this.success(`Активный профиль: ${args.profile}`);
43
+ if (secretMissing) {
44
+ process.stderr.write(` Секрет профиля не читается — войдите заново: pachca auth login --profile ${args.profile}\n`);
45
+ }
46
+ else if (expired) {
47
+ process.stderr.write(` Срок действия токена истёк — он будет обновлён при следующей команде\n`);
34
48
  }
35
49
  }
36
50
  }
@@ -0,0 +1,98 @@
1
+ // Auto-generated from openapi.yaml — DO NOT EDIT
2
+ import { Flags } from '@oclif/core';
3
+ import { BaseCommand } from '../../base-command.js';
4
+ export default class BotsListCompany extends BaseCommand {
5
+ static description = "Список ботов пространства";
6
+ static examples = [
7
+ "Инвентаризация всех ботов пространства — Получи всех ботов пространства, а не только доступных для редактирования:\n $ pachca bots list-company"
8
+ ];
9
+ static scope = "company_bots:read";
10
+ static plan = "corporation";
11
+ static apiMethod = "GET";
12
+ static apiPath = "/company/bots";
13
+ static defaultColumns = ["id", "webhook"];
14
+ static args = {};
15
+ static flags = {
16
+ ...BaseCommand.baseFlags,
17
+ 'query': Flags.string({
18
+ description: "Поисковая фраза для фильтрации ботов по имени",
19
+ }),
20
+ limit: Flags.integer({
21
+ description: 'Количество результатов на страницу',
22
+ }),
23
+ cursor: Flags.string({
24
+ description: 'Курсор для следующей страницы',
25
+ }),
26
+ all: Flags.boolean({
27
+ description: 'Загрузить все страницы автоматически',
28
+ default: false,
29
+ }),
30
+ };
31
+ async run() {
32
+ const { args, flags } = await this.parse(BotsListCompany);
33
+ this.parsedFlags = flags;
34
+ if (flags.all) {
35
+ // Auto-pagination
36
+ const allData = [];
37
+ let nextCursor = undefined;
38
+ let pages = 0;
39
+ const seenCursors = new Set();
40
+ while (pages < 500) {
41
+ const query = {
42
+ query: flags['query'],
43
+ limit: flags.limit,
44
+ cursor: nextCursor,
45
+ };
46
+ const response = await this.apiRequest({ method: 'GET', path: '/company/bots', query });
47
+ const body = response.data;
48
+ const items = body.data;
49
+ if (items)
50
+ allData.push(...items);
51
+ const meta = body.meta;
52
+ const paginate = meta?.paginate;
53
+ nextCursor = paginate?.next_page;
54
+ pages++;
55
+ // Условие конца: списочные методы — has_next === false; методы поиска и /users?query= (без has_next) — пустой data
56
+ const hasNext = paginate?.has_next;
57
+ if (typeof hasNext === 'boolean') {
58
+ if (!hasNext)
59
+ break;
60
+ }
61
+ else if (!items || items.length === 0) {
62
+ break;
63
+ }
64
+ if (process.stderr.isTTY) {
65
+ const total = paginate?.total;
66
+ const progress = total ? `${allData.length} / ${total}` : String(allData.length);
67
+ process.stderr.write(`\r Загружено: ${progress}...`);
68
+ }
69
+ if (!nextCursor)
70
+ break;
71
+ if (seenCursors.has(nextCursor)) {
72
+ process.stderr.write('\n⚠ Обнаружен цикл пагинации, остановка.\n');
73
+ break;
74
+ }
75
+ seenCursors.add(nextCursor);
76
+ }
77
+ if (pages >= 500) {
78
+ process.stderr.write('\n⚠ Достигнут лимит 500 страниц.\n');
79
+ }
80
+ if (process.stderr.isTTY)
81
+ process.stderr.write('\n');
82
+ this.output(allData);
83
+ return;
84
+ }
85
+ const { data } = await this.apiRequest({
86
+ method: 'GET',
87
+ path: '/company/bots',
88
+ query: {
89
+ query: flags['query'],
90
+ limit: flags.limit,
91
+ cursor: flags.cursor,
92
+ },
93
+ });
94
+ const responseBody = (data ?? {});
95
+ const items = responseBody.data ?? responseBody;
96
+ this.output(items);
97
+ }
98
+ }
@@ -0,0 +1,99 @@
1
+ // Auto-generated from openapi.yaml — DO NOT EDIT
2
+ import { Flags } from '@oclif/core';
3
+ import { BaseCommand } from '../../base-command.js';
4
+ export default class ChatsListCompany extends BaseCommand {
5
+ static description = "Список чатов пространства";
6
+ static examples = [
7
+ "Выгрузить все чаты пространства, включая закрытые — Получи беседы и каналы всего пространства, включая закрытые, где владелец токена не состоит:\n $ pachca chats list-company"
8
+ ];
9
+ static scope = "company_chats:read";
10
+ static plan = "corporation";
11
+ static apiMethod = "GET";
12
+ static apiPath = "/company/chats";
13
+ static defaultColumns = ["id", "name", "created_at", "owner_id", "channel"];
14
+ static args = {};
15
+ static flags = {
16
+ ...BaseCommand.baseFlags,
17
+ 'activity': Flags.string({
18
+ description: "Состояние чатов. Если параметр не указан, возвращаются и активные, и архивные чаты.",
19
+ options: ["active", "archived"],
20
+ }),
21
+ limit: Flags.integer({
22
+ description: 'Количество результатов на страницу',
23
+ }),
24
+ cursor: Flags.string({
25
+ description: 'Курсор для следующей страницы',
26
+ }),
27
+ all: Flags.boolean({
28
+ description: 'Загрузить все страницы автоматически',
29
+ default: false,
30
+ }),
31
+ };
32
+ async run() {
33
+ const { args, flags } = await this.parse(ChatsListCompany);
34
+ this.parsedFlags = flags;
35
+ if (flags.all) {
36
+ // Auto-pagination
37
+ const allData = [];
38
+ let nextCursor = undefined;
39
+ let pages = 0;
40
+ const seenCursors = new Set();
41
+ while (pages < 500) {
42
+ const query = {
43
+ activity: flags['activity'],
44
+ limit: flags.limit,
45
+ cursor: nextCursor,
46
+ };
47
+ const response = await this.apiRequest({ method: 'GET', path: '/company/chats', query });
48
+ const body = response.data;
49
+ const items = body.data;
50
+ if (items)
51
+ allData.push(...items);
52
+ const meta = body.meta;
53
+ const paginate = meta?.paginate;
54
+ nextCursor = paginate?.next_page;
55
+ pages++;
56
+ // Условие конца: списочные методы — has_next === false; методы поиска и /users?query= (без has_next) — пустой data
57
+ const hasNext = paginate?.has_next;
58
+ if (typeof hasNext === 'boolean') {
59
+ if (!hasNext)
60
+ break;
61
+ }
62
+ else if (!items || items.length === 0) {
63
+ break;
64
+ }
65
+ if (process.stderr.isTTY) {
66
+ const total = paginate?.total;
67
+ const progress = total ? `${allData.length} / ${total}` : String(allData.length);
68
+ process.stderr.write(`\r Загружено: ${progress}...`);
69
+ }
70
+ if (!nextCursor)
71
+ break;
72
+ if (seenCursors.has(nextCursor)) {
73
+ process.stderr.write('\n⚠ Обнаружен цикл пагинации, остановка.\n');
74
+ break;
75
+ }
76
+ seenCursors.add(nextCursor);
77
+ }
78
+ if (pages >= 500) {
79
+ process.stderr.write('\n⚠ Достигнут лимит 500 страниц.\n');
80
+ }
81
+ if (process.stderr.isTTY)
82
+ process.stderr.write('\n');
83
+ this.output(allData);
84
+ return;
85
+ }
86
+ const { data } = await this.apiRequest({
87
+ method: 'GET',
88
+ path: '/company/chats',
89
+ query: {
90
+ activity: flags['activity'],
91
+ limit: flags.limit,
92
+ cursor: flags.cursor,
93
+ },
94
+ });
95
+ const responseBody = (data ?? {});
96
+ const items = responseBody.data ?? responseBody;
97
+ this.output(items);
98
+ }
99
+ }
@@ -6,7 +6,7 @@ export default class ChatsList extends BaseCommand {
6
6
  static examples = [
7
7
  "Найти активные чаты за период — Получи чаты с активностью после указанной даты:\n $ pachca chats list",
8
8
  "Найти и заархивировать неактивные чаты — Получи чаты без активности с нужной даты:\n $ pachca chats list",
9
- "Создать гостя в чат — Выбери активный чат, в который добавить гостя — узнай его ID:\n $ pachca chats list"
9
+ "Выгрузить все чаты пространства, включая закрытые — Если метод ответил `403` — у токена нет скоупа `company_chats:read`, либо владелец токена не владелец пространства, либо у пространства нет тарифа «Корпорация». Тогда возьми открытые чаты обычным списком:\n $ pachca chats list"
10
10
  ];
11
11
  static scope = "chats:read";
12
12
  static apiMethod = "GET";
@@ -30,6 +30,11 @@ export default class ChatsList extends BaseCommand {
30
30
  options: ["is_member", "public"],
31
31
  default: "is_member",
32
32
  }),
33
+ 'archived': Flags.boolean({
34
+ description: "Добавляет к выдаче архивные чаты. По умолчанию возвращаются только активные. Работает при любом значении `availability`.",
35
+ default: false,
36
+ allowNo: true,
37
+ }),
33
38
  'last-message-at-after': Flags.string({
34
39
  description: "Фильтрация по времени создания последнего сообщения. Будут возвращены те чаты, время последнего созданного сообщения в которых не раньше чем указанное (в формате YYYY-MM-DDThh:mm:ss.sssZ).",
35
40
  }),
@@ -65,6 +70,7 @@ export default class ChatsList extends BaseCommand {
65
70
  sort: flags['sort'],
66
71
  order: flags['order'],
67
72
  availability: flags['availability'],
73
+ archived: flags['archived'],
68
74
  'last_message_at_after': flags['last-message-at-after'],
69
75
  'last_message_at_before': flags['last-message-at-before'],
70
76
  personal: flags['personal'],
@@ -117,6 +123,7 @@ export default class ChatsList extends BaseCommand {
117
123
  sort: flags['sort'],
118
124
  order: flags['order'],
119
125
  availability: flags['availability'],
126
+ archived: flags['archived'],
120
127
  'last_message_at_after': flags['last-message-at-after'],
121
128
  'last_message_at_before': flags['last-message-at-before'],
122
129
  personal: flags['personal'],
@@ -2,7 +2,9 @@ import * as fs from 'node:fs';
2
2
  import * as process from 'node:process';
3
3
  import semver from 'semver';
4
4
  import { BaseCommand } from '../base-command.js';
5
- import { getActiveProfile, getProfile, getConfigFilePath, getConfigFilePermissions, } from '../profiles.js';
5
+ import { getActiveProfile, getProfile, getConfigFilePath, getConfigFilePermissions, getAuthMethod, getSecretStorage, isExpiring, isRefreshable, listProfiles, } from '../profiles.js';
6
+ import { isSecretStoreAvailable } from '../secret-store.js';
7
+ import { getBaseUrl } from '../client.js';
6
8
  export default class Doctor extends BaseCommand {
7
9
  static description = 'Диагностика окружения: Node.js, сеть, токен, конфигурация';
8
10
  static examples = [
@@ -30,30 +32,39 @@ export default class Doctor extends BaseCommand {
30
32
  checks.push({ name: 'node', status: 'error', version: nodeVersion, required: '>=20' });
31
33
  this.printCheck('error', `Node.js v${nodeVersion} — требуется >=20`, format);
32
34
  }
33
- // 2. Network
35
+ // 2. Network. Check the host the CLI actually talks to: on-premise installs
36
+ // point elsewhere, and probing the cloud there reports a healthy network
37
+ // while the server the user works against is down.
38
+ const baseUrl = getBaseUrl();
39
+ const host = new URL(baseUrl).host;
34
40
  try {
35
41
  const start = Date.now();
36
42
  const controller = new AbortController();
37
43
  const timer = setTimeout(() => controller.abort(), 5000);
38
- await fetch('https://api.pachca.com/api/shared/v1/profile', {
44
+ await fetch(`${baseUrl}/profile`, {
39
45
  method: 'HEAD',
40
46
  signal: controller.signal,
41
47
  });
42
48
  clearTimeout(timer);
43
49
  const latency = Date.now() - start;
44
- checks.push({ name: 'network', status: 'ok', host: 'api.pachca.com', latency_ms: latency });
45
- this.printCheck('ok', `Сеть api.pachca.com доступен (${latency}ms)`, format);
50
+ checks.push({ name: 'network', status: 'ok', host, latency_ms: latency });
51
+ this.printCheck('ok', `Сеть ${host} доступен (${latency}ms)`, format);
46
52
  networkOk = true;
47
53
  }
48
54
  catch {
49
- checks.push({ name: 'network', status: 'error', host: 'api.pachca.com' });
50
- this.printCheck('error', `Сеть api.pachca.com недоступен (таймаут 5s)`, format);
55
+ checks.push({ name: 'network', status: 'error', host });
56
+ this.printCheck('error', `Сеть ${host} недоступен (таймаут 5s)`, format);
51
57
  }
52
- // 3. Config file
58
+ // 3. Config file. File permissions only matter for profiles that keep the
59
+ // secret there — for a keyring profile the file holds no token at all, and
60
+ // reporting its mode as a security state would be misleading.
53
61
  const configPath = getConfigFilePath();
62
+ const activeName = getActiveProfile();
63
+ const activeProfile = activeName ? listProfiles()[activeName] : undefined;
64
+ const storage = activeProfile ? getSecretStorage(activeProfile) : 'file';
54
65
  if (fs.existsSync(configPath)) {
55
66
  const permissions = getConfigFilePermissions();
56
- if (permissions && Number.parseInt(permissions, 8) > 0o600) {
67
+ if (storage === 'file' && permissions && Number.parseInt(permissions, 8) > 0o600) {
57
68
  checks.push({ name: 'config', status: 'warning', path: configPath, permissions, expected: '600' });
58
69
  this.printCheck('warning', `Конфиг ${configPath} (права: ${permissions} — рекомендуется chmod 600)`, format);
59
70
  }
@@ -66,13 +77,48 @@ export default class Doctor extends BaseCommand {
66
77
  checks.push({ name: 'config', status: 'error', path: configPath });
67
78
  this.printCheck('error', `Конфиг файл не найден (запустите pachca auth login)`, format);
68
79
  }
80
+ // 3a. Secret store. A locked keyring is the most likely failure of the new
81
+ // login, and without this check it surfaces below as "token is invalid".
82
+ let secretReadable = true;
83
+ if (activeProfile && storage === 'keyring') {
84
+ const storeUp = isSecretStoreAvailable();
85
+ secretReadable = storeUp && !!getProfile(activeName)?.token;
86
+ if (secretReadable) {
87
+ checks.push({ name: 'secret_store', status: 'ok', storage });
88
+ this.printCheck('ok', `Хранилище хранилище ключей ОС, секрет читается`, format);
89
+ }
90
+ else {
91
+ checks.push({ name: 'secret_store', status: 'error', storage, available: storeUp });
92
+ this.printCheck('error', storeUp
93
+ ? `Хранилище секрет профиля не найден — войдите заново: pachca auth login`
94
+ : `Хранилище хранилище ключей ОС недоступно — разблокируйте связку ключей`, format);
95
+ }
96
+ }
97
+ else if (activeProfile) {
98
+ checks.push({ name: 'secret_store', status: 'ok', storage: 'file' });
99
+ this.printCheck('ok', `Хранилище файл конфигурации`, format);
100
+ }
69
101
  // 4. Profile
70
102
  const profileName = getActiveProfile();
71
103
  if (profileName) {
72
104
  const profile = getProfile(profileName);
73
105
  if (profile) {
74
- checks.push({ name: 'profile', status: 'ok', profile: profileName, type: profile.type, user: profile.user });
75
- this.printCheck('ok', `Профиль ${profileName} (${profile.type}: ${profile.user})`, format);
106
+ const method = getAuthMethod(profile);
107
+ const expiry = method === 'oauth' && profile.expires_at
108
+ ? `, истекает ${new Date(profile.expires_at).toLocaleString('ru-RU')}`
109
+ : method === 'token'
110
+ ? ', бессрочный'
111
+ : '';
112
+ checks.push({
113
+ name: 'profile',
114
+ status: 'ok',
115
+ profile: profileName,
116
+ type: profile.type,
117
+ user: profile.user,
118
+ auth: method,
119
+ storage,
120
+ });
121
+ this.printCheck('ok', `Профиль ${profileName} (${profile.type}: ${profile.user}) — ${method === 'oauth' ? 'вход через браузер' : 'готовый токен'}${expiry}`, format);
76
122
  profileOk = true;
77
123
  }
78
124
  else {
@@ -84,16 +130,22 @@ export default class Doctor extends BaseCommand {
84
130
  checks.push({ name: 'profile', status: 'error' });
85
131
  this.printCheck('error', `Профиль не настроен (запустите pachca auth login)`, format);
86
132
  }
87
- // 5. Token (skip if no network or no profile)
88
- if (!networkOk || !profileOk) {
89
- const reason = !networkOk ? 'нет сети' : 'нет профиля';
133
+ // 5. Token (skip if no network, no profile, or the secret is unreadable —
134
+ // asking the server with an empty Bearer would come back 401 and get
135
+ // reported as an invalid token, sending the user to log in for nothing)
136
+ if (!networkOk || !profileOk || !secretReadable) {
137
+ const reason = !networkOk ? 'нет сети' : !profileOk ? 'нет профиля' : 'секрет недоступен';
90
138
  checks.push({ name: 'token', status: 'skipped', reason });
91
139
  this.printCheck('skipped', `Токен пропущено (${reason})`, format);
92
140
  }
93
141
  else {
94
142
  const profile = getProfile(profileName);
143
+ // An expired token that still has its refresh token is not a broken login:
144
+ // the next command renews it on the init hook. Reporting it as invalid sent
145
+ // people to log in again for nothing.
146
+ const willRenew = isRefreshable(profile) && isExpiring(profile);
95
147
  try {
96
- const response = await fetch('https://api.pachca.com/api/shared/v1/oauth/token/info', {
148
+ const response = await fetch(`${baseUrl}/oauth/token/info`, {
97
149
  headers: { Authorization: `Bearer ${profile.token}` },
98
150
  });
99
151
  if (response.ok) {
@@ -102,6 +154,10 @@ export default class Doctor extends BaseCommand {
102
154
  checks.push({ name: 'token', status: 'ok', scopes_count: scopeCount });
103
155
  this.printCheck('ok', `Токен действителен (${scopeCount} скоупов)`, format);
104
156
  }
157
+ else if (willRenew) {
158
+ checks.push({ name: 'token', status: 'ok', renewable: true, http_status: response.status });
159
+ this.printCheck('ok', `Токен срок истёк — обновится при следующей команде`, format);
160
+ }
105
161
  else {
106
162
  checks.push({ name: 'token', status: 'error', http_status: response.status });
107
163
  this.printCheck('error', `Токен недействителен (${response.status}) — обновите: pachca auth login`, format);
@@ -48,6 +48,7 @@ export default class Introspect extends BaseCommand {
48
48
  }
49
49
  // D3 single source: baseFlagNames derived from BaseCommand.baseFlags
50
50
  const baseFlagNames = new Set(Object.keys(BaseCommand.baseFlags));
51
- this.output(normalizeCommand(commandId, cmdMeta, baseFlagNames));
51
+ const baseFlagDescriptions = new Map(Object.entries(BaseCommand.baseFlags).map(([name, def]) => [name, def.description ?? '']));
52
+ this.output(normalizeCommand(commandId, cmdMeta, baseFlagNames, baseFlagDescriptions));
52
53
  }
53
54
  }
@@ -18,7 +18,7 @@ export default class MessagesCreate extends BaseCommand {
18
18
  static flags = {
19
19
  ...BaseCommand.baseFlags,
20
20
  'entity-type': Flags.string({
21
- description: "Тип сущности",
21
+ description: "Тип сущности. Регистр значения не учитывается. Значение вне списка возвращает `400` с кодом `invalid`.",
22
22
  }),
23
23
  'entity-id': Flags.integer({
24
24
  description: "Идентификатор сущности (pachca chats list | pachca users list)",
@@ -24,7 +24,7 @@ export default class SecurityList extends BaseCommand {
24
24
  }),
25
25
  'event-key': Flags.string({
26
26
  description: "Фильтр по конкретному типу события",
27
- options: ["user_login", "user_logout", "user_2fa_fail", "user_2fa_success", "user_created", "user_deleted", "user_role_changed", "user_updated", "tag_created", "tag_deleted", "user_added_to_tag", "user_removed_from_tag", "chat_created", "chat_renamed", "chat_permission_changed", "user_chat_join", "user_chat_leave", "tag_added_to_chat", "tag_removed_from_chat", "message_updated", "message_deleted", "message_created", "reaction_created", "reaction_deleted", "thread_created", "access_token_created", "access_token_updated", "access_token_destroy", "kms_encrypt", "kms_decrypt", "audit_events_accessed", "dlp_violation_detected", "search_users_api", "search_chats_api", "search_messages_api", "bot_scopes_updated", "bot_webhook_settings_updated", "bot_token_recreated", "bot_deleted", "video_call_started", "video_call_finished", "video_call_recording_ready"],
27
+ options: ["user_login", "user_logout", "user_2fa_fail", "user_2fa_success", "user_created", "user_deleted", "user_role_changed", "user_updated", "tag_created", "tag_deleted", "user_added_to_tag", "user_removed_from_tag", "chat_created", "chat_renamed", "chat_permission_changed", "user_chat_join", "user_chat_leave", "tag_added_to_chat", "tag_removed_from_chat", "message_updated", "message_deleted", "message_created", "reaction_created", "reaction_deleted", "thread_created", "access_token_created", "access_token_updated", "access_token_destroy", "kms_encrypt", "kms_decrypt", "audit_events_accessed", "company_chats_accessed", "company_bots_accessed", "dlp_violation_detected", "search_users_api", "search_chats_api", "search_messages_api", "bot_scopes_updated", "bot_webhook_settings_updated", "bot_token_recreated", "bot_deleted", "bot_oauth_client_updated", "oauth_authorization_granted", "oauth_authorization_revoked", "video_call_started", "video_call_finished", "video_call_recording_ready"],
28
28
  }),
29
29
  'actor-id': Flags.string({
30
30
  description: "Идентификатор пользователя, выполнившего действие",