claude-nonstop 0.3.0
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/.env.example +33 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/assets/icon.jpeg +0 -0
- package/assets/screenshot.png +0 -0
- package/bin/claude-nonstop.js +1679 -0
- package/lib/config.js +163 -0
- package/lib/keychain.js +397 -0
- package/lib/platform.js +9 -0
- package/lib/reauth.js +147 -0
- package/lib/runner.js +566 -0
- package/lib/scorer.js +100 -0
- package/lib/service.js +196 -0
- package/lib/session.js +294 -0
- package/lib/tmux.js +95 -0
- package/lib/usage.js +146 -0
- package/package.json +56 -0
- package/remote/channel-manager.cjs +548 -0
- package/remote/hook-notify.cjs +504 -0
- package/remote/load-env.cjs +32 -0
- package/remote/paths.cjs +17 -0
- package/remote/start-webhook.cjs +97 -0
- package/remote/webhook.cjs +228 -0
- package/scripts/postinstall.js +40 -0
- package/slack-manifest.yaml +32 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join, normalize } from 'path';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), '.claude-nonstop');
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
7
|
+
const PROFILES_DIR = join(CONFIG_DIR, 'profiles');
|
|
8
|
+
const DEFAULT_CLAUDE_DIR = normalize(join(homedir(), '.claude'));
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ensure the config directory and profiles directory exist.
|
|
12
|
+
*/
|
|
13
|
+
export function ensureConfigDir() {
|
|
14
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
15
|
+
if (!existsSync(PROFILES_DIR)) mkdirSync(PROFILES_DIR, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load config from disk. Returns { accounts: [{name, configDir}] }.
|
|
20
|
+
* Pure read — does not write to disk.
|
|
21
|
+
*/
|
|
22
|
+
export function loadConfig() {
|
|
23
|
+
ensureConfigDir();
|
|
24
|
+
|
|
25
|
+
let config = { accounts: [] };
|
|
26
|
+
|
|
27
|
+
if (existsSync(CONFIG_FILE)) {
|
|
28
|
+
try {
|
|
29
|
+
config = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
|
|
30
|
+
} catch {
|
|
31
|
+
config = { accounts: [] };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return config;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ensure the default ~/.claude account is registered if it exists on disk.
|
|
40
|
+
* Call once at CLI startup, not on every read.
|
|
41
|
+
*/
|
|
42
|
+
export function ensureDefaultAccount() {
|
|
43
|
+
const config = loadConfig();
|
|
44
|
+
const hasDefault = config.accounts.some(a => a.configDir === DEFAULT_CLAUDE_DIR);
|
|
45
|
+
if (!hasDefault && existsSync(DEFAULT_CLAUDE_DIR)) {
|
|
46
|
+
config.accounts.unshift({ name: 'default', configDir: DEFAULT_CLAUDE_DIR });
|
|
47
|
+
saveConfig(config);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Save config to disk using atomic write (write-to-temp + rename).
|
|
53
|
+
*/
|
|
54
|
+
export function saveConfig(config) {
|
|
55
|
+
ensureConfigDir();
|
|
56
|
+
const tmpFile = `${CONFIG_FILE}.${process.pid}.${Date.now()}.tmp`;
|
|
57
|
+
writeFileSync(tmpFile, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
58
|
+
renameSync(tmpFile, CONFIG_FILE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const VALID_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
62
|
+
const MAX_NAME_LENGTH = 64;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate an account name for safety.
|
|
66
|
+
* Allows letters, numbers, hyphens, and underscores only.
|
|
67
|
+
*/
|
|
68
|
+
export function validateAccountName(name) {
|
|
69
|
+
if (!name || typeof name !== 'string') {
|
|
70
|
+
throw new Error('Account name is required');
|
|
71
|
+
}
|
|
72
|
+
if (name.length > MAX_NAME_LENGTH) {
|
|
73
|
+
throw new Error(`Account name must be ${MAX_NAME_LENGTH} characters or fewer`);
|
|
74
|
+
}
|
|
75
|
+
if (!VALID_NAME_PATTERN.test(name)) {
|
|
76
|
+
throw new Error('Account name may only contain letters, numbers, hyphens, and underscores');
|
|
77
|
+
}
|
|
78
|
+
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
|
|
79
|
+
throw new Error('Account name contains invalid characters');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Add a new account. Returns the configDir for the new profile.
|
|
85
|
+
*/
|
|
86
|
+
export function addAccount(name) {
|
|
87
|
+
validateAccountName(name);
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
|
|
90
|
+
if (config.accounts.some(a => a.name === name)) {
|
|
91
|
+
throw new Error(`Account "${name}" already exists`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const configDir = join(PROFILES_DIR, name);
|
|
95
|
+
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
config.accounts.push({ name, configDir });
|
|
98
|
+
saveConfig(config);
|
|
99
|
+
|
|
100
|
+
return configDir;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Remove an account by name.
|
|
105
|
+
*/
|
|
106
|
+
export function removeAccount(name) {
|
|
107
|
+
const config = loadConfig();
|
|
108
|
+
const idx = config.accounts.findIndex(a => a.name === name);
|
|
109
|
+
|
|
110
|
+
if (idx === -1) throw new Error(`Account "${name}" not found`);
|
|
111
|
+
if (config.accounts[idx].configDir === DEFAULT_CLAUDE_DIR) {
|
|
112
|
+
throw new Error('Cannot remove the default account');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
config.accounts.splice(idx, 1);
|
|
116
|
+
saveConfig(config);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Set priority for an account. Lower number = higher priority.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} name - Account name
|
|
123
|
+
* @param {number} priority - Positive integer (1 = highest priority)
|
|
124
|
+
*/
|
|
125
|
+
export function setAccountPriority(name, priority) {
|
|
126
|
+
validateAccountName(name);
|
|
127
|
+
if (!Number.isInteger(priority) || priority < 1) {
|
|
128
|
+
throw new Error('Priority must be a positive integer (1 = highest)');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const config = loadConfig();
|
|
132
|
+
const account = config.accounts.find(a => a.name === name);
|
|
133
|
+
|
|
134
|
+
if (!account) throw new Error(`Account "${name}" not found`);
|
|
135
|
+
|
|
136
|
+
account.priority = priority;
|
|
137
|
+
saveConfig(config);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Remove priority from an account (reverts to unranked).
|
|
142
|
+
*
|
|
143
|
+
* @param {string} name - Account name
|
|
144
|
+
*/
|
|
145
|
+
export function clearAccountPriority(name) {
|
|
146
|
+
validateAccountName(name);
|
|
147
|
+
const config = loadConfig();
|
|
148
|
+
const account = config.accounts.find(a => a.name === name);
|
|
149
|
+
|
|
150
|
+
if (!account) throw new Error(`Account "${name}" not found`);
|
|
151
|
+
|
|
152
|
+
delete account.priority;
|
|
153
|
+
saveConfig(config);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Get all registered accounts.
|
|
158
|
+
*/
|
|
159
|
+
export function getAccounts() {
|
|
160
|
+
return loadConfig().accounts;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export { CONFIG_DIR, PROFILES_DIR, DEFAULT_CLAUDE_DIR };
|
package/lib/keychain.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential reading and token refresh for OS-specific secure storage.
|
|
3
|
+
*
|
|
4
|
+
* macOS: Keychain via `security find-generic-password` / `add-generic-password`
|
|
5
|
+
* Linux: Secret Service via `secret-tool` or ~/.credentials.json fallback
|
|
6
|
+
*
|
|
7
|
+
* Service name format:
|
|
8
|
+
* - Default (~/.claude): "Claude Code-credentials"
|
|
9
|
+
* - Custom dirs: "Claude Code-credentials-{sha256_8(expandedPath)}"
|
|
10
|
+
*
|
|
11
|
+
* Token refresh uses the same OAuth endpoint and client ID as Claude Code CLI,
|
|
12
|
+
* so both tools share the same credentials in the keychain seamlessly.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFileSync } from 'child_process';
|
|
16
|
+
import { createHash } from 'crypto';
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
18
|
+
import { homedir, userInfo } from 'os';
|
|
19
|
+
import { join, normalize } from 'path';
|
|
20
|
+
import { isMacOS, isLinux } from './platform.js';
|
|
21
|
+
import { DEFAULT_CLAUDE_DIR } from './config.js';
|
|
22
|
+
|
|
23
|
+
const OAUTH_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
|
|
24
|
+
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
25
|
+
const REFRESH_TIMEOUT_MS = 10_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get the macOS Keychain account name.
|
|
29
|
+
*
|
|
30
|
+
* Claude Code CLI uses the system username (e.g. "rc") as the `-a` account
|
|
31
|
+
* field. We must match this exactly, otherwise `add-generic-password -U`
|
|
32
|
+
* creates a second entry instead of updating the existing one, and
|
|
33
|
+
* `find-generic-password` without `-a` returns whichever entry it finds
|
|
34
|
+
* first — leading to stale-token bugs after silent refresh.
|
|
35
|
+
*/
|
|
36
|
+
function getKeychainAccount() {
|
|
37
|
+
return userInfo().username;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compute the 8-char SHA256 hash suffix for a config directory.
|
|
42
|
+
* Matches Claude Code CLI's hash computation.
|
|
43
|
+
*/
|
|
44
|
+
export function calculateConfigDirHash(configDir) {
|
|
45
|
+
const expanded = expandPath(configDir);
|
|
46
|
+
return createHash('sha256').update(expanded).digest('hex').slice(0, 8);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Expand ~ to homedir and normalize the path.
|
|
51
|
+
*/
|
|
52
|
+
export function expandPath(p) {
|
|
53
|
+
const expanded = p.startsWith('~') ? p.replace(/^~/, homedir()) : p;
|
|
54
|
+
return normalize(expanded);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the Keychain service name for a config directory.
|
|
59
|
+
*
|
|
60
|
+
* Default (~/.claude) uses "Claude Code-credentials" (no hash) —
|
|
61
|
+
* this matches the standard Claude Code CLI behavior.
|
|
62
|
+
* Custom dirs use "Claude Code-credentials-{hash}" for isolation.
|
|
63
|
+
*/
|
|
64
|
+
export function getServiceName(configDir) {
|
|
65
|
+
const expanded = expandPath(configDir);
|
|
66
|
+
|
|
67
|
+
if (expanded === DEFAULT_CLAUDE_DIR) {
|
|
68
|
+
return 'Claude Code-credentials';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const hash = calculateConfigDirHash(configDir);
|
|
72
|
+
return `Claude Code-credentials-${hash}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read OAuth credentials from the platform credential store.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} configDir - The CLAUDE_CONFIG_DIR path
|
|
79
|
+
* @returns {{ token: string|null, email: string|null, name: string|null, error: string|null }}
|
|
80
|
+
*/
|
|
81
|
+
export function readCredentials(configDir) {
|
|
82
|
+
if (isMacOS()) {
|
|
83
|
+
return readFromMacKeychain(configDir);
|
|
84
|
+
}
|
|
85
|
+
if (isLinux()) {
|
|
86
|
+
return readFromLinux(configDir);
|
|
87
|
+
}
|
|
88
|
+
return { token: null, email: null, name: null, expiresAt: null, error: 'unsupported_platform' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* macOS: Read from Keychain using `security` command.
|
|
93
|
+
*/
|
|
94
|
+
function readFromMacKeychain(configDir) {
|
|
95
|
+
const serviceName = getServiceName(configDir);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const raw = execFileSync('security', [
|
|
99
|
+
'find-generic-password',
|
|
100
|
+
'-s', serviceName,
|
|
101
|
+
'-a', getKeychainAccount(),
|
|
102
|
+
'-w'
|
|
103
|
+
], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
104
|
+
|
|
105
|
+
return parseCredentialJson(raw);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
// Exit code 44 = errSecItemNotFound (item not in keychain)
|
|
108
|
+
if (error?.status === 44) {
|
|
109
|
+
return { token: null, email: null, name: null, expiresAt: null, error: 'not_found' };
|
|
110
|
+
}
|
|
111
|
+
return { token: null, email: null, name: null, expiresAt: null, error: error.message };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Linux: Try secret-tool first, fall back to .credentials.json file.
|
|
117
|
+
*/
|
|
118
|
+
function readFromLinux(configDir) {
|
|
119
|
+
const expanded = expandPath(configDir);
|
|
120
|
+
const serviceName = getServiceName(configDir);
|
|
121
|
+
|
|
122
|
+
// Try secret-tool (GNOME Keyring / KDE Wallet)
|
|
123
|
+
try {
|
|
124
|
+
const raw = execFileSync('secret-tool', [
|
|
125
|
+
'lookup',
|
|
126
|
+
'service', serviceName
|
|
127
|
+
], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
128
|
+
|
|
129
|
+
if (raw) return parseCredentialJson(raw);
|
|
130
|
+
} catch {
|
|
131
|
+
// Fall through to file-based
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Fall back to .credentials.json
|
|
135
|
+
const credFile = join(expanded, '.credentials.json');
|
|
136
|
+
if (existsSync(credFile)) {
|
|
137
|
+
try {
|
|
138
|
+
const raw = readFileSync(credFile, 'utf-8');
|
|
139
|
+
return parseCredentialJson(raw);
|
|
140
|
+
} catch {
|
|
141
|
+
return { token: null, email: null, name: null, expiresAt: null, error: 'parse_failed' };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { token: null, email: null, name: null, expiresAt: null, error: 'not_found' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Parse the credential JSON blob and extract token + email.
|
|
150
|
+
*
|
|
151
|
+
* Claude Code stores credentials as:
|
|
152
|
+
* {
|
|
153
|
+
* "claudeAiOauth": {
|
|
154
|
+
* "accessToken": "sk-ant-oat01-...",
|
|
155
|
+
* "refreshToken": "sk-ant-ort01-...",
|
|
156
|
+
* "email": "user@example.com",
|
|
157
|
+
* "expiresAt": 1234567890000
|
|
158
|
+
* }
|
|
159
|
+
* }
|
|
160
|
+
*/
|
|
161
|
+
export function parseCredentialJson(raw) {
|
|
162
|
+
try {
|
|
163
|
+
const data = JSON.parse(raw);
|
|
164
|
+
const oauth = data?.claudeAiOauth;
|
|
165
|
+
|
|
166
|
+
if (!oauth) {
|
|
167
|
+
return { token: null, email: null, name: null, expiresAt: null, error: 'no_oauth_data' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const token = oauth.accessToken || null;
|
|
171
|
+
const email = oauth.email || oauth.emailAddress || data?.email || null;
|
|
172
|
+
const name = oauth.name || oauth.fullName || oauth.displayName || data?.name || null;
|
|
173
|
+
const expiresAt = oauth.expiresAt || null;
|
|
174
|
+
|
|
175
|
+
if (token && !token.startsWith('sk-ant-')) {
|
|
176
|
+
return { token: null, email, name, expiresAt, error: 'invalid_token_format' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { token, email, name, expiresAt, error: null };
|
|
180
|
+
} catch {
|
|
181
|
+
return { token: null, email: null, name: null, expiresAt: null, error: 'parse_failed' };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Read the raw credential JSON blob from the credential store.
|
|
187
|
+
* Returns the full JSON string, not parsed — needed for read-modify-write.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} configDir - The CLAUDE_CONFIG_DIR path
|
|
190
|
+
* @returns {string|null} Raw JSON string or null if not found
|
|
191
|
+
*/
|
|
192
|
+
function readRawCredentialBlob(configDir) {
|
|
193
|
+
const serviceName = getServiceName(configDir);
|
|
194
|
+
|
|
195
|
+
if (isMacOS()) {
|
|
196
|
+
try {
|
|
197
|
+
return execFileSync('security', [
|
|
198
|
+
'find-generic-password', '-s', serviceName, '-a', getKeychainAccount(), '-w'
|
|
199
|
+
], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (isLinux()) {
|
|
206
|
+
try {
|
|
207
|
+
const raw = execFileSync('secret-tool', [
|
|
208
|
+
'lookup', 'service', serviceName
|
|
209
|
+
], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
210
|
+
if (raw) return raw;
|
|
211
|
+
} catch { /* fall through */ }
|
|
212
|
+
|
|
213
|
+
const expanded = expandPath(configDir);
|
|
214
|
+
const credFile = join(expanded, '.credentials.json');
|
|
215
|
+
if (existsSync(credFile)) {
|
|
216
|
+
try { return readFileSync(credFile, 'utf-8'); } catch { /* fall through */ }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Write a credential JSON blob back to the credential store.
|
|
225
|
+
* Both Claude Code and claude-nonstop share the same keychain entries.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} configDir - The CLAUDE_CONFIG_DIR path
|
|
228
|
+
* @param {string} jsonString - The full credential JSON to write
|
|
229
|
+
* @returns {{ written: boolean, error: string|null }}
|
|
230
|
+
*/
|
|
231
|
+
function writeCredentialBlob(configDir, jsonString) {
|
|
232
|
+
const serviceName = getServiceName(configDir);
|
|
233
|
+
|
|
234
|
+
if (isMacOS()) {
|
|
235
|
+
try {
|
|
236
|
+
// -U flag updates existing entry (or creates if missing)
|
|
237
|
+
execFileSync('security', [
|
|
238
|
+
'add-generic-password',
|
|
239
|
+
'-s', serviceName,
|
|
240
|
+
'-a', getKeychainAccount(),
|
|
241
|
+
'-w', jsonString,
|
|
242
|
+
'-U'
|
|
243
|
+
], { stdio: 'pipe', timeout: 5000 });
|
|
244
|
+
return { written: true, error: null };
|
|
245
|
+
} catch (error) {
|
|
246
|
+
return { written: false, error: error.message };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (isLinux()) {
|
|
251
|
+
// Write to .credentials.json (atomic write)
|
|
252
|
+
const expanded = expandPath(configDir);
|
|
253
|
+
const credFile = join(expanded, '.credentials.json');
|
|
254
|
+
try {
|
|
255
|
+
const tmpFile = `${credFile}.${process.pid}.tmp`;
|
|
256
|
+
writeFileSync(tmpFile, jsonString, { mode: 0o600 });
|
|
257
|
+
renameSync(tmpFile, credFile);
|
|
258
|
+
return { written: true, error: null };
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return { written: false, error: error.message };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { written: false, error: 'unsupported_platform' };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Refresh an expired access token using the OAuth refresh token.
|
|
269
|
+
*
|
|
270
|
+
* Uses the same endpoint and client ID as Claude Code CLI, so the refreshed
|
|
271
|
+
* tokens are written back to the shared keychain entry. Both Claude Code and
|
|
272
|
+
* claude-nonstop will see the fresh tokens.
|
|
273
|
+
*
|
|
274
|
+
* Refresh tokens are single-use — the new refresh token MUST be saved back.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} configDir - The CLAUDE_CONFIG_DIR path
|
|
277
|
+
* @returns {Promise<{ token: string|null, email: string|null, name: string|null, expiresAt: number|null, error: string|null }>}
|
|
278
|
+
*/
|
|
279
|
+
export async function refreshAccessToken(configDir) {
|
|
280
|
+
const raw = readRawCredentialBlob(configDir);
|
|
281
|
+
if (!raw) {
|
|
282
|
+
return { token: null, email: null, name: null, expiresAt: null, error: 'no_credentials' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let data;
|
|
286
|
+
try {
|
|
287
|
+
data = JSON.parse(raw);
|
|
288
|
+
} catch {
|
|
289
|
+
return { token: null, email: null, name: null, expiresAt: null, error: 'parse_failed' };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const refreshToken = data?.claudeAiOauth?.refreshToken;
|
|
293
|
+
if (!refreshToken) {
|
|
294
|
+
return { token: null, email: null, name: null, expiresAt: null, error: 'no_refresh_token' };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Call the OAuth token endpoint
|
|
298
|
+
try {
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
const timeoutId = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS);
|
|
301
|
+
|
|
302
|
+
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
303
|
+
method: 'POST',
|
|
304
|
+
headers: { 'Content-Type': 'application/json' },
|
|
305
|
+
body: JSON.stringify({
|
|
306
|
+
grant_type: 'refresh_token',
|
|
307
|
+
refresh_token: refreshToken,
|
|
308
|
+
client_id: OAUTH_CLIENT_ID,
|
|
309
|
+
}),
|
|
310
|
+
signal: controller.signal,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
clearTimeout(timeoutId);
|
|
314
|
+
|
|
315
|
+
if (!res.ok) {
|
|
316
|
+
const body = await res.json().catch(() => ({}));
|
|
317
|
+
return { token: null, email: null, name: null, expiresAt: null, error: body.error || `HTTP ${res.status}` };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const tokens = await res.json();
|
|
321
|
+
if (!tokens.access_token) {
|
|
322
|
+
return { token: null, email: null, name: null, expiresAt: null, error: 'no_access_token_in_response' };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Update the credential blob with new tokens
|
|
326
|
+
data.claudeAiOauth.accessToken = tokens.access_token;
|
|
327
|
+
if (tokens.refresh_token) {
|
|
328
|
+
data.claudeAiOauth.refreshToken = tokens.refresh_token;
|
|
329
|
+
}
|
|
330
|
+
data.claudeAiOauth.expiresAt = Date.now() + (tokens.expires_in * 1000);
|
|
331
|
+
|
|
332
|
+
// Write back to keychain immediately (refresh tokens are single-use)
|
|
333
|
+
const writeResult = writeCredentialBlob(configDir, JSON.stringify(data));
|
|
334
|
+
if (!writeResult.written) {
|
|
335
|
+
return { token: null, email: null, name: null, expiresAt: null, error: `keychain_write_failed: ${writeResult.error}` };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return parseCredentialJson(JSON.stringify(data));
|
|
339
|
+
} catch (error) {
|
|
340
|
+
return { token: null, email: null, name: null, expiresAt: null, error: error.name === 'AbortError' ? 'timeout' : error.message };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Delete a keychain entry for a config directory.
|
|
346
|
+
*
|
|
347
|
+
* @param {string} configDir - The CLAUDE_CONFIG_DIR path
|
|
348
|
+
* @returns {{ deleted: boolean, error: string|null }}
|
|
349
|
+
*/
|
|
350
|
+
export function deleteKeychainEntry(configDir) {
|
|
351
|
+
const serviceName = getServiceName(configDir);
|
|
352
|
+
|
|
353
|
+
if (isMacOS()) {
|
|
354
|
+
try {
|
|
355
|
+
execFileSync('security', [
|
|
356
|
+
'delete-generic-password',
|
|
357
|
+
'-s', serviceName,
|
|
358
|
+
'-a', getKeychainAccount()
|
|
359
|
+
], { stdio: 'pipe', timeout: 5000 });
|
|
360
|
+
return { deleted: true, error: null };
|
|
361
|
+
} catch (error) {
|
|
362
|
+
// Exit code 44 = errSecItemNotFound
|
|
363
|
+
if (error?.status === 44) {
|
|
364
|
+
return { deleted: false, error: null };
|
|
365
|
+
}
|
|
366
|
+
return { deleted: false, error: error.message };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (isLinux()) {
|
|
371
|
+
// File-based .credentials.json lives inside the profile dir,
|
|
372
|
+
// which gets removed by rmSync. Only need to clear secret-tool.
|
|
373
|
+
try {
|
|
374
|
+
execFileSync('secret-tool', [
|
|
375
|
+
'clear',
|
|
376
|
+
'service', serviceName
|
|
377
|
+
], { stdio: 'pipe', timeout: 5000 });
|
|
378
|
+
return { deleted: true, error: null };
|
|
379
|
+
} catch {
|
|
380
|
+
// secret-tool may not be installed or entry may not exist
|
|
381
|
+
return { deleted: false, error: null };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { deleted: false, error: 'unsupported_platform' };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Check if credentials have an expired access token.
|
|
390
|
+
*
|
|
391
|
+
* @param {{ expiresAt: number|null }} creds - Credentials from readCredentials()
|
|
392
|
+
* @returns {boolean}
|
|
393
|
+
*/
|
|
394
|
+
export function isTokenExpired(creds) {
|
|
395
|
+
if (!creds.expiresAt) return false;
|
|
396
|
+
return creds.expiresAt < Date.now();
|
|
397
|
+
}
|