@notis_ai/cli 0.2.10 → 0.2.12

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,7 +11,7 @@ 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');
@@ -19,16 +19,25 @@ export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
19
19
  export const DEFAULT_API_BASE = 'https://api.notis.ai';
20
20
  export const BETA_API_BASE = 'https://api-beta.notis.ai';
21
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
+ ];
22
32
  const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
23
33
  const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
24
34
  const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
25
35
  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.
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.
32
41
  const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
33
42
  const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
34
43
  const CONFIG_WRITE_LOCK_POLL_MS = 10;
@@ -37,61 +46,107 @@ function clone(value) {
37
46
  return JSON.parse(JSON.stringify(value));
38
47
  }
39
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
+
40
127
  export function normalizeConfig(rawConfig = {}) {
41
128
  const raw = rawConfig && typeof rawConfig === 'object' ? clone(rawConfig) : {};
42
129
 
43
130
  if (raw.profiles && typeof raw.profiles === 'object') {
44
131
  const profiles = {};
45
132
  for (const [name, profile] of Object.entries(raw.profiles)) {
46
- 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') {
47
137
  continue;
48
138
  }
49
- profiles[name] = {
50
- jwt: typeof profile.jwt === 'string' ? profile.jwt : undefined,
51
- api_base: typeof profile.api_base === 'string' ? profile.api_base : undefined,
52
- beta: typeof profile.beta === 'boolean' ? profile.beta : undefined,
53
- auth_mode: profile.auth_mode === 'dev_portal' ? profile.auth_mode : undefined,
54
- refresh_token:
55
- typeof profile.refresh_token === 'string' ? profile.refresh_token : undefined,
56
- access_expires_at:
57
- typeof profile.access_expires_at === 'number' ? profile.access_expires_at : undefined,
58
- refresh_expires_at:
59
- typeof profile.refresh_expires_at === 'number' ? profile.refresh_expires_at : undefined,
60
- desktop_app_name:
61
- typeof profile.desktop_app_name === 'string' ? profile.desktop_app_name : undefined,
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,
85
- };
139
+ profiles[name] = normalizeProfile(profile);
86
140
  }
87
141
 
88
- if (!profiles[DEFAULT_PROFILE]) {
142
+ if (!Object.hasOwn(profiles, DEFAULT_PROFILE)) {
89
143
  profiles[DEFAULT_PROFILE] = {};
90
144
  }
91
145
 
92
146
  return {
93
147
  current_profile:
94
- typeof raw.current_profile === 'string' && raw.current_profile in profiles
148
+ typeof raw.current_profile === 'string'
149
+ && Object.hasOwn(profiles, raw.current_profile)
95
150
  ? raw.current_profile
96
151
  : DEFAULT_PROFILE,
97
152
  profiles,
@@ -100,47 +155,44 @@ export function normalizeConfig(rawConfig = {}) {
100
155
 
101
156
  return {
102
157
  current_profile: DEFAULT_PROFILE,
103
- profiles: {
104
- [DEFAULT_PROFILE]: {
105
- jwt: typeof raw.jwt === 'string' ? raw.jwt : undefined,
106
- api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
107
- beta: typeof raw.beta === 'boolean' ? raw.beta : 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;
@@ -440,10 +622,10 @@ function isLiveApiBase(value) {
440
622
  /**
441
623
  * Pick the live Notis API for this profile.
442
624
  *
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.
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.
447
629
  */
448
630
  export function resolveDefaultLiveApiBase(profile = {}) {
449
631
  if (profile.beta === true) {
@@ -452,9 +634,6 @@ export function resolveDefaultLiveApiBase(profile = {}) {
452
634
  if (profile.beta === false) {
453
635
  return DEFAULT_API_BASE;
454
636
  }
455
- if (profile.desktop_app_name === 'Notis Beta') {
456
- return BETA_API_BASE;
457
- }
458
637
  if (isLiveApiBase(profile.api_base)) {
459
638
  try {
460
639
  if (new URL(profile.api_base).hostname === 'api-beta.notis.ai') {
@@ -478,10 +657,10 @@ export function getApiBase(config, profileName, override) {
478
657
  const profile = getProfile(config, profileName);
479
658
  const profileApiBase = profile.api_base;
480
659
 
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.
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.
485
664
  if (typeof profileApiBase === 'string' && profileApiBase && !isLocalApiBase(profileApiBase)) {
486
665
  return profileApiBase.replace(/\/+$/, '');
487
666
  }
@@ -489,15 +668,6 @@ export function getApiBase(config, profileName, override) {
489
668
  return resolveDefaultLiveApiBase(profile);
490
669
  }
491
670
 
492
- export function getJwt(config, profileName) {
493
- const env = process.env.NOTIS_JWT;
494
- if (env) {
495
- return env;
496
- }
497
- const profile = getProfile(config, profileName);
498
- return profile.jwt;
499
- }
500
-
501
671
  export function isAgentMode(globalOptions = {}) {
502
672
  return process.env.NOTIS_AGENT === '1' || Boolean(globalOptions.agentMode);
503
673
  }
@@ -594,62 +764,95 @@ export function getOAuthApiBase(profile = {}) {
594
764
 
595
765
  export function resolveRuntimeProfile(
596
766
  globalOptions = {},
597
- { requireAuth = true, includeDebugEntitlementOverride = true } = {},
767
+ {
768
+ requireAuth = true,
769
+ includeDebugEntitlementOverride = true,
770
+ allowUnknownProfile = false,
771
+ allowUnavailableWorktree = false,
772
+ } = {},
598
773
  ) {
599
- const worktreeRuntime = resolveWorktreeRuntime();
600
- const config = loadConfig(worktreeRuntime);
601
- 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;
602
796
  const requestedApiBase = globalOptions.apiBase;
603
797
  if (
604
- worktreeRuntime &&
798
+ devRuntime &&
605
799
  requestedApiBase &&
606
- requestedApiBase.replace(/\/+$/, '') !== worktreeRuntime.api_base
800
+ requestedApiBase.replace(/\/+$/, '') !== devRuntime.api_base
607
801
  ) {
608
802
  throw new CliError({
609
803
  code: 'dev_runtime_route_mismatch',
610
- message: `This local-only worktree cannot route to ${requestedApiBase}`,
804
+ message: `Profile "${profileName}" is bound to this worktree and cannot route to ${requestedApiBase}`,
611
805
  exitCode: EXIT_CODES.usage,
612
- 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
+ ],
613
810
  });
614
811
  }
615
- let apiBase = worktreeRuntime
616
- ? worktreeRuntime.api_base
812
+ let apiBase = devRuntime
813
+ ? devRuntime.api_base
617
814
  : getApiBase(config, profileName, globalOptions.apiBase);
618
815
  const profile = getProfile(config, profileName);
619
- const envJwt = !worktreeRuntime ? process.env.NOTIS_JWT : undefined;
620
- const desktopJwt = profile.jwt;
816
+ const envJwt = !devRuntime ? process.env.NOTIS_JWT : undefined;
817
+ const devJwt = devRuntime?.dev_access_token || profile.dev_access_token;
621
818
  const oauthJwt = profile.oauth_access_token;
622
819
  let jwt;
623
820
  let credentialKind;
624
821
 
625
- if (worktreeRuntime && desktopJwt) {
626
- 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;
627
843
  credentialKind = 'worktree';
628
844
  } else if (envJwt) {
629
845
  jwt = envJwt;
630
846
  credentialKind = 'env';
631
- } else if (
632
- desktopJwt
633
- && !credentialIsExpired({ credentialKind: 'desktop', jwt: desktopJwt }, profile)
634
- ) {
635
- jwt = desktopJwt;
636
- credentialKind = 'desktop';
637
847
  } else if (
638
848
  oauthJwt
639
849
  && !credentialIsExpired({ credentialKind: 'oauth', jwt: oauthJwt }, profile)
640
850
  ) {
641
851
  jwt = oauthJwt;
642
852
  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
853
  } else if (oauthJwt) {
652
- // 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.
653
856
  jwt = oauthJwt;
654
857
  credentialKind = 'oauth';
655
858
  }
@@ -665,7 +868,6 @@ export function resolveRuntimeProfile(
665
868
  && oauthApiBase
666
869
  && normalizedRequestedApiBase !== oauthApiBase
667
870
  ) {
668
- const quoteShellArgument = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
669
871
  throw new CliError({
670
872
  code: 'oauth_api_target_mismatch',
671
873
  message: (
@@ -678,7 +880,7 @@ export function resolveRuntimeProfile(
678
880
  'npx --package @notis_ai/cli@latest -- notis',
679
881
  `--profile ${quoteShellArgument(profileName)}`,
680
882
  `--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
681
- 'login --force',
883
+ 'login',
682
884
  ].join(' '),
683
885
  reason: 'Authorize a separate OAuth grant for the requested Notis environment',
684
886
  }],
@@ -696,51 +898,42 @@ export function resolveRuntimeProfile(
696
898
  : null;
697
899
 
698
900
  if (requireAuth && !jwt) {
699
- const recovery = getDesktopAuthRecovery(
700
- {
701
- apiBase,
702
- desktopAppName: profile.desktop_app_name,
703
- desktopPid: profile.desktop_pid,
704
- },
705
- { mode: 'missing' },
706
- );
707
901
  throw new CliError({
708
902
  code: 'auth_missing',
709
- message: `No JWT configured for profile ${profileName}`,
903
+ message: `Profile "${profileName}" has no Notis credential`,
710
904
  exitCode: EXIT_CODES.auth,
711
- hints: recovery.hints,
905
+ hints: getAuthRecovery({ profileName, apiBase }, { mode: 'missing' }).hints,
712
906
  });
713
907
  }
714
908
  if (
715
- worktreeRuntime?.expected_user_id &&
909
+ devRuntime?.expected_user_id &&
716
910
  (
717
911
  credentialKind === 'oauth'
718
912
  ? profile.oauth_user_id
719
913
  : getJwtSubject(jwt)
720
- ) !== worktreeRuntime.expected_user_id
914
+ ) !== devRuntime.expected_user_id
721
915
  ) {
722
916
  throw new CliError({
723
917
  code: 'dev_runtime_identity_mismatch',
724
- 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`,
725
919
  exitCode: EXIT_CODES.auth,
726
920
  hints: [
727
921
  { message: 'Restart ./dev.sh to restore the approved worktree identity.' },
728
- { message: `Expected user: ${worktreeRuntime.expected_user_id}` },
922
+ { message: `Expected user: ${devRuntime.expected_user_id}` },
729
923
  ],
730
924
  });
731
925
  }
732
926
 
733
- // An explicit NOTIS_JWT is a complete credential override. Use it verbatim
734
- // and never replace it with a token later synced by the desktop profile.
735
- const usingEnvJwt = credentialKind === 'env';
736
927
  return {
737
928
  config,
738
929
  profileName,
930
+ profileSource,
931
+ profileLabel: profile.label,
739
932
  apiBase,
740
933
  requestedApiBase: normalizedRequestedApiBase,
741
934
  jwt,
742
935
  credentialKind,
743
- credentialSource: credentialKind === 'desktop' ? 'profile' : credentialKind,
936
+ credentialSource: credentialKind,
744
937
  oauthAccessToken: profile.oauth_access_token,
745
938
  oauthRefreshToken: profile.oauth_refresh_token,
746
939
  oauthAccessExpiresAt: profile.oauth_access_expires_at,
@@ -751,14 +944,14 @@ export function resolveRuntimeProfile(
751
944
  oauthResource,
752
945
  oauthScopes: profile.oauth_scopes || [],
753
946
  oauthUserId: profile.oauth_user_id,
754
- desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
755
- desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
756
947
  agentMode,
757
948
  nonInteractive,
758
949
  outputMode,
759
950
  timeoutMs,
760
951
  debugEntitlementOverride,
761
- worktreeRuntime,
952
+ worktreeRuntime: devRuntime,
953
+ detachedWorktreeRuntime: devRuntime ? null : worktreeRuntime,
954
+ worktreeRuntimeUnavailable: worktreeUnavailable,
762
955
  };
763
956
  }
764
957
 
@@ -790,23 +983,6 @@ export function getJwtSubject(jwt) {
790
983
  }
791
984
  }
792
985
 
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
-
810
986
  export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
811
987
  const expiration = getJwtExpiration(jwt);
812
988
  return expiration !== null && expiration <= nowSeconds;
@@ -817,11 +993,10 @@ export function credentialIsExpired(
817
993
  profile = {},
818
994
  nowSeconds = Math.floor(Date.now() / 1000),
819
995
  ) {
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.
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.
823
998
  const credentialKind = runtime?.credentialKind
824
- || (runtime?.credentialSource === 'env' ? 'env' : 'desktop');
999
+ || (runtime?.credentialSource === 'env' ? 'env' : null);
825
1000
  switch (credentialKind) {
826
1001
  case 'oauth': {
827
1002
  const expiration = Number(profile.oauth_access_expires_at);
@@ -829,16 +1004,15 @@ export function credentialIsExpired(
829
1004
  }
830
1005
  case 'env':
831
1006
  // NOTIS_JWT is a complete override. Never combine it with expiry
832
- // metadata left behind by a desktop credential in the same profile.
1007
+ // metadata belonging to a different credential in the same profile.
833
1008
  // A token with no readable `exp` is a personal API key, which never
834
1009
  // expires, so let the server rather than the CLI reject it.
835
1010
  {
836
1011
  const expiration = getJwtExpiration(runtime?.jwt);
837
1012
  return expiration !== null && expiration <= nowSeconds;
838
1013
  }
839
- case 'worktree':
840
- case 'desktop': {
841
- const rawExpiration = profile.access_expires_at ?? getJwtExpiration(runtime?.jwt);
1014
+ case 'worktree': {
1015
+ const rawExpiration = profile.dev_access_expires_at ?? getJwtExpiration(runtime?.jwt);
842
1016
  // Every selected credential must carry an independently verifiable
843
1017
  // expiry. Missing or malformed expiry metadata fails closed.
844
1018
  if (rawExpiration === null || rawExpiration === undefined || rawExpiration === '') {