@notis_ai/cli 0.2.0-beta.16.1 → 0.2.0-beta.160.1

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.
Files changed (154) hide show
  1. package/README.md +433 -133
  2. package/config/notis_app_boundary_rules.json +50 -0
  3. package/config/notis_app_design_rules.json +135 -0
  4. package/dist/agent-hooks/notis-agent-hook.mjs +18672 -0
  5. package/dist/base-skills/notis-apps/SKILL.md +70 -0
  6. package/dist/base-skills/notis-apps/references/architecture.md +164 -0
  7. package/dist/base-skills/notis-apps/references/context.md +81 -0
  8. package/dist/base-skills/notis-apps/references/design.md +165 -0
  9. package/dist/base-skills/notis-apps/references/reading.md +89 -0
  10. package/dist/base-skills/notis-apps/references/release.md +99 -0
  11. package/dist/base-skills/notis-apps/references/sdk.md +62 -0
  12. package/dist/base-skills/notis-apps/references/troubleshooting.md +23 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +140 -0
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +18 -0
  15. package/dist/base-skills/notis-cli/references/native-databases.md +20 -0
  16. package/dist/base-skills/notis-cli/references/tool-examples.md +56 -0
  17. package/dist/base-skills/notis-cli/references/troubleshooting.md +39 -0
  18. package/dist/base-skills/notis-query/SKILL.md +67 -0
  19. package/dist/base-skills/notis-query/references/database-discovery.md +59 -0
  20. package/dist/base-skills/notis-query/references/documents.md +50 -0
  21. package/dist/base-skills/notis-query/references/query.md +543 -0
  22. package/dist/skill-sync/index.js +1626 -0
  23. package/dist/skill-sync/index.js.map +7 -0
  24. package/dist/skill-sync-worker.mjs +2990 -0
  25. package/package.json +16 -6
  26. package/skills/notis-apps/cli.md +313 -0
  27. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +39 -0
  28. package/skills/notis-onboarding/BRIEF.md +129 -0
  29. package/skills/notis-query/cli.md +39 -0
  30. package/src/agent-hook-entry.js +5 -0
  31. package/src/cli.js +294 -25
  32. package/src/command-specs/agents.js +392 -0
  33. package/src/command-specs/apps.js +1470 -202
  34. package/src/command-specs/auth.js +114 -137
  35. package/src/command-specs/diagnostics.js +716 -0
  36. package/src/command-specs/handover.js +374 -0
  37. package/src/command-specs/helpers.js +84 -82
  38. package/src/command-specs/index.js +25 -6
  39. package/src/command-specs/meta.js +150 -18
  40. package/src/command-specs/onboarding.js +290 -0
  41. package/src/command-specs/profile.js +358 -0
  42. package/src/command-specs/reports.js +86 -0
  43. package/src/command-specs/skills.js +75 -0
  44. package/src/command-specs/smoke.js +386 -0
  45. package/src/command-specs/tools.js +455 -139
  46. package/src/runtime/agent-browser.js +632 -0
  47. package/src/runtime/agent-memory-state.js +126 -0
  48. package/src/runtime/agent-setup.js +383 -0
  49. package/src/runtime/app-boundary-validator.js +404 -0
  50. package/src/runtime/app-changelog.js +79 -0
  51. package/src/runtime/app-platform.js +2633 -210
  52. package/src/runtime/app-registry-scaffolds.js +367 -0
  53. package/src/runtime/app-test-server.js +292 -0
  54. package/src/runtime/assets/store-screenshot-dark.png +0 -0
  55. package/src/runtime/auth-recovery.js +110 -0
  56. package/src/runtime/base-skills.d.ts +20 -0
  57. package/src/runtime/base-skills.js +167 -0
  58. package/src/runtime/channel.js +133 -0
  59. package/src/runtime/delegated-context.js +68 -0
  60. package/src/runtime/errors.js +1 -0
  61. package/src/runtime/git.js +233 -0
  62. package/src/runtime/login-listener.js +15 -0
  63. package/src/runtime/oauth.js +2622 -0
  64. package/src/runtime/output.js +37 -5
  65. package/src/runtime/ports.js +31 -0
  66. package/src/runtime/profiles.js +906 -55
  67. package/src/runtime/skill-sync/cloud-client.ts +99 -0
  68. package/src/runtime/skill-sync/index.ts +697 -0
  69. package/src/runtime/skill-sync/local-scanner.ts +1046 -0
  70. package/src/runtime/skill-sync/symlink-manager.ts +433 -0
  71. package/src/runtime/skill-sync/sync-plan.ts +22 -0
  72. package/src/runtime/skill-sync/types.ts +110 -0
  73. package/src/runtime/skill-sync/write-cloud-skill.ts +50 -0
  74. package/src/runtime/skill-sync-service.js +109 -0
  75. package/src/runtime/store-screenshot.js +143 -0
  76. package/src/runtime/sync-skills.d.ts +37 -0
  77. package/src/runtime/sync-skills.js +231 -0
  78. package/src/runtime/telemetry.js +92 -0
  79. package/src/runtime/transport.js +324 -45
  80. package/src/skill-sync-worker-entry.js +2 -0
  81. package/src/skill-sync-worker.js +50 -0
  82. package/template/.harness/index.html.tmpl +430 -0
  83. package/template/CHANGELOG.md +5 -0
  84. package/template/app/layout.tsx +5 -2
  85. package/template/app/page.tsx +49 -42
  86. package/template/components/page-heading.tsx +23 -0
  87. package/template/components/ui/badge.tsx +7 -4
  88. package/template/components/ui/card.tsx +24 -11
  89. package/template/components/ui/native-select.tsx +24 -0
  90. package/template/notis.config.ts +24 -6
  91. package/template/package-lock.json +4137 -0
  92. package/template/package.json +5 -5
  93. package/template/packages/{notis-sdk → sdk}/package.json +13 -3
  94. package/template/packages/sdk/src/agentContext.ts +36 -0
  95. package/template/packages/sdk/src/components/DocumentEditor.tsx +103 -0
  96. package/template/packages/sdk/src/components/Markdown.tsx +60 -0
  97. package/template/packages/sdk/src/components/MarkdownEditor.tsx +121 -0
  98. package/template/packages/sdk/src/components/MultiSelectActionBar.tsx +285 -0
  99. package/template/packages/sdk/src/components/MultiSelectCheckbox.tsx +97 -0
  100. package/template/packages/sdk/src/components/MultiSelectDragOverlay.tsx +39 -0
  101. package/template/packages/sdk/src/components/NotisCommentBoundary.tsx +172 -0
  102. package/template/packages/sdk/src/components/NotisSelectionBoundary.tsx +59 -0
  103. package/template/packages/sdk/src/components/ShortcutHints.tsx +56 -0
  104. package/template/packages/sdk/src/components/Skeleton.tsx +24 -0
  105. package/template/packages/sdk/src/config.ts +257 -0
  106. package/template/packages/sdk/src/documents.ts +256 -0
  107. package/template/packages/sdk/src/hooks/useActiveResource.ts +19 -0
  108. package/template/packages/sdk/src/hooks/useAgentContext.ts +23 -0
  109. package/template/packages/sdk/src/hooks/useCloudComputer.ts +64 -0
  110. package/template/packages/sdk/src/hooks/useCollectionInteractions.ts +836 -0
  111. package/template/packages/sdk/src/hooks/useDatabaseSchema.ts +49 -0
  112. package/template/packages/sdk/src/hooks/useDatabaseSubscription.ts +76 -0
  113. package/template/packages/sdk/src/hooks/useDocument.ts +43 -0
  114. package/template/packages/sdk/src/hooks/useDocuments.ts +84 -0
  115. package/template/packages/sdk/src/hooks/useHandover.ts +78 -0
  116. package/template/packages/sdk/src/hooks/useLongPressSelection.ts +79 -0
  117. package/template/packages/sdk/src/hooks/useMultiSelect.ts +95 -0
  118. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotis.ts +10 -4
  119. package/template/packages/{notis-sdk → sdk}/src/hooks/useNotisNavigation.ts +11 -8
  120. package/template/packages/sdk/src/hooks/useQuery.ts +71 -0
  121. package/template/packages/sdk/src/hooks/useTool.ts +65 -0
  122. package/template/packages/sdk/src/hooks/useToolQuery.ts +12 -0
  123. package/template/packages/sdk/src/hooks/useTopBarSearch.ts +81 -0
  124. package/template/packages/sdk/src/hooks/useUpsertDocument.ts +95 -0
  125. package/template/packages/sdk/src/index.ts +161 -0
  126. package/template/packages/sdk/src/interactions/actions.ts +59 -0
  127. package/template/packages/sdk/src/interactions/shortcuts.tsx +694 -0
  128. package/template/packages/sdk/src/interactions/visibility.ts +13 -0
  129. package/template/packages/sdk/src/interactions.ts +45 -0
  130. package/template/packages/sdk/src/provider.tsx +44 -0
  131. package/template/packages/sdk/src/queryCache.ts +170 -0
  132. package/template/packages/sdk/src/runtime.ts +451 -0
  133. package/template/packages/sdk/src/styles.css +213 -0
  134. package/template/packages/sdk/src/tailwind.ts +56 -0
  135. package/template/packages/{notis-sdk → sdk}/src/vite.ts +5 -1
  136. package/template/tailwind.config.ts +1 -0
  137. package/src/command-specs/db.js +0 -163
  138. package/src/runtime/app-preview-server.js +0 -312
  139. package/template/packages/notis-sdk/src/config.ts +0 -48
  140. package/template/packages/notis-sdk/src/helpers.ts +0 -131
  141. package/template/packages/notis-sdk/src/hooks/useAppState.ts +0 -50
  142. package/template/packages/notis-sdk/src/hooks/useCollectionItem.ts +0 -58
  143. package/template/packages/notis-sdk/src/hooks/useDatabase.ts +0 -87
  144. package/template/packages/notis-sdk/src/hooks/useDocument.ts +0 -61
  145. package/template/packages/notis-sdk/src/hooks/useTool.ts +0 -49
  146. package/template/packages/notis-sdk/src/hooks/useUpsertDocument.ts +0 -57
  147. package/template/packages/notis-sdk/src/index.ts +0 -47
  148. package/template/packages/notis-sdk/src/provider.tsx +0 -44
  149. package/template/packages/notis-sdk/src/runtime.ts +0 -159
  150. package/template/packages/notis-sdk/src/styles.css +0 -123
  151. /package/template/packages/{notis-sdk → sdk}/src/hooks/useBackend.ts +0 -0
  152. /package/template/packages/{notis-sdk → sdk}/src/hooks/useTools.ts +0 -0
  153. /package/template/packages/{notis-sdk → sdk}/src/ui.ts +0 -0
  154. /package/template/packages/{notis-sdk → sdk}/tsconfig.json +0 -0
