@notis_ai/cli 0.2.8 → 0.2.10

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';
@@ -8,14 +17,21 @@ export const CONFIG_DIR = join(homedir(), '.notis');
8
17
  export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
9
18
  export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
10
19
  export const DEFAULT_API_BASE = 'https://api.notis.ai';
20
+ export const BETA_API_BASE = 'https://api-beta.notis.ai';
11
21
  export const DEFAULT_PROFILE = 'default';
12
22
  const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
13
23
  const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
14
- const LOCAL_DEFAULT_API_BASES = new Set([
15
- 'http://localhost:3001',
16
- 'http://127.0.0.1:3001',
17
- ]);
18
24
  const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
25
+ const LIVE_API_HOSTS = new Set(['api.notis.ai', 'api-beta.notis.ai']);
26
+ // Cross-process write lock over ~/.notis/config.json. Notis Desktop implements
27
+ // the same protocol independently in electron/src/cli-auth.ts (updateConfig);
28
+ // the `${configFile}.write-lock` directory name and these three timings must
29
+ // stay identical on both sides or `notis login` and desktop syncAuth stop
30
+ // excluding each other and clobber stored OAuth tokens. Guarded by the drift
31
+ // test in packages/cli/test/runtime-auth.test.js.
32
+ const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
33
+ const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
34
+ const CONFIG_WRITE_LOCK_POLL_MS = 10;
19
35
 
