@notis_ai/cli 0.2.0-beta.95.1 → 0.2.0-beta.99.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.95.1",
3
+ "version": "0.2.0-beta.99.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -239,6 +239,15 @@ Most importantly: if you do not currently have the tool you need, especially for
239
239
 
240
240
  ## Troubleshooting
241
241
 
242
+ ### CLI returns `auth_expired`
243
+
244
+ Local CLI auth is renewed by Notis Desktop. In JSON/agent mode, follow the
245
+ first hint exactly: on macOS it is an executable `open -a 'Notis'` (or
246
+ `open -a 'Notis Beta'`) command when the owning desktop app is not running.
247
+ Wait for the app to restore the signed-in session, then rerun the original
248
+ command. Do not copy refresh tokens into commands or try to refresh the shared
249
+ desktop session yourself.
250
+
242
251
  ### Deploy fails with "network_error" or "fetch failed"
243
252
 
244
253
  The backend server at `http://localhost:3001` is not running. Solutions:
@@ -1,5 +1,7 @@
1
1
  import { COMPOSIO_SEARCH_TOOLS, healthCheck, probeAuth } from './helpers.js';
2
2
  import { findCommandSpec, formatDescribe } from '../runtime/help.js';
3
+ import { createExpiredAuthError } from '../runtime/desktop-auth.js';
4
+ import { isJwtExpired } from '../runtime/profiles.js';
3
5
 