@@ -1,44 +1,157 @@
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
- import { join } from 'node:path';
12
+ import { dirname, join, parse, resolve } from 'node:path';
4
13
  import { CliError, EXIT_CODES } from './errors.js';
14
+ import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
15
+ import { channelFromProfile, cliCommandForChannel, isReleaseChannel } from './channel.js';
5
16
 
6
17
  export const CONFIG_DIR = join(homedir(), '.notis');
7
18
  export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
8
19
  export const WORKSPACE_DIR = join(CONFIG_DIR, 'workspace');
9
20
  export const DEFAULT_API_BASE = 'https://api.notis.ai';
21
+ export const BETA_API_BASE = 'https://api-beta.notis.ai';
10
22
  export const DEFAULT_PROFILE = 'default';
11
- const LOCAL_DEFAULT_API_BASES = new Set([
12
- 'http://localhost:3001',
13
- 'http://127.0.0.1:3001',
14
- ]);
23
+ const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
24
+ const LEGACY_DESKTOP_PROFILE_KEYS = [
25
+ 'jwt',
26
+ 'auth_mode',
27
+ 'refresh_token',
28
+ 'access_expires_at',
29
+ 'refresh_expires_at',
30
+ 'desktop_app_name',
31
+ 'desktop_pid',
32
+ ];
33
+ const WORKTREE_RUNTIME_FILENAME = join('.context', 'notis-runtime.json');
34
+ const WORKTREE_ROUTING_FILENAME = join('.context', 'notis-routing.json');
35
+ const LOCAL_API_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
36
+ const LIVE_API_HOSTS = new Set(['api.notis.ai', 'api-beta.notis.ai']);
37
+ // Cross-process write lock over ~/.notis/config.json. Independent `notis`
38
+ // processes (a `login` in one terminal and a `tools exec` in another) all
39
+ // rewrite this file, so the lock directory name and these three timings are the
40
+ // whole coordination protocol. Guarded by the concurrency test in
41
+ // packages/cli/test/runtime-auth.test.js.
42
+ const CONFIG_WRITE_LOCK_TIMEOUT_MS = 5_000;
43
+ const CONFIG_WRITE_LOCK_STALE_MS = 2_000;
44
+ const CONFIG_WRITE_LOCK_POLL_MS = 10;
15
45
 
16
46
  function clone(value) {
17
47
  return JSON.parse(JSON.stringify(value));
18
48
  }
19
49
 
