@notis_ai/cli 0.2.8 → 0.2.9

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.
@@ -101,6 +101,13 @@ export class OutputManager {
101
101
  process.stderr.write(`${message}\n`);
102
102
  }
103
103
 
104
+ note(message) {
105
+ if (this.isMachineMode()) {
106
+ return;
107
+ }
108
+ process.stderr.write(`${message}\n`);
109
+ }
110
+
104
111
  emitProgress({ phase, message, requestId = null }) {
105
112
  if (this.runtime.quiet) {
106
113
  return;
@@ -1,4 +1,13 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
2
11
  import { homedir } from 'node:os';
3
12
  import { dirname, join, parse, resolve } from 'node:path';
4
13
  import { CliError, EXIT_CODES } from './errors.js';
@@ -16,6 +25,15 @@ const LOCAL_DEFAULT_API_BASES = new Set([
16
25
  'http://127.0.0.1:3001',
17
26
  ]);
18
27
  const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
28
+ // Cross-process write lock over ~/.notis/config.json. Notis Desktop implements
29
+ // the same protocol independently in electron/src/cli-auth.ts (updateConfig);
30
+ // the `${configFile}.write-lock` directory name and these three timings must
31
+ // stay identical on both sides or `notis login` and desktop syncAuth stop
32
+ // excluding each other and clobber stored OAuth tokens. Guarded by the drift
33
+ // test in packages/cli/test/runtime-auth.test.js.
34
+ const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
35
+ const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
36
+ const CONFIG_WRITE_LOCK_POLL_MS = 10;
19
37
 
20
38
  function clone(value) {
21
39
  return JSON.parse(JSON.stringify(value));
@@ -43,6 +61,28 @@ export function normalizeConfig(rawConfig = {}) {
43
61
  desktop_app_name:
44
62
  typeof profile.desktop_app_name === 'string' ? profile.desktop_app_name : undefined,
45
63
  desktop_pid: typeof profile.desktop_pid === 'number' ? profile.desktop_pid : undefined,
64
+ oauth_access_token:
65
+ typeof profile.oauth_access_token === 'string' ? profile.oauth_access_token : undefined,
66
+ oauth_refresh_token:
67
+ typeof profile.oauth_refresh_token === 'string' ? profile.oauth_refresh_token : undefined,
68
+ oauth_access_expires_at:
69
+ typeof profile.oauth_access_expires_at === 'number' ? profile.oauth_access_expires_at : undefined,
70
+ oauth_refresh_expires_at:
71
+ typeof profile.oauth_refresh_expires_at === 'number' ? profile.oauth_refresh_expires_at : undefined,
72
+ oauth_client_id:
73
+ typeof profile.oauth_client_id === 'string' ? profile.oauth_client_id : undefined,
74
+ oauth_issuer:
75
+ typeof profile.oauth_issuer === 'string' ? profile.oauth_issuer : undefined,
76
+ oauth_api_base:
77
+ typeof profile.oauth_api_base === 'string' ? profile.oauth_api_base : undefined,
78
+ oauth_resource:
79
+ typeof profile.oauth_resource === 'string' ? profile.oauth_resource : undefined,
80
+ oauth_scopes:
81
+ Array.isArray(profile.oauth_scopes)
82
+ ? profile.oauth_scopes.filter((scope) => typeof scope === 'string')
83
+ : undefined,
84
+ oauth_user_id:
85
+ typeof profile.oauth_user_id === 'string' ? profile.oauth_user_id : undefined,
46
86
  };
47
87
  }
48
88
 
@@ -74,6 +114,28 @@ export function normalizeConfig(rawConfig = {}) {
74
114
  desktop_app_name:
75
115
  typeof raw.desktop_app_name === 'string' ? raw.desktop_app_name : undefined,
76
116
  desktop_pid: typeof raw.desktop_pid === 'number' ? raw.desktop_pid : undefined,
117
+ oauth_access_token:
118
+ typeof raw.oauth_access_token === 'string' ? raw.oauth_access_token : undefined,
119
+ oauth_refresh_token:
120
+ typeof raw.oauth_refresh_token === 'string' ? raw.oauth_refresh_token : undefined,
121
+ oauth_access_expires_at:
122
+ typeof raw.oauth_access_expires_at === 'number' ? raw.oauth_access_expires_at : undefined,
123
+ oauth_refresh_expires_at:
124
+ typeof raw.oauth_refresh_expires_at === 'number' ? raw.oauth_refresh_expires_at : undefined,
125
+ oauth_client_id:
126
+ typeof raw.oauth_client_id === 'string' ? raw.oauth_client_id : undefined,
127
+ oauth_issuer:
128
+ typeof raw.oauth_issuer === 'string' ? raw.oauth_issuer : undefined,
129
+ oauth_api_base:
130
+ typeof raw.oauth_api_base === 'string' ? raw.oauth_api_base : undefined,
131
+ oauth_resource:
132
+ typeof raw.oauth_resource === 'string' ? raw.oauth_resource : undefined,
133
+ oauth_scopes:
134
+ Array.isArray(raw.oauth_scopes)
135
+ ? raw.oauth_scopes.filter((scope) => typeof scope === 'string')
136
+ : undefined,
137
+ oauth_user_id:
138
+ typeof raw.oauth_user_id === 'string' ? raw.oauth_user_id : undefined,
77
139
  },
78
140
  },
79
141
  };
