@notis_ai/cli 0.2.9 → 0.2.11

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.
@@ -11,26 +11,33 @@ import {
11
11
  import { homedir } from 'node:os';
12
12
  import { dirname, join, parse, resolve } from 'node:path';
13
13
  import { CliError, EXIT_CODES } from './errors.js';
14
- import { getDesktopAuthRecovery } from './desktop-auth.js';
14
+ import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
15
15
 
16
16
  export const CONFIG_DIR = join(homedir(), '.notis');
17
17
  export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
18
18
  export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
19
19
  export const DEFAULT_API_BASE = 'https://api.notis.ai';
20
+ export const BETA_API_BASE = 'https://api-beta.notis.ai';
20
21
  export const DEFAULT_PROFILE = 'default';
22
+ const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
23
+ const LEGACY_DESKTOP_PROFILE_KEYS = [
24
+ 'jwt',
25
+ 'auth_mode',
26
+ 'refresh_token',
27
+ 'access_expires_at',
28
+ 'refresh_expires_at',
29
+ 'desktop_app_name',
30
+ 'desktop_pid',
31
+ ];
21
32
  const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
22
33
  const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
23
- const LOCAL_DEFAULT_API_BASES = new Set([
24
- 'http://localhost:3001',
25
- 'http://127.0.0.1:3001',
26
- ]);
27
34
  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.
35
+ const LIVE_API_HOSTS = new Set(['api.notis.ai', 'api-beta.notis.ai']);
36
+ // Cross-process write lock over ~/.notis/config.json. Independent `notis`
37
+ // processes (a `login` in one terminal and a `tools exec` in another) all
38
+ // rewrite this file, so the lock directory name and these three timings are the
39
+ // whole coordination protocol. Guarded by the concurrency test in
40
+ // packages/cli/test/runtime-auth.test.js.
34
41
  const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
35
42
  const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
36
43
  const CONFIG_WRITE_LOCK_POLL_MS = 10;
@@ -39,60 +46,107 @@ function clone(value) {
39
46
  return JSON.parse(JSON.stringify(value));
40
47
  }
41
48
 
49
+ export function isValidProfileName(profileName) {
50
+ return typeof profileName === 'string'
51
+ && PROFILE_NAME_PATTERN.test(profileName)
52
+ && !Object.hasOwn(Object.prototype, profileName);
53
+ }
54
+
55
+ function isSafeStoredProfileName(profileName) {
56
+ return typeof profileName === 'string'
57
+ && profileName.length > 0
58
+ && !Object.hasOwn(Object.prototype, profileName);
59
+ }
60
+
61
+ export function profileExists(config, profileName) {
62
+ const normalized = normalizeConfig(config);
63
+ return isSafeStoredProfileName(profileName)
64
+ && Object.hasOwn(normalized.profiles, profileName);
65
+ }
66
+
67
+ function assertValidProfileName(profileName) {
68
+ if (isValidProfileName(profileName)) {
69
+ return;
70
+ }
71
+ throw new CliError({
72
+ code: 'profile_name_invalid',
73
+ message: 'CLI profile names must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens (maximum 64 characters)',
74
+ exitCode: EXIT_CODES.usage,
75
+ hints: [
76
+ { command: 'notis profile list', reason: 'See the valid profiles already on this machine' },
77
+ ],
78
+ });
79
+ }
80
+
81
+ /**
82
+ * A profile is one account paired with one API endpoint.
83
+ *
84
+ * Only two credential shapes survive normalization: the browser-authorized
85
+ * OAuth grant (`oauth_*`) that owns every real account, and the loopback
86
+ * credential `./dev.sh` mints for its worktree test user (`dev_*`). Notis
87
+ * Desktop used to write a Supabase access token here as `jwt`, plus the
88
+ * `desktop_*` liveness hints the CLI used to tell people which app to reopen.
89
+ * Dropping those keys on read is what actually retires that path: a config
90
+ * left behind by an older Desktop build cannot silently keep authenticating
91
+ * the CLI with a credential nothing renews anymore.
92
+ */
93
+ function normalizeProfile(rawProfile = {}) {
94
+ const raw = rawProfile && typeof rawProfile === 'object' ? rawProfile : {};
95
+ return {
96
+ api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
97
+ beta: typeof raw.beta === 'boolean' ? raw.beta : undefined,
98
+ label: typeof raw.label === 'string' ? raw.label : undefined,
99
+ dev_access_token:
100
+ typeof raw.dev_access_token === 'string' ? raw.dev_access_token : undefined,
101
+ dev_access_expires_at:
102
+ typeof raw.dev_access_expires_at === 'number' ? raw.dev_access_expires_at : undefined,
103
+ dev_user_id: typeof raw.dev_user_id === 'string' ? raw.dev_user_id : undefined,
104
+ dev_workspace_root:
105
+ typeof raw.dev_workspace_root === 'string' ? raw.dev_workspace_root : undefined,
106
+ oauth_access_token:
107
+ typeof raw.oauth_access_token === 'string' ? raw.oauth_access_token : undefined,
108
+ oauth_refresh_token:
109
+ typeof raw.oauth_refresh_token === 'string' ? raw.oauth_refresh_token : undefined,
110
+ oauth_access_expires_at:
111
+ typeof raw.oauth_access_expires_at === 'number' ? raw.oauth_access_expires_at : undefined,
112
+ oauth_refresh_expires_at:
113
+ typeof raw.oauth_refresh_expires_at === 'number' ? raw.oauth_refresh_expires_at : undefined,
114
+ oauth_client_id:
115
+ typeof raw.oauth_client_id === 'string' ? raw.oauth_client_id : undefined,
116
+ oauth_issuer: typeof raw.oauth_issuer === 'string' ? raw.oauth_issuer : undefined,
117
+ oauth_api_base: typeof raw.oauth_api_base === 'string' ? raw.oauth_api_base : undefined,
118
+ oauth_resource: typeof raw.oauth_resource === 'string' ? raw.oauth_resource : undefined,
119
+ oauth_scopes:
120
+ Array.isArray(raw.oauth_scopes)
121
+ ? raw.oauth_scopes.filter((scope) => typeof scope === 'string')
122
+ : undefined,
123
+ oauth_user_id: typeof raw.oauth_user_id === 'string' ? raw.oauth_user_id : undefined,
124
+ };
125
+ }
126
+
42
127
  export function normalizeConfig(rawConfig = {}) {
43
128
  const raw = rawConfig && typeof rawConfig === 'object' ? clone(rawConfig) : {};
44
129
 
45
130
  if (raw.profiles && typeof raw.profiles === 'object') {
46
131
  const profiles = {};
47
132
  for (const [name, profile] of Object.entries(raw.profiles)) {
48
- if (!profile || typeof profile !== 'object') {
133
+ // Older CLI releases accepted arbitrary profile names. Keep every safe
134
+ // own-property name readable; the stricter grammar applies only when a
135
+ // command creates a new profile.
136
+ if (!isSafeStoredProfileName(name) || !profile || typeof profile !== 'object') {
49
137
  continue;
50
138
  }
51
- profiles[name] = {
52
- jwt: typeof profile.jwt === 'string' ? profile.jwt : undefined,
53
- api_base: typeof profile.api_base === 'string' ? profile.api_base : undefined,
54
- auth_mode: profile.auth_mode === 'dev_portal' ? profile.auth_mode : undefined,
55
- refresh_token:
56
- typeof profile.refresh_token === 'string' ? profile.refresh_token : undefined,
57
- access_expires_at:
58
- typeof profile.access_expires_at === 'number' ? profile.access_expires_at : undefined,
59
- refresh_expires_at:
60
- typeof profile.refresh_expires_at === 'number' ? profile.refresh_expires_at : undefined,
61
- desktop_app_name:
62
- typeof profile.desktop_app_name === 'string' ? profile.desktop_app_name : undefined,
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,
86
- };
139
+ profiles[name] = normalizeProfile(profile);
87
140
  }
88
141
 
89
- if (!profiles[DEFAULT_PROFILE]) {
142
+ if (!Object.hasOwn(profiles, DEFAULT_PROFILE)) {
90
143
  profiles[DEFAULT_PROFILE] = {};
91
144
  }
92
145
 
93
146
  return {
94
147
  current_profile:
95
- typeof raw.current_profile === 'string' && raw.current_profile in profiles
148
+ typeof raw.current_profile === 'string'
149
+ && Object.hasOwn(profiles, raw.current_profile)
96
150
  ? raw.current_profile
97
151
  : DEFAULT_PROFILE,
98
152
  profiles,
@@ -101,46 +155,44 @@ export function normalizeConfig(rawConfig = {}) {
101
155
 
102
156
  return {
103
157
  current_profile: DEFAULT_PROFILE,
104
- profiles: {
105
- [DEFAULT_PROFILE]: {
106
- jwt: typeof raw.jwt === 'string' ? raw.jwt : undefined,
107
- api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
108
- auth_mode: raw.auth_mode === 'dev_portal' ? raw.auth_mode : undefined,
109
- refresh_token: typeof raw.refresh_token === 'string' ? raw.refresh_token : undefined,
110
- access_expires_at:
111
- typeof raw.access_expires_at === 'number' ? raw.access_expires_at : undefined,
112
- refresh_expires_at:
113
- typeof raw.refresh_expires_at === 'number' ? raw.refresh_expires_at : undefined,
114
- desktop_app_name:
115
- typeof raw.desktop_app_name === 'string' ? raw.desktop_app_name : undefined,
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,
139
- },
140
- },
158
+ profiles: { [DEFAULT_PROFILE]: normalizeProfile(raw) },
141
159
  };
142
160
  }
143
161
 
162
+ export function profileHasCredential(profile = {}) {
163
+ return Boolean(profile.oauth_access_token || profile.dev_access_token);
164
+ }
165
+
166
+ /**
167
+ * Names every profile a switch can land on, newest config order preserved.
168
+ * `notis profile use` and `--profile` both validate against this.
169
+ */
170
+ export function listProfiles(config) {
171
+ const normalized = normalizeConfig(config);
172
+ return Object.entries(normalized.profiles).map(([name, profile]) => ({
173
+ name,
174
+ active: name === normalized.current_profile,
175
+ // Report the endpoint the profile was created against, including the
176
+ // loopback address of a `./dev.sh` profile. getApiBase resolves a stale
177
+ // loopback value to the live API for routing; showing that here would
178
+ // claim a dev profile targets production, which it must never do.
179
+ // OAuth metadata owns the route for an OAuth profile. An older Desktop
180
+ // release may have left a conflicting api_base behind, but commands ignore
181
+ // that legacy value and so must profile inspection.
182
+ api_base: profile.oauth_access_token
183
+ ? getOAuthApiBase(profile) || resolveDefaultLiveApiBase(profile)
184
+ : profile.api_base || resolveDefaultLiveApiBase(profile),
185
+ label: profile.label || null,
186
+ credential_kind: profile.oauth_access_token
187
+ ? 'oauth'
188
+ : profile.dev_access_token
189
+ ? 'dev'
190
+ : null,
191
+ user_id: profile.oauth_user_id || profile.dev_user_id || null,
192
+ authenticated: profileHasCredential(profile),
193
+ }));
194
+ }
195
+
144
196
  function readJsonFile(path) {
145
197
  try {
146
198
  return JSON.parse(readFileSync(path, 'utf-8'));
@@ -189,22 +241,27 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
189
241
 
190
242
  if (!runtimePath) {
191
243
  if (routing?.mode === 'local-only') {
192
- throw new CliError({
193
- code: 'dev_runtime_unavailable',
194
- message: `This worktree is local-only, but its dev.sh runtime is not active`,
195
- exitCode: EXIT_CODES.network,
196
- hints: [
197
- { message: 'Start ./dev.sh in this worktree, then retry the command.' },
198
- { message: `Routing policy: ${routingPath}` },
199
- ],
200
- });
244
+ return {
245
+ unavailable: new CliError({
246
+ code: 'dev_runtime_unavailable',
247
+ message: 'This worktree is local-only, but its dev.sh runtime is not active',
248
+ exitCode: EXIT_CODES.network,
249
+ hints: [
250
+ { message: 'Start ./dev.sh in this worktree, then retry the command.' },
251
+ { command: 'notis profile list', reason: 'Run against a live account profile instead' },
252
+ { message: `Routing policy: ${routingPath}` },
253
+ ],
254
+ }),
255
+ };
201
256
  }
202
257
  return null;
203
258
  }
204
259
 
205
260
  const runtime = readJsonFile(runtimePath);
206
261
  const apiBase = typeof runtime?.api_base === 'string' ? runtime.api_base.replace(/\/+$/, '') : '';
207
- const configFile = typeof runtime?.config_file === 'string' ? runtime.config_file : '';
262
+ const profile = typeof runtime?.profile === 'string' ? runtime.profile.trim() : '';
263
+ const devAccessToken =
264
+ typeof runtime?.dev_access_token === 'string' ? runtime.dev_access_token.trim() : '';
208
265
  const appDevSessionsFile =
209
266
  typeof runtime?.app_dev_sessions_file === 'string' && runtime.app_dev_sessions_file.trim()
210
267
  ? runtime.app_dev_sessions_file.trim()
@@ -217,24 +274,33 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
217
274
  if (
218
275
  runtime?.mode !== 'local-only' ||
219
276
  !isLocalApiBase(apiBase) ||
220
- !configFile ||
277
+ !profile ||
278
+ !devAccessToken ||
221
279
  !processIsAlive(pid)
222
280
  ) {
223
- throw new CliError({
224
- code: 'dev_runtime_unavailable',
225
- message: 'The local-only worktree runtime is stale or invalid',
226
- exitCode: EXIT_CODES.network,
227
- hints: [
228
- { message: 'Restart ./dev.sh in this worktree, then retry the command.' },
229
- { message: `Runtime lease: ${runtimePath}` },
230
- ],
231
- });
281
+ // Reported rather than thrown: an unusable lease must not stop someone
282
+ // from listing profiles or switching to a live account from inside the
283
+ // same checkout. resolveRuntimeProfile raises it only when a command is
284
+ // about to route through the dead local backend.
285
+ return {
286
+ unavailable: new CliError({
287
+ code: 'dev_runtime_unavailable',
288
+ message: 'The local-only worktree runtime is stale or invalid',
289
+ exitCode: EXIT_CODES.network,
290
+ hints: [
291
+ { message: 'Restart ./dev.sh in this worktree, then retry the command.' },
292
+ { command: 'notis profile list', reason: 'Run against a live account profile instead' },
293
+ { message: `Runtime lease: ${runtimePath}` },
294
+ ],
295
+ }),
296
+ };
232
297
  }
233
298
 
234
299
  return {
235
300
  ...runtime,
236
301
  api_base: apiBase,
237
- config_file: resolve(dirname(runtimePath), configFile),
302
+ profile,
303
+ dev_access_token: devAccessToken,
238
304
  app_dev_sessions_file: resolve(dirname(runtimePath), appDevSessionsFile),
239
305
  desktop_deep_link_scheme: desktopDeepLinkScheme || undefined,
240
306
  runtime_path: runtimePath,
@@ -242,10 +308,12 @@ export function resolveWorktreeRuntime(startDir = process.cwd()) {
242
308
  };
243
309
  }
244
310
 
245
- export function resolveConfigFile(runtime = null) {
246
- if (runtime?.config_file) {
247
- return runtime.config_file;
248
- }
311
+ /**
312
+ * Real account profiles live in one shared file. A `./dev.sh` worktree keeps
313
+ * its synthetic profile credential in the worktree-owned runtime lease, so an
314
+ * older CLI process cannot normalize it away while rewriting this file.
315
+ */
316
+ export function resolveConfigFile() {
249
317
  const envConfigFile = process.env.NOTIS_CLI_CONFIG_FILE;
250
318
  if (envConfigFile) {
251
319
  return resolve(envConfigFile);
@@ -253,13 +321,13 @@ export function resolveConfigFile(runtime = null) {
253
321
  return CONFIG_FILE;
254
322
  }
255
323
 
256
- export function loadConfig(runtime = null) {
257
- const configFile = resolveConfigFile(runtime);
324
+ export function loadConfig() {
325
+ const configFile = resolveConfigFile();
258
326
  if (!existsSync(configFile)) {
259
327
  return normalizeConfig({});
260
328
  }
261
329
 
262
- // The desktop app rewrites this file while the CLI may be reading it, so a
330
+ // Concurrent `notis` processes rewrite this file while others read it, so a
263
331
  // torn or corrupt read is expected rather than exceptional. Degrade to
264
332
  // "unauthenticated", which surfaces the actionable auth_missing error instead
265
333
  // of a raw SyntaxError from deep inside the runtime.
@@ -271,9 +339,33 @@ export function loadConfig(runtime = null) {
271
339
  }
272
340
 
273
341
  function writeConfig(configFile, config) {
342
+ let rawConfig = null;
343
+ try {
344
+ rawConfig = JSON.parse(readFileSync(configFile, 'utf-8'));
345
+ } catch {
346
+ // A missing or corrupt file has no upgrade fields to preserve.
347
+ }
348
+ const normalized = normalizeConfig(config);
349
+ const persisted = clone(normalized);
350
+ const rawProfiles = rawConfig?.profiles && typeof rawConfig.profiles === 'object'
351
+ ? rawConfig.profiles
352
+ : { [DEFAULT_PROFILE]: rawConfig };
353
+ for (const [name, profile] of Object.entries(persisted.profiles)) {
354
+ const rawProfile = Object.hasOwn(rawProfiles || {}, name) ? rawProfiles[name] : null;
355
+ if (!rawProfile || typeof rawProfile !== 'object') continue;
356
+ for (const key of LEGACY_DESKTOP_PROFILE_KEYS) {
357
+ if (Object.hasOwn(rawProfile, key)) {
358
+ profile[key] = rawProfile[key];
359
+ }
360
+ }
361
+ }
362
+ writeRawConfig(configFile, persisted);
363
+ }
364
+
365
+ function writeRawConfig(configFile, config) {
274
366
  mkdirSync(dirname(configFile), { recursive: true });
275
367
  const temporaryFile = `${configFile}.${process.pid}.${Date.now()}.tmp`;
276
- writeFileSync(temporaryFile, JSON.stringify(normalizeConfig(config), null, 2), { mode: 0o600 });
368
+ writeFileSync(temporaryFile, JSON.stringify(config, null, 2), { mode: 0o600 });
277
369
  renameSync(temporaryFile, configFile);
278
370
  }
279
371
 
@@ -319,8 +411,8 @@ function publishConfigWriteLock(lockDirectory, ownerId) {
319
411
  }
320
412
  }
321
413
 
322
- function withConfigWriteLock(runtime, callback) {
323
- const configFile = resolveConfigFile(runtime);
414
+ function withConfigWriteLock(callback) {
415
+ const configFile = resolveConfigFile();
324
416
  const lockDirectory = `${configFile}.write-lock`;
325
417
  const ownerId = `${process.pid}.${randomUUID()}`;
326
418
  const deadline = Date.now() + CONFIG_WRITE_LOCK_TIMEOUT_MS;
@@ -376,15 +468,15 @@ function withConfigWriteLock(runtime, callback) {
376
468
  }
377
469
  }
378
470
 
379
- export function saveConfig(config, runtime = null) {
380
- return withConfigWriteLock(runtime, (configFile) => {
471
+ export function saveConfig(config) {
472
+ return withConfigWriteLock((configFile) => {
381
473
  writeConfig(configFile, config);
382
474
  });
383
475
  }
384
476
 
385
- export function updateConfig(updater, runtime = null) {
386
- return withConfigWriteLock(runtime, (configFile) => {
387
- const current = loadConfig(runtime);
477
+ export function updateConfig(updater) {
478
+ return withConfigWriteLock((configFile) => {
479
+ const current = loadConfig();
388
480
  const updated = updater(normalizeConfig(current));
389
481
  const next = normalizeConfig(updated ?? current);
390
482
  writeConfig(configFile, next);
@@ -392,22 +484,112 @@ export function updateConfig(updater, runtime = null) {
392
484
  });
393
485
  }
394
486
 
487
+ /**
488
+ * Remove only worktree-owned profiles without normalizing the rest of the
489
+ * shared file. Archive cleanup can run before a packaged Desktop upgrade has
490
+ * migrated its legacy `jwt`; preserving unknown/raw fields here keeps that
491
+ * migrate-then-strip handoff intact.
492
+ */
493
+ export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
494
+ return withConfigWriteLock((configFile) => {
495
+ let raw;
496
+ try {
497
+ raw = JSON.parse(readFileSync(configFile, 'utf-8'));
498
+ } catch {
499
+ return [];
500
+ }
501
+ if (!raw || typeof raw !== 'object' || !raw.profiles || typeof raw.profiles !== 'object') {
502
+ return [];
503
+ }
504
+
505
+ const removed = [];
506
+ for (const name of profileNames) {
507
+ const profile = Object.hasOwn(raw.profiles, name) ? raw.profiles[name] : null;
508
+ if (
509
+ !profile
510
+ || typeof profile !== 'object'
511
+ || profile.dev_workspace_root !== workspaceRoot
512
+ ) {
513
+ continue;
514
+ }
515
+ delete raw.profiles[name];
516
+ removed.push(name);
517
+ if (raw.current_profile === name) raw.current_profile = DEFAULT_PROFILE;
518
+ }
519
+ if (removed.length > 0) writeRawConfig(configFile, raw);
520
+ return removed;
521
+ });
522
+ }
523
+
395
524
  export function getProfile(config, profileName) {
396
525
  const normalized = normalizeConfig(config);
397
- return normalized.profiles[profileName] || {};
526
+ return Object.hasOwn(normalized.profiles, profileName)
527
+ ? normalized.profiles[profileName]
528
+ : {};
398
529
  }
399
530
 
400
531
  export function getCurrentProfileName(config, preferredName) {
401
532
  const normalized = normalizeConfig(config);
402
- if (preferredName && normalized.profiles[preferredName]) {
533
+ if (preferredName && Object.hasOwn(normalized.profiles, preferredName)) {
403
534
  return preferredName;
404
535
  }
405
536
  return normalized.current_profile || DEFAULT_PROFILE;
406
537
  }
407
538
 
539
+ /**
540
+ * Resolve which profile this invocation runs as.
541
+ *
542
+ * An explicit `--profile` / `NOTIS_PROFILE` always wins, including inside a
543
+ * `./dev.sh` worktree: naming another account is the documented way to reach
544
+ * production from a checkout whose local backend is wedged. With nothing
545
+ * explicit, a live worktree lease selects its own dev profile, and otherwise
546
+ * the switch persisted by `notis profile use` applies.
547
+ */
548
+ export function resolveProfileSelection(
549
+ globalOptions = {},
550
+ worktreeRuntime = null,
551
+ config,
552
+ { allowUnknownProfile = false } = {},
553
+ ) {
554
+ const normalized = normalizeConfig(config);
555
+ const requested = globalOptions.profile || process.env.NOTIS_PROFILE || '';
556
+ if (requested) {
557
+ const existingProfile = Object.hasOwn(normalized.profiles, requested);
558
+ if (!existingProfile) {
559
+ // Existing profiles may have names accepted by earlier releases. Only a
560
+ // new profile created by login/start must satisfy today's grammar.
561
+ assertValidProfileName(requested);
562
+ }
563
+ if (!existingProfile && !allowUnknownProfile) {
564
+ throw new CliError({
565
+ code: 'profile_unknown',
566
+ message: `No CLI profile named "${requested}"`,
567
+ exitCode: EXIT_CODES.usage,
568
+ details: { known_profiles: Object.keys(normalized.profiles) },
569
+ hints: [
570
+ { command: 'notis profile list', reason: 'See which profiles this machine has' },
571
+ {
572
+ command: `notis login --profile ${quoteShellArgument(requested)}`,
573
+ reason: 'Authorize a new account under this profile name',
574
+ },
575
+ ],
576
+ });
577
+ }
578
+ return { profileName: requested, source: 'explicit' };
579
+ }
580
+ if (worktreeRuntime?.profile) {
581
+ return { profileName: worktreeRuntime.profile, source: 'worktree' };
582
+ }
583
+ return {
584
+ profileName: normalized.current_profile || DEFAULT_PROFILE,
585
+ source: 'current',
586
+ };
587
+ }
588
+
408
589
  export function ensureProfile(config, profileName) {
409
590
  const normalized = normalizeConfig(config);
410
- if (!normalized.profiles[profileName]) {
591
+ if (!Object.hasOwn(normalized.profiles, profileName)) {
592
+ assertValidProfileName(profileName);
411
593
  normalized.profiles[profileName] = {};
412
594
  }
413
595
  return normalized;
@@ -425,6 +607,45 @@ function isLocalApiBase(value) {
425
607
  }
426
608
  }
427
609
 
610
+ function isLiveApiBase(value) {
611
+ if (typeof value !== 'string' || !value) {
612
+ return false;
613
+ }
614
+ try {
615
+ const parsed = new URL(value);
616
+ return parsed.protocol === 'https:' && LIVE_API_HOSTS.has(parsed.hostname);
617
+ } catch {
618
+ return false;
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Pick the live Notis API for this profile.
624
+ *
625
+ * Beta users (`users.beta = true`, mirrored onto the profile when OAuth
626
+ * authorizes against api-beta) hit api-beta.notis.ai; everyone else hits
627
+ * api.notis.ai. Localhost is never a default — only a `./dev.sh` profile
628
+ * backed by a live worktree lease may retarget the CLI at loopback.
629
+ */
630
+ export function resolveDefaultLiveApiBase(profile = {}) {
631
+ if (profile.beta === true) {
632
+ return BETA_API_BASE;
633
+ }
634
+ if (profile.beta === false) {
635
+ return DEFAULT_API_BASE;
636
+ }
637
+ if (isLiveApiBase(profile.api_base)) {
638
+ try {
639
+ if (new URL(profile.api_base).hostname === 'api-beta.notis.ai') {
640
+ return BETA_API_BASE;
641
+ }
642
+ } catch {
643
+ // fall through
644
+ }
645
+ }
646
+ return DEFAULT_API_BASE;
647
+ }
648
+
428
649
  export function getApiBase(config, profileName, override) {
429
650
  if (override) {
430
651
  return override;
@@ -435,29 +656,16 @@ export function getApiBase(config, profileName, override) {
435
656
  }
436
657
  const profile = getProfile(config, profileName);
437
658
  const profileApiBase = profile.api_base;
438
- const conductorPort = Number.parseInt(process.env.CONDUCTOR_PORT || '', 10);
439
659
 
440
- // Conductor assigns dynamic local ports. When a workspace is running under
441
- // Conductor, treat saved localhost profile values as stale and transparently
442
- // retarget the CLI at the active backend port.
443
- if (
444
- Number.isInteger(conductorPort) &&
445
- conductorPort > 0 &&
446
- (!profileApiBase || LOCAL_DEFAULT_API_BASES.has(profileApiBase) || isLocalApiBase(profileApiBase))
447
- ) {
448
- return `http://localhost:${conductorPort + 1}`;
660
+ // A loopback api_base is only meaningful while the `./dev.sh` that wrote it
661
+ // is still running, and resolveRuntimeProfile checks that lease before it
662
+ // routes anywhere. Here with no lease in hand — a leftover localhost value
663
+ // resolves to the live API rather than to a port nothing is listening on.
664
+ if (typeof profileApiBase === 'string' && profileApiBase && !isLocalApiBase(profileApiBase)) {
665
+ return profileApiBase.replace(/\/+$/, '');
449
666
  }
450
667
 
451
- return profileApiBase || DEFAULT_API_BASE;
452
- }
453
-
454
- export function getJwt(config, profileName) {
455
- const env = process.env.NOTIS_JWT;
456
- if (env) {
457
- return env;
458
- }
459
- const profile = getProfile(config, profileName);
460
- return profile.jwt;
668
+ return resolveDefaultLiveApiBase(profile);
461
669
  }
462
670
 
463
671
  export function isAgentMode(globalOptions = {}) {
@@ -556,62 +764,95 @@ export function getOAuthApiBase(profile = {}) {
556
764
 
557
765
  export function resolveRuntimeProfile(
558
766
  globalOptions = {},
559
- { requireAuth = true, includeDebugEntitlementOverride = true } = {},
767
+ {
768
+ requireAuth = true,
769
+ includeDebugEntitlementOverride = true,
770
+ allowUnknownProfile = false,
771
+ allowUnavailableWorktree = false,
772
+ } = {},
560
773
  ) {
561
- const worktreeRuntime = resolveWorktreeRuntime();
562
- const config = loadConfig(worktreeRuntime);
563
- const profileName = getCurrentProfileName(config, globalOptions.profile);
774
+ const resolvedWorktree = resolveWorktreeRuntime();
775
+ const worktreeUnavailable = resolvedWorktree?.unavailable || null;
776
+ const worktreeRuntime = worktreeUnavailable ? null : resolvedWorktree;
777
+ const config = loadConfig();
778
+ const { profileName, source: profileSource } = resolveProfileSelection(
779
+ globalOptions,
780
+ worktreeRuntime,
781
+ config,
782
+ { allowUnknownProfile },
783
+ );
784
+ // A dead lease is an error for every command that can leave the machine,
785
+ // including unauthenticated health and OAuth calls. Only command specs that
786
+ // are explicitly local may continue inside a stopped worktree.
787
+ if (worktreeUnavailable && !allowUnavailableWorktree && profileSource !== 'explicit') {
788
+ throw worktreeUnavailable;
789
+ }
790
+ // Only the synthetic profile exposed by `./dev.sh` is pinned to its loopback
791
+ // backend.
792
+ // Naming any other profile opts out of the worktree entirely, which is how a
793
+ // developer reaches production from a checkout whose local API is down.
794
+ const devRuntime =
795
+ worktreeRuntime && worktreeRuntime.profile === profileName ? worktreeRuntime : null;
564
796
  const requestedApiBase = globalOptions.apiBase;
565
797
  if (
566
- worktreeRuntime &&
798
+ devRuntime &&
567
799
  requestedApiBase &&
568
- requestedApiBase.replace(/\/+$/, '') !== worktreeRuntime.api_base
800
+ requestedApiBase.replace(/\/+$/, '') !== devRuntime.api_base
569
801
  ) {
570
802
  throw new CliError({
571
803
  code: 'dev_runtime_route_mismatch',
572
- message: `This local-only worktree cannot route to ${requestedApiBase}`,
804
+ message: `Profile "${profileName}" is bound to this worktree and cannot route to ${requestedApiBase}`,
573
805
  exitCode: EXIT_CODES.usage,
574
- hints: [{ message: `Expected local API: ${worktreeRuntime.api_base}` }],
806
+ hints: [
807
+ { message: `Expected local API: ${devRuntime.api_base}` },
808
+ { command: 'notis profile list', reason: 'Switch to a profile that targets that API instead' },
809
+ ],
575
810
  });
576
811
  }
577
- let apiBase = worktreeRuntime
578
- ? worktreeRuntime.api_base
812
+ let apiBase = devRuntime
813
+ ? devRuntime.api_base
579
814
  : getApiBase(config, profileName, globalOptions.apiBase);
580
815
  const profile = getProfile(config, profileName);
581
- const envJwt = !worktreeRuntime ? process.env.NOTIS_JWT : undefined;
582
- const desktopJwt = profile.jwt;
816
+ const envJwt = !devRuntime ? process.env.NOTIS_JWT : undefined;
817
+ const devJwt = devRuntime?.dev_access_token || profile.dev_access_token;
583
818
  const oauthJwt = profile.oauth_access_token;
584
819
  let jwt;
585
820
  let credentialKind;
586
821
 
587
- if (worktreeRuntime && desktopJwt) {
588
- jwt = desktopJwt;
822
+ // A dev credential is a real Supabase token for the worktree's test user. It
823
+ // is only ever spendable against the loopback backend that minted it, so a
824
+ // profile holding one is unusable without its live lease rather than falling
825
+ // through to the live API and authenticating there as the test user.
826
+ if (requireAuth && !devRuntime && devJwt && !oauthJwt && !process.env.NOTIS_JWT) {
827
+ throw new CliError({
828
+ code: 'dev_runtime_unavailable',
829
+ message: `Profile "${profileName}" is a ./dev.sh profile and its local runtime is not active`,
830
+ exitCode: EXIT_CODES.network,
831
+ details: { workspace_root: profile.dev_workspace_root || null },
832
+ hints: [
833
+ profile.dev_workspace_root
834
+ ? { message: `Start ./dev.sh in ${profile.dev_workspace_root}, then retry.` }
835
+ : { message: 'Start ./dev.sh in the worktree that owns this profile, then retry.' },
836
+ { command: 'notis profile list', reason: 'Switch to a live account profile instead' },
837
+ ],
838
+ });
839
+ }
840
+
841
+ if (devRuntime && devJwt) {
842
+ jwt = devJwt;
589
843
  credentialKind = 'worktree';
590
844
  } else if (envJwt) {
591
845
  jwt = envJwt;
592
846
  credentialKind = 'env';
593
- } else if (
594
- desktopJwt
595
- && !credentialIsExpired({ credentialKind: 'desktop', jwt: desktopJwt }, profile)
596
- ) {
597
- jwt = desktopJwt;
598
- credentialKind = 'desktop';
599
847
  } else if (
600
848
  oauthJwt
601
849
  && !credentialIsExpired({ credentialKind: 'oauth', jwt: oauthJwt }, profile)
602
850
  ) {
603
851
  jwt = oauthJwt;
604
852
  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
853
  } else if (oauthJwt) {
614
- // Preserve the OAuth credential so transport can refresh it before use.
854
+ // A lapsed access token is still usable through its rotating refresh token,
855
+ // so preserve it and let transport refresh before the first request.
615
856
  jwt = oauthJwt;
616
857
  credentialKind = 'oauth';
617
858
  }
@@ -627,7 +868,6 @@ export function resolveRuntimeProfile(
627
868
  && oauthApiBase
628
869
  && normalizedRequestedApiBase !== oauthApiBase
629
870
  ) {
630
- const quoteShellArgument = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
631
871
  throw new CliError({
632
872
  code: 'oauth_api_target_mismatch',
633
873
  message: (
@@ -640,7 +880,7 @@ export function resolveRuntimeProfile(
640
880
  'npx --package @notis_ai/cli@latest -- notis',
641
881
  `--profile ${quoteShellArgument(profileName)}`,
642
882
  `--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
643
- 'login --force',
883
+ 'login',
644
884
  ].join(' '),
645
885
  reason: 'Authorize a separate OAuth grant for the requested Notis environment',
646
886
  }],
@@ -658,51 +898,42 @@ export function resolveRuntimeProfile(
658
898
  : null;
659
899
 
660
900
  if (requireAuth && !jwt) {
661
- const recovery = getDesktopAuthRecovery(
662
- {
663
- apiBase,
664
- desktopAppName: profile.desktop_app_name,
665
- desktopPid: profile.desktop_pid,
666
- },
667
- { mode: 'missing' },
668
- );
669
901
  throw new CliError({
670
902
  code: 'auth_missing',
671
- message: `No JWT configured for profile ${profileName}`,
903
+ message: `Profile "${profileName}" has no Notis credential`,
672
904
  exitCode: EXIT_CODES.auth,
673
- hints: recovery.hints,
905
+ hints: getAuthRecovery({ profileName, apiBase }, { mode: 'missing' }).hints,
674
906
  });
675
907
  }
676
908
  if (
677
- worktreeRuntime?.expected_user_id &&
909
+ devRuntime?.expected_user_id &&
678
910
  (
679
911
  credentialKind === 'oauth'
680
912
  ? profile.oauth_user_id
681
913
  : getJwtSubject(jwt)
682
- ) !== worktreeRuntime.expected_user_id
914
+ ) !== devRuntime.expected_user_id
683
915
  ) {
684
916
  throw new CliError({
685
917
  code: 'dev_runtime_identity_mismatch',
686
- message: 'The scoped dev credential does not belong to this worktree test user',
918
+ message: `Profile "${profileName}" no longer holds this worktree's test identity`,
687
919
  exitCode: EXIT_CODES.auth,
688
920
  hints: [
689
921
  { message: 'Restart ./dev.sh to restore the approved worktree identity.' },
690
- { message: `Expected user: ${worktreeRuntime.expected_user_id}` },
922
+ { message: `Expected user: ${devRuntime.expected_user_id}` },
691
923
  ],
692
924
  });
693
925
  }
694
926
 
695
- // An explicit NOTIS_JWT is a complete credential override. Use it verbatim
696
- // and never replace it with a token later synced by the desktop profile.
697
- const usingEnvJwt = credentialKind === 'env';
698
927
  return {
699
928
  config,
700
929
  profileName,
930
+ profileSource,
931
+ profileLabel: profile.label,
701
932
  apiBase,
702
933
  requestedApiBase: normalizedRequestedApiBase,
703
934
  jwt,
704
935
  credentialKind,
705
- credentialSource: credentialKind === 'desktop' ? 'profile' : credentialKind,
936
+ credentialSource: credentialKind,
706
937
  oauthAccessToken: profile.oauth_access_token,
707
938
  oauthRefreshToken: profile.oauth_refresh_token,
708
939
  oauthAccessExpiresAt: profile.oauth_access_expires_at,
@@ -713,14 +944,14 @@ export function resolveRuntimeProfile(
713
944
  oauthResource,
714
945
  oauthScopes: profile.oauth_scopes || [],
715
946
  oauthUserId: profile.oauth_user_id,
716
- desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
717
- desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
718
947
  agentMode,
719
948
  nonInteractive,
720
949
  outputMode,
721
950
  timeoutMs,
722
951
  debugEntitlementOverride,
723
- worktreeRuntime,
952
+ worktreeRuntime: devRuntime,
953
+ detachedWorktreeRuntime: devRuntime ? null : worktreeRuntime,
954
+ worktreeRuntimeUnavailable: worktreeUnavailable,
724
955
  };
725
956
  }
726
957
 
@@ -752,23 +983,6 @@ export function getJwtSubject(jwt) {
752
983
  }
753
984
  }
754
985
 
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
-
772
986
  export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
773
987
  const expiration = getJwtExpiration(jwt);
774
988
  return expiration !== null && expiration <= nowSeconds;
@@ -779,11 +993,10 @@ export function credentialIsExpired(
779
993
  profile = {},
780
994
  nowSeconds = Math.floor(Date.now() / 1000),
781
995
  ) {
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.
996
+ // A credential with no declared kind is not one this CLI knows how to keep
997
+ // alive, so it fails closed rather than defaulting to a permissive shape.
785
998
  const credentialKind = runtime?.credentialKind
786
- || (runtime?.credentialSource === 'env' ? 'env' : 'desktop');
999
+ || (runtime?.credentialSource === 'env' ? 'env' : null);
787
1000
  switch (credentialKind) {
788
1001
  case 'oauth': {
789
1002
  const expiration = Number(profile.oauth_access_expires_at);
@@ -791,16 +1004,15 @@ export function credentialIsExpired(
791
1004
  }
792
1005
  case 'env':
793
1006
  // NOTIS_JWT is a complete override. Never combine it with expiry
794
- // metadata left behind by a desktop credential in the same profile.
1007
+ // metadata belonging to a different credential in the same profile.
795
1008
  // A token with no readable `exp` is a personal API key, which never
796
1009
  // expires, so let the server rather than the CLI reject it.
797
1010
  {
798
1011
  const expiration = getJwtExpiration(runtime?.jwt);
799
1012
  return expiration !== null && expiration <= nowSeconds;
800
1013
  }
801
- case 'worktree':
802
- case 'desktop': {
803
- const rawExpiration = profile.access_expires_at ?? getJwtExpiration(runtime?.jwt);
1014
+ case 'worktree': {
1015
+ const rawExpiration = profile.dev_access_expires_at ?? getJwtExpiration(runtime?.jwt);
804
1016
  // Every selected credential must carry an independently verifiable
805
1017
  // expiry. Missing or malformed expiry metadata fails closed.
806
1018
  if (rawExpiration === null || rawExpiration === undefined || rawExpiration === '') {