@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.
- package/CHANGELOG.md +40 -13
- package/dist/base-command.js +63 -6
- package/dist/client.js +21 -3
- package/dist/commands/auth/list.js +3 -1
- package/dist/commands/auth/login.js +184 -63
- package/dist/commands/auth/logout.js +38 -4
- package/dist/commands/auth/status.js +90 -3
- package/dist/commands/auth/switch.js +18 -4
- package/dist/commands/chats/list.js +7 -0
- package/dist/commands/doctor.js +71 -15
- package/dist/commands/introspect.js +2 -1
- package/dist/commands/messages/create.js +1 -1
- package/dist/commands/messages/list.js +2 -1
- package/dist/commands/security/list.js +1 -1
- package/dist/commands/upgrade.js +1 -1
- package/dist/data/changelog.json +115 -0
- package/dist/data/endpoints.json +1 -1
- package/dist/data/workflows.json +6 -1
- package/dist/help.js +21 -0
- package/dist/hooks/command-not-found.js +1 -1
- package/dist/hooks/init.js +35 -6
- package/dist/lib/manifest.js +15 -4
- package/dist/oauth.js +231 -0
- package/dist/profiles.js +148 -2
- package/dist/secret-store.js +129 -0
- package/dist/token-refresh.js +136 -0
- package/oclif.manifest.json +1 -1
- package/package.json +8 -3
package/dist/data/workflows.json
CHANGED
|
@@ -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, ' ')}
|
|
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) {
|
package/dist/hooks/init.js
CHANGED
|
@@ -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
|
|
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(`
|
|
170
|
+
process.stderr.write(` Вход через браузер:\n`);
|
|
143
171
|
process.stderr.write(` pachca auth login\n\n`);
|
|
144
|
-
process.stderr.write(`
|
|
145
|
-
process.stderr.write(` pachca auth login --token <ваш токен>\n
|
|
146
|
-
process.stderr.write(`
|
|
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
|
}
|
package/dist/lib/manifest.js
CHANGED
|
@@ -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
|
|
65
|
-
return
|
|
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
|
+
}
|