4
6
  async function doctorHandler(ctx) {
5
7
  const checks = {
@@ -9,7 +11,9 @@ async function doctorHandler(ctx) {
9
11
  tool_roundtrip: 'unknown',
10
12
  };
11
13
 
12
- checks.auth = ctx.runtime.jwt ? 'configured' : 'missing';
14
+ checks.auth = ctx.runtime.jwt
15
+ ? (isJwtExpired(ctx.runtime.jwt) ? 'expired' : 'configured')
16
+ : 'missing';
13
17
 
14
18
  try {
15
19
  await healthCheck(ctx.runtime);
@@ -30,8 +34,10 @@ async function doctorHandler(ctx) {
30
34
  const hints = [];
31
35
  if (checks.auth === 'missing') {
32
36
  hints.push({ command: 'Open the Notis desktop app and sign in', reason: 'Configure CLI credentials' });
37
+ } else if (checks.auth === 'expired') {
38
+ hints.push(...createExpiredAuthError(ctx.runtime).hints);
33
39
  }
34
- if (checks.health === 'error') {
40
+ if (checks.health === 'error' && checks.auth !== 'expired') {
35
41
  hints.push({ command: 'Open the Notis desktop app and sign in again', reason: 'Refresh local CLI configuration' });
36
42
  }
37
43
  if (checks.tool_roundtrip === 'error') {
@@ -0,0 +1,93 @@
1
+ import { CliError, EXIT_CODES } from './errors.js';
2
+
3
+ function isPidRunning(pid) {
4
+ if (!Number.isInteger(pid) || pid <= 0) {
5
+ return false;
6
+ }
7
+ try {
8
+ process.kill(pid, 0);
9
+ return true;
10
+ } catch (error) {
11
+ return error?.code === 'EPERM';
12
+ }
13
+ }
14
+
15
+ function quoteShellArgument(value) {
16
+ return `'${String(value).replace(/'/g, `'"'"'`)}'`;
17
+ }
18
+
19
+ function defaultDesktopAppName(apiBase) {
20
+ try {
21
+ return new URL(apiBase).hostname === 'api-beta.notis.ai' ? 'Notis Beta' : 'Notis';
22
+ } catch {
23
+ return 'Notis';
24
+ }
25
+ }
26
+
27
+ export function getDesktopAuthRecovery(runtime) {
28
+ const desktopRunning = isPidRunning(runtime.desktopPid);
29
+ const appName = runtime.desktopAppName || defaultDesktopAppName(runtime.apiBase);
30
+ let command = 'Start the Notis desktop app';
31
+ if (process.platform === 'darwin') {
32
+ command = `open -a ${quoteShellArgument(appName)}`;
33
+ }
34
+
35
+ return {
36
+ desktopRunning,
37
+ appName,
38
+ hints: [
39
+ {
40
+ command,
41
+ reason: desktopRunning
42
+ ? `Bring ${appName} forward so it can renew CLI authentication, then retry`
43
+ : `Start ${appName} to renew expired CLI authentication, then retry`,
44
+ },
45
+ {
46
+ command: 'notis doctor',
47
+ reason: 'Retry the auth and API checks after the desktop app is ready',
48
+ },
49
+ ],
50
+ };
51
+ }
52
+
53
+ export function createExpiredAuthError(runtime) {
54
+ if (runtime.credentialSource === 'env') {
55
+ return new CliError({
56
+ code: 'auth_expired',
57
+ message: 'NOTIS_JWT is expired',
58
+ exitCode: EXIT_CODES.auth,
59
+ details: { credential_source: 'env' },
60
+ hints: [
61
+ {
62
+ command: 'Set NOTIS_JWT to a fresh token',
63
+ reason: 'The explicit environment credential overrides desktop-managed auth',
64
+ },
65
+ ],
66
+ });
67
+ }
68
+
69
+ const recovery = getDesktopAuthRecovery(runtime);
70
+ return new CliError({
71
+ code: 'auth_expired',
72
+ message: 'Notis CLI authentication has expired',
73
+ exitCode: EXIT_CODES.auth,
74
+ details: {
75
+ credential_source: 'desktop',
76
+ desktop_running: recovery.desktopRunning,
77
+ desktop_app_name: recovery.appName,
78
+ },
79
+ hints: recovery.hints,
80
+ });
81
+ }
82
+
83
+ export function createInvalidAuthHints(runtime) {
84
+ if (runtime?.credentialSource === 'env') {
85
+ return [
86
+ {
87
+ command: 'Set NOTIS_JWT to a fresh token',
88
+ reason: 'The explicit environment credential was rejected',
89
+ },
90
+ ];
91
+ }
92
+ return getDesktopAuthRecovery(runtime || {}).hints;
93
+ }
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { CliError, EXIT_CODES } from './errors.js';
5
+ import { getDesktopAuthRecovery } from './desktop-auth.js';
5
6
 
6
7
  export const CONFIG_DIR = join(homedir(), '.notis');
7
8
  export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
@@ -37,6 +38,9 @@ export function normalizeConfig(rawConfig = {}) {
37
38
  typeof profile.access_expires_at === 'number' ? profile.access_expires_at : undefined,
38
39
  refresh_expires_at:
39
40
  typeof profile.refresh_expires_at === 'number' ? profile.refresh_expires_at : undefined,
41
+ desktop_app_name:
42
+ typeof profile.desktop_app_name === 'string' ? profile.desktop_app_name : undefined,
43
+ desktop_pid: typeof profile.desktop_pid === 'number' ? profile.desktop_pid : undefined,
40
44
  };
41
45
  }
42
46
 
@@ -65,6 +69,9 @@ export function normalizeConfig(rawConfig = {}) {
65
69
  typeof raw.access_expires_at === 'number' ? raw.access_expires_at : undefined,
66
70
  refresh_expires_at:
67
71
  typeof raw.refresh_expires_at === 'number' ? raw.refresh_expires_at : undefined,
72
+ desktop_app_name:
73
+ typeof raw.desktop_app_name === 'string' ? raw.desktop_app_name : undefined,
74
+ desktop_pid: typeof raw.desktop_pid === 'number' ? raw.desktop_pid : undefined,
68
75
  },
69
76
  },
70
77
  };
@@ -199,34 +206,30 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
199
206
  const timeoutMs = resolveTimeoutMs(globalOptions);
200
207
 
201
208
  if (requireAuth && !jwt) {
209
+ const recovery = getDesktopAuthRecovery({
210
+ apiBase,
211
+ desktopAppName: profile.desktop_app_name,
212
+ desktopPid: profile.desktop_pid,
213
+ });
202
214
  throw new CliError({
203
215
  code: 'auth_missing',
204
216
  message: `No JWT configured for profile ${profileName}`,
205
217
  exitCode: EXIT_CODES.auth,
206
- hints: [
207
- {
208
- command: 'Open the Notis desktop app and sign in',
209
- reason: 'Set NOTIS_JWT or sign in through the Notis desktop app',
210
- },
211
- ],
218
+ hints: recovery.hints,
212
219
  });
213
220
  }
214
221
 
215
- // An explicit NOTIS_JWT is a complete credential override: use it verbatim and do
216
- // NOT attempt a stored-profile dev_portal token refresh (whose refresh_token may be
217
- // stale or belong to a different session). Without this, a long-lived shell with an
218
- // expired profile refresh_token fails every command with invalid_grant even though
219
- // the supplied NOTIS_JWT is valid.
222
+ // An explicit NOTIS_JWT is a complete credential override. Use it verbatim
223
+ // and never replace it with a token later synced by the desktop profile.
220
224
  const usingEnvJwt = Boolean(process.env.NOTIS_JWT);
221
225
  return {
222
226
  config,
223
227
  profileName,
224
228
  apiBase,
225
229
  jwt,
226
- authMode: usingEnvJwt ? 'jwt' : profile.auth_mode,
227
- refreshToken: usingEnvJwt ? undefined : profile.refresh_token,
228
- accessExpiresAt: usingEnvJwt ? undefined : profile.access_expires_at,
229
- refreshExpiresAt: usingEnvJwt ? undefined : profile.refresh_expires_at,
230
+ credentialSource: usingEnvJwt ? 'env' : 'profile',
231
+ desktopAppName: usingEnvJwt ? undefined : profile.desktop_app_name,
232
+ desktopPid: usingEnvJwt ? undefined : profile.desktop_pid,
230
233
  agentMode,
231
234
  nonInteractive,
232
235
  outputMode,
@@ -234,6 +237,25 @@ export function resolveRuntimeProfile(globalOptions = {}, { requireAuth = true }
234
237
  };
235
238
  }
236
239
 
240
+ export function getJwtExpiration(jwt) {
241
+ if (typeof jwt !== 'string' || !jwt) {
242
+ return null;
243
+ }
244
+ try {
245
+ const parts = jwt.split('.');
246
+ if (parts.length !== 3) return null;
247
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
248
+ return typeof payload.exp === 'number' ? payload.exp : null;
249
+ } catch {
250
+ return null;
251
+ }
252
+ }
253
+
254
+ export function isJwtExpired(jwt, nowSeconds = Math.floor(Date.now() / 1000)) {
255
+ const expiration = getJwtExpiration(jwt);
256
+ return expiration !== null && expiration <= nowSeconds;
257
+ }
258
+
237
259
  export function workspacePath(appId) {
238
260
  return join(WORKSPACE_DIR, appId);
239
261
  }
@@ -3,11 +3,11 @@ import { createReadStream } from 'node:fs';
3
3
  import { CliError, EXIT_CODES } from './errors.js';
4
4
  import {
5
5
  DEFAULT_PROFILE,
6
- ensureProfile,
7
6
  getProfile,
7
+ isJwtExpired,
8
8
  loadConfig,
9
- saveConfig,
10
9
  } from './profiles.js';
10
+ import { createExpiredAuthError, createInvalidAuthHints } from './desktop-auth.js';
11
11
 
12
12
  function escapeMultipartHeaderValue(value) {
13
13
  return String(value ?? '')
@@ -76,7 +76,7 @@ function createMultipartFileUpload(payload, bindingMetadata, fileBindings) {
76
76
  };
77
77
  }
78
78
 
79
- function normalizeBackendError(status, payload) {
79
+ function normalizeBackendError(status, payload, runtime) {
80
80
  const backendError = payload?.error;
81
81
  const message =
82
82
  backendError?.message ||
@@ -91,10 +91,7 @@ function normalizeBackendError(status, payload) {
91
91
  message,
92
92
  exitCode: EXIT_CODES.auth,
93
93
  hints: [
94
- {
95
- command: 'Open the Notis desktop app and sign in',
96
- reason: 'Open the Notis desktop app to refresh CLI auth, or set NOTIS_JWT',
97
- },
94
+ ...createInvalidAuthHints(runtime),
98
95
  ],
99
96
  details: payload || {},
100
97
  });
@@ -145,46 +142,10 @@ function normalizeBackendError(status, payload) {
145
142
  });
146
143
  }
147
144
 
148
- function shouldAttemptDevPortalRefresh(runtime, { force = false } = {}) {
149
- if (runtime.authMode !== 'dev_portal' || !runtime.refreshToken || !runtime.apiBase) {
150
- return false;
151
- }
152
-
153
- if (force) {
154
- return true;
155
- }
156
-
157
- if (typeof runtime.accessExpiresAt !== 'number' || runtime.accessExpiresAt <= 0) {
145
+ function reloadJwtFromConfig(runtime) {
146
+ if (runtime.credentialSource === 'env') {
158
147
  return false;
159
148
  }
160
-
161
- const thresholdSeconds = 60;
162
- return runtime.accessExpiresAt - Math.floor(Date.now() / 1000) <= thresholdSeconds;
163
- }
164
-
165
- function persistDevPortalRuntimeAuth(runtime, session) {
166
- const config = ensureProfile(loadConfig(), runtime.profileName || DEFAULT_PROFILE);
167
- const profileName = runtime.profileName || DEFAULT_PROFILE;
168
- config.current_profile = profileName;
169
- config.profiles[profileName] = {
170
- ...config.profiles[profileName],
171
- jwt: session.access_token,
172
- api_base: runtime.apiBase,
173
- auth_mode: 'dev_portal',
174
- refresh_token: session.refresh_token,
175
- access_expires_at: session.access_expires_at,
176
- refresh_expires_at: session.refresh_expires_at,
177
- };
178
- saveConfig(config);
179
-
180
- runtime.jwt = session.access_token;
181
- runtime.authMode = 'dev_portal';
182
- runtime.refreshToken = session.refresh_token;
183
- runtime.accessExpiresAt = session.access_expires_at;
184
- runtime.refreshExpiresAt = session.refresh_expires_at;
185
- }
186
-
187
- function reloadJwtFromConfig(runtime) {
188
149
  const profileName = runtime.profileName || DEFAULT_PROFILE;
189
150
  let profile;
190
151
  try {
@@ -197,39 +158,8 @@ function reloadJwtFromConfig(runtime) {
197
158
  return false;
198
159
  }
199
160
  runtime.jwt = nextJwt;
200
- runtime.authMode = profile.auth_mode;
201
- runtime.refreshToken = profile.refresh_token;
202
- runtime.accessExpiresAt = profile.access_expires_at;
203
- runtime.refreshExpiresAt = profile.refresh_expires_at;
204
- return true;
205
- }
206
-
207
- async function maybeRefreshDevPortalAuth(runtime, { force = false } = {}) {
208
- if (!shouldAttemptDevPortalRefresh(runtime, { force })) {
209
- return false;
210
- }
211
-
212
- const response = await fetch(`${runtime.apiBase}/portal_auth/dev-refresh`, {
213
- method: 'POST',
214
- headers: {
215
- 'Content-Type': 'application/json',
216
- 'X-Notis-CLI-Version': runtime.cliVersion,
217
- },
218
- body: JSON.stringify({ refresh_token: runtime.refreshToken }),
219
- });
220
-
221
- let payload = null;
222
- try {
223
- payload = await response.json();
224
- } catch {
225
- payload = null;
226
- }
227
-
228
- if (!response.ok || !payload?.session?.access_token || !payload?.session?.refresh_token) {
229
- throw normalizeBackendError(response.status, payload);
230
- }
231
-
232
- persistDevPortalRuntimeAuth(runtime, payload.session);
161
+ runtime.desktopAppName = profile.desktop_app_name;
162
+ runtime.desktopPid = profile.desktop_pid;
233
163
  return true;
234
164
  }
235
165
 
@@ -241,7 +171,14 @@ export async function httpRequest({
241
171
  multipart = false,
242
172
  requireAuth = true,
243
173
  }) {
244
- await maybeRefreshDevPortalAuth(runtime);
174
+ // The desktop renderer is the single owner of the rotating Supabase refresh
175
+ // token. Each CLI request only consumes the newest access token it synced to
176
+ // disk, avoiding refresh-token races between independent CLI processes and
177
+ // the running desktop session.
178
+ reloadJwtFromConfig(runtime);
179
+ if (requireAuth && isJwtExpired(runtime.jwt)) {
180
+ throw createExpiredAuthError(runtime);
181
+ }
245
182
 
246
183
  let controller = new AbortController();
247
184
  let timeout = setTimeout(() => controller.abort(), runtime.timeoutMs);
@@ -298,8 +235,7 @@ export async function httpRequest({
298
235
  }
299
236
 
300
237
  if (response.status === 401) {
301
- const refreshed = (await maybeRefreshDevPortalAuth(runtime, { force: true }))
302
- || reloadJwtFromConfig(runtime);
238
+ const refreshed = reloadJwtFromConfig(runtime) && !isJwtExpired(runtime.jwt);
303
239
  if (refreshed) {
304
240
  if (requireAuth && runtime.jwt) {
305
241
  headers.Authorization = `Bearer ${runtime.jwt}`;
@@ -324,7 +260,10 @@ export async function httpRequest({
324
260
  clearTimeout(timeout);
325
261
 
326
262
  if (!response.ok) {
327
- throw normalizeBackendError(response.status, payload);
263
+ if (response.status === 401 && isJwtExpired(runtime.jwt)) {
264
+ throw createExpiredAuthError(runtime);
265
+ }
266
+ throw normalizeBackendError(response.status, payload, runtime);
328
267
  }
329
268
 
330
269
  return {