@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 +1 -1
- package/skills/notis-cli/SKILL.md +9 -0
- package/src/command-specs/meta.js +8 -2
- package/src/runtime/desktop-auth.js +93 -0
- package/src/runtime/profiles.js +37 -15
- package/src/runtime/transport.js +21 -82
package/package.json
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/runtime/profiles.js
CHANGED
|
@@ -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
|
|
216
|
-
//
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
}
|
package/src/runtime/transport.js
CHANGED
|
@@ -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
|
|
149
|
-
if (runtime.
|
|
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.
|
|
201
|
-
runtime.
|
|
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
|
-
|
|
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 = (
|
|
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
|
-
|
|
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 {
|