50
+ export function isValidProfileName(profileName) {
51
+ return typeof profileName === 'string'
52
+ && PROFILE_NAME_PATTERN.test(profileName)
53
+ && !Object.hasOwn(Object.prototype, profileName);
54
+ }
55
+
56
+ function isSafeStoredProfileName(profileName) {
57
+ return typeof profileName === 'string'
58
+ && profileName.length > 0
59
+ && !Object.hasOwn(Object.prototype, profileName);
60
+ }
61
+
62
+ export function profileExists(config, profileName) {
63
+ const normalized = normalizeConfig(config);
64
+ return isSafeStoredProfileName(profileName)
65
+ && Object.hasOwn(normalized.profiles, profileName);
66
+ }
67
+
68
+ function assertValidProfileName(profileName) {
69
+ if (isValidProfileName(profileName)) {
70
+ return;
71
+ }
72
+ throw new CliError({
73
+ code: 'profile_name_invalid',
74
+ message: 'CLI profile names must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens (maximum 64 characters)',
75
+ exitCode: EXIT_CODES.usage,
76
+ hints: [
77
+ { command: 'notis profile list', reason: 'See the valid profiles already on this machine' },
78
+ ],
79
+ });
80
+ }
81
+
82
+ /**
83
+ * A profile is one account paired with one API endpoint.
84
+ *
85
+ * Only two credential shapes survive normalization: the browser-authorized
86
+ * OAuth grant (`oauth_*`) that owns every real account, and the loopback
87
+ * credential `./dev.sh` mints for its worktree test user (`dev_*`). Notis
88
+ * Desktop used to write a Supabase access token here as `jwt`, plus the
89
+ * `desktop_*` liveness hints the CLI used to tell people which app to reopen.
90
+ * Dropping those keys on read is what actually retires that path: a config
91
+ * left behind by an older Desktop build cannot silently keep authenticating
92
+ * the CLI with a credential nothing renews anymore.
93
+ */
94
+ function normalizeProfile(rawProfile = {}) {
95
+ const raw = rawProfile && typeof rawProfile === 'object' ? rawProfile : {};
96
+ return {
97
+ api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
98
+ beta: typeof raw.beta === 'boolean' ? raw.beta : undefined,
99
+ // Which published CLI build this profile runs, as the deployment reported
100
+ // it at login. Unknown values are dropped rather than trusted: this key
101
+ // decides which code executes on the next run.
102
+ channel: isReleaseChannel(raw.channel) ? raw.channel : undefined,
103
+ label: typeof raw.label === 'string' ? raw.label : undefined,
104
+ dev_access_token:
105
+ typeof raw.dev_access_token === 'string' ? raw.dev_access_token : undefined,
106
+ dev_access_expires_at:
107
+ typeof raw.dev_access_expires_at === 'number' ? raw.dev_access_expires_at : undefined,
108
+ dev_user_id: typeof raw.dev_user_id === 'string' ? raw.dev_user_id : undefined,
109
+ dev_workspace_root:
110
+ typeof raw.dev_workspace_root === 'string' ? raw.dev_workspace_root : undefined,
111
+ oauth_access_token:
112
+ typeof raw.oauth_access_token === 'string' ? raw.oauth_access_token : undefined,
113
+ oauth_refresh_token:
114
+ typeof raw.oauth_refresh_token === 'string' ? raw.oauth_refresh_token : undefined,
115
+ oauth_access_expires_at:
116
+ typeof raw.oauth_access_expires_at === 'number' ? raw.oauth_access_expires_at : undefined,
117
+ oauth_refresh_expires_at:
118
+ typeof raw.oauth_refresh_expires_at === 'number' ? raw.oauth_refresh_expires_at : undefined,
119
+ oauth_client_id:
120
+ typeof raw.oauth_client_id === 'string' ? raw.oauth_client_id : undefined,
121
+ oauth_issuer: typeof raw.oauth_issuer === 'string' ? raw.oauth_issuer : undefined,
122
+ oauth_api_base: typeof raw.oauth_api_base === 'string' ? raw.oauth_api_base : undefined,
123
+ oauth_resource: typeof raw.oauth_resource === 'string' ? raw.oauth_resource : undefined,
124
+ oauth_scopes:
125
+ Array.isArray(raw.oauth_scopes)
126
+ ? raw.oauth_scopes.filter((scope) => typeof scope === 'string')
127
+ : undefined,
128
+ oauth_user_id: typeof raw.oauth_user_id === 'string' ? raw.oauth_user_id : undefined,
129
+ };
130
+ }
131
+
20
132
  export function normalizeConfig(rawConfig = {}) {
21
133
  const raw = rawConfig && typeof rawConfig === 'object' ? clone(rawConfig) : {};
22
134
 
23
135
  if (raw.profiles && typeof raw.profiles === 'object') {
24
136
  const profiles = {};
25
137
  for (const [name, profile] of Object.entries(raw.profiles)) {
26
- if (!profile || typeof profile !== 'object') {
138
+ // Older CLI releases accepted arbitrary profile names. Keep every safe
139
+ // own-property name readable; the stricter grammar applies only when a
140
+ // command creates a new profile.
141
+ if (!isSafeStoredProfileName(name) || !profile || typeof profile !== 'object') {
27
142
  continue;
28
143
  }
29
- profiles[name] = {
30
- jwt: typeof profile.jwt === 'string' ? profile.jwt : undefined,
31
- api_base: typeof profile.api_base === 'string' ? profile.api_base : undefined,
32
- };
144
+ profiles[name] = normalizeProfile(profile);
33
145
  }
34
146
 
35
- if (!profiles[DEFAULT_PROFILE]) {
147
+ if (!Object.hasOwn(profiles, DEFAULT_PROFILE)) {
36
148
  profiles[DEFAULT_PROFILE] = {};
37
149
  }
38
150
 
39
151
  return {
40
152
  current_profile:
41
- typeof raw.current_profile === 'string' && raw.current_profile in profiles
153
+ typeof raw.current_profile === 'string'
154
+ && Object.hasOwn(profiles, raw.current_profile)
42
155
  ? raw.current_profile
43
156
  : DEFAULT_PROFILE,
44
157
  profiles,
@@ -47,49 +160,503 @@ export function normalizeConfig(rawConfig = {}) {
47
160
 
48
161
  return {
49
162
  current_profile: DEFAULT_PROFILE,
50
- profiles: {
51
- [DEFAULT_PROFILE]: {
52
- jwt: typeof raw.jwt === 'string' ? raw.jwt : undefined,
53
- api_base: typeof raw.api_base === 'string' ? raw.api_base : undefined,
54
- },
55
- },
163
+ profiles: { [DEFAULT_PROFILE]: normalizeProfile(raw) },
164
+ };
165
+ }
166
+
167
+ export function profileHasCredential(profile = {}) {
168
+ return Boolean(profile.oauth_access_token || profile.dev_access_token);
169
+ }
170
+
171
+ /**
172
+ * Names every profile a switch can land on, newest config order preserved.
173
+ * `notis profile use` and `--profile` both validate against this.
174
+ */
175
+ export function listProfiles(config) {
176
+ const normalized = normalizeConfig(config);
177
+ return Object.entries(normalized.profiles).map(([name, profile]) => ({
178
+ name,
179
+ active: name === normalized.current_profile,
180
+ // Report the endpoint the profile was created against, including the
181
+ // loopback address of a `./dev.sh` profile. getApiBase resolves a stale
182
+ // loopback value to the live API for routing; showing that here would
183
+ // claim a dev profile targets production, which it must never do.
184
+ // OAuth metadata owns the route for an OAuth profile. An older Desktop
185
+ // release may have left a conflicting api_base behind, but commands ignore
186
+ // that legacy value and so must profile inspection.
187
+ api_base: profile.oauth_access_token
188
+ ? getOAuthApiBase(profile) || resolveDefaultLiveApiBase(profile)
189
+ : profile.api_base || resolveDefaultLiveApiBase(profile),
190
+ label: profile.label || null,
191
+ credential_kind: profile.oauth_access_token
192
+ ? 'oauth'
193
+ : profile.dev_access_token
194
+ ? 'dev'
195
+ : null,
196
+ user_id: profile.oauth_user_id || profile.dev_user_id || null,
197
+ authenticated: profileHasCredential(profile),
198
+ }));
199
+ }
200
+
201
+ function readJsonFile(path) {
202
+ try {
203
+ return JSON.parse(readFileSync(path, 'utf-8'));
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ function findUp(filename, startDir = process.cwd()) {
210
+ let current = resolve(startDir);
211
+ const root = parse(current).root;
212
+ while (true) {
213
+ const candidate = join(current, filename);
214
+ if (existsSync(candidate)) {
215
+ return candidate;
216
+ }
217
+ if (current === root) {
218
+ return null;
219
+ }
220
+ current = dirname(current);
221
+ }
222
+ }
223
+
224
+ function processIsAlive(pid) {
225
+ if (!Number.isInteger(pid) || pid <= 0) {
226
+ return false;
227
+ }
228
+ try {
229
+ process.kill(pid, 0);
230
+ return true;
231
+ } catch (error) {
232
+ // Sandboxed children may be allowed to observe the worktree lease but not
233
+ // signal its dev.sh supervisor. POSIX EPERM proves that the PID exists;
234
+ // ESRCH (and every other probe failure) does not.
235
+ return error?.code === 'EPERM';
236
+ }
237
+ }
238
+
239
+ export function resolveWorktreeRuntime(startDir = process.cwd()) {
240
+ if (
241
+ process.env.NODE_ENV === 'test' &&
242
+ process.env.NOTIS_TEST_DISABLE_WORKTREE_ROUTING === '1'
243
+ ) {
244
+ return null;
245
+ }
246
+ const runtimePath = findUp(WORKTREE_RUNTIME_FILENAME, startDir);
247
+ const routingPath = findUp(WORKTREE_ROUTING_FILENAME, startDir);
248
+ const routing = routingPath ? readJsonFile(routingPath) : null;
249
+
250
+ if (!runtimePath) {
251
+ if (routing?.mode === 'local-only') {
252
+ return {
253
+ unavailable: new CliError({
254
+ code: 'dev_runtime_unavailable',
255
+ message: 'This worktree is local-only, but its dev.sh runtime is not active',
256
+ exitCode: EXIT_CODES.network,
257
+ hints: [
258
+ { message: 'Start ./dev.sh in this worktree, then retry the command.' },
259
+ { command: 'notis profile list', reason: 'Run against a live account profile instead' },
260
+ { message: `Routing policy: ${routingPath}` },
261
+ ],
262
+ }),
263
+ };
264
+ }
265
+ return null;
266
+ }
267
+
268
+ const runtime = readJsonFile(runtimePath);
269
+ const apiBase = typeof runtime?.api_base === 'string' ? runtime.api_base.replace(/\/+$/, '') : '';
270
+ const profile = typeof runtime?.profile === 'string' ? runtime.profile.trim() : '';
271
+ const devAccessToken =
272
+ typeof runtime?.dev_access_token === 'string' ? runtime.dev_access_token.trim() : '';
273
+ const desktopDeepLinkScheme =
274
+ typeof runtime?.desktop_deep_link_scheme === 'string'
275
+ ? runtime.desktop_deep_link_scheme.trim()
276
+ : '';
277
+ const pid = Number(runtime?.dev_pid);
278
+ if (
279
+ runtime?.mode !== 'local-only' ||
280
+ !isLocalApiBase(apiBase) ||
281
+ !profile ||
282
+ !devAccessToken ||
283
+ !processIsAlive(pid)
284
+ ) {
285
+ // Reported rather than thrown: an unusable lease must not stop someone
286
+ // from listing profiles or switching to a live account from inside the
287
+ // same checkout. resolveRuntimeProfile raises it only when a command is
288
+ // about to route through the dead local backend.
289
+ return {
290
+ unavailable: new CliError({
291
+ code: 'dev_runtime_unavailable',
292
+ message: 'The local-only worktree runtime is stale or invalid',
293
+ exitCode: EXIT_CODES.network,
294
+ hints: [
295
+ { message: 'Restart ./dev.sh in this worktree, then retry the command.' },
296
+ { command: 'notis profile list', reason: 'Run against a live account profile instead' },
297
+ { message: `Runtime lease: ${runtimePath}` },
298
+ ],
299
+ }),
300
+ };
301
+ }
302
+
303
+ return {
304
+ ...runtime,
305
+ api_base: apiBase,
306
+ profile,
307
+ dev_access_token: devAccessToken,
308
+ desktop_deep_link_scheme: desktopDeepLinkScheme || undefined,
309
+ runtime_path: runtimePath,
310
+ routing_path: routingPath,
56
311
  };
57
312
  }
58
313
 
314
+ /**
315
+ * Real account profiles live in one shared file. A `./dev.sh` worktree keeps
316
+ * its synthetic profile credential in the worktree-owned runtime lease, so an
317
+ * older CLI process cannot normalize it away while rewriting this file.
318
+ */
319
+ export function resolveConfigFile() {
320
+ const envConfigFile = process.env.NOTIS_CLI_CONFIG_FILE;
321
+ if (envConfigFile) {
322
+ return resolve(envConfigFile);
323
+ }
324
+ return CONFIG_FILE;
325
+ }
326
+
59
327
  export function loadConfig() {
60
- if (!existsSync(CONFIG_FILE)) {
328
+ const configFile = resolveConfigFile();
329
+ if (!existsSync(configFile)) {
61
330
  return normalizeConfig({});
62
331
  }
63
332
 
64
- return normalizeConfig(JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')));
333
+ // Concurrent `notis` processes rewrite this file while others read it, so a
334
+ // torn or corrupt read is expected rather than exceptional. Degrade to
335
+ // "unauthenticated", which surfaces the actionable auth_missing error instead
336
+ // of a raw SyntaxError from deep inside the runtime.
337
+ try {
338
+ return normalizeConfig(JSON.parse(readFileSync(configFile, 'utf-8')));
339
+ } catch {
340
+ return normalizeConfig({});
341
+ }
342
+ }
343
+
344
+ function writeConfig(configFile, config) {
345
+ let rawConfig = null;
346
+ try {
347
+ rawConfig = JSON.parse(readFileSync(configFile, 'utf-8'));
348
+ } catch {
349
+ // A missing or corrupt file has no upgrade fields to preserve.
350
+ }
351
+ const normalized = normalizeConfig(config);
352
+ const persisted = clone(normalized);
353
+ const rawProfiles = rawConfig?.profiles && typeof rawConfig.profiles === 'object'
354
+ ? rawConfig.profiles
355
+ : { [DEFAULT_PROFILE]: rawConfig };
356
+ for (const [name, profile] of Object.entries(persisted.profiles)) {
357
+ const rawProfile = Object.hasOwn(rawProfiles || {}, name) ? rawProfiles[name] : null;
358
+ if (!rawProfile || typeof rawProfile !== 'object') continue;
359
+ for (const key of LEGACY_DESKTOP_PROFILE_KEYS) {
360
+ if (Object.hasOwn(rawProfile, key)) {
361
+ profile[key] = rawProfile[key];
362
+ }
363
+ }
364
+ }
365
+ writeRawConfig(configFile, persisted);
366
+ }
367
+
368
+ function writeRawConfig(configFile, config) {
369
+ mkdirSync(dirname(configFile), { recursive: true });
370
+ const temporaryFile = `${configFile}.${process.pid}.${Date.now()}.tmp`;
371
+ writeFileSync(temporaryFile, JSON.stringify(config, null, 2), { mode: 0o600 });
372
+ renameSync(temporaryFile, configFile);
373
+ }
374
+
375
+ // The lock records who holds it and when they took it. Reclaiming a stale lock
376
+ // by mtime alone is not safe: the reclaimer cannot tell an abandoned lock from
377
+ // one it is about to steal from a live writer, and the victim's own release
378
+ // would then delete the thief's lock and admit a third writer. Both sides are
379
+ // on the same host, so the stored timestamp and Date.now() share a clock.
380
+ function readLockOwner(lockDirectory) {
381
+ try {
382
+ return JSON.parse(readFileSync(join(lockDirectory, 'owner'), 'utf-8'));
383
+ } catch {
384
+ return null;
385
+ }
386
+ }
387
+
388
+ function lockDirectoryMtime(lockDirectory) {
389
+ try {
390
+ return statSync(lockDirectory).mtimeMs;
391
+ } catch {
392
+ return null;
393
+ }
394
+ }
395
+
396
+ function publishConfigWriteLock(lockDirectory, ownerId) {
397
+ const candidateDirectory = `${lockDirectory}.candidate-${ownerId}`;
398
+ try {
399
+ mkdirSync(candidateDirectory);
400
+ writeFileSync(
401
+ join(candidateDirectory, 'owner'),
402
+ JSON.stringify({ id: ownerId, at: Date.now() }),
403
+ { mode: 0o600 },
404
+ );
405
+ try {
406
+ renameSync(candidateDirectory, lockDirectory);
407
+ return true;
408
+ } catch (error) {
409
+ if (!existsSync(lockDirectory)) throw error;
410
+ return false;
411
+ }
412
+ } finally {
413
+ rmSync(candidateDirectory, { recursive: true, force: true });
414
+ }
415
+ }
416
+
417
+ function withConfigWriteLock(callback) {
418
+ const configFile = resolveConfigFile();
419
+ const lockDirectory = `${configFile}.write-lock`;
420
+ const ownerId = `${process.pid}.${randomUUID()}`;
421
+ const deadline = Date.now() + CONFIG_WRITE_LOCK_TIMEOUT_MS;
422
+ mkdirSync(dirname(configFile), { recursive: true });
423
+ for (;;) {
424
+ if (!existsSync(lockDirectory) && publishConfigWriteLock(lockDirectory, ownerId)) {
425
+ break;
426
+ }
427
+
428
+ const owner = readLockOwner(lockDirectory);
429
+ const observedMtime = lockDirectoryMtime(lockDirectory);
430
+ const ownerIsStale =
431
+ owner?.id && Date.now() - Number(owner.at) > CONFIG_WRITE_LOCK_STALE_MS;
432
+ const ownerlessIsStale =
433
+ !owner
434
+ && observedMtime !== null
435
+ && Date.now() - observedMtime > CONFIG_WRITE_LOCK_STALE_MS;
436
+ if (ownerIsStale || ownerlessIsStale) {
437
+ try {
438
+ const currentOwner = readLockOwner(lockDirectory);
439
+ const ownerUnchanged = owner?.id
440
+ ? currentOwner?.id === owner.id
441
+ : !currentOwner && lockDirectoryMtime(lockDirectory) === observedMtime;
442
+ if (ownerUnchanged) {
443
+ rmSync(lockDirectory, { recursive: true, force: true });
444
+ continue;
445
+ }
446
+ } catch {
447
+ // Losing the reclaim race is normal; fall through and keep waiting.
448
+ }
449
+ }
450
+ if (Date.now() >= deadline) {
451
+ throw new Error(`Timed out waiting to update ${configFile}`);
452
+ }
453
+ Atomics.wait(
454
+ new Int32Array(new SharedArrayBuffer(4)),
455
+ 0,
456
+ 0,
457
+ CONFIG_WRITE_LOCK_POLL_MS,
458
+ );
459
+ }
460
+ try {
461
+ return callback(configFile);
462
+ } finally {
463
+ try {
464
+ // Never release a lock that was reclaimed from us while we worked.
465
+ if (readLockOwner(lockDirectory)?.id === ownerId) {
466
+ rmSync(lockDirectory, { recursive: true, force: true });
467
+ }
468
+ } catch {
469
+ // The lock may already have been removed after an interrupted write.
470
+ }
471
+ }
65
472
  }
66
473
 
67
474
  export function saveConfig(config) {
68
- mkdirSync(CONFIG_DIR, { recursive: true });
69
- writeFileSync(CONFIG_FILE, JSON.stringify(normalizeConfig(config), null, 2));
475
+ return withConfigWriteLock((configFile) => {
476
+ writeConfig(configFile, config);
477
+ });
478
+ }
479
+
480
+ export function updateConfig(updater) {
481
+ return withConfigWriteLock((configFile) => {
482
+ const current = loadConfig();
483
+ const updated = updater(normalizeConfig(current));
484
+ const next = normalizeConfig(updated ?? current);
485
+ writeConfig(configFile, next);
486
+ return next;
487
+ });
488
+ }
489
+
490
+ /**
491
+ * Remove every profile owned by one worktree, plus stale localhost stubs that
492
+ * use one of that worktree's generated profile names. Older builds sometimes
493
+ * lost `dev_workspace_root`, so requiring the ownership marker alone leaves a
494
+ * dead local profile behind after archive.
495
+ *
496
+ * Do this without normalizing the rest of the shared file. Archive cleanup can
497
+ * run before a packaged Desktop upgrade has migrated its legacy `jwt`;
498
+ * preserving unknown/raw fields here keeps that migrate-then-strip handoff
499
+ * intact.
500
+ */
501
+ export function removeOwnedDevProfiles(profileNames, workspaceRoot) {
502
+ return withConfigWriteLock((configFile) => {
503
+ let raw;
504
+ try {
505
+ raw = JSON.parse(readFileSync(configFile, 'utf-8'));
506
+ } catch {
507
+ return [];
508
+ }
509
+ if (!raw || typeof raw !== 'object' || !raw.profiles || typeof raw.profiles !== 'object') {
510
+ return [];
511
+ }
512
+
513
+ const generatedNames = new Set(profileNames);
514
+ const removed = [];
515
+ for (const [name, profile] of Object.entries(raw.profiles)) {
516
+ if (!profile || typeof profile !== 'object') {
517
+ continue;
518
+ }
519
+
520
+ const ownedByWorkspace = profile.dev_workspace_root === workspaceRoot;
521
+ const staleGeneratedLocalProfile = generatedNames.has(name)
522
+ && !profile.dev_workspace_root
523
+ && isLocalApiBase(profile.api_base);
524
+ if (!ownedByWorkspace && !staleGeneratedLocalProfile) continue;
525
+
526
+ delete raw.profiles[name];
527
+ removed.push(name);
528
+ if (raw.current_profile === name) raw.current_profile = DEFAULT_PROFILE;
529
+ }
530
+ if (removed.length > 0) writeRawConfig(configFile, raw);
531
+ return removed;
532
+ });
70
533
  }
71
534
 
72
535
  export function getProfile(config, profileName) {
73
536
  const normalized = normalizeConfig(config);
74
- return normalized.profiles[profileName] || {};
537
+ return Object.hasOwn(normalized.profiles, profileName)
538
+ ? normalized.profiles[profileName]
539
+ : {};
75
540
  }
76
541
 
77
542
  export function getCurrentProfileName(config, preferredName) {
78
543
  const normalized = normalizeConfig(config);
79
- if (preferredName && normalized.profiles[preferredName]) {
544
+ if (preferredName && Object.hasOwn(normalized.profiles, preferredName)) {
80
545
  return preferredName;
81
546
  }
82
547
  return normalized.current_profile || DEFAULT_PROFILE;
83
548
  }
84
549
 
550
+ /**
551
+ * Resolve which profile this invocation runs as.
552
+ *
553
+ * An explicit `--profile` / `NOTIS_PROFILE` always wins, including inside a
554
+ * `./dev.sh` worktree: naming another account is the documented way to reach
555
+ * production from a checkout whose local backend is wedged. With nothing
556
+ * explicit, a live worktree lease selects its own dev profile, and otherwise
557
+ * the switch persisted by `notis profile use` applies.
558
+ */
559
+ export function resolveProfileSelection(
560
+ globalOptions = {},
561
+ worktreeRuntime = null,
562
+ config,
563
+ { allowUnknownProfile = false } = {},
564
+ ) {
565
+ const normalized = normalizeConfig(config);
566
+ const requested = globalOptions.profile || process.env.NOTIS_PROFILE || '';
567
+ if (requested) {
568
+ const existingProfile = Object.hasOwn(normalized.profiles, requested);
569
+ if (!existingProfile) {
570
+ // Existing profiles may have names accepted by earlier releases. Only a
571
+ // new profile created by login/start must satisfy today's grammar.
572
+ assertValidProfileName(requested);
573
+ }
574
+ if (!existingProfile && !allowUnknownProfile) {
575
+ throw new CliError({
576
+ code: 'profile_unknown',
577
+ message: `No CLI profile named "${requested}"`,
578
+ exitCode: EXIT_CODES.usage,
579
+ details: { known_profiles: Object.keys(normalized.profiles) },
580
+ hints: [
581
+ { command: 'notis profile list', reason: 'See which profiles this machine has' },
582
+ {
583
+ command: `notis login --profile ${quoteShellArgument(requested)}`,
584
+ reason: 'Authorize a new account under this profile name',
585
+ },
586
+ ],
587
+ });
588
+ }
589
+ return { profileName: requested, source: 'explicit' };
590
+ }
591
+ if (worktreeRuntime?.profile) {
592
+ return { profileName: worktreeRuntime.profile, source: 'worktree' };
593
+ }
594
+ return {
595
+ profileName: normalized.current_profile || DEFAULT_PROFILE,
596
+ source: 'current',
597
+ };
598
+ }
599
+
85
600
  export function ensureProfile(config, profileName) {
86
601
  const normalized = normalizeConfig(config);
87
- if (!normalized.profiles[profileName]) {
602
+ if (!Object.hasOwn(normalized.profiles, profileName)) {
603
+ assertValidProfileName(profileName);
88
604
  normalized.profiles[profileName] = {};
89
605
  }
90
606
  return normalized;
91
607
  }
92
608
 
609
+ function isLocalApiBase(value) {
610
+ if (typeof value !== 'string' || !value) {
611
+ return false;
612
+ }
613
+ try {
614
+ const parsed = new URL(value);
615
+ return ['http:', 'https:'].includes(parsed.protocol) && LOCAL_API_HOSTS.has(parsed.hostname);
616
+ } catch {
617
+ return false;
618
+ }
619
+ }
620
+
621
+ function isLiveApiBase(value) {
622
+ if (typeof value !== 'string' || !value) {
623
+ return false;
624
+ }
625
+ try {
626
+ const parsed = new URL(value);
627
+ return parsed.protocol === 'https:' && LIVE_API_HOSTS.has(parsed.hostname);
628
+ } catch {
629
+ return false;
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Pick the live Notis API for this profile.
635
+ *
636
+ * Beta users (`users.beta = true`, mirrored onto the profile when OAuth
637
+ * authorizes against api-beta) hit api-beta.notis.ai; everyone else hits
638
+ * api.notis.ai. Localhost is never a default — only a `./dev.sh` profile
639
+ * backed by a live worktree lease may retarget the CLI at loopback.
640
+ */
641
+ export function resolveDefaultLiveApiBase(profile = {}) {
642
+ if (profile.beta === true) {
643
+ return BETA_API_BASE;
644
+ }
645
+ if (profile.beta === false) {
646
+ return DEFAULT_API_BASE;
647
+ }
648
+ if (isLiveApiBase(profile.api_base)) {
649
+ try {
650
+ if (new URL(profile.api_base).hostname === 'api-beta.notis.ai') {
651
+ return BETA_API_BASE;
652
+ }
653
+ } catch {
654
+ // fall through
655
+ }
656
+ }
657
+ return DEFAULT_API_BASE;
658
+ }
659
+
93
660
  export function getApiBase(config, profileName, override) {
94
661
  if (override) {
95
662
  return override;
@@ -100,29 +667,16 @@ export function getApiBase(config, profileName, override) {
100
667
  }
101
668
  const profile = getProfile(config, profileName);
102
669
  const profileApiBase = profile.api_base;
103
- const conductorPort = Number.parseInt(process.env.CONDUCTOR_PORT || '', 10);
104
670
 
105
- // Conductor assigns dynamic local ports. When a workspace is running under
106
- // Conductor, treat the legacy localhost:3001 profile value as stale and
107
- // transparently retarget the CLI at the active backend port.
108
- if (
109
- Number.isInteger(conductorPort) &&
110
- conductorPort > 0 &&
111
- (!profileApiBase || LOCAL_DEFAULT_API_BASES.has(profileApiBase))
112
- ) {
113
- return `http://localhost:${conductorPort + 1}`;
671
+ // A loopback api_base is only meaningful while the `./dev.sh` that wrote it
672
+ // is still running, and resolveRuntimeProfile checks that lease before it
673
+ // routes anywhere. Here with no lease in hand — a leftover localhost value
674
+ // resolves to the live API rather than to a port nothing is listening on.
675
+ if (typeof profileApiBase === 'string' && profileApiBase && !isLocalApiBase(profileApiBase)) {
676
+ return profileApiBase.replace(/\/+$/, '');
114
677
  }
115
678
 
116
- return profileApiBase || DEFAULT_API_BASE;
117
- }
118
-
119
- export function getJwt(config, profileName) {
120
- const env = process.env.NOTIS_JWT;
121
- if (env) {
122
- return env;
123
- }
124
- const profile = getProfile(config, profileName);
125
- return profile.jwt;
679
+ return resolveDefaultLiveApiBase(profile);
126
680
  }
127
681
 
128
682
  export function isAgentMode(globalOptions = {}) {
@@ -161,26 +715,222 @@ export function resolveTimeoutMs(globalOptions = {}) {
161
715
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 30000;
162
716
  }
163
717
 
164
- export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true } = {}) {
718
+ export function parseDebugEntitlementOverride(value = process.env.NOTIS_DEBUG_ENTITLEMENT_OVERRIDE) {
719
+ if (!value) {
720
+ return null;
721
+ }
722
+ const candidates = [value];
723
+ try {
724
+ candidates.push(Buffer.from(value, 'base64url').toString('utf-8'));
725
+ } catch {
726
+ // The plain JSON candidate below will provide the actionable error.
727
+ }
728
+ for (const candidate of candidates) {
729
+ try {
730
+ const parsed = JSON.parse(candidate);
731
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
732
+ return parsed;
733
+ }
734
+ } catch {
735
+ // Try the next supported encoding.
736
+ }
737
+ }
738
+ throw new CliError({
739
+ code: 'debug_entitlement_override_invalid',
740
+ message: 'NOTIS_DEBUG_ENTITLEMENT_OVERRIDE must be a JSON object or base64url-encoded JSON object.',
741
+ exitCode: EXIT_CODES.usage,
742
+ });
743
+ }
744
+
745
+ export function getOAuthResource(profile = {}) {
746
+ if (typeof profile.oauth_resource === 'string' && profile.oauth_resource) {
747
+ return profile.oauth_resource.replace(/\/+$/, '');
748
+ }
749
+ if (typeof profile.oauth_access_token === 'string' && profile.oauth_access_token) {
750
+ try {
751
+ const parts = profile.oauth_access_token.split('.');
752
+ if (parts.length === 3) {
753
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
754
+ if (typeof payload.aud === 'string' && payload.aud) {
755
+ return payload.aud.replace(/\/+$/, '');
756
+ }
757
+ }
758
+ } catch {
759
+ // Legacy tokens without a readable audience fall back below.
760
+ }
761
+ }
762
+ return null;
763
+ }
764
+
765
+ export function getOAuthApiBase(profile = {}) {
766
+ if (typeof profile.oauth_api_base === 'string' && profile.oauth_api_base) {
767
+ return profile.oauth_api_base.replace(/\/+$/, '');
768
+ }
769
+ const resource = getOAuthResource(profile);
770
+ if (resource?.endsWith('/cli')) {
771
+ return resource.slice(0, -'/cli'.length);
772
+ }
773
+ return null;
774
+ }
775
+
776
+ export function resolveRuntimeProfile(
777
+ globalOptions = {},
778
+ {
779
+ requireAuth = true,
780
+ includeDebugEntitlementOverride = true,
781
+ allowUnknownProfile = false,
782
+ allowUnavailableWorktree = false,
783
+ } = {},
784
+ ) {
785
+ const resolvedWorktree = resolveWorktreeRuntime();
786
+ const worktreeUnavailable = resolvedWorktree?.unavailable || null;
787
+ const worktreeRuntime = worktreeUnavailable ? null : resolvedWorktree;
165
788
  const config = loadConfig();
166
- const profileName = getCurrentProfileName(config, globalOptions.profile);
167
- const apiBase = getApiBase(config, profileName, globalOptions.apiBase);
168
- const jwt = getJwt(config, profileName);
789
+ const { profileName, source: profileSource } = resolveProfileSelection(
790
+ globalOptions,
791
+ worktreeRuntime,
792
+ config,
793
+ { allowUnknownProfile },
794
+ );
795
+ // A dead lease is an error for every command that can leave the machine,
796
+ // including unauthenticated health and OAuth calls. Only command specs that
797
+ // are explicitly local may continue inside a stopped worktree.
798
+ if (worktreeUnavailable && !allowUnavailableWorktree && profileSource !== 'explicit') {
799
+ throw worktreeUnavailable;
800
+ }
801
+ // Only the synthetic profile exposed by `./dev.sh` is pinned to its loopback
802
+ // backend.
803
+ // Naming any other profile opts out of the worktree entirely, which is how a
804
+ // developer reaches production from a checkout whose local API is down.
805
+ const devRuntime =
806
+ worktreeRuntime && worktreeRuntime.profile === profileName ? worktreeRuntime : null;
807
+ const requestedApiBase = globalOptions.apiBase;
808
+ if (
809
+ devRuntime &&
810
+ requestedApiBase &&
811
+ requestedApiBase.replace(/\/+$/, '') !== devRuntime.api_base
812
+ ) {
813
+ throw new CliError({
814
+ code: 'dev_runtime_route_mismatch',
815
+ message: `Profile "${profileName}" is bound to this worktree and cannot route to ${requestedApiBase}`,
816
+ exitCode: EXIT_CODES.usage,
817
+ hints: [
818
+ { message: `Expected local API: ${devRuntime.api_base}` },
819
+ { command: 'notis profile list', reason: 'Switch to a profile that targets that API instead' },
820
+ ],
821
+ });
822
+ }
823
+ let apiBase = devRuntime
824
+ ? devRuntime.api_base
825
+ : getApiBase(config, profileName, globalOptions.apiBase);
826
+ const profile = getProfile(config, profileName);
827
+ const envJwt = !devRuntime ? process.env.NOTIS_JWT : undefined;
828
+ const devJwt = devRuntime?.dev_access_token || profile.dev_access_token;
829
+ const oauthJwt = profile.oauth_access_token;
830
+ let jwt;
831
+ let credentialKind;
832
+
833
+ // A dev credential is a real Supabase token for the worktree's test user. It
834
+ // is only ever spendable against the loopback backend that minted it, so a
835
+ // profile holding one is unusable without its live lease rather than falling
836
+ // through to the live API and authenticating there as the test user.
837
+ if (requireAuth && !devRuntime && devJwt && !oauthJwt && !process.env.NOTIS_JWT) {
838
+ throw new CliError({
839
+ code: 'dev_runtime_unavailable',
840
+ message: `Profile "${profileName}" is a ./dev.sh profile and its local runtime is not active`,
841
+ exitCode: EXIT_CODES.network,
842
+ details: { workspace_root: profile.dev_workspace_root || null },
843
+ hints: [
844
+ profile.dev_workspace_root
845
+ ? { message: `Start ./dev.sh in ${profile.dev_workspace_root}, then retry.` }
846
+ : { message: 'Start ./dev.sh in the worktree that owns this profile, then retry.' },
847
+ { command: 'notis profile list', reason: 'Switch to a live account profile instead' },
848
+ ],
849
+ });
850
+ }
851
+
852
+ if (devRuntime && devJwt) {
853
+ jwt = devJwt;
854
+ credentialKind = 'worktree';
855
+ } else if (envJwt) {
856
+ jwt = envJwt;
857
+ credentialKind = 'env';
858
+ } else if (
859
+ oauthJwt
860
+ && !credentialIsExpired({ credentialKind: 'oauth', jwt: oauthJwt }, profile)
861
+ ) {
862
+ jwt = oauthJwt;
863
+ credentialKind = 'oauth';
864
+ } else if (oauthJwt) {
865
+ // A lapsed access token is still usable through its rotating refresh token,
866
+ // so preserve it and let transport refresh before the first request.
867
+ jwt = oauthJwt;
868
+ credentialKind = 'oauth';
869
+ }
870
+ const oauthApiBase = getOAuthApiBase(profile);
871
+ const oauthResource = getOAuthResource(profile);
872
+ const normalizedRequestedApiBase = requestedApiBase
873
+ ? requestedApiBase.replace(/\/+$/, '')
874
+ : null;
875
+ if (
876
+ requireAuth
877
+ && credentialKind === 'oauth'
878
+ && normalizedRequestedApiBase
879
+ && oauthApiBase
880
+ && normalizedRequestedApiBase !== oauthApiBase
881
+ ) {
882
+ throw new CliError({
883
+ code: 'oauth_api_target_mismatch',
884
+ message: (
885
+ `The OAuth credential for profile ${profileName} belongs to ${oauthApiBase}, `
886
+ + `not ${normalizedRequestedApiBase}`
887
+ ),
888
+ exitCode: EXIT_CODES.auth,
889
+ hints: [{
890
+ command: [
891
+ cliCommandForChannel(channelFromProfile({ api_base: normalizedRequestedApiBase })),
892
+ `--profile ${quoteShellArgument(profileName)}`,
893
+ `--api-base ${quoteShellArgument(normalizedRequestedApiBase)}`,
894
+ 'login',
895
+ ].join(' '),
896
+ reason: 'Authorize a separate OAuth grant for the requested Notis environment',
897
+ }],
898
+ });
899
+ }
900
+ if (credentialKind === 'oauth' && oauthApiBase && !requestedApiBase) {
901
+ apiBase = oauthApiBase;
902
+ }
169
903
  const agentMode = isAgentMode(globalOptions);
170
904
  const nonInteractive = isNonInteractive(globalOptions);
171
905
  const outputMode = resolveOutputMode(globalOptions);
172
906
  const timeoutMs = resolveTimeoutMs(globalOptions);
907
+ const debugEntitlementOverride = includeDebugEntitlementOverride
908
+ ? parseDebugEntitlementOverride()
909
+ : null;
173
910
 
174
911
  if (requireAuth && !jwt) {
175
912
  throw new CliError({
176
913
  code: 'auth_missing',
177
- message: `No JWT configured for profile ${profileName}`,
914
+ message: `Profile "${profileName}" has no Notis credential`,
915
+ exitCode: EXIT_CODES.auth,
916
+ hints: getAuthRecovery({ profileName, apiBase }, { mode: 'missing' }).hints,
917
+ });
918
+ }
919
+ if (
920
+ devRuntime?.expected_user_id &&
921
+ (
922
+ credentialKind === 'oauth'
923
+ ? profile.oauth_user_id
924
+ : getJwtSubject(jwt)
925
+ ) !== devRuntime.expected_user_id
926
+ ) {
927
+ throw new CliError({
928
+ code: 'dev_runtime_identity_mismatch',
929
+ message: `Profile "${profileName}" no longer holds this worktree's test identity`,
178
930
  exitCode: EXIT_CODES.auth,
179
931
  hints: [
180
- {
181
- command: 'notis auth login --jwt <token>',
182
- reason: 'Configure a token for non-interactive use',
183
- },
932
+ { message: 'Restart ./dev.sh to restore the approved worktree identity.' },
933
+ { message: `Expected user: ${devRuntime.expected_user_id}` },
184
934
  ],
185
935
  });
186
936
  }
@@ -188,15 +938,116 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
188
938
  return {
189
939
  config,
190
940
  profileName,
941
+ profileSource,
942
+ profileLabel: profile.label,
943
+ // Which published build serves this profile. Carried on the runtime so
944
+ // every recovery hint prints the command that will actually run.
945
+ channel: devRuntime
946
+ ? null
947
+ : channelFromProfile(
948
+ (globalOptions.apiBase || process.env.NOTIS_API_BASE)
949
+ // An explicit route owns this invocation even when the stored
950
+ // profile is pinned to the opposite release channel.
951
+ ? { api_base: apiBase }
952
+ : { ...profile, api_base: apiBase },
953
+ ),
191
954
  apiBase,
955
+ requestedApiBase: normalizedRequestedApiBase,
192
956
  jwt,
957
+ credentialKind,
958
+ credentialSource: credentialKind,
959
+ oauthAccessToken: profile.oauth_access_token,
960
+ oauthRefreshToken: profile.oauth_refresh_token,
961
+ oauthAccessExpiresAt: profile.oauth_access_expires_at,
962
+ oauthRefreshExpiresAt: profile.oauth_refresh_expires_at,
963
+ oauthClientId: profile.oauth_client_id,
964
+ oauthIssuer: profile.oauth_issuer,
965
+ oauthApiBase,
966
+ oauthResource,
967
+ oauthScopes: profile.oauth_scopes || [],
968
+ oauthUserId: profile.oauth_user_id,
193
969
  agentMode,
194
970
  nonInteractive,
195
971
  outputMode,
196
972
  timeoutMs,
973
+ debugEntitlementOverride,
974
+ worktreeRuntime: devRuntime,
975
+ detachedWorktreeRuntime: devRuntime ? null : worktreeRuntime,
976
+ worktreeRuntimeUnavailable: worktreeUnavailable,
197
977
  };
198
978
  }
199
979
 
980
+ export function getJwtExpiration(jwt) {
981
+ if (typeof jwt !== 'string' || !jwt) {
982
+ return null;
983
+ }
984
+ try {
985
+ const parts = jwt.split('.');
986
+ if (parts.length !== 3) return null;
987
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
988
+ return typeof payload.exp === 'number' ? payload.exp : null;
989
+ } catch {
990
+ return null;
991
+ }
992
+ }
993
+
994
+ export function getJwtSubject(jwt) {
995
+ if (typeof jwt !== 'string' || !jwt) {
996
+ return null;
997
+ }
998
+ try {
999
+ const parts = jwt.split('.');
1000
+ if (parts.length !== 3) return null;
1001
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
1002
+ return typeof payload.sub === 'string' && payload.sub ? payload.sub : null;
1003
+ } catch {
1004
+ return null;
1005
+ }
1006
+ }
1007
+
1008
+ export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
1009
+ const expiration = getJwtExpiration(jwt);
1010
+ return expiration !== null && expiration <= nowSeconds;
1011
+ }
1012
+
1013
+ export function credentialIsExpired(
1014
+ runtime,
1015
+ profile = {},
1016
+ nowSeconds = Math.floor(Date.now() / 1000),
1017
+ ) {
1018
+ // A credential with no declared kind is not one this CLI knows how to keep
1019
+ // alive, so it fails closed rather than defaulting to a permissive shape.
1020
+ const credentialKind = runtime?.credentialKind
1021
+ || (runtime?.credentialSource === 'env' ? 'env' : null);
1022
+ switch (credentialKind) {
1023
+ case 'oauth': {
1024
+ const expiration = Number(profile.oauth_access_expires_at);
1025
+ return !Number.isFinite(expiration) || expiration <= nowSeconds;
1026
+ }
1027
+ case 'env':
1028
+ // NOTIS_JWT is a complete override. Never combine it with expiry
1029
+ // metadata belonging to a different credential in the same profile.
1030
+ // A token with no readable `exp` is a personal API key, which never
1031
+ // expires, so let the server rather than the CLI reject it.
1032
+ {
1033
+ const expiration = getJwtExpiration(runtime?.jwt);
1034
+ return expiration !== null && expiration <= nowSeconds;
1035
+ }
1036
+ case 'worktree': {
1037
+ const rawExpiration = profile.dev_access_expires_at ?? getJwtExpiration(runtime?.jwt);
1038
+ // Every selected credential must carry an independently verifiable
1039
+ // expiry. Missing or malformed expiry metadata fails closed.
1040
+ if (rawExpiration === null || rawExpiration === undefined || rawExpiration === '') {
1041
+ return true;
1042
+ }
1043
+ const expiration = Number(rawExpiration);
1044
+ return !Number.isFinite(expiration) || expiration <= nowSeconds;
1045
+ }
1046
+ default:
1047
+ return true;
1048
+ }
1049
+ }
1050
+
200
1051
  export function workspacePath(appId) {
201
1052
  return join(WORKSPACE_DIR, appId);
202
1053
  }