@pachca/cli 2026.7.2 → 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.
@@ -160,6 +160,10 @@
160
160
  {
161
161
  "description": "Если в ответе `meta.paginate.next_page` — пройди по страницам через `--cursor`",
162
162
  "command": "pachca threads list --last-message-at-after=2026-05-19T00:00:00.000Z --cursor=<next_page>"
163
+ },
164
+ {
165
+ "description": "Для каждого треда возьми `chat_id` и подтяни сообщения",
166
+ "command": "pachca messages list --chat-id=<thread.chat_id>"
163
167
  }
164
168
  ]
165
169
  },
@@ -366,7 +370,8 @@
366
370
  "steps": [
367
371
  {
368
372
  "description": "Получи чаты без активности с нужной даты",
369
- "command": "pachca chats list --last-message-at-before=<порог> --all"
373
+ "command": "pachca chats list --last-message-at-before=<порог> --all",
374
+ "notes": "Архивные чаты в выдачу не попадают, поэтому отсеивать уже заархивированные не нужно. Чтобы получить их вместе с активными, добавь `--archived` и смотри поле `archived` каждого чата"
370
375
  },
371
376
  {
372
377
  "description": "Для каждого чата: архивируй",
package/dist/help.js ADDED
@@ -0,0 +1,21 @@
1
+ import { Help } from '@oclif/core';
2
+ /**
3
+ * Root help with a footer pointing at the web documentation.
4
+ *
5
+ * Terminal help is necessarily terse — it lists commands and flags, not
6
+ * scenarios. Somebody who did not find what they needed has nowhere to go from
7
+ * here unless we say where the long-form version lives.
8
+ *
9
+ * The footer goes after the command list, not before it: the end of the output
10
+ * is where the eye rests, and it is the last thing left on screen after a long
11
+ * list scrolls past.
12
+ */
13
+ export default class PachcaHelp extends Help {
14
+ async showRootHelp() {
15
+ await super.showRootHelp();
16
+ this.log('ДОКУМЕНТАЦИЯ');
17
+ this.log(' Руководства и справочник API https://dev.pachca.com');
18
+ this.log(' Сценарии в терминале pachca guide');
19
+ this.log('');
20
+ }
21
+ }
@@ -34,7 +34,7 @@ const hook = async function (opts) {
34
34
  outputError(errorObj, 'json');
35
35
  }
36
36
  else {
37
- process.stderr.write(`✗ Команда не найдена: ${commandId.replace(/:/g, ' ')}\n`);
37
+ process.stderr.write(`✗ Команда не найдена: ${commandId.replace(/:/g, ' ')}.\n`);
38
38
  if (suggestions.length > 0) {
39
39
  process.stderr.write(`\n Возможно, вы имели в виду:\n`);
40
40
  for (const s of suggestions) {
@@ -5,6 +5,7 @@ import { spawn } from 'node:child_process';
5
5
  import semver from 'semver';
6
6
  import ansis from 'ansis';
7
7
  import { getDefaults, resolveToken, TokenNotFoundError, ProfileNotFoundError, } from '../profiles.js';
8
+ import { ensureFreshToken, isSessionExpired } from '../token-refresh.js';
8
9
  import { outputError } from '../output.js';
9
10
  import { defaultOutputFormat, isInteractive } from '../utils.js';
10
11
  /**
@@ -127,23 +128,51 @@ const hook = async function (opts) {
127
128
  const argvToken = tokenIdx !== -1 ? process.argv[tokenIdx + 1] : undefined;
128
129
  const profileIdx = process.argv.indexOf('--profile');
129
130
  const argvProfile = profileIdx !== -1 ? process.argv[profileIdx + 1] : undefined;
130
- // Verify auth exists
131
+ // Verify auth exists, and renew an OAuth token that is about to run out.
132
+ // An OAuth session lives an hour, so without this a long agent run would lose
133
+ // access halfway through.
131
134
  try {
132
135
  const { profileName, profile } = resolveToken({ token: argvToken, profile: argvProfile });
136
+ if (profileName && profile) {
137
+ try {
138
+ await ensureFreshToken(profileName, profile);
139
+ }
140
+ catch (error) {
141
+ // A network hiccup during refresh is not worth blocking on: the command
142
+ // runs with the current token and reports the real API error itself.
143
+ if (!isSessionExpired(error))
144
+ return;
145
+ const format = resolveOutputFromArgv();
146
+ if (format === 'json' || !process.stderr.isTTY) {
147
+ outputError({
148
+ error: 'Session expired',
149
+ type: 'PACHCA_AUTH_ERROR',
150
+ code: null,
151
+ hint: 'pachca auth login',
152
+ }, format);
153
+ }
154
+ else {
155
+ process.stderr.write(`✗ Срок входа истёк. Войдите заново:\n\n`);
156
+ process.stderr.write(` pachca auth login\n`);
157
+ }
158
+ process.exit(3);
159
+ }
160
+ }
133
161
  }
134
162
  catch (error) {
135
163
  if (error instanceof TokenNotFoundError) {
136
164
  const format = resolveOutputFromArgv();
137
165
  if (format === 'json' || !process.stderr.isTTY) {
138
- outputError({ error: 'Token not found', type: 'PACHCA_AUTH_ERROR', code: null, hint: 'pachca auth login --token <your-token>' }, format);
166
+ outputError({ error: 'Token not found', type: 'PACHCA_AUTH_ERROR', code: null, hint: 'pachca auth login, or set PACHCA_TOKEN' }, format);
139
167
  }
140
168
  else {
141
169
  process.stderr.write(`✗ Токен не найден. Войдите в аккаунт:\n\n`);
142
- process.stderr.write(` Интерактивно (человек):\n`);
170
+ process.stderr.write(` Вход через браузер:\n`);
143
171
  process.stderr.write(` pachca auth login\n\n`);
144
- process.stderr.write(` Неинтерактивно (агент, CI):\n`);
145
- process.stderr.write(` pachca auth login --token <ваш токен>\n\n`);
146
- process.stderr.write(` Получить токен: https://dev.pachca.com/guides/authorization\n`);
172
+ process.stderr.write(` Готовым токеном (агент, CI):\n`);
173
+ process.stderr.write(` pachca auth login --token <ваш токен>\n`);
174
+ process.stderr.write(` либо переменная окружения PACHCA_TOKEN — без входа вообще\n\n`);
175
+ process.stderr.write(` Получить токен: https://dev.pachca.com/api/authorization\n`);
147
176
  }
148
177
  process.exit(3);
149
178
  }
@@ -18,12 +18,23 @@ export function listCommandEntries(commands) {
18
18
  }));
19
19
  }
20
20
  /** `pachca introspect <command>` — full metadata for one command. */
21
- export function normalizeCommand(commandId, cmdMeta, baseFlagNames) {
21
+ export function normalizeCommand(commandId, cmdMeta, baseFlagNames, baseFlagDescriptions) {
22
22
  const requiredFlagNames = new Set(cmdMeta.requiredFlags || []);
23
+ // Global flags are documented once in their own table, so they are dropped
24
+ // here. But a command may redefine one with a different meaning — `auth login`
25
+ // turns `--token` into "log in with this ready token" and `--profile` into
26
+ // "name of the profile to create". Those belong on the command: keep a
27
+ // base-named flag whenever its description differs from the global one.
28
+ const overridesBaseFlag = (name, meta) => {
29
+ const baseDescription = baseFlagDescriptions?.get(name);
30
+ if (baseDescription === undefined)
31
+ return false;
32
+ return (meta.description || '') !== baseDescription;
33
+ };
23
34
  const flagsMeta = cmdMeta.flags;
24
35
  const flags = flagsMeta
25
36
  ? Object.entries(flagsMeta)
26
- .filter(([name]) => !baseFlagNames.has(name))
37
+ .filter(([name, meta]) => !baseFlagNames.has(name) || overridesBaseFlag(name, meta))
27
38
  .map(([name, meta]) => ({
28
39
  name,
29
40
  type: (meta.type || 'string'),
@@ -57,7 +68,7 @@ export function normalizeCommand(commandId, cmdMeta, baseFlagNames) {
57
68
  * e.g. "messages:create" → "messages"; "doctor" → "doctor"). Used by the
58
69
  * build-time emitter for the docs Commands page.
59
70
  */
60
- export function groupCommandsBySection(commands, baseFlagNames) {
71
+ export function groupCommandsBySection(commands, baseFlagNames, baseFlagDescriptions) {
61
72
  // Collect backward-compat hidden aliases (old command ids kept working after an IA rename).
62
73
  // oclif registers them as resolvable command entries, but they must NOT appear in docs/help —
63
74
  // only the new canonical command names are shown.
@@ -71,7 +82,7 @@ export function groupCommandsBySection(commands, baseFlagNames) {
71
82
  if (hiddenAliasIds.has(id))
72
83
  continue; // hidden backward-compat alias — keep it out of docs/help
73
84
  const section = id.includes(':') ? id.split(':')[0] : id;
74
- const normalized = normalizeCommand(id, meta, baseFlagNames);
85
+ const normalized = normalizeCommand(id, meta, baseFlagNames, baseFlagDescriptions);
75
86
  if (!bySection.has(section))
76
87
  bySection.set(section, []);
77
88
  bySection.get(section).push(normalized);
package/dist/oauth.js ADDED
@@ -0,0 +1,231 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { getBaseUrl } from './client.js';
3
+ /** Uses the global timer on purpose: `node:timers/promises` is not fakeable in tests. */
4
+ function delay(ms) {
5
+ return new Promise((resolve) => {
6
+ setTimeout(resolve, ms);
7
+ });
8
+ }
9
+ /**
10
+ * OAuth device authorization grant (RFC 8628) against Pachca.
11
+ *
12
+ * The CLI is a public client: it ships no secret and identifies itself with a
13
+ * fixed `client_id`. Protection comes from the code never leaving the user's
14
+ * own screen — see `printDeviceInstructions` in `commands/auth/login.ts`.
15
+ */
16
+ export const CLI_CLIENT_ID = 'pachca_cli';
17
+ const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
18
+ /** Backend enforces a floor of 5 seconds between polls. */
19
+ const MIN_POLL_INTERVAL_SECONDS = 5;
20
+ /** RFC 8628: on `slow_down` the client raises its interval by 5 seconds. */
21
+ const SLOW_DOWN_STEP_SECONDS = 5;
22
+ export class OAuthError extends Error {
23
+ code;
24
+ description;
25
+ status;
26
+ constructor(code, description, status) {
27
+ super(description || code);
28
+ this.code = code;
29
+ this.description = description;
30
+ this.status = status;
31
+ this.name = 'OAuthError';
32
+ }
33
+ }
34
+ function oauthUrl(endpoint) {
35
+ return `${getBaseUrl()}/oauth/${endpoint}`;
36
+ }
37
+ async function postForm(endpoint, params) {
38
+ let response;
39
+ try {
40
+ response = await fetch(oauthUrl(endpoint), {
41
+ method: 'POST',
42
+ headers: {
43
+ 'Content-Type': 'application/x-www-form-urlencoded',
44
+ Accept: 'application/json',
45
+ },
46
+ body: new URLSearchParams(params).toString(),
47
+ });
48
+ }
49
+ catch (error) {
50
+ throw new OAuthError('network_error', error.message);
51
+ }
52
+ // Reading the body is a second trip over the network and fails on its own when
53
+ // the connection drops mid-response. Outside this guard it surfaced as a raw
54
+ // TypeError and killed a login that only needed another poll.
55
+ let text;
56
+ try {
57
+ text = await response.text();
58
+ }
59
+ catch (error) {
60
+ throw new OAuthError('network_error', error.message);
61
+ }
62
+ let body = null;
63
+ if (text) {
64
+ try {
65
+ body = JSON.parse(text);
66
+ }
67
+ catch {
68
+ body = null;
69
+ }
70
+ }
71
+ if (!response.ok) {
72
+ const record = (body ?? {});
73
+ const code = typeof record.error === 'string' ? record.error : `http_${response.status}`;
74
+ const description = typeof record.error_description === 'string' ? record.error_description : undefined;
75
+ throw new OAuthError(code, description, response.status);
76
+ }
77
+ return body;
78
+ }
79
+ /** Step 1 — ask the server for a device code and the code the user types in. */
80
+ export async function requestDeviceCode() {
81
+ const body = (await postForm('device_authorization', { client_id: CLI_CLIENT_ID }));
82
+ if (!body?.device_code || !body.user_code || !body.verification_uri) {
83
+ throw new OAuthError('invalid_response', 'Сервер вернул неполный ответ на запрос кода');
84
+ }
85
+ return {
86
+ device_code: body.device_code,
87
+ user_code: body.user_code,
88
+ verification_uri: body.verification_uri,
89
+ expires_in: body.expires_in ?? 600,
90
+ interval: Math.max(body.interval ?? MIN_POLL_INTERVAL_SECONDS, MIN_POLL_INTERVAL_SECONDS),
91
+ };
92
+ }
93
+ async function exchangeDeviceCode(deviceCode) {
94
+ const body = (await postForm('token', {
95
+ grant_type: DEVICE_CODE_GRANT,
96
+ client_id: CLI_CLIENT_ID,
97
+ device_code: deviceCode,
98
+ }));
99
+ if (!body?.access_token) {
100
+ throw new OAuthError('invalid_response', 'Сервер вернул ответ без токена');
101
+ }
102
+ return body;
103
+ }
104
+ /**
105
+ * Step 2 — poll until the user confirms in the browser.
106
+ *
107
+ * `slow_down` and a throttled `429` both mean the same thing to us: back off and
108
+ * keep waiting. Everything else (`expired_token`, `access_denied`) ends the wait.
109
+ */
110
+ export async function pollForToken(grant) {
111
+ let interval = grant.interval;
112
+ const deadline = Date.now() + grant.expires_in * 1000;
113
+ while (Date.now() < deadline) {
114
+ await delay(interval * 1000);
115
+ try {
116
+ return await exchangeDeviceCode(grant.device_code);
117
+ }
118
+ catch (error) {
119
+ if (!(error instanceof OAuthError))
120
+ throw error;
121
+ if (error.code === 'authorization_pending')
122
+ continue;
123
+ if (error.code === 'slow_down' || error.status === 429) {
124
+ interval += SLOW_DOWN_STEP_SECONDS;
125
+ continue;
126
+ }
127
+ // A blip on one poll is not a failed login: the person is still in the
128
+ // browser and there are minutes of deadline left. Give up only when the
129
+ // code actually expires.
130
+ if (error.code === 'network_error')
131
+ continue;
132
+ throw error;
133
+ }
134
+ }
135
+ throw new OAuthError('expired_token', 'Код подтверждения истёк');
136
+ }
137
+ /**
138
+ * Put out an access token server-side (RFC 7009).
139
+ *
140
+ * The endpoint only accepts self-revocation: the same token must arrive both in
141
+ * the body and as the bearer, and it must belong to the CLI's public client.
142
+ * Returns false when the server declined, so the caller can say plainly that the
143
+ * token is gone from this machine but still alive.
144
+ */
145
+ export async function revokeToken(token) {
146
+ try {
147
+ const response = await fetch(oauthUrl('revoke'), {
148
+ method: 'POST',
149
+ headers: {
150
+ 'Content-Type': 'application/x-www-form-urlencoded',
151
+ Accept: 'application/json',
152
+ Authorization: `Bearer ${token}`,
153
+ },
154
+ body: new URLSearchParams({ token }).toString(),
155
+ });
156
+ return response.ok;
157
+ }
158
+ catch {
159
+ // Offline logout is a normal thing to do. The profile still goes away.
160
+ return false;
161
+ }
162
+ }
163
+ /** Exchange a stored refresh token for a fresh access token. */
164
+ export async function refreshAccessToken(refreshToken) {
165
+ const body = (await postForm('token', {
166
+ grant_type: 'refresh_token',
167
+ client_id: CLI_CLIENT_ID,
168
+ refresh_token: refreshToken,
169
+ }));
170
+ if (!body?.access_token) {
171
+ throw new OAuthError('invalid_response', 'Сервер вернул ответ без токена');
172
+ }
173
+ return body;
174
+ }
175
+ /**
176
+ * Copy the confirmation code to the clipboard. Best effort: no clipboard tool is
177
+ * a normal state on a server, and the code is printed anyway.
178
+ */
179
+ export function copyToClipboard(text) {
180
+ const candidates = process.platform === 'darwin'
181
+ ? [['pbcopy', []]]
182
+ : process.platform === 'win32'
183
+ ? [['clip', []]]
184
+ : [
185
+ ['wl-copy', []],
186
+ ['xclip', ['-selection', 'clipboard']],
187
+ ['xsel', ['--clipboard', '--input']],
188
+ ];
189
+ for (const [command, args] of candidates) {
190
+ try {
191
+ const result = spawnSync(command, args, { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
192
+ if (!result.error && result.status === 0)
193
+ return true;
194
+ }
195
+ catch {
196
+ // try the next tool
197
+ }
198
+ }
199
+ return false;
200
+ }
201
+ /**
202
+ * Open the confirmation page. Best effort — the URL is printed either way, and a
203
+ * headless machine has nothing to open it with.
204
+ */
205
+ export function openBrowser(url) {
206
+ let parsed;
207
+ try {
208
+ parsed = new URL(url);
209
+ }
210
+ catch {
211
+ return false;
212
+ }
213
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
214
+ return false;
215
+ const [command, args] = process.platform === 'darwin'
216
+ ? ['open', [url]]
217
+ : process.platform === 'win32'
218
+ ? ['cmd', ['/c', 'start', '', url]]
219
+ : ['xdg-open', [url]];
220
+ try {
221
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
222
+ child.on('error', () => {
223
+ // no browser on this machine — the printed URL is the fallback
224
+ });
225
+ child.unref();
226
+ return true;
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ }
package/dist/profiles.js CHANGED
@@ -2,6 +2,32 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
4
  import * as TOML from 'smol-toml';
5
+ import { deleteSecret, readSecret, writeSecret } from './secret-store.js';
6
+ export function getSecretStorage(profile) {
7
+ return profile.storage === 'keyring' ? 'keyring' : 'file';
8
+ }
9
+ export function getAuthMethod(profile) {
10
+ return profile.auth === 'oauth' ? 'oauth' : 'token';
11
+ }
12
+ /** True when the profile holds a refreshable OAuth session. */
13
+ export function isRefreshable(profile) {
14
+ return getAuthMethod(profile) === 'oauth' && !!profile.refresh_token;
15
+ }
16
+ /**
17
+ * True when the access token is gone or about to be. The skew keeps a command
18
+ * from starting with a token that expires while the request is in flight.
19
+ */
20
+ export function isExpiring(profile, skewSeconds = 120) {
21
+ if (!profile.expires_at)
22
+ return false;
23
+ const expiresAt = Date.parse(profile.expires_at);
24
+ if (Number.isNaN(expiresAt))
25
+ return false;
26
+ return expiresAt - skewSeconds * 1000 <= Date.now();
27
+ }
28
+ export function expiresAtFromNow(expiresInSeconds) {
29
+ return new Date(Date.now() + expiresInSeconds * 1000).toISOString();
30
+ }
5
31
  const CONFIG_FILENAME = 'config.toml';
6
32
  function getConfigDir() {
7
33
  // C8 — explicit override (highest priority). Guarded: when PACHCA_HOME is
@@ -60,20 +86,106 @@ export function setActiveProfile(name) {
60
86
  config.active_profile = name;
61
87
  writeConfig(config);
62
88
  }
89
+ /**
90
+ * Put the secret back on a profile read from the config file.
91
+ *
92
+ * A keyring-backed profile carries no token on disk, so every read has to go to
93
+ * the OS store. When the store cannot be read — locked keyring, moved config,
94
+ * removed entry — the token stays empty and `resolveToken` turns that into a
95
+ * message the user can act on, rather than an opaque 401 later.
96
+ */
97
+ function hydrate(name, profile) {
98
+ if (getSecretStorage(profile) !== 'keyring')
99
+ return profile;
100
+ const secret = readSecret(name);
101
+ return {
102
+ ...profile,
103
+ token: secret?.token ?? '',
104
+ ...(secret?.refresh_token ? { refresh_token: secret.refresh_token } : {}),
105
+ };
106
+ }
63
107
  export function getProfile(name) {
64
- const config = readConfig();
65
- return config.profiles?.[name];
108
+ const stored = readConfig().profiles?.[name];
109
+ return stored ? hydrate(name, stored) : undefined;
66
110
  }
111
+ /**
112
+ * Write a profile, keeping the secret out of the config file when the profile
113
+ * asked for the OS store.
114
+ *
115
+ * A failed keyring write silently downgrades to the file: losing the session
116
+ * because the store went away between login and now would be worse than storing
117
+ * a token the way every previous version stored it.
118
+ */
67
119
  export function setProfile(name, profile) {
68
120
  const config = readConfig();
69
121
  if (!config.profiles) {
70
122
  config.profiles = {};
71
123
  }
124
+ if (getSecretStorage(profile) === 'keyring') {
125
+ const stored = writeSecret(name, {
126
+ token: profile.token,
127
+ ...(profile.refresh_token ? { refresh_token: profile.refresh_token } : {}),
128
+ });
129
+ if (stored) {
130
+ const { token: _token, refresh_token: _refresh, ...rest } = profile;
131
+ config.profiles[name] = { ...rest, token: '', storage: 'keyring' };
132
+ writeConfig(config);
133
+ return;
134
+ }
135
+ config.profiles[name] = { ...profile, storage: 'file' };
136
+ writeConfig(config);
137
+ return;
138
+ }
139
+ // Moving the other way (keyring → file) must not leave the old secret behind.
140
+ const previous = config.profiles[name];
141
+ if (previous && getSecretStorage(previous) === 'keyring') {
142
+ deleteSecret(name);
143
+ }
72
144
  config.profiles[name] = profile;
73
145
  writeConfig(config);
74
146
  }
147
+ /**
148
+ * Overwrite just the token fields, re-reading the config first: a parallel CLI
149
+ * process may have written a different profile in the meantime, and a blind
150
+ * `setProfile` of a stale snapshot would drop its work.
151
+ */
152
+ export function updateProfileTokens(name, tokens) {
153
+ const config = readConfig();
154
+ const profile = config.profiles?.[name];
155
+ if (!profile)
156
+ return undefined;
157
+ profile.auth = 'oauth';
158
+ profile.token = tokens.token;
159
+ profile.refresh_token = tokens.refresh_token ?? profile.refresh_token;
160
+ profile.expires_at = tokens.expires_at ?? profile.expires_at;
161
+ if (tokens.scopes)
162
+ profile.scopes = tokens.scopes;
163
+ // The refreshed pair goes wherever the profile already keeps its secrets, so a
164
+ // keyring-backed session does not quietly start writing tokens to disk an hour
165
+ // after login.
166
+ if (getSecretStorage(profile) === 'keyring') {
167
+ const refreshed = { ...profile };
168
+ const stored = writeSecret(name, {
169
+ token: profile.token,
170
+ ...(profile.refresh_token ? { refresh_token: profile.refresh_token } : {}),
171
+ });
172
+ if (stored) {
173
+ profile.token = '';
174
+ delete profile.refresh_token;
175
+ writeConfig(config);
176
+ return refreshed;
177
+ }
178
+ profile.storage = 'file';
179
+ }
180
+ writeConfig(config);
181
+ return profile;
182
+ }
75
183
  export function deleteProfile(name) {
76
184
  const config = readConfig();
185
+ const stored = config.profiles?.[name];
186
+ if (stored && getSecretStorage(stored) === 'keyring') {
187
+ deleteSecret(name);
188
+ }
77
189
  if (config.profiles) {
78
190
  delete config.profiles[name];
79
191
  }
@@ -167,11 +279,31 @@ export function resolveToken(flags) {
167
279
  if (!profile) {
168
280
  throw new ProfileNotFoundError(profileName);
169
281
  }
282
+ if (!profile.token) {
283
+ throw new SecretStoreUnavailableError(profileName);
284
+ }
170
285
  return { token: profile.token, profileName, profile };
171
286
  }
287
+ /**
288
+ * Refresh just the cached scope list from the server's answer.
289
+ *
290
+ * Deliberately does not touch the secret: the scopes live in the config file for
291
+ * every profile, keyring-backed or not, so there is nothing to write to the store.
292
+ */
293
+ export function updateProfileScopes(name, scopes) {
294
+ const config = readConfig();
295
+ const profile = config.profiles?.[name];
296
+ if (!profile)
297
+ return;
298
+ profile.scopes = scopes;
299
+ writeConfig(config);
300
+ }
172
301
  export function getConfigFilePath() {
173
302
  return getConfigPath();
174
303
  }
304
+ export function getConfigDirPath() {
305
+ return getConfigDir();
306
+ }
175
307
  export function getConfigFilePermissions() {
176
308
  const configPath = getConfigPath();
177
309
  if (!fs.existsSync(configPath))
@@ -195,3 +327,17 @@ export class ProfileNotFoundError extends Error {
195
327
  this.name = 'ProfileNotFoundError';
196
328
  }
197
329
  }
330
+ /**
331
+ * The profile exists and points at the OS store, but the secret is not there.
332
+ * Usually a locked keyring or a config copied to another machine — both fixed by
333
+ * logging in again, which is what the message says.
334
+ */
335
+ export class SecretStoreUnavailableError extends Error {
336
+ profileName;
337
+ constructor(profileName) {
338
+ super(`Token for profile "${profileName}" is stored in the OS credential store and cannot be read. ` +
339
+ `Unlock the store, or run: pachca auth login --profile ${profileName}`);
340
+ this.profileName = profileName;
341
+ this.name = 'SecretStoreUnavailableError';
342
+ }
343
+ }