@@ -143,6 +205,14 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
143
205
  const runtime = readJsonFile(runtimePath);
144
206
  const apiBase = typeof runtime?.api_base === 'string' ? runtime.api_base.replace(/\/+$/, '') : '';
145
207
  const configFile = typeof runtime?.config_file === 'string' ? runtime.config_file : '';
208
+ const appDevSessionsFile =
209
+ typeof runtime?.app_dev_sessions_file === 'string' && runtime.app_dev_sessions_file.trim()
210
+ ? runtime.app_dev_sessions_file.trim()
211
+ : join(dirname(runtimePath), 'app-dev-sessions.json');
212
+ const desktopDeepLinkScheme =
213
+ typeof runtime?.desktop_deep_link_scheme === 'string'
214
+ ? runtime.desktop_deep_link_scheme.trim()
215
+ : '';
146
216
  const pid = Number(runtime?.dev_pid);
147
217
  if (
148
218
  runtime?.mode !== 'local-only' ||
@@ -165,6 +235,8 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
165
235
  ...runtime,
166
236
  api_base: apiBase,
167
237
  config_file: resolve(dirname(runtimePath), configFile),
238
+ app_dev_sessions_file: resolve(dirname(runtimePath), appDevSessionsFile),
239
+ desktop_deep_link_scheme: desktopDeepLinkScheme || undefined,
168
240
  runtime_path: runtimePath,
169
241
  routing_path: routingPath,
170
242
  };
@@ -198,10 +270,126 @@ export function loadConfig(runtime = null) {
198
270
  }
199
271
  }
200
272
 
