@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
@@ -0,0 +1,2622 @@
1
+ import {
2
+ createHash,
3
+ randomBytes,
4
+ timingSafeEqual,
5
+ } from 'node:crypto';
6
+ import {
7
+ mkdirSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ renameSync,
11
+ rmdirSync,
12
+ rmSync,
13
+ statSync,
14
+ writeFileSync,
15
+ } from 'node:fs';
16
+ import { createServer } from 'node:http';
17
+ import { homedir } from 'node:os';
18
+ import { basename, dirname, join } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { execFileSync, spawn } from 'node:child_process';
21
+ import { createInterface } from 'node:readline/promises';
22
+
23
+ import { CliError, EXIT_CODES, usageError } from './errors.js';
24
+ import { getAuthRecovery, quoteShellArgument } from './auth-recovery.js';
25
+ import {
26
+ channelFromProfile,
27
+ cliCommandForChannel,
28
+ isReleaseChannel,
29
+ } from './channel.js';
30
+ import {
31
+ credentialIsExpired,
32
+ ensureProfile,
33
+ getOAuthApiBase,
34
+ getOAuthResource,
35
+ getProfile,
36
+ loadConfig,
37
+ resolveConfigFile,
38
+ updateConfig,
39
+ } from './profiles.js';
40
+
41
+ export const DEFAULT_CLI_OAUTH_SCOPES = [
42
+ 'notis:read',
43
+ 'notis:write',
44
+ 'notis:connections',
45
+ 'notis:apps',
46
+ ];
47
+ const OAUTH_LOCK_DIR = join(homedir(), '.notis', 'oauth.lock');
48
+ const DEFAULT_LOGIN_TIMEOUT_MS = 300_000;
49
+ const DEFAULT_REFRESH_EXPIRES_IN = 30 * 24 * 60 * 60;
50
+ // A parked authorization outlives the terminal that started it: the user may
51
+ // still have to sign up, verify an email, and consent before pasting the code.
52
+ const PENDING_LOGIN_TTL_SECONDS = 30 * 60;
53
+ const OAUTH_HTTP_TIMEOUT_MS = 10_000;
54
+ const RESPONSE_FLUSH_GRACE_MS = 2_000;
55
+ const MAX_NODE_TIMER_MS = 2_147_483_647;
56
+
57
+ function oauthError(code, message, hints = null, details = {}) {
58
+ return new CliError({
59
+ code,
60
+ message,
61
+ exitCode: EXIT_CODES.auth,
62
+ details,
63
+ hints: hints || [
64
+ { command: 'notis login', reason: 'Start a new browser authorization' },
65
+ { command: 'notis doctor', reason: 'Inspect the active credential state' },
66
+ ],
67
+ });
68
+ }
69
+
70
+ function base64url(value) {
71
+ return Buffer.from(value).toString('base64url');
72
+ }
73
+
74
+ function decodeJwtPayload(token) {
75
+ try {
76
+ const parts = String(token || '').split('.');
77
+ if (parts.length !== 3) return {};
78
+ return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
79
+ } catch {
80
+ return {};
81
+ }
82
+ }
83
+
84
+ async function fetchJson(url, init = {}, fetchImpl = fetch) {
85
+ const controller = new AbortController();
86
+ const timeout = setTimeout(() => controller.abort(), OAUTH_HTTP_TIMEOUT_MS);
87
+ const abortFromCaller = () => controller.abort();
88
+ init.signal?.addEventListener?.('abort', abortFromCaller, { once: true });
89
+ try {
90
+ const response = await fetchImpl(url, { ...init, signal: controller.signal });
91
+ const payload = await response.json().catch(() => ({}));
92
+ if (!response.ok) {
93
+ throw oauthError(
94
+ payload.error || 'oauth_request_failed',
95
+ payload.error_description || payload.message || `OAuth request failed with status ${response.status}`,
96
+ null,
97
+ payload,
98
+ );
99
+ }
100
+ return payload;
101
+ } catch (error) {
102
+ if (controller.signal.aborted && !init.signal?.aborted) {
103
+ throw oauthError(
104
+ 'oauth_request_timeout',
105
+ 'The OAuth server did not respond in time. Retry the command.',
106
+ );
107
+ }
108
+ throw error;
109
+ } finally {
110
+ clearTimeout(timeout);
111
+ init.signal?.removeEventListener?.('abort', abortFromCaller);
112
+ }
113
+ }
114
+
115
+ export async function discoverCliOAuth(apiBase, fetchImpl = fetch) {
116
+ const normalizedApiBase = apiBase.replace(/\/+$/, '');
117
+ const protectedResource = await fetchJson(
118
+ `${normalizedApiBase}/.well-known/oauth-protected-resource/cli`,
119
+ {},
120
+ fetchImpl,
121
+ );
122
+ const issuer = protectedResource.authorization_servers?.[0];
123
+ if (
124
+ typeof protectedResource.resource !== 'string'
125
+ || !protectedResource.resource
126
+ || typeof issuer !== 'string'
127
+ || !issuer
128
+ ) {
129
+ throw oauthError('oauth_metadata_invalid', 'Notis returned incomplete CLI OAuth metadata.');
130
+ }
131
+ const authorizationServer = await fetchJson(
132
+ `${issuer.replace(/\/+$/, '')}/.well-known/oauth-authorization-server`,
133
+ {},
134
+ fetchImpl,
135
+ );
136
+ if (
137
+ typeof authorizationServer.authorization_endpoint !== 'string'
138
+ || typeof authorizationServer.token_endpoint !== 'string'
139
+ || typeof authorizationServer.revocation_endpoint !== 'string'
140
+ ) {
141
+ throw oauthError('oauth_metadata_invalid', 'Notis returned incomplete authorization server metadata.');
142
+ }
143
+ return {
144
+ apiBase: normalizedApiBase,
145
+ issuer,
146
+ resource: protectedResource.resource,
147
+ clientId: protectedResource.notis_cli_client_id || 'notis_cli',
148
+ copyPasteRedirectUri: protectedResource.notis_cli_copy_paste_redirect_uri,
149
+ // A deployment that predates channel advertising, or a local one with no
150
+ // published build, leaves this null and the profile keeps resolving its
151
+ // channel from the endpoint it authorized against.
152
+ channel: isReleaseChannel(protectedResource.notis_cli_channel)
153
+ ? protectedResource.notis_cli_channel
154
+ : null,
155
+ authorizationEndpoint: authorizationServer.authorization_endpoint,
156
+ tokenEndpoint: authorizationServer.token_endpoint,
157
+ revocationEndpoint: authorizationServer.revocation_endpoint,
158
+ };
159
+ }
160
+
161
+ export function createPkce() {
162
+ const verifier = base64url(randomBytes(64));
163
+ const challenge = createHash('sha256').update(verifier, 'ascii').digest('base64url');
164
+ return { verifier, challenge };
165
+ }
166
+
167
+ function stateMatches(expected, actual) {
168
+ const expectedBuffer = Buffer.from(expected);
169
+ const actualBuffer = Buffer.from(actual || '');
170
+ return (
171
+ expectedBuffer.length === actualBuffer.length
172
+ && timingSafeEqual(expectedBuffer, actualBuffer)
173
+ );
174
+ }
175
+
176
+ const DESKTOP_DOWNLOAD_FALLBACK_URL = 'https://notis.ai/channels/desktop-app/#download';
177
+ const DESKTOP_DOWNLOAD_BASE_URL = (
178
+ 'https://jhgrvlwivajaifrunqpe.supabase.co/storage/v1/object/public/'
179
+ + 'desktop-releases/production'
180
+ );
181
+
182
+ function escapeHtml(value) {
183
+ return String(value)
184
+ .replaceAll('&', '&')
185
+ .replaceAll('<', '&lt;')
186
+ .replaceAll('>', '&gt;')
187
+ .replaceAll('"', '&quot;')
188
+ .replaceAll("'", '&#39;');
189
+ }
190
+
191
+ function normalizePortalOrigin(value) {
192
+ try {
193
+ const parsed = new URL(value || 'https://app.notis.ai');
194
+ if (!['http:', 'https:'].includes(parsed.protocol)) return 'https://app.notis.ai';
195
+ return parsed.origin;
196
+ } catch {
197
+ return 'https://app.notis.ai';
198
+ }
199
+ }
200
+
201
+ function callbackHtml(title, detail) {
202
+ return `<!doctype html>
203
+ <html lang="en">
204
+ <head>
205
+ <meta charset="utf-8">
206
+ <meta name="viewport" content="width=device-width, initial-scale=1">
207
+ <meta name="referrer" content="no-referrer">
208
+ <title>${escapeHtml(title)}</title>
209
+ <style>
210
+ :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
211
+ * { box-sizing: border-box; }
212
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px; background: #09090b; color: #fafafa; }
213
+ main { width: min(100%, 520px); padding: clamp(24px, 6vw, 40px); border: 1px solid #27272a; border-radius: 22px; background: #111113; box-shadow: 0 24px 80px rgb(0 0 0 / 35%); }
214
+ h1 { margin: 0; font-size: clamp(1.75rem, 6vw, 2.4rem); line-height: 1.08; letter-spacing: -0.035em; }
215
+ p { margin: 14px 0 0; color: #a1a1aa; line-height: 1.6; }
216
+ </style>
217
+ </head>
218
+ <body>
219
+ <main>
220
+ <h1>${escapeHtml(title)}</h1>
221
+ <p>${escapeHtml(detail)}</p>
222
+ </main>
223
+ </body>
224
+ </html>`;
225
+ }
226
+
227
+ function connectedCallbackHtml({ portalOrigin }) {
228
+ const safePortalOrigin = normalizePortalOrigin(portalOrigin);
229
+ const webAppUrl = new URL('/manage', `${safePortalOrigin}/`).toString();
230
+ const quickLoginUrl = new URL('/desktop-quick-login', `${safePortalOrigin}/`);
231
+ quickLoginUrl.searchParams.set('redirect', '/manage');
232
+ const serializedDownloads = JSON.stringify({
233
+ fallback: DESKTOP_DOWNLOAD_FALLBACK_URL,
234
+ base: DESKTOP_DOWNLOAD_BASE_URL,
235
+ }).replaceAll('<', '\\u003c');
236
+
237
+ return `<!doctype html>
238
+ <html lang="en">
239
+ <head>
240
+ <meta charset="utf-8">
241
+ <meta name="viewport" content="width=device-width, initial-scale=1">
242
+ <meta name="referrer" content="no-referrer">
243
+ <title>Notis CLI is connected</title>
244
+ <style>
245
+ :root {
246
+ color-scheme: dark;
247
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
248
+ --background: #08090b;
249
+ --surface: #111317;
250
+ --surface-raised: #171a20;
251
+ --border: #292d35;
252
+ --muted: #a6acb8;
253
+ --primary: #f5f7fa;
254
+ --primary-ink: #111317;
255
+ --success: #72f2ad;
256
+ --focus: #8cb8ff;
257
+ }
258
+ * { box-sizing: border-box; }
259
+ [hidden] { display: none !important; }
260
+ body {
261
+ margin: 0;
262
+ min-height: 100vh;
263
+ display: grid;
264
+ place-items: center;
265
+ padding: clamp(16px, 4vw, 40px);
266
+ background:
267
+ radial-gradient(circle at 50% -10%, rgb(63 80 120 / 20%), transparent 38%),
268
+ var(--background);
269
+ color: #f8fafc;
270
+ }
271
+ main {
272
+ width: min(100%, 680px);
273
+ padding: clamp(24px, 6vw, 44px);
274
+ border: 1px solid var(--border);
275
+ border-radius: 24px;
276
+ background: rgb(17 19 23 / 96%);
277
+ box-shadow: 0 28px 100px rgb(0 0 0 / 42%);
278
+ }
279
+ .brand {
280
+ display: inline-flex;
281
+ align-items: center;
282
+ gap: 9px;
283
+ margin-block-end: 30px;
284
+ color: #d9dde5;
285
+ font-size: 0.82rem;
286
+ font-weight: 750;
287
+ letter-spacing: 0.08em;
288
+ text-transform: uppercase;
289
+ }
290
+ .brand-mark {
291
+ display: grid;
292
+ width: 30px;
293
+ aspect-ratio: 1;
294
+ place-items: center;
295
+ border-radius: 9px;
296
+ background: #f4f4f5;
297
+ color: #09090b;
298
+ font-size: 1rem;
299
+ font-weight: 900;
300
+ }
301
+ .status {
302
+ display: inline-flex;
303
+ align-items: center;
304
+ gap: 8px;
305
+ margin-block-end: 14px;
306
+ color: var(--success);
307
+ font-size: 0.78rem;
308
+ font-weight: 800;
309
+ letter-spacing: 0.08em;
310
+ text-transform: uppercase;
311
+ }
312
+ .status-dot {
313
+ width: 8px;
314
+ aspect-ratio: 1;
315
+ border-radius: 999px;
316
+ background: currentColor;
317
+ box-shadow: 0 0 0 5px rgb(114 242 173 / 10%);
318
+ }
319
+ h1 {
320
+ margin: 0;
321
+ max-width: 16ch;
322
+ font-size: clamp(2rem, 7vw, 3.25rem);
323
+ line-height: 1.02;
324
+ letter-spacing: -0.055em;
325
+ }
326
+ .lede {
327
+ margin: 16px 0 0;
328
+ max-width: 58ch;
329
+ color: var(--muted);
330
+ font-size: clamp(0.96rem, 2vw, 1.08rem);
331
+ line-height: 1.6;
332
+ }
333
+ .capabilities {
334
+ display: grid;
335
+ grid-template-columns: repeat(2, minmax(0, 1fr));
336
+ gap: 10px;
337
+ margin: 26px 0 0;
338
+ padding: 0;
339
+ list-style: none;
340
+ }
341
+ .capabilities li {
342
+ min-height: 84px;
343
+ padding: 15px;
344
+ border: 1px solid var(--border);
345
+ border-radius: 15px;
346
+ background: var(--surface-raised);
347
+ }
348
+ .capabilities strong { display: block; margin-block-end: 4px; font-size: 0.94rem; }
349
+ .capabilities span { display: block; color: var(--muted); font-size: 0.81rem; line-height: 1.45; }
350
+ .actions { display: grid; grid-template-columns: 1.45fr 1fr; gap: 10px; margin-block-start: 26px; }
351
+ .button {
352
+ display: inline-flex;
353
+ min-height: 48px;
354
+ align-items: center;
355
+ justify-content: center;
356
+ padding: 12px 18px;
357
+ border: 1px solid var(--border);
358
+ border-radius: 13px;
359
+ background: transparent;
360
+ color: #f4f4f5;
361
+ font: inherit;
362
+ font-weight: 750;
363
+ text-align: center;
364
+ text-decoration: none;
365
+ cursor: pointer;
366
+ transition: transform 140ms ease, background 140ms ease, border-color 140ms ease;
367
+ }
368
+ .button:hover { transform: translateY(-1px); border-color: #454b57; background: #1b1e24; }
369
+ .button:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
370
+ .button-primary { border-color: var(--primary); background: var(--primary); color: var(--primary-ink); }
371
+ .button-primary:hover { border-color: #fff; background: #fff; }
372
+ .helper { margin: 12px 0 0; color: #777f8d; font-size: 0.78rem; line-height: 1.45; }
373
+ .next-step {
374
+ margin-block-start: 24px;
375
+ padding: 18px;
376
+ border: 1px solid rgb(114 242 173 / 28%);
377
+ border-radius: 16px;
378
+ background: rgb(114 242 173 / 7%);
379
+ }
380
+ .next-step h2 { margin: 0; font-size: clamp(1.3rem, 4vw, 1.65rem); letter-spacing: -0.03em; }
381
+ .next-step p { margin: 8px 0 0; color: var(--muted); line-height: 1.55; }
382
+ .next-step .actions { margin-block-start: 18px; }
383
+ @media (max-width: 560px) {
384
+ body { place-items: start center; }
385
+ main { border-radius: 20px; }
386
+ .capabilities, .actions { grid-template-columns: 1fr; }
387
+ .capabilities li { min-height: auto; }
388
+ }
389
+ </style>
390
+ </head>
391
+ <body>
392
+ <main>
393
+ <div class="brand"><span class="brand-mark">N</span> Notis</div>
394
+ <section id="connected-view" aria-labelledby="connected-title">
395
+ <div class="status"><span class="status-dot"></span> CLI ready</div>
396
+ <h1 id="connected-title">Notis CLI is connected.</h1>
397
+ <p class="lede">
398
+ Your terminal is ready. Add the Desktop app to unlock everything that runs safely on this computer.
399
+ </p>
400
+ <ul class="capabilities" aria-label="Desktop capabilities">
401
+ <li><strong>Sync agent skills</strong><span>Keep your Notis skills available in Codex, Claude Code, Cursor, and other local agents.</span></li>
402
+ <li><strong>Control your computer</strong><span>Let approved agents use local apps, files, and computer controls when you ask.</span></li>
403
+ <li><strong>Connect local MCP</strong><span>Use local MCP servers and tools without exposing them to the public internet.</span></li>
404
+ <li><strong>Approve local actions</strong><span>Review sensitive computer actions from the Desktop app before they run.</span></li>
405
+ </ul>
406
+ <div class="actions">
407
+ <button class="button button-primary" id="download-desktop" type="button">Download Notis Desktop</button>
408
+ <a class="button" href="${escapeHtml(webAppUrl)}">Open Web App</a>
409
+ </div>
410
+ <p class="helper">The CLI stays connected even if you continue in the web app.</p>
411
+ </section>
412
+ <section class="next-step" id="desktop-ready-view" aria-labelledby="desktop-ready-title" hidden>
413
+ <div class="status"><span class="status-dot"></span> Download started</div>
414
+ <h2 id="desktop-ready-title" tabindex="-1">Quick login to Notis Desktop</h2>
415
+ <p>
416
+ Install the app, then use quick login. Your signed-in browser securely hands the current session to Desktop.
417
+ </p>
418
+ <div class="actions">
419
+ <a class="button button-primary" href="${escapeHtml(quickLoginUrl.toString())}">Open &amp; sign in to Desktop</a>
420
+ <a class="button" href="${escapeHtml(webAppUrl)}">Open Web App</a>
421
+ </div>
422
+ <button class="button" id="download-again" type="button" style="width:100%;margin-top:10px">Download again</button>
423
+ </section>
424
+ </main>
425
+ <script>
426
+ (() => {
427
+ const downloads = ${serializedDownloads};
428
+ const downloadButton = document.getElementById('download-desktop');
429
+ const downloadAgainButton = document.getElementById('download-again');
430
+ const connectedView = document.getElementById('connected-view');
431
+ const readyView = document.getElementById('desktop-ready-view');
432
+
433
+ const resolveDownload = async () => {
434
+ const ua = navigator.userAgent || '';
435
+ if (/Windows/i.test(ua)) return downloads.base + '/win32/x64/notis-x64.exe';
436
+ if (/Linux/i.test(ua) && !/Android/i.test(ua)) return downloads.base + '/linux/x64/notis-linux-x64.zip';
437
+ if (/Macintosh|Mac OS X/i.test(ua)) {
438
+ let architecture = '';
439
+ try {
440
+ architecture = (await navigator.userAgentData?.getHighEntropyValues?.(['architecture']))?.architecture || '';
441
+ } catch {}
442
+ const path = /arm/i.test(architecture)
443
+ ? '/darwin/arm64/notis-arm64.dmg'
444
+ : '/darwin/x64/notis-x64.dmg';
445
+ return downloads.base + path;
446
+ }
447
+ return downloads.fallback;
448
+ };
449
+
450
+ const startDownload = async () => {
451
+ const downloadUrl = await resolveDownload();
452
+ window.open(downloadUrl, '_blank', 'noopener,noreferrer');
453
+ connectedView.hidden = true;
454
+ readyView.hidden = false;
455
+ document.title = 'Quick login to Notis Desktop';
456
+ document.getElementById('desktop-ready-title')?.focus?.();
457
+ };
458
+
459
+ downloadButton?.addEventListener('click', startDownload);
460
+ downloadAgainButton?.addEventListener('click', async () => {
461
+ const downloadUrl = await resolveDownload();
462
+ window.open(downloadUrl, '_blank', 'noopener,noreferrer');
463
+ });
464
+ })();
465
+ </script>
466
+ </body>
467
+ </html>`;
468
+ }
469
+
470
+ export async function createLoopbackReceiver({
471
+ state,
472
+ timeoutMs = DEFAULT_LOGIN_TIMEOUT_MS,
473
+ portalOrigin,
474
+ } = {}) {
475
+ let consumed = false;
476
+ let resolveCode;
477
+ let rejectCode;
478
+ let timeout;
479
+ let pendingResponse = null;
480
+ // Browsers routinely park speculative connections that never send a request.
481
+ // `server.close()` waits for every socket it accepted, so the sockets have to
482
+ // be tracked and dropped by hand or a finished login would keep waiting.
483
+ const sockets = new Set();
484
+ let responseFlushed = Promise.resolve();
485
+ const result = new Promise((resolve, reject) => {
486
+ resolveCode = resolve;
487
+ rejectCode = reject;
488
+ });
489
+
490
+ const endResponse = (response, body) => {
491
+ responseFlushed = new Promise((resolve) => {
492
+ response.end(body, resolve);
493
+ });
494
+ };
495
+
496
+ const server = createServer((request, response) => {
497
+ const address = server.address();
498
+ const expectedHost = address && typeof address === 'object'
499
+ ? `127.0.0.1:${address.port}`
500
+ : '';
501
+ response.setHeader('Cache-Control', 'no-store');
502
+ response.setHeader('Referrer-Policy', 'no-referrer');
503
+ response.setHeader('X-Content-Type-Options', 'nosniff');
504
+ response.setHeader('X-Frame-Options', 'DENY');
505
+
506
+ if (request.headers.host !== expectedHost) {
507
+ response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
508
+ endResponse(response, callbackHtml('Invalid callback', 'The callback host was not accepted.'));
509
+ return;
510
+ }
511
+ const url = new URL(request.url || '/', `http://${expectedHost}`);
512
+ if (request.method !== 'GET' || url.pathname !== '/callback') {
513
+ response.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
514
+ endResponse(response, callbackHtml('Not found', 'This callback path does not exist.'));
515
+ return;
516
+ }
517
+ if (consumed) {
518
+ response.writeHead(410, { 'Content-Type': 'text/html; charset=utf-8' });
519
+ endResponse(response, callbackHtml('Already used', 'This authorization callback was already handled.'));
520
+ return;
521
+ }
522
+ const returnedState = url.searchParams.get('state') || '';
523
+ const code = url.searchParams.get('code');
524
+ const error = url.searchParams.get('error');
525
+ if (!stateMatches(state, returnedState)) {
526
+ response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
527
+ endResponse(response, callbackHtml('Authorization failed', 'The callback state did not match.'));
528
+ return;
529
+ }
530
+ // A stray or malicious request must not burn the one legitimate callback.
531
+ // Only a request carrying this authorization's state consumes the receiver.
532
+ consumed = true;
533
+ if (error || !code) {
534
+ const description = url.searchParams.get('error_description') || 'Authorization was not completed.';
535
+ response.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
536
+ endResponse(response, callbackHtml('Authorization not completed', description));
537
+ rejectCode(oauthError(error || 'oauth_code_missing', description));
538
+ return;
539
+ }
540
+ // Keep the browser request pending until the CLI has exchanged and
541
+ // persisted the code. Receiving a valid callback is not enough to claim
542
+ // that the command line is connected.
543
+ pendingResponse = response;
544
+ resolveCode(code);
545
+ });
546
+
547
+ server.on('connection', (socket) => {
548
+ sockets.add(socket);
549
+ socket.on('close', () => sockets.delete(socket));
550
+ });
551
+
552
+ await new Promise((resolve, reject) => {
553
+ server.once('error', reject);
554
+ server.listen(0, '127.0.0.1', () => {
555
+ server.off('error', reject);
556
+ resolve();
557
+ });
558
+ });
559
+ const address = server.address();
560
+ const port = address && typeof address === 'object' ? address.port : null;
561
+ if (!port) {
562
+ server.close();
563
+ throw oauthError('oauth_loopback_failed', 'Could not start the local OAuth callback listener.');
564
+ }
565
+
566
+ timeout = setTimeout(() => {
567
+ rejectCode(oauthError('oauth_timeout', `Authorization timed out after ${timeoutMs}ms.`));
568
+ }, timeoutMs);
569
+
570
+ const close = async () => {
571
+ clearTimeout(timeout);
572
+ if (pendingResponse && !pendingResponse.writableEnded) {
573
+ pendingResponse.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
574
+ endResponse(pendingResponse, callbackHtml(
575
+ 'Authorization did not finish',
576
+ 'Return to the terminal for details, then retry sign in.',
577
+ ));
578
+ pendingResponse = null;
579
+ }
580
+ // The browser answer is already written; wait only for the kernel to take
581
+ // it so tearing the socket down cannot truncate the connected page.
582
+ await Promise.race([
583
+ responseFlushed,
584
+ new Promise((resolve) => { setTimeout(resolve, RESPONSE_FLUSH_GRACE_MS).unref?.(); }),
585
+ ]);
586
+ // Chrome parks a speculative connection next to the one that carried the
587
+ // callback. It never sends a request, so Node counts it as active and
588
+ // `server.close()` waits for a socket only the browser will ever release:
589
+ // a login that already succeeded would sit in the terminal for minutes.
590
+ for (const socket of sockets) socket.destroy();
591
+ sockets.clear();
592
+ if (!server.listening) return;
593
+ await new Promise((resolve) => server.close(resolve));
594
+ };
595
+
596
+ return {
597
+ port,
598
+ redirectUri: `http://127.0.0.1:${port}/callback`,
599
+ waitForCode: () => result,
600
+ complete: () => {
601
+ if (!pendingResponse || pendingResponse.writableEnded) return;
602
+ const connectedUrl = new URL(
603
+ '/cli-connected',
604
+ `${normalizePortalOrigin(portalOrigin)}/`,
605
+ );
606
+ pendingResponse.writeHead(302, {
607
+ 'Content-Type': 'text/html; charset=utf-8',
608
+ Location: connectedUrl.toString(),
609
+ });
610
+ endResponse(pendingResponse, connectedCallbackHtml({ portalOrigin }));
611
+ pendingResponse = null;
612
+ },
613
+ fail: ({ detached = false } = {}) => {
614
+ if (!pendingResponse || pendingResponse.writableEnded) return;
615
+ pendingResponse.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
616
+ endResponse(pendingResponse, callbackHtml(
617
+ 'Authorization failed',
618
+ detached
619
+ ? 'The CLI could not finish signing in. Start sign in again from your agent or terminal.'
620
+ : 'The CLI could not finish signing in. Return to the terminal for details.',
621
+ ));
622
+ pendingResponse = null;
623
+ },
624
+ cancel: () => {
625
+ rejectCode(oauthError('oauth_listener_cancelled', 'This browser authorization is no longer active.'));
626
+ },
627
+ close,
628
+ };
629
+ }
630
+
631
+ export function browserOpenCommand(url, platform = process.platform) {
632
+ const command = platform === 'darwin'
633
+ ? 'open'
634
+ : platform === 'win32'
635
+ ? 'rundll32.exe'
636
+ : 'xdg-open';
637
+ const args = platform === 'win32'
638
+ ? ['url.dll,FileProtocolHandler', url]
639
+ : [url];
640
+ return { command, args };
641
+ }
642
+
643
+ function openBrowser(url) {
644
+ const { command, args } = browserOpenCommand(url);
645
+ try {
646
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
647
+ // A missing opener emits an async 'error' event that no try/catch can reach,
648
+ // and an unhandled one would take the whole login down.
649
+ child.on('error', () => {});
650
+ child.unref();
651
+ } catch {
652
+ // Non-fatal. The authorize URL is printed alongside this call.
653
+ }
654
+ }
655
+
656
+ function storedOAuthMetadata(runtime, profile) {
657
+ const issuer = String(profile.oauth_issuer || '').replace(/\/+$/, '');
658
+ const apiBase = getOAuthApiBase(profile)
659
+ || String(profile.api_base || runtime.apiBase || '').replace(/\/+$/, '');
660
+ const resource = getOAuthResource(profile) || (apiBase ? `${apiBase}/cli` : '');
661
+ if (!issuer || !apiBase || !resource || !profile.oauth_client_id) {
662
+ throw oauthError(
663
+ 'oauth_metadata_missing',
664
+ 'The stored OAuth profile is incomplete. Run notis login again.',
665
+ );
666
+ }
667
+ return {
668
+ apiBase,
669
+ issuer,
670
+ resource,
671
+ clientId: profile.oauth_client_id,
672
+ tokenEndpoint: `${issuer}/oauth/token`,
673
+ revocationEndpoint: `${issuer}/oauth/revoke`,
674
+ };
675
+ }
676
+
677
+ function buildAuthorizeUrl(metadata, {
678
+ redirectUri,
679
+ challenge,
680
+ state,
681
+ scopes,
682
+ }) {
683
+ const url = new URL(metadata.authorizationEndpoint);
684
+ url.searchParams.set('response_type', 'code');
685
+ url.searchParams.set('client_id', metadata.clientId);
686
+ url.searchParams.set('redirect_uri', redirectUri);
687
+ url.searchParams.set('resource', metadata.resource);
688
+ url.searchParams.set('scope', scopes.join(' '));
689
+ url.searchParams.set('code_challenge', challenge);
690
+ url.searchParams.set('code_challenge_method', 'S256');
691
+ url.searchParams.set('state', state);
692
+ return url.toString();
693
+ }
694
+
695
+ async function exchangeCode(metadata, {
696
+ code,
697
+ redirectUri,
698
+ verifier,
699
+ }, fetchImpl = fetch) {
700
+ const body = new URLSearchParams({
701
+ grant_type: 'authorization_code',
702
+ code,
703
+ client_id: metadata.clientId,
704
+ redirect_uri: redirectUri,
705
+ resource: metadata.resource,
706
+ code_verifier: verifier,
707
+ });
708
+ return fetchJson(
709
+ metadata.tokenEndpoint,
710
+ {
711
+ method: 'POST',
712
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
713
+ body,
714
+ },
715
+ fetchImpl,
716
+ );
717
+ }
718
+
719
+ function persistOAuthTokenResponse(runtime, metadata, tokenResponse) {
720
+ const now = Math.floor(Date.now() / 1000);
721
+ const payload = decodeJwtPayload(tokenResponse.access_token);
722
+ const oauthApiBase = (metadata.apiBase || runtime.apiBase || '').replace(/\/+$/, '');
723
+ let beta;
724
+ try {
725
+ const hostname = new URL(oauthApiBase).hostname;
726
+ if (hostname === 'api-beta.notis.ai') beta = true;
727
+ else if (hostname === 'api.notis.ai') beta = false;
728
+ } catch {
729
+ beta = undefined;
730
+ }
731
+ const config = updateConfig((latest) => {
732
+ const next = ensureProfile(latest, runtime.profileName);
733
+ const profile = next.profiles[runtime.profileName];
734
+ next.profiles[runtime.profileName] = {
735
+ ...profile,
736
+ // The grant defines this profile's endpoint. A profile is one account on
737
+ // one API, and the environment the user just authorized against is the
738
+ // only endpoint the resulting token is accepted by.
739
+ api_base: oauthApiBase || profile.api_base,
740
+ beta: beta ?? profile.beta,
741
+ // The deployment that just authorized this profile also names the
742
+ // published build that belongs to it. Pinning it here is what lets the
743
+ // next run correct itself without the user knowing a channel exists.
744
+ channel: isReleaseChannel(metadata.channel)
745
+ ? metadata.channel
746
+ : channelFromProfile({ ...profile, beta: beta ?? profile.beta, api_base: oauthApiBase })
747
+ ?? profile.channel,
748
+ oauth_api_base: oauthApiBase || profile.oauth_api_base,
749
+ oauth_resource: metadata.resource,
750
+ oauth_access_token: tokenResponse.access_token,
751
+ oauth_refresh_token: tokenResponse.refresh_token || profile.oauth_refresh_token,
752
+ oauth_access_expires_at: now + Number(tokenResponse.expires_in || 0),
753
+ oauth_refresh_expires_at:
754
+ now + Number(tokenResponse.refresh_expires_in || DEFAULT_REFRESH_EXPIRES_IN),
755
+ oauth_client_id: metadata.clientId,
756
+ oauth_issuer: metadata.issuer,
757
+ oauth_scopes: String(tokenResponse.scope || '').split(/\s+/).filter(Boolean),
758
+ oauth_user_id: payload.sub || payload.notis_user_id,
759
+ };
760
+ return next;
761
+ });
762
+ return config.profiles[runtime.profileName];
763
+ }
764
+
765
+ function pendingAuthorizationFile(runtime) {
766
+ const profileKey = createHash('sha256')
767
+ .update(String(runtime.profileName || 'default'))
768
+ .digest('hex')
769
+ .slice(0, 16);
770
+ return `${resolveConfigFile()}.pending-login.${profileKey}`;
771
+ }
772
+
773
+ function legacyPendingAuthorizationFile(runtime) {
774
+ return `${resolveConfigFile()}.pending-login`;
775
+ }
776
+
777
+ // The PKCE verifier outlives the process that created it whenever the browser
778
+ // hand-off cannot block on a terminal, so it is parked next to the config
779
+ // rather than in it: normalizeConfig drops unknown profile keys, and a
780
+ // half-finished login must never survive as profile state.
781
+ function savePendingAuthorization(runtime, pending) {
782
+ const file = pendingAuthorizationFile(runtime);
783
+ mkdirSync(dirname(file), { recursive: true });
784
+ writeFileSync(file, JSON.stringify(pending, null, 2), { mode: 0o600 });
785
+ }
786
+
787
+ function readPendingAuthorization(runtime, { includeExpired = false } = {}) {
788
+ for (const file of [
789
+ pendingAuthorizationFile(runtime),
790
+ legacyPendingAuthorizationFile(runtime),
791
+ ]) {
792
+ let pending;
793
+ try {
794
+ pending = JSON.parse(readFileSync(file, 'utf-8'));
795
+ } catch {
796
+ continue;
797
+ }
798
+ if (!pending?.verifier || !pending?.redirect_uri) continue;
799
+ if (!includeExpired && Number(pending.expires_at) <= Math.floor(Date.now() / 1000)) continue;
800
+ if (pending.profile !== runtime.profileName) continue;
801
+ return { ...pending, pending_file: file };
802
+ }
803
+ return null;
804
+ }
805
+
806
+ function clearPendingAuthorization(runtime, file = pendingAuthorizationFile(runtime)) {
807
+ try {
808
+ rmSync(file);
809
+ } catch {
810
+ // Nothing to clear.
811
+ }
812
+ }
813
+
814
+ function clearPendingAuthorizations(runtime) {
815
+ clearPendingAuthorization(runtime);
816
+ const legacyFile = legacyPendingAuthorizationFile(runtime);
817
+ try {
818
+ const pending = JSON.parse(readFileSync(legacyFile, 'utf-8'));
819
+ if (pending?.profile === runtime.profileName) rmSync(legacyFile);
820
+ } catch {
821
+ // Missing, malformed, or owned by another profile.
822
+ }
823
+ }
824
+
825
+ function pendingAuthorizationOwnedBy(pending, owner) {
826
+ return Boolean(
827
+ pending
828
+ && pending.hand_off === owner.hand_off
829
+ && pending.state === owner.state
830
+ && pending.verifier === owner.verifier
831
+ && pending.redirect_uri === owner.redirect_uri
832
+ && pending.api_base === owner.api_base
833
+ );
834
+ }
835
+
836
+ function clearPendingAuthorizationIfOwner(runtime, owner) {
837
+ // Cleanup must still remove an owner that expired at the same instant as a
838
+ // receiver timeout; ordinary reads continue to ignore expired grants.
839
+ const pending = readPendingAuthorization(runtime, { includeExpired: true });
840
+ if (!pendingAuthorizationOwnedBy(pending, owner)) return false;
841
+ clearPendingAuthorization(runtime, pending.pending_file);
842
+ return true;
843
+ }
844
+
845
+ async function publishForegroundAuthorization(runtime, authorization) {
846
+ const globalLock = await acquireListenerGlobalLock();
847
+ let lockDir = null;
848
+ try {
849
+ lockDir = await acquireListenerStartLock(runtime);
850
+ stopPendingListener(runtime);
851
+ clearPendingAuthorizations(runtime);
852
+ savePendingAuthorization(runtime, authorization);
853
+ } finally {
854
+ releaseListenerStartLock(lockDir);
855
+ releaseListenerGlobalLock(globalLock);
856
+ }
857
+ }
858
+
859
+ const LISTENER_SCRIPT = fileURLToPath(new URL('./login-listener.js', import.meta.url));
860
+ const LISTENER_SCRIPT_NAME = 'login-listener.js';
861
+ // How long the parent waits for the detached child to bind and report its port.
862
+ // Only a bind, so this is generous; exceeding it means the child is not coming.
863
+ const LISTENER_HANDSHAKE_TIMEOUT_MS = 10_000;
864
+ const LISTENER_START_LOCK_STALE_MS = LISTENER_HANDSHAKE_TIMEOUT_MS * 2;
865
+ const LISTENER_GLOBAL_LOCK_HEARTBEAT_MS = 5_000;
866
+ const LISTENER_CANCELLATION_POLL_MS = 100;
867
+ const LISTENER_IDENTITY_PROBE_TIMEOUT_MS = 2_000;
868
+
869
+ const LISTENER_CHILD_ENV_KEYS = [
870
+ 'HOME',
871
+ 'USERPROFILE',
872
+ 'APPDATA',
873
+ 'LOCALAPPDATA',
874
+ 'TMPDIR',
875
+ 'TEMP',
876
+ 'TMP',
877
+ 'SystemRoot',
878
+ 'WINDIR',
879
+ 'ComSpec',
880
+ 'PATH',
881
+ 'Path',
882
+ 'PATHEXT',
883
+ 'NODE_EXTRA_CA_CERTS',
884
+ 'SSL_CERT_FILE',
885
+ 'SSL_CERT_DIR',
886
+ ];
887
+
888
+ /**
889
+ * A detached listener outlives the command that launched it. Give it only the
890
+ * OS/runtime values needed to start Node and find its private config, never the
891
+ * caller's unrelated API keys, service credentials, or inherited NOTIS_JWT.
892
+ */
893
+ export function listenerChildEnvironment(env = process.env) {
894
+ const childEnv = {};
895
+ for (const key of LISTENER_CHILD_ENV_KEYS) {
896
+ if (typeof env[key] === 'string' && env[key]) childEnv[key] = env[key];
897
+ }
898
+ childEnv.NOTIS_CLI_CONFIG_FILE = resolveConfigFile();
899
+ return childEnv;
900
+ }
901
+
902
+ function listenerStateFile(runtime) {
903
+ const profileKey = createHash('sha256')
904
+ .update(String(runtime.profileName || 'default'))
905
+ .digest('hex')
906
+ .slice(0, 16);
907
+ return `${resolveConfigFile()}.login-listener.${profileKey}`;
908
+ }
909
+
910
+ function listenerStartLockDir(runtime) {
911
+ return `${listenerStateFile(runtime)}.lock`;
912
+ }
913
+
914
+ function listenerGlobalLockDir() {
915
+ return `${resolveConfigFile()}.oauth-listener-global.lock`;
916
+ }
917
+
918
+ function listenerGlobalLockOwnerFile(lockDir) {
919
+ return join(lockDir, 'owner.json');
920
+ }
921
+
922
+ function readListenerGlobalLockOwner(lockDir) {
923
+ try {
924
+ return JSON.parse(readFileSync(listenerGlobalLockOwnerFile(lockDir), 'utf-8'));
925
+ } catch {
926
+ return null;
927
+ }
928
+ }
929
+
930
+ function writeListenerGlobalLockOwner(lock) {
931
+ const current = readListenerGlobalLockOwner(lock.lockDir);
932
+ if (current && current.owner_token !== lock.ownerToken) return false;
933
+ writeFileSync(listenerGlobalLockOwnerFile(lock.lockDir), JSON.stringify({
934
+ owner_token: lock.ownerToken,
935
+ owner_pid: process.pid,
936
+ updated_at: Date.now(),
937
+ }), { mode: 0o600 });
938
+ return true;
939
+ }
940
+
941
+ function listenerGlobalLockIsStale(lockDir, staleMs) {
942
+ try {
943
+ return Date.now() - statSync(listenerGlobalLockOwnerFile(lockDir)).mtimeMs > staleMs;
944
+ } catch {
945
+ try {
946
+ return Date.now() - statSync(lockDir).mtimeMs > staleMs;
947
+ } catch {
948
+ return false;
949
+ }
950
+ }
951
+ }
952
+
953
+ function retireStaleListenerGlobalLock(lockDir) {
954
+ const retiredDir = `${lockDir}.stale-${process.pid}-${base64url(randomBytes(8))}`;
955
+ try {
956
+ renameSync(lockDir, retiredDir);
957
+ } catch {
958
+ return false;
959
+ }
960
+ rmSync(retiredDir, { recursive: true, force: true });
961
+ return true;
962
+ }
963
+
964
+ async function acquireListenerStartLock(runtime) {
965
+ const lockDir = listenerStartLockDir(runtime);
966
+ mkdirSync(dirname(lockDir), { recursive: true });
967
+ const deadline = Date.now() + LISTENER_START_LOCK_STALE_MS * 2;
968
+ for (;;) {
969
+ try {
970
+ mkdirSync(lockDir);
971
+ return lockDir;
972
+ } catch (error) {
973
+ if (error?.code !== 'EEXIST') throw error;
974
+ try {
975
+ if (Date.now() - statSync(lockDir).mtimeMs > LISTENER_START_LOCK_STALE_MS) {
976
+ rmdirSync(lockDir);
977
+ continue;
978
+ }
979
+ } catch {
980
+ // The owner may have released the lock between stat and removal.
981
+ }
982
+ if (Date.now() >= deadline) {
983
+ throw oauthError(
984
+ 'oauth_listener_lock_timeout',
985
+ 'Timed out waiting for another CLI process to start browser authorization.',
986
+ );
987
+ }
988
+ await new Promise((resolve) => setTimeout(resolve, 50));
989
+ }
990
+ }
991
+ }
992
+
993
+ function releaseListenerStartLock(lockDir) {
994
+ if (!lockDir) return;
995
+ try {
996
+ rmdirSync(lockDir);
997
+ } catch {
998
+ // A crashed owner or stale-lock cleanup may already have removed it.
999
+ }
1000
+ }
1001
+
1002
+ export async function acquireListenerGlobalLock({
1003
+ staleMs = LISTENER_START_LOCK_STALE_MS,
1004
+ waitMs = LISTENER_START_LOCK_STALE_MS * 2,
1005
+ heartbeatMs = LISTENER_GLOBAL_LOCK_HEARTBEAT_MS,
1006
+ } = {}) {
1007
+ const lockDir = listenerGlobalLockDir();
1008
+ mkdirSync(dirname(lockDir), { recursive: true });
1009
+ const deadline = Date.now() + waitMs;
1010
+ for (;;) {
1011
+ try {
1012
+ mkdirSync(lockDir);
1013
+ const lock = {
1014
+ lockDir,
1015
+ ownerToken: base64url(randomBytes(24)),
1016
+ heartbeat: null,
1017
+ };
1018
+ writeListenerGlobalLockOwner(lock);
1019
+ lock.heartbeat = setInterval(() => {
1020
+ try {
1021
+ writeListenerGlobalLockOwner(lock);
1022
+ } catch {
1023
+ // A stale-lock recovery may have retired this directory. Ownership-
1024
+ // qualified release below must not disturb the successor.
1025
+ }
1026
+ }, heartbeatMs);
1027
+ lock.heartbeat.unref?.();
1028
+ return lock;
1029
+ } catch (error) {
1030
+ if (error?.code !== 'EEXIST') throw error;
1031
+ if (
1032
+ listenerGlobalLockIsStale(lockDir, staleMs)
1033
+ && retireStaleListenerGlobalLock(lockDir)
1034
+ ) {
1035
+ continue;
1036
+ }
1037
+ if (Date.now() >= deadline) {
1038
+ throw oauthError(
1039
+ 'oauth_listener_global_lock_timeout',
1040
+ 'Timed out waiting for another CLI process to finish OAuth account changes.',
1041
+ );
1042
+ }
1043
+ await new Promise((resolve) => setTimeout(resolve, 50));
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ export function releaseListenerGlobalLock(lock) {
1049
+ if (!lock) return;
1050
+ clearInterval(lock.heartbeat);
1051
+ const owner = readListenerGlobalLockOwner(lock.lockDir);
1052
+ if (owner?.owner_token !== lock.ownerToken) return;
1053
+ const releasedDir = `${lock.lockDir}.released-${process.pid}-${lock.ownerToken}`;
1054
+ try {
1055
+ renameSync(lock.lockDir, releasedDir);
1056
+ } catch {
1057
+ return;
1058
+ }
1059
+ const movedOwner = readListenerGlobalLockOwner(releasedDir);
1060
+ if (movedOwner?.owner_token !== lock.ownerToken) {
1061
+ try { renameSync(releasedDir, lock.lockDir); } catch { /* successor already owns the path */ }
1062
+ return;
1063
+ }
1064
+ rmSync(releasedDir, { recursive: true, force: true });
1065
+ }
1066
+
1067
+ function saveListenerState(runtime, state) {
1068
+ const file = listenerStateFile(runtime);
1069
+ mkdirSync(dirname(file), { recursive: true });
1070
+ writeFileSync(file, JSON.stringify(state, null, 2), { mode: 0o600 });
1071
+ }
1072
+
1073
+ function readListenerState(runtime) {
1074
+ try {
1075
+ const state = JSON.parse(readFileSync(listenerStateFile(runtime), 'utf-8'));
1076
+ return state?.profile === runtime.profileName ? state : null;
1077
+ } catch {
1078
+ return null;
1079
+ }
1080
+ }
1081
+
1082
+ function listenerStateAllowsPersistence(runtime, ownerPid) {
1083
+ const state = readListenerState(runtime);
1084
+ return Boolean(
1085
+ state
1086
+ && Number(state.pid) === Number(ownerPid)
1087
+ && state.cancelled !== true
1088
+ && Number(state.expires_at) > Math.floor(Date.now() / 1000),
1089
+ );
1090
+ }
1091
+
1092
+ /**
1093
+ * Drop the listener record, optionally only when it still describes `ownerPid`.
1094
+ *
1095
+ * A child that times out must not delete a record a newer child already wrote:
1096
+ * losing it orphans the live listener, because every later stop and reuse finds
1097
+ * the profile through this file.
1098
+ */
1099
+ export function clearListenerState(runtime, { ownerPid = null } = {}) {
1100
+ if (ownerPid !== null) {
1101
+ try {
1102
+ const state = JSON.parse(readFileSync(listenerStateFile(runtime), 'utf-8'));
1103
+ if (Number(state?.pid) !== Number(ownerPid)) return;
1104
+ } catch {
1105
+ return;
1106
+ }
1107
+ }
1108
+ try {
1109
+ rmSync(listenerStateFile(runtime));
1110
+ } catch {
1111
+ // Nothing to clear.
1112
+ }
1113
+ }
1114
+
1115
+ /**
1116
+ * A listener from an earlier run that is still waiting for the same browser.
1117
+ *
1118
+ * Re-running `notis start` is routine — an agent does it to check whether the
1119
+ * user has finished — and each run would otherwise strand another detached
1120
+ * process holding another port. Handing back the authorization URL the user was
1121
+ * already given is also the only answer that stays true: the earlier URL is the
1122
+ * one whose PKCE verifier the live listener holds.
1123
+ */
1124
+ function readLiveListener(runtime, { sameApiBase = true } = {}) {
1125
+ const state = readListenerState(runtime);
1126
+ if (!state?.pid || !state?.authorize_url) return null;
1127
+ if (state.cancelled === true) return null;
1128
+ // Reuse needs the endpoint to match, because a grant belongs to the API that
1129
+ // issued it. Stopping one does not: a listener for any endpoint is still
1130
+ // about to write this profile.
1131
+ if (sameApiBase && state.api_base !== runtime.apiBase) return null;
1132
+ if (Number(state.expires_at) <= Math.floor(Date.now() / 1000)) return null;
1133
+ if (!listenerProcessIsAlive(state.pid, {
1134
+ expectedIdentity: state.identity_token,
1135
+ expectedScriptPath: state.listener_script,
1136
+ })) return null;
1137
+ return state;
1138
+ }
1139
+
1140
+ /**
1141
+ * Is this pid still *our* listener?
1142
+ *
1143
+ * A bare `kill(pid, 0)` only proves some process holds the number. State files
1144
+ * outlive reboots and SIGKILLs, and pids are recycled, so that test eventually
1145
+ * reports a stranger as the listener — which would hand out an authorize URL
1146
+ * whose loopback port is dead, and let `logout` signal an unrelated process.
1147
+ * Matching the command line costs one `ps` on a rare path and rules both out.
1148
+ */
1149
+ export function listenerProcessIsAlive(
1150
+ pid,
1151
+ {
1152
+ platform = process.platform,
1153
+ run = execFileSync,
1154
+ signal = process.kill.bind(process),
1155
+ expectedIdentity = null,
1156
+ expectedScriptPath = null,
1157
+ } = {},
1158
+ ) {
1159
+ const numericPid = Number(pid);
1160
+ if (!Number.isSafeInteger(numericPid) || numericPid <= 0) return false;
1161
+ try {
1162
+ // Signal 0 tests for a live process without touching it.
1163
+ signal(numericPid, 0);
1164
+ } catch {
1165
+ return false;
1166
+ }
1167
+ // tasklist's verbose view does not contain a process command line, so it can
1168
+ // never identify the script behind node.exe. CIM exposes the actual command
1169
+ // line and is available through Windows PowerShell and modern PowerShell.
1170
+ const windowsCommand = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${numericPid}").CommandLine`;
1171
+ const probes = platform === 'win32'
1172
+ ? [
1173
+ ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsCommand]],
1174
+ ['pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsCommand]],
1175
+ ]
1176
+ : [['ps', ['-o', 'command=', '-p', String(numericPid)]]];
1177
+ for (const [command, args] of probes) {
1178
+ try {
1179
+ const output = run(command, args, {
1180
+ encoding: 'utf-8',
1181
+ stdio: ['ignore', 'pipe', 'ignore'],
1182
+ timeout: LISTENER_IDENTITY_PROBE_TIMEOUT_MS,
1183
+ });
1184
+ return Boolean(
1185
+ expectedIdentity
1186
+ && expectedScriptPath
1187
+ && output.includes(expectedScriptPath)
1188
+ && output.includes(LISTENER_SCRIPT_NAME)
1189
+ && output.includes(expectedIdentity),
1190
+ );
1191
+ } catch {
1192
+ // Try the next probe.
1193
+ }
1194
+ }
1195
+ // Nothing here can tell this pid apart from a stranger that inherited the
1196
+ // number. Fail closed: the cost is a listener we stop reusing, against
1197
+ // signalling an unrelated process, which is the failure that cannot be undone.
1198
+ return false;
1199
+ }
1200
+
1201
+ /**
1202
+ * End an authorization that is still in flight for this profile.
1203
+ *
1204
+ * Best effort by design: the listener may already have exited, and failing to
1205
+ * reach it is never a reason to fail the command that asked for this.
1206
+ */
1207
+ export function stopPendingListener(
1208
+ runtime,
1209
+ {
1210
+ platform = process.platform,
1211
+ signal = process.kill.bind(process),
1212
+ } = {},
1213
+ ) {
1214
+ const state = readListenerState(runtime);
1215
+ const live = readLiveListener(runtime, { sameApiBase: false });
1216
+ if (live) {
1217
+ // Persist cancellation before notifying the child. POSIX can deliver a
1218
+ // catchable SIGTERM, but Windows terminates Node immediately for that
1219
+ // signal. On Windows the child observes this tombstone through its polling
1220
+ // channel and stays alive long enough to revoke an in-flight exchange.
1221
+ saveListenerState(runtime, { ...live, cancelled: true });
1222
+ if (platform === 'win32') return;
1223
+ try {
1224
+ signal(live.pid, 'SIGTERM');
1225
+ clearListenerState(runtime, { ownerPid: live.pid });
1226
+ } catch {
1227
+ // Already gone, or owned by another user. Keep the tombstone so a child
1228
+ // that is still alive cannot persist this authorization.
1229
+ }
1230
+ return;
1231
+ }
1232
+ if (
1233
+ state?.pid
1234
+ && Number(state.expires_at) > Math.floor(Date.now() / 1000)
1235
+ ) {
1236
+ // Signal 0 may prove the PID exists while ps/CIM cannot prove it is ours.
1237
+ // Never signal that process, but keep a cancellation tombstone the child
1238
+ // must observe under the start lock before it can persist credentials.
1239
+ saveListenerState(runtime, { ...state, cancelled: true });
1240
+ return;
1241
+ }
1242
+ clearListenerState(runtime);
1243
+ }
1244
+
1245
+ async function stopPendingListenerAfterStart(runtime) {
1246
+ const lockDir = await acquireListenerStartLock(runtime);
1247
+ try {
1248
+ stopPendingListener(runtime);
1249
+ } finally {
1250
+ releaseListenerStartLock(lockDir);
1251
+ }
1252
+ }
1253
+
1254
+ async function clearListenerStateAfterStart(runtime, { ownerPid }) {
1255
+ const lockDir = await acquireListenerStartLock(runtime);
1256
+ try {
1257
+ clearListenerState(runtime, { ownerPid });
1258
+ } finally {
1259
+ releaseListenerStartLock(lockDir);
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * Hand the loopback callback to a process that outlives this command.
1265
+ *
1266
+ * An agent reads a command's output only once the command exits, so a login
1267
+ * that waited in-process would hold the authorization URL hostage inside a
1268
+ * command that cannot finish until the user opens the URL they were never
1269
+ * shown. Detaching breaks that deadlock: this process prints the URL and exits
1270
+ * while the child keeps the listener, so an agent-driven signup gets the same
1271
+ * no-copy browser hand-off a human at a terminal gets.
1272
+ *
1273
+ * Returns null rather than throwing when the child cannot be started or cannot
1274
+ * bind — a sandbox that forbids either is a reason to fall back to the code
1275
+ * flow, not to fail the login.
1276
+ */
1277
+ async function startDetachedLoopbackListener(runtime, {
1278
+ metadata,
1279
+ verifier,
1280
+ state,
1281
+ scopes,
1282
+ timeoutMs,
1283
+ portalOrigin,
1284
+ }) {
1285
+ // A retry can start in another process before this child consumes its input.
1286
+ // Keep each verifier/state payload private to exactly one child.
1287
+ const identityToken = randomBytes(24).toString('base64url');
1288
+ const payloadFile = `${listenerStateFile(runtime)}.payload.${process.pid}.${randomBytes(8).toString('hex')}`;
1289
+ try {
1290
+ mkdirSync(dirname(payloadFile), { recursive: true });
1291
+ writeFileSync(payloadFile, JSON.stringify({
1292
+ profile: runtime.profileName,
1293
+ api_base: runtime.apiBase,
1294
+ verifier,
1295
+ state,
1296
+ scopes,
1297
+ timeout_ms: timeoutMs,
1298
+ portal_origin: portalOrigin,
1299
+ metadata,
1300
+ }), { mode: 0o600, flag: 'wx' });
1301
+ } catch {
1302
+ return null;
1303
+ }
1304
+
1305
+ let child;
1306
+ try {
1307
+ child = spawn(process.execPath, [LISTENER_SCRIPT, payloadFile, identityToken], {
1308
+ detached: true,
1309
+ windowsHide: true,
1310
+ // An IPC channel only so the child can report the port it bound. Every
1311
+ // other stream is dropped: the child must not write to a terminal the
1312
+ // parent no longer owns.
1313
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
1314
+ env: listenerChildEnvironment(),
1315
+ });
1316
+ } catch {
1317
+ try { rmSync(payloadFile); } catch { /* best effort */ }
1318
+ return null;
1319
+ }
1320
+
1321
+ const port = await new Promise((resolve) => {
1322
+ let settled = false;
1323
+ const finish = (value) => {
1324
+ if (settled) return;
1325
+ settled = true;
1326
+ clearTimeout(timer);
1327
+ child.off('message', onMessage);
1328
+ child.off('error', onFailure);
1329
+ child.off('exit', onFailure);
1330
+ resolve(value);
1331
+ };
1332
+ const onMessage = (message) => finish(Number(message?.port) || null);
1333
+ const onFailure = () => finish(null);
1334
+ const timer = setTimeout(() => finish(null), LISTENER_HANDSHAKE_TIMEOUT_MS);
1335
+ child.once('message', onMessage);
1336
+ child.once('error', onFailure);
1337
+ child.once('exit', onFailure);
1338
+ });
1339
+
1340
+ if (!port) {
1341
+ try { child.kill(); } catch { /* already gone */ }
1342
+ try { rmSync(payloadFile); } catch { /* the child may have consumed it */ }
1343
+ return null;
1344
+ }
1345
+
1346
+ // The child owns its lifetime from here. Disconnecting drops the only handle
1347
+ // keeping this process's event loop alive on its behalf.
1348
+ try { child.disconnect(); } catch { /* already disconnected */ }
1349
+ child.unref();
1350
+ return {
1351
+ pid: child.pid,
1352
+ port,
1353
+ redirectUri: `http://127.0.0.1:${port}/callback`,
1354
+ identityToken,
1355
+ scriptPath: LISTENER_SCRIPT,
1356
+ };
1357
+ }
1358
+
1359
+ async function revokeCancelledToken(metadata, tokenResponse, fetchImpl) {
1360
+ const token = tokenResponse?.refresh_token || tokenResponse?.access_token;
1361
+ if (!token || !metadata?.revocationEndpoint || !metadata?.clientId) return;
1362
+ try {
1363
+ await fetchJson(
1364
+ metadata.revocationEndpoint,
1365
+ {
1366
+ method: 'POST',
1367
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1368
+ body: new URLSearchParams({ token, client_id: metadata.clientId }),
1369
+ },
1370
+ fetchImpl,
1371
+ );
1372
+ } catch {
1373
+ // The child still refuses local persistence. Revocation is compensating
1374
+ // cleanup and must not turn a cancelled browser page into a credential.
1375
+ }
1376
+ }
1377
+
1378
+ /**
1379
+ * The detached child's whole life: bind, report the port, wait, persist.
1380
+ *
1381
+ * Runs in its own process with no terminal, so nothing here may write to stdout
1382
+ * or throw past the top level — a crash would leave the user staring at a
1383
+ * browser page that never resolves.
1384
+ */
1385
+ export async function runDetachedLoginListener(
1386
+ payloadFile,
1387
+ fetchImpl = fetch,
1388
+ identityToken = process.argv[3] || null,
1389
+ ) {
1390
+ let payload;
1391
+ try {
1392
+ payload = JSON.parse(readFileSync(payloadFile, 'utf-8'));
1393
+ } catch {
1394
+ return 1;
1395
+ }
1396
+ // The verifier is a bearer secret for this authorization. It has been read;
1397
+ // it should not outlive the read.
1398
+ try { rmSync(payloadFile); } catch { /* best effort */ }
1399
+
1400
+ const runtime = { profileName: payload.profile, apiBase: payload.api_base };
1401
+ let receiver;
1402
+ try {
1403
+ receiver = await createLoopbackReceiver({
1404
+ state: payload.state,
1405
+ timeoutMs: Number(payload.timeout_ms) || DEFAULT_LOGIN_TIMEOUT_MS,
1406
+ portalOrigin: payload.portal_origin,
1407
+ });
1408
+ } catch {
1409
+ return 1;
1410
+ }
1411
+
1412
+ process.send?.({ port: receiver.port });
1413
+
1414
+ // Logout signals the child instead of killing it blindly. Before exchange,
1415
+ // cancellation closes the wait immediately. During exchange, the child stays
1416
+ // alive long enough to revoke any grant the server may already have issued.
1417
+ let terminationRequested = false;
1418
+ const requestTermination = () => {
1419
+ if (terminationRequested) return;
1420
+ terminationRequested = true;
1421
+ receiver.cancel();
1422
+ };
1423
+ process.once('SIGTERM', requestTermination);
1424
+ // Windows cannot deliver a catchable SIGTERM to another Node process. The
1425
+ // state file is therefore also a cross-platform cancellation channel. A
1426
+ // replacement listener changes the owner pid; logout marks this owner as
1427
+ // cancelled. Either transition must stop this child before it can persist.
1428
+ let ownPublicationObserved = false;
1429
+ const cancellationPoll = setInterval(() => {
1430
+ const state = readListenerState(runtime);
1431
+ if (!state) return;
1432
+ const stateBelongsToThisChild = Boolean(
1433
+ Number(state.pid) === Number(process.pid)
1434
+ && (!identityToken || state.identity_token === identityToken),
1435
+ );
1436
+ // The parent publishes this child's ownership only after the child has
1437
+ // bound and reported its port. Until that publication is visible, an old
1438
+ // Windows/unverifiable cancellation tombstone still belongs to the
1439
+ // predecessor and must not make the replacement cancel itself.
1440
+ if (!ownPublicationObserved) {
1441
+ if (!stateBelongsToThisChild) return;
1442
+ ownPublicationObserved = true;
1443
+ }
1444
+ if (
1445
+ !stateBelongsToThisChild
1446
+ || state.cancelled === true
1447
+ || Number(state.expires_at) <= Math.floor(Date.now() / 1000)
1448
+ ) {
1449
+ requestTermination();
1450
+ }
1451
+ }, LISTENER_CANCELLATION_POLL_MS);
1452
+ cancellationPoll.unref?.();
1453
+
1454
+ let tokenResponse = null;
1455
+ let tokenPersisted = false;
1456
+ try {
1457
+ const code = await receiver.waitForCode();
1458
+ // Keep replacement publication and logout serialized across the whole
1459
+ // exchange. Revoking an old refresh token revokes the CLI grant, not just
1460
+ // one token family, so compensation must complete before a successor can
1461
+ // exchange and persist under that same grant.
1462
+ const globalLock = await acquireListenerGlobalLock();
1463
+ let lockDir = null;
1464
+ try {
1465
+ lockDir = await acquireListenerStartLock(runtime);
1466
+ if (terminationRequested || !listenerStateAllowsPersistence(runtime, process.pid)) {
1467
+ throw oauthError(
1468
+ 'oauth_listener_cancelled',
1469
+ 'This browser authorization is no longer active.',
1470
+ );
1471
+ }
1472
+ tokenResponse = await exchangeCode(
1473
+ payload.metadata,
1474
+ { code, redirectUri: receiver.redirectUri, verifier: payload.verifier },
1475
+ fetchImpl,
1476
+ );
1477
+ if (terminationRequested || !listenerStateAllowsPersistence(runtime, process.pid)) {
1478
+ throw oauthError(
1479
+ 'oauth_listener_cancelled',
1480
+ 'This browser authorization is no longer active.',
1481
+ );
1482
+ }
1483
+ persistOAuthTokenResponse(runtime, payload.metadata, tokenResponse);
1484
+ tokenPersisted = true;
1485
+ clearListenerState(runtime, { ownerPid: process.pid });
1486
+ receiver.complete();
1487
+ } catch (error) {
1488
+ if (tokenResponse && !tokenPersisted) {
1489
+ await revokeCancelledToken(payload.metadata, tokenResponse, fetchImpl);
1490
+ }
1491
+ throw error;
1492
+ } finally {
1493
+ releaseListenerStartLock(lockDir);
1494
+ releaseListenerGlobalLock(globalLock);
1495
+ }
1496
+ return 0;
1497
+ } catch {
1498
+ receiver.fail({ detached: true });
1499
+ return 1;
1500
+ } finally {
1501
+ clearInterval(cancellationPoll);
1502
+ process.off('SIGTERM', requestTermination);
1503
+ await receiver.close();
1504
+ try {
1505
+ await clearListenerStateAfterStart(runtime, { ownerPid: process.pid });
1506
+ } catch {
1507
+ // The child is exiting and can no longer authorize anything. A stale
1508
+ // owner-qualified record is safe for the next login to replace.
1509
+ }
1510
+ }
1511
+ }
1512
+
1513
+ /**
1514
+ * Would a `127.0.0.1` callback on this host reach the browser the user is in?
1515
+ *
1516
+ * Over SSH or inside a container it usually would not: the listener binds fine,
1517
+ * so nothing fails, and the user's own machine answers the callback URL with a
1518
+ * connection refused. The Portal code hand-off works from any browser, so an
1519
+ * uncertain answer has to resolve to `false` — the cost of choosing it wrongly
1520
+ * is one copied code, against a login that cannot be completed at all.
1521
+ *
1522
+ * `--mode browser` still forces the loopback for anyone who has forwarded the
1523
+ * port and knows better.
1524
+ */
1525
+ export function loopbackReachesTheUsersBrowser(
1526
+ env = process.env,
1527
+ pathExists = (path) => {
1528
+ try {
1529
+ statSync(path);
1530
+ return true;
1531
+ } catch {
1532
+ return false;
1533
+ }
1534
+ },
1535
+ platform = process.platform,
1536
+ ) {
1537
+ // Connection markers carry identity/address data, so their presence is the
1538
+ // signal. CI/provider flags are booleans encoded as strings; conventional
1539
+ // disabled values must not turn a local machine into a remote host merely
1540
+ // because non-empty strings are truthy in JavaScript.
1541
+ const remoteConnectionMarkers = [
1542
+ 'SSH_CONNECTION',
1543
+ 'SSH_CLIENT',
1544
+ 'SSH_TTY',
1545
+ 'JENKINS_URL',
1546
+ 'CODEBUILD_BUILD_ID',
1547
+ ];
1548
+ const remoteBooleanMarkers = [
1549
+ 'CODESPACES',
1550
+ 'REMOTE_CONTAINERS',
1551
+ 'DEVCONTAINER',
1552
+ 'CI',
1553
+ 'GITHUB_ACTIONS',
1554
+ 'GITLAB_CI',
1555
+ 'BUILDKITE',
1556
+ 'CIRCLECI',
1557
+ 'TF_BUILD',
1558
+ 'RENDER',
1559
+ 'VERCEL',
1560
+ ];
1561
+ const disabledFlagValues = new Set(['', '0', 'false', 'no', 'off']);
1562
+ const remoteFlagEnabled = (name) => {
1563
+ if (env[name] === undefined || env[name] === null) return false;
1564
+ return !disabledFlagValues.has(String(env[name]).trim().toLowerCase());
1565
+ };
1566
+ if (
1567
+ remoteConnectionMarkers.some((name) => Boolean(env[name]))
1568
+ || remoteBooleanMarkers.some(remoteFlagEnabled)
1569
+ ) return false;
1570
+ // Notis cloud agents run under this canonical root. Their browser belongs to
1571
+ // the user, not the Vercel VM, so a listener on the VM's loopback is unreachable.
1572
+ if (pathExists('/vercel/sandbox') || pathExists('/.dockerenv')) return false;
1573
+ // macOS and Windows browser launches are local unless one of the remote
1574
+ // markers above says otherwise. On Linux, require a graphical session:
1575
+ // marker-free cloud workers are commonly plain VMs where 127.0.0.1 belongs
1576
+ // to the worker rather than to the user's browser.
1577
+ if (platform === 'darwin' || platform === 'win32') return true;
1578
+ if (platform === 'linux') {
1579
+ return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY || env.MIR_SOCKET);
1580
+ }
1581
+ return false;
1582
+ }
1583
+
1584
+ const LOGIN_MODES = new Set(['auto', 'browser', 'code']);
1585
+
1586
+ function requestedLoginMode(options) {
1587
+ const requested = options.mode ? String(options.mode).toLowerCase() : null;
1588
+ if (requested && !LOGIN_MODES.has(requested)) {
1589
+ throw usageError(
1590
+ `Unknown login mode "${requested}". Use auto, browser, or code.`,
1591
+ { code: 'oauth_login_mode_invalid', mode: requested },
1592
+ );
1593
+ }
1594
+ return requested;
1595
+ }
1596
+
1597
+ function requestedAuthorizationTimeoutMs(options) {
1598
+ const hasSeconds = options.timeoutSeconds !== undefined && options.timeoutSeconds !== null;
1599
+ const hasMilliseconds = options.timeoutMs !== undefined && options.timeoutMs !== null;
1600
+ if (!hasSeconds && !hasMilliseconds) return null;
1601
+ const raw = hasSeconds ? options.timeoutSeconds : options.timeoutMs;
1602
+ const numeric = Number(raw);
1603
+ const milliseconds = hasSeconds ? numeric * 1000 : numeric;
1604
+ if (
1605
+ !Number.isFinite(numeric)
1606
+ || numeric <= 0
1607
+ || !Number.isInteger(numeric)
1608
+ || !Number.isSafeInteger(milliseconds)
1609
+ || milliseconds > MAX_NODE_TIMER_MS
1610
+ ) {
1611
+ throw usageError(
1612
+ '--timeout-seconds must be a positive whole number within the supported timer range.',
1613
+ { timeout_seconds: raw },
1614
+ );
1615
+ }
1616
+ return milliseconds;
1617
+ }
1618
+
1619
+ /**
1620
+ * Which hand-off the browser uses to return the authorization code.
1621
+ *
1622
+ * `auto`, the default, always tries the loopback hand-off first so nobody has
1623
+ * to copy a code. Whether this process can wait for the browser decides only
1624
+ * *who* holds the listener: a terminal login keeps it in-process, and a login
1625
+ * that has to return immediately hands it to a detached child. The Portal code
1626
+ * flow is the fallback when no listener can start or the user's browser cannot
1627
+ * safely reach this machine's loopback callback.
1628
+ *
1629
+ * `browser` additionally insists on waiting in-process, which is what a piped
1630
+ * but human-driven run (CI, `| tee`) wants. `code` forces the Portal flow for
1631
+ * an SSH session where no browser on this machine can reach 127.0.0.1.
1632
+ */
1633
+ function resolveLoginMode(runtime, options) {
1634
+ const requested = requestedLoginMode(options);
1635
+ // --paste-code predates --mode and stays an alias so published commands and
1636
+ // documented recipes keep working unchanged.
1637
+ const mode = requested || (options.pasteCode ? 'code' : 'auto');
1638
+ const wantsNonBlocking = Boolean(
1639
+ runtime.agentMode
1640
+ || ['json', 'yaml', 'ndjson'].includes(runtime.outputMode)
1641
+ || runtime.nonInteractive,
1642
+ );
1643
+
1644
+ if (mode === 'browser' && runtime.agentMode) {
1645
+ throw oauthError(
1646
+ 'oauth_login_mode_unavailable',
1647
+ 'Agent mode cannot block on a browser callback: the authorization URL only reaches the user once this command exits.',
1648
+ [
1649
+ {
1650
+ command: 'notis login',
1651
+ reason: 'The default already hands the browser callback to a background listener, so no code is copied',
1652
+ },
1653
+ { command: 'notis login --mode code', reason: 'Show a code the agent can hand to the user instead' },
1654
+ ],
1655
+ );
1656
+ }
1657
+
1658
+ const loopbackReachable = loopbackReachesTheUsersBrowser(
1659
+ runtime.hostEnvironment ?? process.env,
1660
+ undefined,
1661
+ runtime.hostPlatform ?? process.platform,
1662
+ );
1663
+ return {
1664
+ automaticMode: mode === 'auto',
1665
+ wantsNonBlocking,
1666
+ // An explicit --mode browser is a promise to wait, so it overrides the
1667
+ // non-blocking default that a piped stdout would otherwise imply.
1668
+ nonBlocking: wantsNonBlocking && mode !== 'browser',
1669
+ // Only an explicit request starts on the code flow. Every other mode earns
1670
+ // its way there by failing to bind a loopback port -- except on a host whose
1671
+ // loopback the user's browser cannot reach, where binding succeeds and the
1672
+ // callback is unreachable anyway.
1673
+ usePasteCode: mode === 'code' || (mode === 'auto' && !loopbackReachable),
1674
+ };
1675
+ }
1676
+
1677
+ function redeemCommand(profileName, channel) {
1678
+ return [
1679
+ cliCommandForChannel(channel),
1680
+ `--profile ${quoteShellArgument(profileName || 'default')}`,
1681
+ 'login --code <code>',
1682
+ ].join(' ');
1683
+ }
1684
+
1685
+ /**
1686
+ * What to run once the browser has finished, when there is no code to redeem.
1687
+ *
1688
+ * The detached listener writes the credential itself, so the only thing left is
1689
+ * to observe that it landed. `start` is idempotent and reports the account, so
1690
+ * it doubles as the confirmation step.
1691
+ */
1692
+ function confirmCommand(profileName, channel, apiBase) {
1693
+ return [
1694
+ cliCommandForChannel(channel),
1695
+ `--profile ${quoteShellArgument(profileName || 'default')}`,
1696
+ `--api-base ${quoteShellArgument(apiBase)}`,
1697
+ 'start',
1698
+ ].join(' ');
1699
+ }
1700
+
1701
+ function authorizationChannel(metadata, runtime, pending = null) {
1702
+ return metadata.channel
1703
+ || pending?.channel
1704
+ || channelFromProfile({ api_base: pending?.api_base || runtime.apiBase });
1705
+ }
1706
+
1707
+ function updateRuntimeFromOAuthProfile(runtime, profile) {
1708
+ const oauthApiBase = getOAuthApiBase(profile);
1709
+ runtime.jwt = profile.oauth_access_token;
1710
+ runtime.credentialKind = 'oauth';
1711
+ runtime.credentialSource = 'oauth';
1712
+ runtime.oauthAccessToken = profile.oauth_access_token;
1713
+ runtime.oauthRefreshToken = profile.oauth_refresh_token;
1714
+ runtime.oauthAccessExpiresAt = profile.oauth_access_expires_at;
1715
+ runtime.oauthRefreshExpiresAt = profile.oauth_refresh_expires_at;
1716
+ runtime.oauthClientId = profile.oauth_client_id;
1717
+ runtime.oauthIssuer = profile.oauth_issuer;
1718
+ runtime.oauthApiBase = oauthApiBase;
1719
+ runtime.oauthResource = getOAuthResource(profile);
1720
+ runtime.oauthScopes = profile.oauth_scopes || [];
1721
+ runtime.oauthUserId = profile.oauth_user_id;
1722
+ if (oauthApiBase && !runtime.requestedApiBase) {
1723
+ runtime.apiBase = oauthApiBase;
1724
+ }
1725
+ }
1726
+
1727
+ function assertOAuthApiTarget(runtime, profile) {
1728
+ const requestedApiBase = String(runtime.requestedApiBase || '').replace(/\/+$/, '');
1729
+ const oauthApiBase = getOAuthApiBase(profile);
1730
+ if (requestedApiBase && oauthApiBase && requestedApiBase !== oauthApiBase) {
1731
+ throw oauthError(
1732
+ 'oauth_api_target_mismatch',
1733
+ `This OAuth grant belongs to ${oauthApiBase}, not ${requestedApiBase}. Run login for the requested environment.`,
1734
+ );
1735
+ }
1736
+ }
1737
+
1738
+ export async function ensureFreshOAuthCredential(runtime, fetchImpl = fetch) {
1739
+ if (runtime.credentialKind !== 'oauth') {
1740
+ return Boolean(runtime.jwt);
1741
+ }
1742
+
1743
+ const profile = getProfile(loadConfig(), runtime.profileName);
1744
+ assertOAuthApiTarget(runtime, profile);
1745
+ if (!credentialIsExpired({ credentialKind: 'oauth' }, profile)) {
1746
+ updateRuntimeFromOAuthProfile(runtime, profile);
1747
+ return true;
1748
+ }
1749
+
1750
+ return refreshOAuthCredential(runtime, fetchImpl);
1751
+ }
1752
+
1753
+ async function readPastedCode() {
1754
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
1755
+ try {
1756
+ const value = (await prompt.question('Paste the authorization code: ')).trim();
1757
+ if (!value) throw oauthError('oauth_code_missing', 'No authorization code was provided.');
1758
+ try {
1759
+ const parsed = new URL(value);
1760
+ return parsed.searchParams.get('code') || value;
1761
+ } catch {
1762
+ return value;
1763
+ }
1764
+ } finally {
1765
+ prompt.close();
1766
+ }
1767
+ }
1768
+
1769
+ async function redeemAuthorizationCode(runtime, code, fetchImpl) {
1770
+ if (!code) {
1771
+ throw oauthError('oauth_code_missing', 'No authorization code was provided.');
1772
+ }
1773
+ const globalLock = await acquireListenerGlobalLock();
1774
+ let lockDir = null;
1775
+ let metadata = null;
1776
+ let tokenResponse = null;
1777
+ let tokenPersisted = false;
1778
+ try {
1779
+ lockDir = await acquireListenerStartLock(runtime);
1780
+ const pending = readPendingAuthorization(runtime);
1781
+ if (!pending) {
1782
+ throw oauthError(
1783
+ 'oauth_pending_login_missing',
1784
+ 'No pending authorization for this profile. Run notis login again to start one.',
1785
+ );
1786
+ }
1787
+ // The grant belongs to the environment that issued it. Redeeming without
1788
+ // repeating --api-base must not persist a token under a different backend.
1789
+ if (pending.api_base) {
1790
+ runtime.apiBase = pending.api_base;
1791
+ }
1792
+ metadata = {
1793
+ apiBase: pending.api_base,
1794
+ issuer: pending.issuer,
1795
+ resource: pending.resource,
1796
+ clientId: pending.client_id,
1797
+ tokenEndpoint: pending.token_endpoint,
1798
+ revocationEndpoint: pending.revocation_endpoint
1799
+ || `${String(pending.issuer || '').replace(/\/+$/, '')}/oauth/revoke`,
1800
+ channel: pending.channel,
1801
+ };
1802
+ if (!metadata.issuer || !metadata.resource || !metadata.clientId || !metadata.tokenEndpoint) {
1803
+ throw oauthError(
1804
+ 'oauth_pending_login_invalid',
1805
+ 'The pending authorization is incomplete. Run notis login again.',
1806
+ );
1807
+ }
1808
+ // Hold publication ownership through exchange and persistence. Otherwise a
1809
+ // new browser start can retire this verifier while its request is in flight
1810
+ // and the stale result can overwrite the newer authorization afterward.
1811
+ tokenResponse = await exchangeCode(
1812
+ metadata,
1813
+ { code, redirectUri: pending.redirect_uri, verifier: pending.verifier },
1814
+ fetchImpl,
1815
+ );
1816
+ stopPendingListener(runtime);
1817
+ const profile = persistOAuthTokenResponse(runtime, metadata, tokenResponse);
1818
+ tokenPersisted = true;
1819
+ clearPendingAuthorization(runtime, pending.pending_file);
1820
+ updateRuntimeFromOAuthProfile(runtime, profile);
1821
+ return { profile, metadata };
1822
+ } catch (error) {
1823
+ if (tokenResponse && !tokenPersisted) {
1824
+ await revokeCancelledToken(metadata, tokenResponse, fetchImpl);
1825
+ }
1826
+ throw error;
1827
+ } finally {
1828
+ releaseListenerStartLock(lockDir);
1829
+ releaseListenerGlobalLock(globalLock);
1830
+ }
1831
+ }
1832
+
1833
+ async function exchangeAndPersistForegroundAuthorization(
1834
+ runtime,
1835
+ metadata,
1836
+ authorization,
1837
+ fetchImpl,
1838
+ ) {
1839
+ const globalLock = await acquireListenerGlobalLock();
1840
+ let lockDir = null;
1841
+ let tokenResponse = null;
1842
+ let tokenPersisted = false;
1843
+ try {
1844
+ lockDir = await acquireListenerStartLock(runtime);
1845
+ const pending = readPendingAuthorization(runtime);
1846
+ if (!pendingAuthorizationOwnedBy(pending, authorization)) {
1847
+ throw oauthError(
1848
+ 'oauth_listener_cancelled',
1849
+ 'This browser authorization is no longer active.',
1850
+ );
1851
+ }
1852
+ // Linearize exchange and persistence with both detached publication and
1853
+ // logout. If logout won first, it removed the owner-qualified pending
1854
+ // record above and no grant is minted. If it starts later, it waits and
1855
+ // clears the credential after this operation completes.
1856
+ stopPendingListener(runtime);
1857
+ tokenResponse = await exchangeCode(
1858
+ metadata,
1859
+ {
1860
+ code: authorization.code,
1861
+ redirectUri: authorization.redirect_uri,
1862
+ verifier: authorization.verifier,
1863
+ },
1864
+ fetchImpl,
1865
+ );
1866
+ const profile = persistOAuthTokenResponse(runtime, metadata, tokenResponse);
1867
+ tokenPersisted = true;
1868
+ clearPendingAuthorizationIfOwner(runtime, authorization);
1869
+ return profile;
1870
+ } catch (error) {
1871
+ if (tokenResponse && !tokenPersisted) {
1872
+ await revokeCancelledToken(metadata, tokenResponse, fetchImpl);
1873
+ }
1874
+ throw error;
1875
+ } finally {
1876
+ releaseListenerStartLock(lockDir);
1877
+ releaseListenerGlobalLock(globalLock);
1878
+ }
1879
+ }
1880
+
1881
+ /**
1882
+ * The parked authorization this profile is already waiting on, if this run is
1883
+ * asking for the same thing.
1884
+ *
1885
+ * Minting a fresh verifier instead would silently invalidate the URL the user
1886
+ * was already handed: the code it returns can only be redeemed against the
1887
+ * verifier that was parked with it. Re-running a login is routine -- an agent
1888
+ * does it to check progress -- so the answer has to stay the same URL.
1889
+ */
1890
+ function reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs = null) {
1891
+ const pending = readPendingAuthorization(runtime);
1892
+ const pendingScopes = Array.isArray(pending?.scopes) && pending.scopes.length > 0
1893
+ ? pending.scopes
1894
+ : DEFAULT_CLI_OAUTH_SCOPES;
1895
+ const sameAuthorization = Boolean(
1896
+ pending
1897
+ && pending.state
1898
+ && pending.api_base === runtime.apiBase
1899
+ && pending.issuer === metadata.issuer
1900
+ && pending.resource === metadata.resource
1901
+ && pending.client_id === metadata.clientId
1902
+ && pending.token_endpoint === metadata.tokenEndpoint
1903
+ && pending.redirect_uri === metadata.copyPasteRedirectUri
1904
+ && (
1905
+ requestedTimeoutMs === null
1906
+ || Number(pending.authorization_timeout_ms) === requestedTimeoutMs
1907
+ )
1908
+ && JSON.stringify(pendingScopes) === JSON.stringify(scopes),
1909
+ );
1910
+ if (!sameAuthorization) return null;
1911
+ const challenge = createHash('sha256')
1912
+ .update(pending.verifier, 'ascii')
1913
+ .digest('base64url');
1914
+ return {
1915
+ agentAuthorization: {
1916
+ authorize_url: buildAuthorizeUrl(metadata, {
1917
+ redirectUri: pending.redirect_uri,
1918
+ challenge,
1919
+ state: pending.state,
1920
+ scopes: pendingScopes,
1921
+ }),
1922
+ expires_in: Math.max(0, Number(pending.expires_at) - Math.floor(Date.now() / 1000)),
1923
+ hand_off: 'code',
1924
+ redeem_command: redeemCommand(
1925
+ runtime.profileName,
1926
+ authorizationChannel(metadata, runtime, pending),
1927
+ ),
1928
+ },
1929
+ };
1930
+ }
1931
+
1932
+ export async function loginWithOAuth(
1933
+ runtime,
1934
+ options = {},
1935
+ output,
1936
+ fetchImpl = fetch,
1937
+ createReceiver = createLoopbackReceiver,
1938
+ spawnListener = startDetachedLoopbackListener,
1939
+ readCode = readPastedCode,
1940
+ ) {
1941
+ // A worktree profile is authenticated by the running `./dev.sh`, not by a
1942
+ // browser grant. Authorizing over it would replace a scoped test identity
1943
+ // with a real account and quietly point local testing at the wrong user.
1944
+ // Check before both starting and redeeming authorization: a copy-paste flow
1945
+ // may have started before the worktree lease claimed this profile.
1946
+ if (runtime.credentialKind === 'worktree') {
1947
+ throw oauthError(
1948
+ 'oauth_profile_is_dev_managed',
1949
+ `Profile "${runtime.profileName}" is managed by ./dev.sh and cannot be authorized in a browser.`,
1950
+ [
1951
+ {
1952
+ command: `notis login --profile ${quoteShellArgument(runtime.profileName === 'default' ? 'personal' : 'default')}`,
1953
+ reason: 'Authorize a real account under a different profile name',
1954
+ },
1955
+ { command: 'notis profile list', reason: 'See the profiles this machine already has' },
1956
+ ],
1957
+ );
1958
+ }
1959
+ // Even redemption is a mutating invocation, so malformed local options must
1960
+ // fail before reading pending state or contacting the token endpoint.
1961
+ requestedLoginMode(options);
1962
+ const requestedTimeoutMs = requestedAuthorizationTimeoutMs(options);
1963
+ if (options.code) {
1964
+ return redeemAuthorizationCode(runtime, String(options.code).trim(), fetchImpl);
1965
+ }
1966
+
1967
+ // Reject a malformed local invocation before making discovery requests.
1968
+ const loginMode = resolveLoginMode(runtime, options);
1969
+ const metadata = await discoverCliOAuth(runtime.apiBase, fetchImpl);
1970
+ const scopes = options.scope?.length
1971
+ ? [...new Set(options.scope)]
1972
+ : DEFAULT_CLI_OAUTH_SCOPES;
1973
+ const timeoutMs = requestedTimeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS;
1974
+ // A detached listener is not a terminal waiting on a prompt: nobody is held
1975
+ // up by it, and the user still has to sign up and verify an email, so its
1976
+ // default is the parked-authorization window rather than the blocking one.
1977
+ // An explicit --timeout-seconds still wins; the flag documents how long the
1978
+ // authorization stays open, and silently ignoring it here made it a lie.
1979
+ const parkedAuthorizationTimeoutMs = requestedTimeoutMs ?? PENDING_LOGIN_TTL_SECONDS * 1000;
1980
+ const {
1981
+ automaticMode,
1982
+ wantsNonBlocking,
1983
+ nonBlocking,
1984
+ usePasteCode,
1985
+ } = loginMode;
1986
+ let nonBlockingAgent = nonBlocking;
1987
+ let listenerGlobalLock = null;
1988
+ let listenerStartLock = null;
1989
+ let receiver;
1990
+ let detachedListener = null;
1991
+ let detachedListenerPublished = false;
1992
+ let redirectUri;
1993
+ let foregroundAuthorization = null;
1994
+
1995
+ if (automaticMode || nonBlockingAgent || usePasteCode || options.reusePersistedCredential) {
1996
+ listenerGlobalLock = await acquireListenerGlobalLock();
1997
+ try {
1998
+ listenerStartLock = await acquireListenerStartLock(runtime);
1999
+ } catch (error) {
2000
+ releaseListenerGlobalLock(listenerGlobalLock);
2001
+ listenerGlobalLock = null;
2002
+ throw error;
2003
+ }
2004
+ }
2005
+
2006
+ try {
2007
+ if (options.reusePersistedCredential) {
2008
+ // `start` doubles as the confirmation command for a detached login. The
2009
+ // child can persist its token while this invocation is doing discovery;
2010
+ // re-read under the publication lock so an idempotent confirmation cannot
2011
+ // mint a second grant from a stale runtime snapshot.
2012
+ const storedProfile = getProfile(loadConfig(), runtime.profileName);
2013
+ if (
2014
+ storedProfile.oauth_access_token
2015
+ && !credentialIsExpired({ credentialKind: 'oauth' }, storedProfile)
2016
+ ) {
2017
+ assertOAuthApiTarget(runtime, storedProfile);
2018
+ updateRuntimeFromOAuthProfile(runtime, storedProfile);
2019
+ return { profile: storedProfile, metadata, reusedPersistedCredential: true };
2020
+ }
2021
+ }
2022
+ // A listener spawned by an earlier run is still holding the browser hand-off,
2023
+ // and its URL is the only one whose verifier that process knows.
2024
+ if (automaticMode && !usePasteCode) {
2025
+ // Auto mode may have degraded to the copy-code hand-off on an earlier run.
2026
+ // That parked verifier owns the URL the user already received, so keep the
2027
+ // hand-off sticky instead of publishing a competing loopback authorization.
2028
+ const parked = reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs);
2029
+ if (parked) return parked;
2030
+ // A non-reusable sidecar belongs to a different/expired authorization. It
2031
+ // must not survive beside the listener this run is about to publish.
2032
+ clearPendingAuthorizations(runtime);
2033
+ const live = readLiveListener(runtime, { sameApiBase: false });
2034
+ // Reuse is only safe when the live listener is authorizing the *same*
2035
+ // thing. The copy-paste path below already compares the full authorization;
2036
+ // matching only the profile here would hand back a URL carrying the scopes
2037
+ // of the earlier run, silently ignoring this one's --scope.
2038
+ const sameListenerAuthorization = Boolean(
2039
+ live
2040
+ && live.api_base === runtime.apiBase
2041
+ && live.issuer === metadata.issuer
2042
+ && live.resource === metadata.resource
2043
+ && live.client_id === metadata.clientId
2044
+ && live.token_endpoint === metadata.tokenEndpoint
2045
+ && (
2046
+ requestedTimeoutMs === null
2047
+ || Number(live.authorization_timeout_ms) === requestedTimeoutMs
2048
+ )
2049
+ && JSON.stringify(live.scopes || []) === JSON.stringify(scopes),
2050
+ );
2051
+ if (live && !sameListenerAuthorization) {
2052
+ // A different authorization is being requested, so the old listener is
2053
+ // now unreachable work holding a port. Stop it before spawning another.
2054
+ stopPendingListener(runtime);
2055
+ }
2056
+ if (live && sameListenerAuthorization) {
2057
+ return {
2058
+ agentAuthorization: {
2059
+ authorize_url: live.authorize_url,
2060
+ expires_in: Math.max(0, Number(live.expires_at) - Math.floor(Date.now() / 1000)),
2061
+ hand_off: 'browser_callback',
2062
+ confirm_command: confirmCommand(
2063
+ runtime.profileName,
2064
+ authorizationChannel(metadata, runtime),
2065
+ live.api_base,
2066
+ ),
2067
+ },
2068
+ };
2069
+ }
2070
+ }
2071
+
2072
+ if (!nonBlockingAgent && !usePasteCode) {
2073
+ // A foreground browser flow never holds publication locks while a person
2074
+ // completes authorization. This also covers explicit browser mode and a
2075
+ // start command checking for an already-persisted credential.
2076
+ releaseListenerStartLock(listenerStartLock);
2077
+ listenerStartLock = null;
2078
+ releaseListenerGlobalLock(listenerGlobalLock);
2079
+ listenerGlobalLock = null;
2080
+ }
2081
+
2082
+ if (usePasteCode) {
2083
+ const reused = reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs);
2084
+ if (reused) return reused;
2085
+ // Code and loopback hand-offs are mutually exclusive for one profile. This
2086
+ // runs under the same publication lock as detached starts so a code request
2087
+ // cannot leave a second valid authorization beside a listener that is
2088
+ // still being published.
2089
+ stopPendingListener(runtime);
2090
+ }
2091
+
2092
+ const { verifier, challenge } = createPkce();
2093
+ const state = base64url(randomBytes(32));
2094
+
2095
+ // May flip to true below: the browser hand-off is a preference, not a
2096
+ // guarantee, and the redirect URI is signed into the authorize URL before the
2097
+ // user ever sees it. Deciding here is the last moment a fallback is free.
2098
+ let pasteCode = usePasteCode;
2099
+
2100
+ const degradeToCode = (error) => {
2101
+ // A locked-down machine that cannot bind 127.0.0.1 used to dead-end here
2102
+ // with no way forward, even though the Portal hand-off would have worked.
2103
+ // Only a missing copy-paste callback is genuinely unrecoverable.
2104
+ if (!metadata.copyPasteRedirectUri) throw error;
2105
+ receiver = undefined;
2106
+ pasteCode = true;
2107
+ // --mode browser promised to wait for a browser callback, not for someone
2108
+ // to type into a pipe. Once no listener exists, returning the URL beats
2109
+ // prompting on a stdout nobody is reading. A caller that declared itself
2110
+ // non-interactive is in the same position: `readPastedCode` would block on
2111
+ // a stdin prompt it has already said it cannot answer.
2112
+ nonBlockingAgent = wantsNonBlocking || Boolean(runtime.nonInteractive);
2113
+ output?.note?.(
2114
+ 'The local callback listener could not start, so this login switched to the copy-paste code flow.',
2115
+ );
2116
+ };
2117
+
2118
+ if (!pasteCode) {
2119
+ let portalOrigin = 'https://app.notis.ai';
2120
+ try {
2121
+ portalOrigin = new URL(metadata.copyPasteRedirectUri).origin;
2122
+ } catch {
2123
+ // The OAuth server owns this metadata. Keep the public Portal fallback if
2124
+ // a development server omits or returns an invalid copy-paste URL.
2125
+ }
2126
+ if (nonBlockingAgent) {
2127
+ // This command has to return before the user has even opened the URL, so
2128
+ // the listener has to belong to something else.
2129
+ detachedListener = await spawnListener(runtime, {
2130
+ metadata,
2131
+ verifier,
2132
+ state,
2133
+ scopes,
2134
+ timeoutMs: parkedAuthorizationTimeoutMs,
2135
+ portalOrigin,
2136
+ });
2137
+ if (detachedListener) {
2138
+ redirectUri = detachedListener.redirectUri;
2139
+ } else {
2140
+ degradeToCode(oauthError(
2141
+ 'oauth_loopback_failed',
2142
+ 'Could not start a background OAuth callback listener.',
2143
+ ));
2144
+ }
2145
+ } else {
2146
+ try {
2147
+ receiver = await createReceiver({ state, timeoutMs, portalOrigin });
2148
+ redirectUri = receiver.redirectUri;
2149
+ } catch (error) {
2150
+ degradeToCode(error);
2151
+ }
2152
+ }
2153
+ }
2154
+
2155
+ if (pasteCode) {
2156
+ // A loopback bind may have degraded after the initial mode decision. Code
2157
+ // publication still has to join the same serialization protocol as every
2158
+ // detached start before it writes a verifier sidecar.
2159
+ if (!listenerStartLock) {
2160
+ listenerGlobalLock = await acquireListenerGlobalLock();
2161
+ try {
2162
+ listenerStartLock = await acquireListenerStartLock(runtime);
2163
+ } catch (error) {
2164
+ releaseListenerGlobalLock(listenerGlobalLock);
2165
+ listenerGlobalLock = null;
2166
+ throw error;
2167
+ }
2168
+ const live = readLiveListener(runtime, { sameApiBase: false });
2169
+ if (live) stopPendingListener(runtime);
2170
+ }
2171
+ if (!usePasteCode) {
2172
+ // This run degraded into the code flow rather than starting there, so it
2173
+ // has not yet checked for a parked authorization. Overwriting one would
2174
+ // make the URL an earlier run already handed the user unredeemable.
2175
+ const reused = reusablePendingAuthorization(runtime, metadata, scopes, requestedTimeoutMs);
2176
+ if (reused) return reused;
2177
+ }
2178
+ redirectUri = metadata.copyPasteRedirectUri;
2179
+ if (!redirectUri) {
2180
+ throw oauthError('oauth_metadata_invalid', 'Notis did not advertise a copy-paste callback.');
2181
+ }
2182
+ }
2183
+
2184
+ const authorizeUrl = buildAuthorizeUrl(metadata, {
2185
+ redirectUri,
2186
+ challenge,
2187
+ state,
2188
+ scopes,
2189
+ });
2190
+
2191
+ // Every copy-paste hand-off leaves the browser holding a code this process
2192
+ // may no longer be around to receive, so park the verifier for `--code`.
2193
+ // The window is the user's, not the terminal's: signing up and consenting
2194
+ // routinely outlasts the in-process wait.
2195
+ if (pasteCode) {
2196
+ savePendingAuthorization(runtime, {
2197
+ profile: runtime.profileName,
2198
+ api_base: runtime.apiBase,
2199
+ verifier,
2200
+ state,
2201
+ redirect_uri: redirectUri,
2202
+ issuer: metadata.issuer,
2203
+ resource: metadata.resource,
2204
+ client_id: metadata.clientId,
2205
+ token_endpoint: metadata.tokenEndpoint,
2206
+ revocation_endpoint: metadata.revocationEndpoint,
2207
+ authorization_endpoint: metadata.authorizationEndpoint,
2208
+ channel: authorizationChannel(metadata, runtime),
2209
+ scopes,
2210
+ authorization_timeout_ms: parkedAuthorizationTimeoutMs,
2211
+ expires_at: Math.floor(Date.now() / 1000) + Math.ceil(parkedAuthorizationTimeoutMs / 1000),
2212
+ });
2213
+ } else if (!nonBlockingAgent) {
2214
+ foregroundAuthorization = {
2215
+ profile: runtime.profileName,
2216
+ api_base: runtime.apiBase,
2217
+ verifier,
2218
+ state,
2219
+ redirect_uri: redirectUri,
2220
+ hand_off: 'browser_callback',
2221
+ issuer: metadata.issuer,
2222
+ resource: metadata.resource,
2223
+ client_id: metadata.clientId,
2224
+ token_endpoint: metadata.tokenEndpoint,
2225
+ revocation_endpoint: metadata.revocationEndpoint,
2226
+ authorization_endpoint: metadata.authorizationEndpoint,
2227
+ channel: authorizationChannel(metadata, runtime),
2228
+ scopes,
2229
+ authorization_timeout_ms: timeoutMs,
2230
+ expires_at: Math.floor(Date.now() / 1000) + Math.ceil(timeoutMs / 1000),
2231
+ };
2232
+ // Logout and competing logins use this owner-qualified sidecar to cancel
2233
+ // a foreground authorization before it can exchange or persist a grant.
2234
+ await publishForegroundAuthorization(runtime, foregroundAuthorization);
2235
+ }
2236
+
2237
+ if (nonBlockingAgent) {
2238
+ await receiver?.close();
2239
+ if (detachedListener) {
2240
+ const listenerTtlSeconds = Math.round(parkedAuthorizationTimeoutMs / 1000);
2241
+ saveListenerState(runtime, {
2242
+ profile: runtime.profileName,
2243
+ api_base: runtime.apiBase,
2244
+ pid: detachedListener.pid,
2245
+ port: detachedListener.port,
2246
+ authorize_url: authorizeUrl,
2247
+ issuer: metadata.issuer,
2248
+ resource: metadata.resource,
2249
+ client_id: metadata.clientId,
2250
+ token_endpoint: metadata.tokenEndpoint,
2251
+ scopes,
2252
+ authorization_timeout_ms: parkedAuthorizationTimeoutMs,
2253
+ identity_token: detachedListener.identityToken,
2254
+ listener_script: detachedListener.scriptPath,
2255
+ // The record must not outlive the child it points at, or reuse hands
2256
+ // back a URL whose listener has already given up.
2257
+ expires_at: Math.floor(Date.now() / 1000) + listenerTtlSeconds,
2258
+ });
2259
+ detachedListenerPublished = true;
2260
+ return {
2261
+ agentAuthorization: {
2262
+ authorize_url: authorizeUrl,
2263
+ expires_in: listenerTtlSeconds,
2264
+ // Nothing to copy: the browser returns the code to the listener the
2265
+ // child is holding, and the credential is written before the user is
2266
+ // told they are connected.
2267
+ hand_off: 'browser_callback',
2268
+ confirm_command: confirmCommand(
2269
+ runtime.profileName,
2270
+ authorizationChannel(metadata, runtime),
2271
+ runtime.apiBase,
2272
+ ),
2273
+ },
2274
+ };
2275
+ }
2276
+ return {
2277
+ agentAuthorization: {
2278
+ authorize_url: authorizeUrl,
2279
+ expires_in: Math.round(parkedAuthorizationTimeoutMs / 1000),
2280
+ hand_off: 'code',
2281
+ redeem_command: redeemCommand(
2282
+ runtime.profileName,
2283
+ authorizationChannel(metadata, runtime),
2284
+ ),
2285
+ },
2286
+ };
2287
+ }
2288
+
2289
+ if (pasteCode) {
2290
+ // Do not hold publication locks while a human finds and pastes a code. The
2291
+ // parked verifier is now authoritative; redemption reacquires both locks
2292
+ // and validates that it still owns the profile before exchanging.
2293
+ releaseListenerStartLock(listenerStartLock);
2294
+ listenerStartLock = null;
2295
+ releaseListenerGlobalLock(listenerGlobalLock);
2296
+ listenerGlobalLock = null;
2297
+ }
2298
+
2299
+ // Always surface the URL: opening the browser can fail silently, and the
2300
+ // wait below is useless without something the user can paste themselves.
2301
+ const announceAuthorization = output?.notice || output?.note;
2302
+ announceAuthorization?.call(output, `Authorize Notis CLI: ${authorizeUrl}`);
2303
+ if (options.browser !== false && !pasteCode) {
2304
+ openBrowser(authorizeUrl);
2305
+ }
2306
+
2307
+ try {
2308
+ if (pasteCode) {
2309
+ return await redeemAuthorizationCode(runtime, await readCode(), fetchImpl);
2310
+ }
2311
+ const code = await receiver.waitForCode();
2312
+ const profile = await exchangeAndPersistForegroundAuthorization(
2313
+ runtime,
2314
+ metadata,
2315
+ { ...foregroundAuthorization, code },
2316
+ fetchImpl,
2317
+ );
2318
+ updateRuntimeFromOAuthProfile(runtime, profile);
2319
+ receiver?.complete();
2320
+ return { profile, metadata };
2321
+ } catch (error) {
2322
+ receiver?.fail();
2323
+ throw error;
2324
+ } finally {
2325
+ await receiver?.close();
2326
+ if (foregroundAuthorization) {
2327
+ const globalLock = await acquireListenerGlobalLock();
2328
+ let lockDir = null;
2329
+ try {
2330
+ lockDir = await acquireListenerStartLock(runtime);
2331
+ clearPendingAuthorizationIfOwner(runtime, foregroundAuthorization);
2332
+ } finally {
2333
+ releaseListenerStartLock(lockDir);
2334
+ releaseListenerGlobalLock(globalLock);
2335
+ }
2336
+ }
2337
+ }
2338
+ } finally {
2339
+ if (detachedListener && !detachedListenerPublished) {
2340
+ try { process.kill(detachedListener.pid); } catch { /* already gone */ }
2341
+ clearListenerState(runtime, { ownerPid: detachedListener.pid });
2342
+ }
2343
+ releaseListenerStartLock(listenerStartLock);
2344
+ releaseListenerGlobalLock(listenerGlobalLock);
2345
+ }
2346
+ }
2347
+
2348
+ function pendingListenerProfiles() {
2349
+ const configFile = resolveConfigFile();
2350
+ const prefix = `${basename(configFile)}.login-listener.`;
2351
+ let entries;
2352
+ try {
2353
+ entries = readdirSync(dirname(configFile));
2354
+ } catch {
2355
+ return [];
2356
+ }
2357
+ const profiles = new Set();
2358
+ for (const entry of entries) {
2359
+ if (!entry.startsWith(prefix) || entry.includes('.payload') || entry.endsWith('.lock')) continue;
2360
+ try {
2361
+ const state = JSON.parse(readFileSync(join(dirname(configFile), entry), 'utf-8'));
2362
+ if (typeof state?.profile === 'string' && state.profile) profiles.add(state.profile);
2363
+ } catch {
2364
+ // Ignore malformed or concurrently removed sidecars.
2365
+ }
2366
+ }
2367
+ return [...profiles];
2368
+ }
2369
+
2370
+ function pendingAuthorizationProfiles() {
2371
+ const configFile = resolveConfigFile();
2372
+ const configName = basename(configFile);
2373
+ const prefix = `${configName}.pending-login.`;
2374
+ const legacyName = `${configName}.pending-login`;
2375
+ let entries;
2376
+ try {
2377
+ entries = readdirSync(dirname(configFile));
2378
+ } catch {
2379
+ return [];
2380
+ }
2381
+ const profiles = new Set();
2382
+ for (const entry of entries) {
2383
+ if (entry !== legacyName && !entry.startsWith(prefix)) continue;
2384
+ try {
2385
+ const pending = JSON.parse(readFileSync(join(dirname(configFile), entry), 'utf-8'));
2386
+ if (typeof pending?.profile === 'string' && pending.profile) profiles.add(pending.profile);
2387
+ } catch {
2388
+ // Ignore malformed or concurrently removed sidecars.
2389
+ }
2390
+ }
2391
+ return [...profiles];
2392
+ }
2393
+
2394
+ function lockIsStale() {
2395
+ try {
2396
+ return Date.now() - statSync(OAUTH_LOCK_DIR).mtimeMs > 45_000;
2397
+ } catch {
2398
+ return false;
2399
+ }
2400
+ }
2401
+
2402
+ async function acquireRefreshLock(runtime, waitMs = 60_000) {
2403
+ mkdirSync(join(homedir(), '.notis'), { recursive: true });
2404
+ const deadline = Date.now() + waitMs;
2405
+ for (;;) {
2406
+ try {
2407
+ mkdirSync(OAUTH_LOCK_DIR);
2408
+ return true;
2409
+ } catch (error) {
2410
+ if (error?.code !== 'EEXIST') throw error;
2411
+ const profile = getProfile(loadConfig(), runtime.profileName);
2412
+ if (
2413
+ profile.oauth_access_token
2414
+ && profile.oauth_access_token !== runtime.oauthAccessToken
2415
+ && !credentialIsExpired({ credentialKind: 'oauth' }, profile)
2416
+ ) {
2417
+ updateRuntimeFromOAuthProfile(runtime, profile);
2418
+ return false;
2419
+ }
2420
+ if (lockIsStale()) {
2421
+ try {
2422
+ rmdirSync(OAUTH_LOCK_DIR);
2423
+ continue;
2424
+ } catch {
2425
+ // The owner may have completed between stat and removal.
2426
+ }
2427
+ }
2428
+ if (Date.now() >= deadline) {
2429
+ throw oauthError('oauth_refresh_lock_timeout', 'Timed out waiting for another CLI process to refresh OAuth.');
2430
+ }
2431
+ await new Promise((resolve) => setTimeout(resolve, 100));
2432
+ }
2433
+ }
2434
+ }
2435
+
2436
+ export async function refreshOAuthCredential(runtime, fetchImpl = fetch) {
2437
+ // Refresh rotates the same stored grant that login publishes and logout
2438
+ // removes. Join their global -> profile lock order so a token rotation can
2439
+ // never be mistaken for a successor authorization by a concurrent logout.
2440
+ const globalLock = await acquireListenerGlobalLock();
2441
+ let profileLock = null;
2442
+ let ownsLock = false;
2443
+ let metadata = null;
2444
+ let rotatedResponse = null;
2445
+ let rotatedPersisted = false;
2446
+ try {
2447
+ profileLock = await acquireListenerStartLock(runtime);
2448
+ ownsLock = await acquireRefreshLock(runtime);
2449
+ if (!ownsLock) return true;
2450
+ const config = loadConfig();
2451
+ const profile = getProfile(config, runtime.profileName);
2452
+ assertOAuthApiTarget(runtime, profile);
2453
+ if (
2454
+ profile.oauth_access_token
2455
+ && profile.oauth_access_token !== runtime.oauthAccessToken
2456
+ && !credentialIsExpired({ credentialKind: 'oauth' }, profile)
2457
+ ) {
2458
+ updateRuntimeFromOAuthProfile(runtime, profile);
2459
+ return true;
2460
+ }
2461
+ if (!profile.oauth_refresh_token || !profile.oauth_client_id || !profile.oauth_issuer) {
2462
+ return false;
2463
+ }
2464
+
2465
+ metadata = storedOAuthMetadata(runtime, profile);
2466
+ rotatedResponse = await fetchJson(
2467
+ metadata.tokenEndpoint,
2468
+ {
2469
+ method: 'POST',
2470
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
2471
+ body: new URLSearchParams({
2472
+ grant_type: 'refresh_token',
2473
+ refresh_token: profile.oauth_refresh_token,
2474
+ client_id: profile.oauth_client_id,
2475
+ resource: metadata.resource,
2476
+ }),
2477
+ },
2478
+ fetchImpl,
2479
+ );
2480
+ const updated = persistOAuthTokenResponse(runtime, metadata, rotatedResponse);
2481
+ rotatedPersisted = true;
2482
+ updateRuntimeFromOAuthProfile(runtime, updated);
2483
+ return true;
2484
+ } catch (error) {
2485
+ if (rotatedResponse && !rotatedPersisted) {
2486
+ await revokeCancelledToken(metadata, rotatedResponse, fetchImpl);
2487
+ }
2488
+ if (error instanceof CliError) {
2489
+ throw new CliError({
2490
+ code: error.code,
2491
+ message: error.message,
2492
+ exitCode: error.exitCode,
2493
+ retryable: error.retryable,
2494
+ details: error.details,
2495
+ hints: getAuthRecovery(runtime).hints,
2496
+ warnings: error.warnings,
2497
+ cause: error,
2498
+ });
2499
+ }
2500
+ throw error;
2501
+ } finally {
2502
+ if (ownsLock) {
2503
+ try {
2504
+ rmdirSync(OAUTH_LOCK_DIR);
2505
+ } catch {
2506
+ // A process exit or external cleanup may already have removed the lock.
2507
+ }
2508
+ }
2509
+ releaseListenerStartLock(profileLock);
2510
+ releaseListenerGlobalLock(globalLock);
2511
+ }
2512
+ }
2513
+
2514
+ export async function logoutOAuth(runtime, { allProfiles = false } = {}, fetchImpl = fetch) {
2515
+ if (runtime.credentialKind === 'worktree' && !allProfiles) {
2516
+ throw oauthError(
2517
+ 'oauth_profile_is_dev_managed',
2518
+ `Profile "${runtime.profileName}" is managed by ./dev.sh and has no OAuth grant to remove.`,
2519
+ [
2520
+ {
2521
+ command: 'notis logout --profile <name>',
2522
+ reason: 'Name a stored OAuth profile to disconnect it',
2523
+ },
2524
+ { command: 'notis profile list', reason: 'See the stored account profiles on this machine' },
2525
+ ],
2526
+ );
2527
+ }
2528
+ // A single-profile logout mutates the same state as login publication and
2529
+ // refresh, so it needs the global lock just as much as --all-profiles does.
2530
+ const globalLock = await acquireListenerGlobalLock();
2531
+ try {
2532
+ const config = loadConfig();
2533
+ const profileNames = allProfiles
2534
+ ? [...new Set([
2535
+ ...Object.keys(config.profiles),
2536
+ ...pendingListenerProfiles(),
2537
+ ...pendingAuthorizationProfiles(),
2538
+ ])]
2539
+ : [runtime.profileName];
2540
+ const clearedProfiles = [];
2541
+ for (const profileName of profileNames) {
2542
+ // An unfinished login is still holding a listener that would write this
2543
+ // profile back in the moment the old URL is opened. Signing out has to end
2544
+ // the authorization in flight, not just the one already stored.
2545
+ const profileRuntime = { ...runtime, profileName };
2546
+ const profileLock = await acquireListenerStartLock(profileRuntime);
2547
+ try {
2548
+ const latestBeforeClear = loadConfig();
2549
+ const storedProfile = latestBeforeClear.profiles[profileName] || {};
2550
+ const hadStoredGrant = Boolean(
2551
+ storedProfile.oauth_access_token
2552
+ || storedProfile.oauth_refresh_token
2553
+ || storedProfile.oauth_client_id
2554
+ || storedProfile.oauth_issuer
2555
+ || storedProfile.oauth_resource
2556
+ || storedProfile.oauth_user_id
2557
+ );
2558
+ const hadPendingAuthorization = Boolean(readPendingAuthorization(profileRuntime));
2559
+ const hadListener = Boolean(readListenerState(profileRuntime));
2560
+ if (hadStoredGrant || hadPendingAuthorization || hadListener) {
2561
+ clearedProfiles.push(profileName);
2562
+ }
2563
+ clearPendingAuthorizations(profileRuntime);
2564
+ stopPendingListener(profileRuntime);
2565
+ // Read only after both locks are held. A publication or refresh that
2566
+ // won first is part of this logout; one that starts later observes the
2567
+ // cleared profile instead of resurrecting it afterward.
2568
+ const profile = loadConfig().profiles[profileName] || {};
2569
+ const revocationToken = profile.oauth_refresh_token || profile.oauth_access_token;
2570
+ if (
2571
+ revocationToken
2572
+ && profile.oauth_client_id
2573
+ && profile.oauth_issuer
2574
+ ) {
2575
+ try {
2576
+ const metadata = storedOAuthMetadata(runtime, profile);
2577
+ await fetchJson(
2578
+ metadata.revocationEndpoint,
2579
+ {
2580
+ method: 'POST',
2581
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
2582
+ body: new URLSearchParams({
2583
+ token: revocationToken,
2584
+ client_id: profile.oauth_client_id,
2585
+ }),
2586
+ },
2587
+ fetchImpl,
2588
+ );
2589
+ } catch {
2590
+ // Local credential removal still succeeds when the remote grant is
2591
+ // already gone or the network is unavailable.
2592
+ }
2593
+ }
2594
+ updateConfig((latest) => {
2595
+ const current = latest.profiles[profileName];
2596
+ // A named pending login has no stored profile yet. Cancelling it must
2597
+ // not create an empty profile as a side effect of logout.
2598
+ if (!current) return latest;
2599
+ latest.profiles[profileName] = {
2600
+ ...current,
2601
+ oauth_access_token: undefined,
2602
+ oauth_refresh_token: undefined,
2603
+ oauth_access_expires_at: undefined,
2604
+ oauth_refresh_expires_at: undefined,
2605
+ oauth_client_id: undefined,
2606
+ oauth_issuer: undefined,
2607
+ oauth_api_base: undefined,
2608
+ oauth_resource: undefined,
2609
+ oauth_scopes: undefined,
2610
+ oauth_user_id: undefined,
2611
+ };
2612
+ return latest;
2613
+ });
2614
+ } finally {
2615
+ releaseListenerStartLock(profileLock);
2616
+ }
2617
+ }
2618
+ return { profiles: clearedProfiles };
2619
+ } finally {
2620
+ releaseListenerGlobalLock(globalLock);
2621
+ }
2622
+ }