20
36
  function clone(value) {
21
37
  return JSON.parse(JSON.stringify(value));
@@ -33,6 +49,7 @@ export function normalizeConfig(rawConfig = {}) {
33
49
  profiles[name] = {
34
50
  jwt: typeof profile.jwt === 'string' ? profile.jwt : undefined,
35
51
  api_base: typeof profile.api_base === 'string' ? profile.api_base : undefined,
52
+ beta: typeof profile.beta === 'boolean' ? profile.beta : undefined,
36
53
  auth_mode: profile.auth_mode === 'dev_portal' ? profile.auth_mode : undefined,
37
54
  refresh_token:
38
55
  typeof profile.refresh_token === 'string' ? profile.refresh_token : undefined,
@@ -43,6 +60,28 @@ export function normalizeConfig(rawConfig = {}) {
43
60
  desktop_app_name:
44
61
  typeof profile.desktop_app_name === 'string' ? profile.desktop_app_name : undefined,
45
62
  desktop_pid: typeof profile.desktop_pid === 'number' ? profile.desktop_pid : undefined,
63
+ oauth_access_token:
64
+ typeof profile.oauth_access_token === 'string' ? profile.oauth_access_token : undefined,
65
+ oauth_refresh_token:
66
+ typeof profile.oauth_refresh_token === 'string' ? profile.oauth_refresh_token : undefined,
67
+ oauth_access_expires_at:
68
+ typeof profile.oauth_access_expires_at === 'number' ? profile.oauth_access_expires_at : undefined,
69
+ oauth_refresh_expires_at:
70
+ typeof profile.oauth_refresh_expires_at === 'number' ? profile.oauth_refresh_expires_at : undefined,
71
+ oauth_client_id:
72
+ typeof profile.oauth_client_id === 'string' ? profile.oauth_client_id : undefined,
73
+ oauth_issuer:
74
+ typeof profile.oauth_issuer === 'string' ? profile.oauth_issuer : undefined,
75
+ oauth_api_base:
76
+ typeof profile.oauth_api_base === 'string' ? profile.oauth_api_base : undefined,
77
+ oauth_resource:
78
+ typeof profile.oauth_resource === 'string' ? profile.oauth_resource : undefined,
79
+ oauth_scopes:
80
+ Array.isArray(profile.oauth_scopes)
81
+ ? profile.oauth_scopes.filter((scope) => typeof scope === 'string')
82
+ : undefined,
83
+ oauth_user_id:
84
+ typeof profile.oauth_user_id === 'string' ? profile.oauth_user_id : undefined,
46
85
  };
47
86
  }
48
87
 
@@ -65,6 +104,7 @@ export function normalizeConfig(rawConfig = {}) {
65
104
  [DEFAULT_PROFILE]: {
66
105
  jwt: typeof raw.jwt === 'string' ? raw.jwt : undefined,
67
106
  api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
107
+ beta: typeof raw.beta === 'boolean' ? raw.beta : undefined,
68
108
  auth_mode: raw.auth_mode === 'dev_portal' ? raw.auth_mode : undefined,
69
109
  refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
70
110
  access_expires_at:
@@ -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) {
@@ -237,6 +425,48 @@ function isLocalApiBase(value) {
237
425
  }
238
426
  }
239
427
 
428
+ function isLiveApiBase(value) {
429
+ if (typeof value !== 'string' || !value) {
430
+ return false;
431
+ }
432
+ try {
433
+ const parsed = new URL(value);
434
+ return parsed.protocol === 'https:' && LIVE_API_HOSTS.has(parsed.hostname);
435
+ } catch {
436
+ return false;
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Pick the live Notis API for this profile.
442
+ *
443
+ * Beta users (`users.beta = true`, mirrored onto the CLI profile by Desktop
444
+ * sync / OAuth against api-beta) hit api-beta.notis.ai; everyone else hits
445
+ * api.notis.ai. Localhost is never a default — only the worktree test lease
446
+ * (`./dev.sh` / `/notis-tests`) may retarget the CLI at loopback.
447
+ */
448
+ export function resolveDefaultLiveApiBase(profile = {}) {
449
+ if (profile.beta === true) {
450
+ return BETA_API_BASE;
451
+ }
452
+ if (profile.beta === false) {
453
+ return DEFAULT_API_BASE;
454
+ }
455
+ if (profile.desktop_app_name === 'Notis Beta') {
456
+ return BETA_API_BASE;
457
+ }
458
+ if (isLiveApiBase(profile.api_base)) {
459
+ try {
460
+ if (new URL(profile.api_base).hostname === 'api-beta.notis.ai') {
461
+ return BETA_API_BASE;
462
+ }
463
+ } catch {
464
+ // fall through
465
+ }
466
+ }
467
+ return DEFAULT_API_BASE;
468
+ }
469
+
240
470
  export function getApiBase(config, profileName, override) {
241
471
  if (override) {
242
472
  return override;
@@ -247,20 +477,16 @@ export function getApiBase(config, profileName, override) {
247
477
  }
248
478
  const profile = getProfile(config, profileName);
249
479
  const profileApiBase = profile.api_base;
250
- const conductorPort = Number.parseInt(process.env.CONDUCTOR_PORT || '', 10);
251
480
 
252
- // Conductor assigns dynamic local ports. When a workspace is running under
253
- // Conductor, treat saved localhost profile values as stale and transparently
254
- // retarget the CLI at the active backend port.
255
- if (
256
- Number.isInteger(conductorPort) &&
257
- conductorPort > 0 &&
258
- (!profileApiBase || LOCAL_DEFAULT_API_BASES.has(profileApiBase) || isLocalApiBase(profileApiBase))
259
- ) {
260
- return `http://localhost:${conductorPort + 1}`;
481
+ // Prefer an explicit non-loopback API stored on the profile (Desktop sync /
482
+ // OAuth / custom overrides). Ignore stale localhost values loopback
483
+ // routing is owned exclusively by the worktree runtime lease under
484
+ // `/notis-tests`, not by CONDUCTOR_PORT or leftover local profile state.
485
+ if (typeof profileApiBase === 'string' && profileApiBase && !isLocalApiBase(profileApiBase)) {
486
+ return profileApiBase.replace(/\/+$/, '');
261
487
  }
262
488
 
263
- return profileApiBase || DEFAULT_API_BASE;
489
+ return resolveDefaultLiveApiBase(profile);
264
490
  }
265
491
 
266
492
  export function getJwt(config, profileName) {
@@ -335,6 +561,37 @@ export function parseDebugEntitlementOverride(value = process.env.NOTIS_DEBUG_EN
335
561
  });
336
562
  }
337
563
 
564
+ export function getOAuthResource(profile = {}) {
565
+ if (typeof profile.oauth_resource === 'string' && profile.oauth_resource) {
566
+ return profile.oauth_resource.replace(/\/+$/, '');
567
+ }
568
+ if (typeof profile.oauth_access_token === 'string' && profile.oauth_access_token) {
569
+ try {
570
+ const parts = profile.oauth_access_token.split('.');
571
+ if (parts.length === 3) {
572
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
573
+ if (typeof payload.aud === 'string' && payload.aud) {
574
+ return payload.aud.replace(/\/+$/, '');
575
+ }
576
+ }
577
+ } catch {
578
+ // Legacy tokens without a readable audience fall back below.
579
+ }
580
+ }
581
+ return null;
582
+ }
583
+
584
+ export function getOAuthApiBase(profile = {}) {
585
+ if (typeof profile.oauth_api_base === 'string' && profile.oauth_api_base) {
586
+ return profile.oauth_api_base.replace(/\/+$/, '');
587
+ }
588
+ const resource = getOAuthResource(profile);
589
+ if (resource?.endsWith('/cli')) {
590
+ return resource.slice(0, -'/cli'.length);
591
+ }
592
+ return null;
593
+ }
594
+
338
595
  export function resolveRuntimeProfile(
339
596
  globalOptions = {},
340
597
  { requireAuth = true, includeDebugEntitlementOverride = true } = {},
@@ -355,11 +612,81 @@ export function resolveRuntimeProfile(
355
612
  hints: [{ message: `Expected local API: ${worktreeRuntime.api_base}` }],
356
613
  });
357
614
  }
358
- const apiBase = worktreeRuntime
615
+ let apiBase = worktreeRuntime
359
616
  ? worktreeRuntime.api_base
360
617
  : getApiBase(config, profileName, globalOptions.apiBase);
361
- const jwt = worktreeRuntime ? getProfile(config, profileName).jwt : getJwt(config, profileName);
362
618
  const profile = getProfile(config, profileName);
619
+ const envJwt = !worktreeRuntime ? process.env.NOTIS_JWT : undefined;
620
+ const desktopJwt = profile.jwt;
621
+ const oauthJwt = profile.oauth_access_token;
622
+ let jwt;
623
+ let credentialKind;
624
+
625
+ if (worktreeRuntime && desktopJwt) {
626
+ jwt = desktopJwt;
627
+ credentialKind = 'worktree';
628
+ } else if (envJwt) {
629
+ jwt = envJwt;
630
+ credentialKind = 'env';
631
+ } else if (
632
+ desktopJwt
633
+ && !credentialIsExpired({ credentialKind: 'desktop', jwt: desktopJwt }, profile)
634
+ ) {
635
+ jwt = desktopJwt;
636
+ credentialKind = 'desktop';
637
+ } else if (
638
+ oauthJwt
639
+ && !credentialIsExpired({ credentialKind: 'oauth', jwt: oauthJwt }, profile)
640
+ ) {
641
+ jwt = oauthJwt;
642
+ credentialKind = 'oauth';
643
+ } else if (oauthJwt && profile.oauth_refresh_token) {
644
+ // A lapsed OAuth access token remains usable through its rotating refresh
645
+ // token and must outrank an abandoned, expired Desktop credential.
646
+ jwt = oauthJwt;
647
+ credentialKind = 'oauth';
648
+ } else if (desktopJwt) {
649
+ jwt = desktopJwt;
650
+ credentialKind = 'desktop';
651
+ } else if (oauthJwt) {
652
+ // Preserve the OAuth credential so transport can refresh it before use.
653
+ jwt = oauthJwt;
654
+ credentialKind = 'oauth';
655
+ }
656
+ const oauthApiBase = getOAuthApiBase(profile);
657
+ const oauthResource = getOAuthResource(profile);
658
+ const normalizedRequestedApiBase = requestedApiBase
659
+ ? requestedApiBase.replace(/\/+$/, '')
660
+ : null;
661
+ if (
662
+ requireAuth
663
+ && credentialKind === 'oauth'
664
+ && normalizedRequestedApiBase
665
+ && oauthApiBase
666
+ && normalizedRequestedApiBase !== oauthApiBase
667
+ ) {
668
+ const quoteShellArgument = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
669
+ throw new CliError({
670
+ code: 'oauth_api_target_mismatch',
671
+ message: (
672
+ `The OAuth credential for profile ${profileName} belongs to ${oauthApiBase}, `
673
+ + `not ${normalizedRequestedApiBase}`
674
+ ),
675
+ exitCode: EXIT_CODES.auth,
676
+ hints: [{
677
+ command: [
678
+ 'npx --package @notis_ai/cli@latest -- notis',
679
+ `--profile ${quoteShellArgument(profileName)}`,
680
+ `--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
681
+ 'login --force',
682
+ ].join(' '),
683
+ reason: 'Authorize a separate OAuth grant for the requested Notis environment',
684
+ }],
685
+ });
686
+ }
687
+ if (credentialKind === 'oauth' && oauthApiBase && !requestedApiBase) {
688
+ apiBase = oauthApiBase;
689
+ }
363
690
  const agentMode = isAgentMode(globalOptions);
364
691
  const nonInteractive = isNonInteractive(globalOptions);
365
692
  const outputMode = resolveOutputMode(globalOptions);
@@ -386,7 +713,11 @@ export function resolveRuntimeProfile(
386
713
  }
387
714
  if (
388
715
  worktreeRuntime?.expected_user_id &&
389
- getJwtSubject(jwt) !== worktreeRuntime.expected_user_id
716
+ (
717
+ credentialKind === 'oauth'
718
+ ? profile.oauth_user_id
719
+ : getJwtSubject(jwt)
720
+ ) !== worktreeRuntime.expected_user_id
390
721
  ) {
391
722
  throw new CliError({
392
723
  code: 'dev_runtime_identity_mismatch',
@@ -401,13 +732,25 @@ export function resolveRuntimeProfile(
401
732
 
402
733
  // An explicit NOTIS_JWT is a complete credential override. Use it verbatim
403
734
  // and never replace it with a token later synced by the desktop profile.
404
- const usingEnvJwt = !worktreeRuntime && Boolean(process.env.NOTIS_JWT);
735
+ const usingEnvJwt = credentialKind === 'env';
405
736
  return {
406
737
  config,
407
738
  profileName,
408
739
  apiBase,
740
+ requestedApiBase: normalizedRequestedApiBase,
409
741
  jwt,
410
- credentialSource: worktreeRuntime ? 'worktree' : usingEnvJwt ? 'env' : 'profile',
742
+ credentialKind,
743
+ credentialSource: credentialKind === 'desktop' ? 'profile' : credentialKind,
744
+ oauthAccessToken: profile.oauth_access_token,
745
+ oauthRefreshToken: profile.oauth_refresh_token,
746
+ oauthAccessExpiresAt: profile.oauth_access_expires_at,
747
+ oauthRefreshExpiresAt: profile.oauth_refresh_expires_at,
748
+ oauthClientId: profile.oauth_client_id,
749
+ oauthIssuer: profile.oauth_issuer,
750
+ oauthApiBase,
751
+ oauthResource,
752
+ oauthScopes: profile.oauth_scopes || [],
753
+ oauthUserId: profile.oauth_user_id,
411
754
  desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
412
755
  desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
413
756
  agentMode,
@@ -447,11 +790,68 @@ export function getJwtSubject(jwt) {
447
790
  }
448
791
  }
449
792
 
793
+ export function getJwtCanonicalUserId(jwt) {
794
+ if (typeof jwt !== 'string' || !jwt) {
795
+ return null;
796
+ }
797
+ try {
798
+ const parts = jwt.split('.');
799
+ if (parts.length !== 3) return null;
800
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
801
+ const candidate = payload?.app_metadata?.app_user_id
802
+ || payload?.user_metadata?.notis_user_id
803
+ || payload?.notis_user_id;
804
+ return typeof candidate === 'string' && candidate ? candidate : null;
805
+ } catch {
806
+ return null;
807
+ }
808
+ }
809
+
450
810
  export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
451
811
  const expiration = getJwtExpiration(jwt);
452
812
  return expiration !== null && expiration <= nowSeconds;
453
813
  }
454
814
 
815
+ export function credentialIsExpired(
816
+ runtime,
817
+ profile = {},
818
+ nowSeconds = Math.floor(Date.now() / 1000),
819
+ ) {
820
+ // Older callers and focused transport tests predate `credentialKind`.
821
+ // Infer only the legacy desktop/env shapes; OAuth must always opt in
822
+ // explicitly so a scoped token can never be mistaken for a Supabase JWT.
823
+ const credentialKind = runtime?.credentialKind
824
+ || (runtime?.credentialSource === 'env' ? 'env' : 'desktop');
825
+ switch (credentialKind) {
826
+ case 'oauth': {
827
+ const expiration = Number(profile.oauth_access_expires_at);
828
+ return !Number.isFinite(expiration) || expiration <= nowSeconds;
829
+ }
830
+ case 'env':
831
+ // NOTIS_JWT is a complete override. Never combine it with expiry
832
+ // metadata left behind by a desktop credential in the same profile.
833
+ // A token with no readable `exp` is a personal API key, which never
834
+ // expires, so let the server rather than the CLI reject it.
835
+ {
836
+ const expiration = getJwtExpiration(runtime?.jwt);
837
+ return expiration !== null && expiration <= nowSeconds;
838
+ }
839
+ case 'worktree':
840
+ case 'desktop': {
841
+ const rawExpiration = profile.access_expires_at ?? getJwtExpiration(runtime?.jwt);
842
+ // Every selected credential must carry an independently verifiable
843
+ // expiry. Missing or malformed expiry metadata fails closed.
844
+ if (rawExpiration === null || rawExpiration === undefined || rawExpiration === '') {
845
+ return true;
846
+ }
847
+ const expiration = Number(rawExpiration);
848
+ return !Number.isFinite(expiration) || expiration <= nowSeconds;
849
+ }
850
+ default:
851
+ return true;
852
+ }
853
+ }
854
+
455
855
  export function workspacePath(appId) {
456
856
  return join(WORKSPACE_DIR, appId);
457
857
  }
@@ -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);