@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.
@@ -0,0 +1,129 @@
1
+ import { createRequire } from 'node:module';
2
+ /**
3
+ * Secrets in the OS credential store: Keychain on macOS, Secret Service on
4
+ * Linux, Credential Manager on Windows.
5
+ *
6
+ * Why bother when the config file is already `chmod 600`: those permissions stop
7
+ * *other users* of the machine, not code running as you — and code running as
8
+ * you is the normal case now (postinstall scripts, editor extensions, agents).
9
+ * The npm worm of 2025 swept developer machines with TruffleHog looking for
10
+ * exactly this kind of file. The OS store is not a silver bullet either, but it
11
+ * takes the secret out of a plain file that anything can read by walking $HOME.
12
+ *
13
+ * The native module is optional on purpose. Headless Linux, containers and
14
+ * minimal images often have no Secret Service at all, and a CLI that refuses to
15
+ * log in there would be worse than one that stores a file. Every entry point
16
+ * below degrades to `null`/`false` instead of throwing, and the caller falls
17
+ * back to the config file and says so.
18
+ */
19
+ const SERVICE = 'pachca-cli';
20
+ // `undefined` = not attempted yet, `null` = attempted and unavailable.
21
+ let keyringModule;
22
+ /**
23
+ * Opt out of the OS store entirely. `--insecure-storage` covers the login, but
24
+ * an environment variable is what agents, containers and CI can actually set —
25
+ * and on a headless box every keyring call is a failed round trip.
26
+ */
27
+ function disabledByEnv() {
28
+ return process.env.PACHCA_SECRET_STORE === 'file';
29
+ }
30
+ function loadKeyring() {
31
+ if (disabledByEnv())
32
+ return null;
33
+ if (keyringModule !== undefined)
34
+ return keyringModule;
35
+ // The package is CJS and optional; a static import would make the whole CLI
36
+ // fail to load on a platform without a prebuilt binary.
37
+ try {
38
+ const require = createRequire(import.meta.url);
39
+ keyringModule = require('@napi-rs/keyring');
40
+ }
41
+ catch {
42
+ keyringModule = null;
43
+ }
44
+ return keyringModule;
45
+ }
46
+ /**
47
+ * Install a stand-in for the native module.
48
+ *
49
+ * Tests need this: the module is pulled in with `createRequire`, which module
50
+ * mocking cannot intercept, so without a seam a test run would read and write
51
+ * the developer's real Keychain.
52
+ */
53
+ export function setKeyringForTests(stub) {
54
+ keyringModule = stub;
55
+ }
56
+ function entryFor(profileName) {
57
+ const keyring = loadKeyring();
58
+ if (!keyring)
59
+ return null;
60
+ try {
61
+ return new keyring.Entry(SERVICE, profileName);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Whether secrets can actually be stored — checked by writing and removing a
69
+ * probe entry, not by loading the module.
70
+ *
71
+ * Loading proves nothing: on Linux the binary loads fine and every call then
72
+ * fails because no Secret Service is running. Finding that out at login time is
73
+ * the point — a profile marked `keyring` that cannot be read back would lock the
74
+ * user out of their own session.
75
+ */
76
+ export function isSecretStoreAvailable() {
77
+ const entry = entryFor('__pachca_probe__');
78
+ if (!entry)
79
+ return false;
80
+ try {
81
+ entry.setPassword('probe');
82
+ const readBack = entry.getPassword();
83
+ entry.deleteCredential();
84
+ return readBack === 'probe';
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ export function readSecret(profileName) {
91
+ const entry = entryFor(profileName);
92
+ if (!entry)
93
+ return null;
94
+ try {
95
+ const raw = entry.getPassword();
96
+ if (!raw)
97
+ return null;
98
+ const parsed = JSON.parse(raw);
99
+ return typeof parsed?.token === 'string' ? parsed : null;
100
+ }
101
+ catch {
102
+ // Missing entry, locked keyring, or something else wrote a value we cannot
103
+ // parse. All three mean "no usable secret here".
104
+ return null;
105
+ }
106
+ }
107
+ export function writeSecret(profileName, secret) {
108
+ const entry = entryFor(profileName);
109
+ if (!entry)
110
+ return false;
111
+ try {
112
+ entry.setPassword(JSON.stringify(secret));
113
+ return true;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ export function deleteSecret(profileName) {
120
+ const entry = entryFor(profileName);
121
+ if (!entry)
122
+ return;
123
+ try {
124
+ entry.deleteCredential();
125
+ }
126
+ catch {
127
+ // Nothing stored, or the store is gone. Either way there is nothing to undo.
128
+ }
129
+ }
@@ -0,0 +1,136 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { getConfigDirPath, getProfile, isExpiring, isRefreshable, updateProfileTokens, expiresAtFromNow, } from './profiles.js';
4
+ import { OAuthError, refreshAccessToken } from './oauth.js';
5
+ const LOCK_FILENAME = 'refresh.lock';
6
+ /** A lock older than this belonged to a process that died mid-refresh. */
7
+ const LOCK_STALE_MS = 30_000;
8
+ /** How long a waiting process gives the lock holder before refreshing itself. */
9
+ const LOCK_WAIT_MS = 20_000;
10
+ const LOCK_POLL_MS = 150;
11
+ function sleep(ms) {
12
+ return new Promise((resolve) => {
13
+ setTimeout(resolve, ms);
14
+ });
15
+ }
16
+ function lockPath() {
17
+ return path.join(getConfigDirPath(), LOCK_FILENAME);
18
+ }
19
+ function acquireLock() {
20
+ const file = lockPath();
21
+ try {
22
+ fs.mkdirSync(path.dirname(file), { recursive: true });
23
+ return fs.openSync(file, 'wx');
24
+ }
25
+ catch (error) {
26
+ if (error.code !== 'EEXIST')
27
+ throw error;
28
+ // Someone holds it — unless they died and left the file behind.
29
+ try {
30
+ const age = Date.now() - fs.statSync(file).mtimeMs;
31
+ if (age > LOCK_STALE_MS) {
32
+ fs.unlinkSync(file);
33
+ return fs.openSync(file, 'wx');
34
+ }
35
+ }
36
+ catch {
37
+ // lost the race to another process; fall through to waiting
38
+ }
39
+ return null;
40
+ }
41
+ }
42
+ function releaseLock(fd) {
43
+ try {
44
+ fs.closeSync(fd);
45
+ }
46
+ catch {
47
+ // already closed
48
+ }
49
+ try {
50
+ fs.unlinkSync(lockPath());
51
+ }
52
+ catch {
53
+ // already removed
54
+ }
55
+ }
56
+ /**
57
+ * Refresh the profile's access token when it is about to expire.
58
+ *
59
+ * OAuth tokens live an hour, so a long agent session would otherwise lose access
60
+ * mid-run. Agents also run several CLI processes at once: without the lock they
61
+ * would all refresh with the same refresh token and invalidate each other, and
62
+ * the user would be logged out. One process refreshes, the rest wait and pick up
63
+ * the result from the config file.
64
+ *
65
+ * Returns the profile to use. Throws `OAuthError` only when the session is truly
66
+ * dead and the user has to log in again.
67
+ */
68
+ export async function ensureFreshToken(profileName, profile) {
69
+ if (!isRefreshable(profile) || !isExpiring(profile))
70
+ return profile;
71
+ const fd = acquireLock();
72
+ if (fd === null) {
73
+ const waited = await waitForOtherProcess(profileName);
74
+ if (waited)
75
+ return waited;
76
+ // The holder gave up or is stuck — refresh ourselves rather than fail.
77
+ return refreshAndStore(profileName, profile);
78
+ }
79
+ try {
80
+ // Re-read: the previous holder may have refreshed while we queued for the lock.
81
+ const current = getProfile(profileName) ?? profile;
82
+ if (!isExpiring(current))
83
+ return current;
84
+ return await refreshAndStore(profileName, current);
85
+ }
86
+ finally {
87
+ releaseLock(fd);
88
+ }
89
+ }
90
+ async function waitForOtherProcess(profileName) {
91
+ const deadline = Date.now() + LOCK_WAIT_MS;
92
+ while (Date.now() < deadline) {
93
+ await sleep(LOCK_POLL_MS);
94
+ const current = getProfile(profileName);
95
+ if (current && !isExpiring(current))
96
+ return current;
97
+ if (!fs.existsSync(lockPath())) {
98
+ // The holder finished between our read of the profile and this check. Look
99
+ // once more before refreshing ourselves: their new token is already on disk,
100
+ // and ours has just been spent.
101
+ const after = getProfile(profileName);
102
+ return after && !isExpiring(after) ? after : undefined;
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+ async function refreshAndStore(profileName, profile) {
108
+ let grant;
109
+ try {
110
+ grant = await refreshAccessToken(profile.refresh_token);
111
+ }
112
+ catch (error) {
113
+ // A refresh token is single-use — the server revokes it the moment someone
114
+ // redeems it. So `invalid_grant` usually means a parallel process got there
115
+ // first, not that the session died: the config already holds a working token.
116
+ // Without this check a lost race logs the user out for no reason.
117
+ if (error instanceof OAuthError && error.code === 'invalid_grant') {
118
+ const current = getProfile(profileName);
119
+ if (current?.token && !isExpiring(current))
120
+ return current;
121
+ }
122
+ throw error;
123
+ }
124
+ const updated = updateProfileTokens(profileName, {
125
+ token: grant.access_token,
126
+ refresh_token: grant.refresh_token,
127
+ expires_at: grant.expires_in ? expiresAtFromNow(grant.expires_in) : undefined,
128
+ scopes: grant.scope ? grant.scope.split(' ').filter(Boolean) : undefined,
129
+ });
130
+ return updated ?? { ...profile, token: grant.access_token };
131
+ }
132
+ /** True when the failure means the session is gone for good, not a hiccup. */
133
+ export function isSessionExpired(error) {
134
+ return (error instanceof OAuthError &&
135
+ ['invalid_grant', 'invalid_request', 'invalid_client', 'unauthorized_client'].includes(error.code));
136
+ }