201
- export function saveConfig(config, runtime = null) {
273
+ function writeConfig(configFile, config) {
274
+ mkdirSync(dirname(configFile), { recursive: true });
275
+ const temporaryFile = `${configFile}.${process.pid}.${Date.now()}.tmp`;
276
+ writeFileSync(temporaryFile, JSON.stringify(normalizeConfig(config), null, 2), { mode: 0o600 });
277
+ renameSync(temporaryFile, configFile);
278
+ }
279
+
280
+ // The lock records who holds it and when they took it. Reclaiming a stale lock
281
+ // by mtime alone is not safe: the reclaimer cannot tell an abandoned lock from
282
+ // one it is about to steal from a live writer, and the victim's own release
283
+ // would then delete the thief's lock and admit a third writer. Both sides are
284
+ // on the same host, so the stored timestamp and Date.now() share a clock.
285
+ function readLockOwner(lockDirectory) {
286
+ try {
287
+ return JSON.parse(readFileSync(join(lockDirectory, 'owner'), 'utf-8'));
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ function lockDirectoryMtime(lockDirectory) {
294
+ try {
295
+ return statSync(lockDirectory).mtimeMs;
296
+ } catch {
297
+ return null;
298
+ }
299
+ }
300
+
301
+ function publishConfigWriteLock(lockDirectory, ownerId) {
302
+ const candidateDirectory = `${lockDirectory}.candidate-${ownerId}`;
303
+ try {
304
+ mkdirSync(candidateDirectory);
305
+ writeFileSync(
306
+ join(candidateDirectory, 'owner'),
307
+ JSON.stringify({ id: ownerId, at: Date.now() }),
308
+ { mode: 0o600 },
309
+ );
310
+ try {
311
+ renameSync(candidateDirectory, lockDirectory);
312
+ return true;
313
+ } catch (error) {
314
+ if (!existsSync(lockDirectory)) throw error;
315
+ return false;
316
+ }
317
+ } finally {
318
+ rmSync(candidateDirectory, { recursive: true, force: true });
319
+ }
320
+ }
321
+
322
+ function withConfigWriteLock(runtime, callback) {
202
323
  const configFile = resolveConfigFile(runtime);
324
+ const lockDirectory = `${configFile}.write-lock`;
325
+ const ownerId = `${process.pid}.${randomUUID()}`;
326
+ const deadline = Date.now() + CONFIG_WRITE_LOCK_TIMEOUT_MS;
203
327
  mkdirSync(dirname(configFile), { recursive: true });
204
- writeFileSync(configFile, JSON.stringify(normalizeConfig(config), null, 2), { mode: 0o600 });
328
+ for (;;) {
329
+ if (!existsSync(lockDirectory) && publishConfigWriteLock(lockDirectory, ownerId)) {
330
+ break;
331
+ }
332
+
333
+ const owner = readLockOwner(lockDirectory);
334
+ const observedMtime = lockDirectoryMtime(lockDirectory);
335
+ const ownerIsStale =
336
+ owner?.id && Date.now() - Number(owner.at) > CONFIG_WRITE_LOCK_STALE_MS;
337
+ const ownerlessIsStale =
338
+ !owner
339
+ && observedMtime !== null
340
+ && Date.now() - observedMtime > CONFIG_WRITE_LOCK_STALE_MS;
341
+ if (ownerIsStale || ownerlessIsStale) {
342
+ try {
343
+ const currentOwner = readLockOwner(lockDirectory);
344
+ const ownerUnchanged = owner?.id
345
+ ? currentOwner?.id === owner.id
346
+ : !currentOwner && lockDirectoryMtime(lockDirectory) === observedMtime;
347
+ if (ownerUnchanged) {
348
+ rmSync(lockDirectory, { recursive: true, force: true });
349
+ continue;
350
+ }
351
+ } catch {
352
+ // Losing the reclaim race is normal; fall through and keep waiting.
353
+ }
354
+ }
355
+ if (Date.now() >= deadline) {
356
+ throw new Error(`Timed out waiting to update ${configFile}`);
357
+ }
358
+ Atomics.wait(
359
+ new Int32Array(new SharedArrayBuffer(4)),
360
+ 0,
361
+ 0,
362
+ CONFIG_WRITE_LOCK_POLL_MS,
363
+ );
364
+ }
365
+ try {
366
+ return callback(configFile);
367
+ } finally {
368
+ try {
369
+ // Never release a lock that was reclaimed from us while we worked.
370
+ if (readLockOwner(lockDirectory)?.id === ownerId) {
371
+ rmSync(lockDirectory, { recursive: true, force: true });
372
+ }
373
+ } catch {
374
+ // The lock may already have been removed after an interrupted write.
375
+ }
376
+ }
377
+ }
378
+
379
+ export function saveConfig(config, runtime = null) {
380
+ return withConfigWriteLock(runtime, (configFile) => {
381
+ writeConfig(configFile, config);
382
+ });
383
+ }
384
+
385
+ export function updateConfig(updater, runtime = null) {
386
+ return withConfigWriteLock(runtime, (configFile) => {
387
+ const current = loadConfig(runtime);
388
+ const updated = updater(normalizeConfig(current));
389
+ const next = normalizeConfig(updated ?? current);
390
+ writeConfig(configFile, next);
391
+ return next;
392
+ });
205
393
  }
206
394
 
207
395
  export function getProfile(config, profileName) {
@@ -335,6 +523,37 @@ export function parseDebugEntitlementOverride(value = process.env.NOTIS_DEBUG_EN
335
523
  });
336
524
  }
337
525
 
526
+ export function getOAuthResource(profile = {}) {
527
+ if (typeof profile.oauth_resource === 'string' && profile.oauth_resource) {
528
+ return profile.oauth_resource.replace(/\/+$/, '');
529
+ }
530
+ if (typeof profile.oauth_access_token === 'string' && profile.oauth_access_token) {
531
+ try {
532
+ const parts = profile.oauth_access_token.split('.');
533
+ if (parts.length === 3) {
534
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
535
+ if (typeof payload.aud === 'string' && payload.aud) {
536
+ return payload.aud.replace(/\/+$/, '');
537
+ }
538
+ }
539
+ } catch {
540
+ // Legacy tokens without a readable audience fall back below.
541
+ }
542
+ }
543
+ return null;
544
+ }
545
+
546
+ export function getOAuthApiBase(profile = {}) {
547
+ if (typeof profile.oauth_api_base === 'string' && profile.oauth_api_base) {
548
+ return profile.oauth_api_base.replace(/\/+$/, '');
549
+ }
550
+ const resource = getOAuthResource(profile);
551
+ if (resource?.endsWith('/cli')) {
552
+ return resource.slice(0, -'/cli'.length);
553
+ }
554
+ return null;
555
+ }
556
+
338
557
  export function resolveRuntimeProfile(
339
558
  globalOptions = {},
340
559
  { requireAuth = true, includeDebugEntitlementOverride = true } = {},
@@ -355,11 +574,81 @@ export function resolveRuntimeProfile(
355
574
  hints: [{ message: `Expected local API: ${worktreeRuntime.api_base}` }],
356
575
  });
357
576
  }
358
- const apiBase = worktreeRuntime
577
+ let apiBase = worktreeRuntime
359
578
  ? worktreeRuntime.api_base
360
579
  : getApiBase(config, profileName, globalOptions.apiBase);
361
- const jwt = worktreeRuntime ? getProfile(config, profileName).jwt : getJwt(config, profileName);
362
580
  const profile = getProfile(config, profileName);
581
+ const envJwt = !worktreeRuntime ? process.env.NOTIS_JWT : undefined;
582
+ const desktopJwt = profile.jwt;
583
+ const oauthJwt = profile.oauth_access_token;
584
+ let jwt;
585
+ let credentialKind;
586
+
587
+ if (worktreeRuntime && desktopJwt) {
588
+ jwt = desktopJwt;
589
+ credentialKind = 'worktree';
590
+ } else if (envJwt) {
591
+ jwt = envJwt;
592
+ credentialKind = 'env';
593
+ } else if (
594
+ desktopJwt
595
+ && !credentialIsExpired({ credentialKind: 'desktop', jwt: desktopJwt }, profile)
596
+ ) {
597
+ jwt = desktopJwt;
598
+ credentialKind = 'desktop';
599
+ } else if (
600
+ oauthJwt
601
+ && !credentialIsExpired({ credentialKind: 'oauth', jwt: oauthJwt }, profile)
602
+ ) {
603
+ jwt = oauthJwt;
604
+ credentialKind = 'oauth';
605
+ } else if (oauthJwt && profile.oauth_refresh_token) {
606
+ // A lapsed OAuth access token remains usable through its rotating refresh
607
+ // token and must outrank an abandoned, expired Desktop credential.
608
+ jwt = oauthJwt;
609
+ credentialKind = 'oauth';
610
+ } else if (desktopJwt) {
611
+ jwt = desktopJwt;
612
+ credentialKind = 'desktop';
613
+ } else if (oauthJwt) {
614
+ // Preserve the OAuth credential so transport can refresh it before use.
615
+ jwt = oauthJwt;
616
+ credentialKind = 'oauth';
617
+ }
618
+ const oauthApiBase = getOAuthApiBase(profile);
619
+ const oauthResource = getOAuthResource(profile);
620
+ const normalizedRequestedApiBase = requestedApiBase
621
+ ? requestedApiBase.replace(/\/+$/, '')
622
+ : null;
623
+ if (
624
+ requireAuth
625
+ && credentialKind === 'oauth'
626
+ && normalizedRequestedApiBase
627
+ && oauthApiBase
628
+ && normalizedRequestedApiBase !== oauthApiBase
629
+ ) {
630
+ const quoteShellArgument = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
631
+ throw new CliError({
632
+ code: 'oauth_api_target_mismatch',
633
+ message: (
634
+ `The OAuth credential for profile ${profileName} belongs to ${oauthApiBase}, `
635
+ + `not ${normalizedRequestedApiBase}`
636
+ ),
637
+ exitCode: EXIT_CODES.auth,
638
+ hints: [{
639
+ command: [
640
+ 'npx --package @notis_ai/cli@latest -- notis',
641
+ `--profile ${quoteShellArgument(profileName)}`,
642
+ `--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
643
+ 'login --force',
644
+ ].join(' '),
645
+ reason: 'Authorize a separate OAuth grant for the requested Notis environment',
646
+ }],
647
+ });
648
+ }
649
+ if (credentialKind === 'oauth' && oauthApiBase && !requestedApiBase) {
650
+ apiBase = oauthApiBase;
651
+ }
363
652
  const agentMode = isAgentMode(globalOptions);
364
653
  const nonInteractive = isNonInteractive(globalOptions);
365
654
  const outputMode = resolveOutputMode(globalOptions);
@@ -386,7 +675,11 @@ export function resolveRuntimeProfile(
386
675
  }
387
676
  if (
388
677
  worktreeRuntime?.expected_user_id &&
389
- getJwtSubject(jwt) !== worktreeRuntime.expected_user_id
678
+ (
679
+ credentialKind === 'oauth'
680
+ ? profile.oauth_user_id
681
+ : getJwtSubject(jwt)
682
+ ) !== worktreeRuntime.expected_user_id
390
683
  ) {
391
684
  throw new CliError({
392
685
  code: 'dev_runtime_identity_mismatch',
@@ -401,13 +694,25 @@ export function resolveRuntimeProfile(
401
694
 
402
695
  // An explicit NOTIS_JWT is a complete credential override. Use it verbatim
403
696
  // and never replace it with a token later synced by the desktop profile.
404
- const usingEnvJwt = !worktreeRuntime && Boolean(process.env.NOTIS_JWT);
697
+ const usingEnvJwt = credentialKind === 'env';
405
698
  return {
406
699
  config,
407
700
  profileName,
408
701
  apiBase,
702
+ requestedApiBase: normalizedRequestedApiBase,
409
703
  jwt,
410
- credentialSource: worktreeRuntime ? 'worktree' : usingEnvJwt ? 'env' : 'profile',
704
+ credentialKind,
705
+ credentialSource: credentialKind === 'desktop' ? 'profile' : credentialKind,
706
+ oauthAccessToken: profile.oauth_access_token,
707
+ oauthRefreshToken: profile.oauth_refresh_token,
708
+ oauthAccessExpiresAt: profile.oauth_access_expires_at,
709
+ oauthRefreshExpiresAt: profile.oauth_refresh_expires_at,
710
+ oauthClientId: profile.oauth_client_id,
711
+ oauthIssuer: profile.oauth_issuer,
712
+ oauthApiBase,
713
+ oauthResource,
714
+ oauthScopes: profile.oauth_scopes || [],
715
+ oauthUserId: profile.oauth_user_id,
411
716
  desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
412
717
  desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
413
718
  agentMode,
@@ -447,11 +752,68 @@ export function getJwtSubject(jwt) {
447
752
  }
448
753
  }
449
754
 
755
+ export function getJwtCanonicalUserId(jwt) {
756
+ if (typeof jwt !== 'string' || !jwt) {
757
+ return null;
758
+ }
759
+ try {
760
+ const parts = jwt.split('.');
761
+ if (parts.length !== 3) return null;
762
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
763
+ const candidate = payload?.app_metadata?.app_user_id
764
+ || payload?.user_metadata?.notis_user_id
765
+ || payload?.notis_user_id;
766
+ return typeof candidate === 'string' && candidate ? candidate : null;
767
+ } catch {
768
+ return null;
769
+ }
770
+ }
771
+
450
772
  export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
451
773
  const expiration = getJwtExpiration(jwt);
452
774
  return expiration !== null && expiration <= nowSeconds;
453
775
  }
454
776
 
777
+ export function credentialIsExpired(
778
+ runtime,
779
+ profile = {},
780
+ nowSeconds = Math.floor(Date.now() / 1000),
781
+ ) {
782
+ // Older callers and focused transport tests predate `credentialKind`.
783
+ // Infer only the legacy desktop/env shapes; OAuth must always opt in
784
+ // explicitly so a scoped token can never be mistaken for a Supabase JWT.
785
+ const credentialKind = runtime?.credentialKind
786
+ || (runtime?.credentialSource === 'env' ? 'env' : 'desktop');
787
+ switch (credentialKind) {
788
+ case 'oauth': {
789
+ const expiration = Number(profile.oauth_access_expires_at);
790
+ return !Number.isFinite(expiration) || expiration <= nowSeconds;
791
+ }
792
+ case 'env':
793
+ // NOTIS_JWT is a complete override. Never combine it with expiry
794
+ // metadata left behind by a desktop credential in the same profile.
795
+ // A token with no readable `exp` is a personal API key, which never
796
+ // expires, so let the server rather than the CLI reject it.
797
+ {
798
+ const expiration = getJwtExpiration(runtime?.jwt);
799
+ return expiration !== null && expiration <= nowSeconds;
800
+ }
801
+ case 'worktree':
802
+ case 'desktop': {
803
+ const rawExpiration = profile.access_expires_at ?? getJwtExpiration(runtime?.jwt);
804
+ // Every selected credential must carry an independently verifiable
805
+ // expiry. Missing or malformed expiry metadata fails closed.
806
+ if (rawExpiration === null || rawExpiration === undefined || rawExpiration === '') {
807
+ return true;
808
+ }
809
+ const expiration = Number(rawExpiration);
810
+ return !Number.isFinite(expiration) || expiration <= nowSeconds;
811
+ }
812
+ default:
813
+ return true;
814
+ }
815
+ }
816
+
455
817
  export function workspacePath(appId) {
456
818
  return join(WORKSPACE_DIR, appId);
457
819
  }
@@ -3,12 +3,14 @@ import { createReadStream } from 'node:fs';
3
3
  import { CliError, EXIT_CODES } from './errors.js';
4
4
  import {
5
5
  DEFAULT_PROFILE,
6
+ credentialIsExpired,
6
7
  getProfile,
7
8
  getJwtSubject,
8
9
  isJwtExpired,
9
10
  loadConfig,
10
11
  } from './profiles.js';
11
12
  import { createExpiredAuthError, createInvalidAuthHints } from './desktop-auth.js';
13
+ import { refreshOAuthCredential } from './oauth.js';
12
14
 
13
15
  function escapeMultipartHeaderValue(value) {
14
16
  return String(value ?? '')
@@ -155,16 +157,18 @@ function normalizeBackendError(status, payload, runtime) {
155
157
  });
156
158
  }
157
159
 
158
- function reloadJwtFromConfig(runtime) {
159
- if (runtime.credentialSource === 'env') {
160
+ function reloadJwtFromConfig(runtime, loadedProfile = null) {
161
+ if (runtime.credentialKind === 'env' || runtime.credentialKind === 'oauth') {
160
162
  return false;
161
163
  }
162
164
  const profileName = runtime.profileName || DEFAULT_PROFILE;
163
- let profile;
164
- try {
165
- profile = getProfile(loadConfig(runtime.worktreeRuntime), profileName);
166
- } catch {
167
- return false;
165
+ let profile = loadedProfile;
166
+ if (!profile) {
167
+ try {
168
+ profile = getProfile(loadConfig(runtime.worktreeRuntime), profileName);
169
+ } catch {
170
+ return false;
171
+ }
168
172
  }
169
173
  const nextJwt = typeof profile.jwt === 'string' && profile.jwt ? profile.jwt : null;
170
174
  if (!nextJwt || nextJwt === runtime.jwt) {
@@ -183,6 +187,7 @@ function reloadJwtFromConfig(runtime) {
183
187
  });
184
188
  }
185
189
  runtime.jwt = nextJwt;
190
+ runtime.credentialKind = runtime.worktreeRuntime ? 'worktree' : 'desktop';
186
191
  runtime.desktopAppName = profile.desktop_app_name;
187
192
  runtime.desktopPid = profile.desktop_pid;
188
193
  return true;
@@ -200,8 +205,25 @@ export async function httpRequest({
200
205
  // token. Each CLI request only consumes the newest access token it synced to
201
206
  // disk, avoiding refresh-token races between independent CLI processes and
202
207
  // the running desktop session.
203
- reloadJwtFromConfig(runtime);
204
- if (requireAuth && isJwtExpired(runtime.jwt)) {
208
+ let currentProfile;
209
+ if (runtime.credentialKind === 'oauth') {
210
+ if (requireAuth && credentialIsExpired(runtime, {
211
+ oauth_access_expires_at: runtime.oauthAccessExpiresAt,
212
+ })) {
213
+ await refreshOAuthCredential(runtime);
214
+ }
215
+ currentProfile = getProfile(
216
+ loadConfig(runtime.worktreeRuntime),
217
+ runtime.profileName,
218
+ );
219
+ } else {
220
+ currentProfile = getProfile(
221
+ loadConfig(runtime.worktreeRuntime),
222
+ runtime.profileName,
223
+ );
224
+ reloadJwtFromConfig(runtime, currentProfile);
225
+ }
226
+ if (requireAuth && credentialIsExpired(runtime, currentProfile)) {
205
227
  throw createExpiredAuthError(runtime);
206
228
  }
207
229
 
@@ -260,7 +282,9 @@ export async function httpRequest({
260
282
  }
261
283
 
262
284
  if (response.status === 401) {
263
- const refreshed = reloadJwtFromConfig(runtime) && !isJwtExpired(runtime.jwt);
285
+ const refreshed = runtime.credentialKind === 'oauth'
286
+ ? await refreshOAuthCredential(runtime)
287
+ : reloadJwtFromConfig(runtime) && !isJwtExpired(runtime.jwt);
264
288
  if (refreshed) {
265
289
  if (requireAuth && runtime.jwt) {
266
290
  headers.Authorization = `Bearer ${runtime.jwt}`;
@@ -285,7 +309,13 @@ export async function httpRequest({
285
309
  clearTimeout(timeout);
286
310
 
287
311
  if (!response.ok) {
288
- if (response.status === 401 && isJwtExpired(runtime.jwt)) {
312
+ if (
313
+ response.status === 401
314
+ && credentialIsExpired(
315
+ runtime,
316
+ getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName),
317
+ )
318
+ ) {
289
319
  throw createExpiredAuthError(runtime);
290
320
  }
291
321
  throw normalizeBackendError(response.status, payload, runtime);