create-claude-workspace 1.1.35 → 1.1.36

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.
@@ -9,6 +9,7 @@ import { createLogger } from './lib/logger.mjs';
9
9
  import { emptyCheckpoint, readCheckpoint, writeCheckpoint } from './lib/state.mjs';
10
10
  import { runClaude, currentChild } from './lib/claude-runner.mjs';
11
11
  import { sleep, formatDuration, acquireLock, releaseLock, readMemory, isProjectComplete, getCurrentTask, getCurrentPhase, checkClaudeInstalled, checkAuth, checkGitIdentity, checkFilesystemWritable, gitFetchAndPull, gitCheckState, notify, printSummary, promptUser, } from './lib/utils.mjs';
12
+ import { isTokenExpiringSoon, refreshOAuthToken } from './lib/oauth-refresh.mjs';
12
13
  // ─── Args ───
13
14
  function parseArgs(argv) {
14
15
  const opts = { ...DEFAULTS };
@@ -291,18 +292,34 @@ async function main() {
291
292
  if (timeSinceLastAuth > opts.processTimeout * 2) {
292
293
  log.warn('Large time gap detected (system sleep?). Re-checking auth...');
293
294
  if (!checkAuth()) {
294
- log.error('Auth expired after sleep. Re-authenticate and restart.');
295
- notify(opts.notifyCommand, 'stopped', 'Auth expired after system sleep', i);
296
- break;
295
+ log.warn('Auth expired after sleep. Attempting OAuth refresh...');
296
+ const refreshed = await refreshOAuthToken(log);
297
+ if (!refreshed || !checkAuth()) {
298
+ log.error('Auth expired after sleep and refresh failed. Run `claude /login`.');
299
+ notify(opts.notifyCommand, 'stopped', 'Auth expired after system sleep (refresh failed)', i);
300
+ break;
301
+ }
302
+ log.info('OAuth token refreshed after sleep. Continuing.');
297
303
  }
298
304
  checkpoint.lastAuthCheckTime = Date.now();
299
305
  }
300
- // Periodic auth re-check (every 30 min)
301
- if (timeSinceLastAuth > 30 * 60_000) {
306
+ // Proactive token refresh refresh before expiry to prevent mid-iteration failures
307
+ if (isTokenExpiringSoon()) {
308
+ log.info('OAuth token expiring soon. Refreshing proactively...');
309
+ await refreshOAuthToken(log);
310
+ }
311
+ // Periodic auth re-check (every 10 min)
312
+ if (timeSinceLastAuth > 10 * 60_000) {
302
313
  if (!checkAuth()) {
303
- log.error('Auth expired. Run `claude /login` or check ANTHROPIC_API_KEY.');
304
- notify(opts.notifyCommand, 'stopped', 'Auth expired', i);
305
- break;
314
+ // Try OAuth refresh before giving up
315
+ log.warn('Auth check failed. Attempting OAuth token refresh...');
316
+ const refreshed = await refreshOAuthToken(log);
317
+ if (!refreshed || !checkAuth()) {
318
+ log.error('Auth expired and refresh failed. Run `claude /login` or check ANTHROPIC_API_KEY.');
319
+ notify(opts.notifyCommand, 'stopped', 'Auth expired (refresh failed)', i);
320
+ break;
321
+ }
322
+ log.info('OAuth token refreshed successfully. Continuing.');
306
323
  }
307
324
  checkpoint.lastAuthCheckTime = Date.now();
308
325
  }
@@ -333,14 +350,37 @@ async function main() {
333
350
  stats.processTimeouts++;
334
351
  if (category === 'activity_timeout')
335
352
  stats.activityTimeouts++;
353
+ // Immediate auth check after any error — catches expired OAuth tokens that
354
+ // weren't classified as auth_expired (e.g., process_timeout with auth errors in stderr)
355
+ if (category !== 'auth_expired' && !checkAuth()) {
356
+ log.warn('Auth check failed after error. Attempting OAuth refresh...');
357
+ const refreshed = await refreshOAuthToken(log);
358
+ if (!refreshed || !checkAuth()) {
359
+ log.error('Auth expired and refresh failed. Run `claude /login` or check ANTHROPIC_API_KEY.');
360
+ notify(opts.notifyCommand, 'stopped', 'Auth expired (refresh failed after process error)', i);
361
+ writeCheckpoint(opts.projectDir, checkpoint, log);
362
+ break;
363
+ }
364
+ log.info('OAuth token refreshed after error. Continuing.');
365
+ }
336
366
  }
337
367
  // Get response action
338
368
  const action = getErrorAction(category, {
339
369
  consecutiveFailures: checkpoint.consecutiveFailures,
340
370
  authRetries: checkpoint.authRetries,
341
371
  });
342
- // Handle stop
372
+ // Handle stop — try OAuth refresh for auth errors before giving up
343
373
  if (action.type === 'stop') {
374
+ if (category === 'auth_expired') {
375
+ log.warn('Auth expired. Attempting OAuth token refresh...');
376
+ const refreshed = await refreshOAuthToken(log);
377
+ if (refreshed && checkAuth()) {
378
+ log.info('OAuth token refreshed. Retrying iteration.');
379
+ checkpoint.lastAuthCheckTime = Date.now();
380
+ i--; // Retry this iteration
381
+ continue;
382
+ }
383
+ }
344
384
  log.error(action.reason);
345
385
  notify(opts.notifyCommand, 'stopped', action.reason, i);
346
386
  writeCheckpoint(opts.projectDir, checkpoint, log);
@@ -352,9 +352,9 @@ export function runClaude(opts, log, runOpts = {}) {
352
352
  // Kill on auth error burst (e.g. expired token causing infinite retry loop)
353
353
  if (AUTH_ERROR_RE.test(lower)) {
354
354
  authErrorCount++;
355
- if (authErrorCount >= 5 && !killed) {
355
+ if (authErrorCount >= 3 && !killed) {
356
356
  log.warn(`Auth error burst detected (${authErrorCount} errors) — killing process.`);
357
- killChild('activity_timeout');
357
+ killChild('process_timeout');
358
358
  return;
359
359
  }
360
360
  }
@@ -15,14 +15,20 @@ export function classifyError(signals) {
15
15
  if (signals.code === 0 && signals.hasResult)
16
16
  return 'none';
17
17
  // Flag-based (set by stream events, not just stderr)
18
+ // Auth errors take priority over everything — prevents 30-min timeout loops on expired tokens
18
19
  if (signals.isAuthError)
19
20
  return 'auth_expired';
20
21
  if (signals.isAuthServerError)
21
22
  return 'auth_server_error';
22
23
  if (signals.isRateLimit)
23
24
  return 'rate_limited';
24
- if (signals.timedOut)
25
+ if (signals.timedOut) {
26
+ // Double-check stderr for auth errors (may not have been caught by stream events)
27
+ if (/authentication_error|failed to authenticate|api error: 401|oauth token has expired/i.test(signals.stderr)) {
28
+ return 'auth_expired';
29
+ }
25
30
  return 'process_timeout';
31
+ }
26
32
  // Activity timeout after result = success (process just hung after finishing)
27
33
  if (signals.activityTimedOut)
28
34
  return signals.hasResult ? 'none' : 'activity_timeout';
@@ -0,0 +1,146 @@
1
+ // ─── OAuth token refresh for Claude Code ───
2
+ // Cross-platform: reads/writes ~/.claude/.credentials.json (Windows/Linux)
3
+ // or macOS Keychain via `security` CLI.
4
+ // Refresh endpoint: https://console.anthropic.com/v1/oauth/token
5
+ import { readFileSync, writeFileSync } from 'node:fs';
6
+ import { resolve } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { execSync } from 'node:child_process';
9
+ const TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
10
+ const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
11
+ const CREDENTIALS_FILE = '.claude/.credentials.json';
12
+ const KEYCHAIN_SERVICE = 'Claude Code-credentials';
13
+ // Buffer: refresh if token expires within this many ms
14
+ const REFRESH_BUFFER_MS = 30 * 60_000; // 30 minutes
15
+ // ─── Credential storage (cross-platform) ───
16
+ function credentialsPath() {
17
+ return resolve(homedir(), CREDENTIALS_FILE);
18
+ }
19
+ function readCredentialsFile() {
20
+ try {
21
+ const raw = readFileSync(credentialsPath(), 'utf-8');
22
+ const parsed = JSON.parse(raw);
23
+ if (parsed?.claudeAiOauth?.refreshToken)
24
+ return parsed;
25
+ return null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ function writeCredentialsFile(creds) {
32
+ writeFileSync(credentialsPath(), JSON.stringify(creds));
33
+ }
34
+ function readKeychainCredentials() {
35
+ try {
36
+ // Try common account names
37
+ const accounts = ['claude', 'Claude Code', 'default'];
38
+ for (const account of accounts) {
39
+ try {
40
+ const raw = execSync(`security find-generic-password -s "${KEYCHAIN_SERVICE}" -a "${account}" -w`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000 }).trim();
41
+ const parsed = JSON.parse(raw);
42
+ if (parsed?.claudeAiOauth?.refreshToken)
43
+ return parsed;
44
+ }
45
+ catch { /* try next account */ }
46
+ }
47
+ return null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ function writeKeychainCredentials(creds, account) {
54
+ const data = JSON.stringify(creds);
55
+ try {
56
+ execSync(`security delete-generic-password -s "${KEYCHAIN_SERVICE}" -a "${account}"`, { stdio: 'ignore', timeout: 5000 });
57
+ }
58
+ catch { /* may not exist */ }
59
+ execSync(`security add-generic-password -s "${KEYCHAIN_SERVICE}" -a "${account}" -w '${data.replace(/'/g, "'\\''")}' -U`, { stdio: 'ignore', timeout: 5000 });
60
+ }
61
+ function readCredentials() {
62
+ // File-based (Windows/Linux) takes priority — always present
63
+ const fileCreds = readCredentialsFile();
64
+ if (fileCreds)
65
+ return fileCreds;
66
+ // macOS Keychain fallback
67
+ if (process.platform === 'darwin')
68
+ return readKeychainCredentials();
69
+ return null;
70
+ }
71
+ function writeCredentials(creds) {
72
+ // Always write to file
73
+ writeCredentialsFile(creds);
74
+ // Also update Keychain on macOS
75
+ if (process.platform === 'darwin') {
76
+ try {
77
+ writeKeychainCredentials(creds, 'claude');
78
+ }
79
+ catch { /* non-fatal */ }
80
+ }
81
+ }
82
+ // ─── Token refresh ───
83
+ async function postRefresh(refreshToken) {
84
+ const body = JSON.stringify({
85
+ grant_type: 'refresh_token',
86
+ refresh_token: refreshToken,
87
+ client_id: CLIENT_ID,
88
+ });
89
+ const response = await fetch(TOKEN_URL, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body,
93
+ signal: AbortSignal.timeout(30_000),
94
+ });
95
+ if (!response.ok) {
96
+ const text = await response.text().catch(() => '');
97
+ throw new Error(`OAuth refresh failed: ${response.status} ${text.slice(0, 200)}`);
98
+ }
99
+ return await response.json();
100
+ }
101
+ // ─── Public API ───
102
+ /** Check if the current OAuth token is expired or about to expire. */
103
+ export function isTokenExpiringSoon() {
104
+ const creds = readCredentials();
105
+ if (!creds)
106
+ return false; // No OAuth credentials (API key user) — not our problem
107
+ return Date.now() > (creds.claudeAiOauth.expiresAt - REFRESH_BUFFER_MS);
108
+ }
109
+ /** Attempt to refresh the OAuth token. Returns true on success. */
110
+ export async function refreshOAuthToken(log) {
111
+ const creds = readCredentials();
112
+ if (!creds) {
113
+ log.warn('No OAuth credentials found — cannot refresh.');
114
+ return false;
115
+ }
116
+ const oauth = creds.claudeAiOauth;
117
+ if (!oauth.refreshToken) {
118
+ log.warn('No refresh token available.');
119
+ return false;
120
+ }
121
+ const remaining = oauth.expiresAt - Date.now();
122
+ log.info(`Token expires in ${Math.round(remaining / 60_000)}m. Attempting refresh...`);
123
+ try {
124
+ const result = await postRefresh(oauth.refreshToken);
125
+ if (!result.access_token || !result.refresh_token) {
126
+ log.error('Refresh response missing tokens.');
127
+ return false;
128
+ }
129
+ // Update credentials
130
+ const updated = {
131
+ claudeAiOauth: {
132
+ ...oauth,
133
+ accessToken: result.access_token,
134
+ refreshToken: result.refresh_token,
135
+ expiresAt: Date.now() + (result.expires_in * 1000),
136
+ },
137
+ };
138
+ writeCredentials(updated);
139
+ log.info(`OAuth token refreshed. New expiry: ${new Date(updated.claudeAiOauth.expiresAt).toISOString()}`);
140
+ return true;
141
+ }
142
+ catch (err) {
143
+ log.error(`OAuth refresh failed: ${err.message}`);
144
+ return false;
145
+ }
146
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-workspace",
3
- "version": "1.1.35",
3
+ "version": "1.1.36",
4
4
  "description": "Scaffold a project with Claude Code agents for autonomous AI-driven development",
5
5
  "type": "module",
6
6
  "bin": {