@notis_ai/cli 0.2.9 → 0.2.11
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/README.md +73 -9
- package/dist/scaffolds.json +1 -1
- package/package.json +1 -1
- package/skills/notis-apps/SKILL.md +3 -3
- package/skills/notis-apps/cli.md +1 -1
- package/skills/notis-cli/SKILL.md +62 -19
- package/skills/notis-onboarding/BRIEF.md +24 -5
- package/skills/notis-query/cli.md +1 -1
- package/src/cli.js +27 -2
- package/src/command-specs/apps.js +18 -10
- package/src/command-specs/auth.js +15 -20
- package/src/command-specs/diagnostics.js +6 -1
- package/src/command-specs/helpers.js +7 -1
- package/src/command-specs/index.js +3 -0
- package/src/command-specs/meta.js +29 -19
- package/src/command-specs/onboarding.js +122 -200
- package/src/command-specs/profile.js +358 -0
- package/src/runtime/app-platform.js +5 -5
- package/src/runtime/auth-recovery.js +100 -0
- package/src/runtime/cli-mode.generated.js +4 -3
- package/src/runtime/cli-mode.js +13 -8
- package/src/runtime/oauth.js +121 -30
- package/src/runtime/profiles.js +435 -223
- package/src/runtime/transport.js +84 -29
- package/src/runtime/desktop-auth.js +0 -162
package/src/runtime/transport.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
3
4
|
import { CliError, EXIT_CODES } from './errors.js';
|
|
4
5
|
import {
|
|
5
|
-
DEFAULT_PROFILE,
|
|
6
6
|
credentialIsExpired,
|
|
7
|
-
|
|
7
|
+
getJwtExpiration,
|
|
8
8
|
getJwtSubject,
|
|
9
|
+
getProfile,
|
|
9
10
|
isJwtExpired,
|
|
10
11
|
loadConfig,
|
|
12
|
+
resolveWorktreeRuntime,
|
|
11
13
|
} from './profiles.js';
|
|
12
|
-
import { createExpiredAuthError, createInvalidAuthHints } from './
|
|
14
|
+
import { createExpiredAuthError, createInvalidAuthHints } from './auth-recovery.js';
|
|
13
15
|
import { refreshOAuthCredential } from './oauth.js';
|
|
14
16
|
|
|
15
17
|
function escapeMultipartHeaderValue(value) {
|
|
@@ -157,28 +159,68 @@ function normalizeBackendError(status, payload, runtime) {
|
|
|
157
159
|
});
|
|
158
160
|
}
|
|
159
161
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Pick up a dev credential that `./dev.sh` re-minted mid-process.
|
|
164
|
+
*
|
|
165
|
+
* Restarting the local runtime rewrites the worktree lease in place, so a
|
|
166
|
+
* long-running command can recover from a single 401 instead of failing and
|
|
167
|
+
* making the caller rerun it. The refreshed token still has to belong to the
|
|
168
|
+
* worktree's approved test user.
|
|
169
|
+
*/
|
|
170
|
+
function reloadDevJwt(runtime, loadedProfile = null) {
|
|
171
|
+
if (runtime.credentialKind !== 'worktree') {
|
|
162
172
|
return false;
|
|
163
173
|
}
|
|
164
|
-
|
|
174
|
+
let nextJwt = null;
|
|
175
|
+
const expectedUserId = runtime.worktreeRuntime?.expected_user_id;
|
|
176
|
+
const runtimePath = runtime.worktreeRuntime?.runtime_path;
|
|
177
|
+
if (runtimePath) {
|
|
178
|
+
const refreshedRuntime = resolveWorktreeRuntime(dirname(runtimePath));
|
|
179
|
+
if (
|
|
180
|
+
refreshedRuntime
|
|
181
|
+
&& !refreshedRuntime.unavailable
|
|
182
|
+
&& refreshedRuntime.runtime_path === runtimePath
|
|
183
|
+
&& refreshedRuntime.profile === runtime.profileName
|
|
184
|
+
&& refreshedRuntime.api_base === runtime.apiBase
|
|
185
|
+
) {
|
|
186
|
+
if (
|
|
187
|
+
expectedUserId
|
|
188
|
+
&& refreshedRuntime.expected_user_id !== expectedUserId
|
|
189
|
+
) {
|
|
190
|
+
throw new CliError({
|
|
191
|
+
code: 'dev_runtime_identity_mismatch',
|
|
192
|
+
message: 'The restarted worktree runtime belongs to a different test user',
|
|
193
|
+
exitCode: EXIT_CODES.auth,
|
|
194
|
+
hints: [
|
|
195
|
+
{ message: 'Restart this CLI command under the worktree\'s current dev identity.' },
|
|
196
|
+
{ message: `Expected user: ${expectedUserId}` },
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
runtime.worktreeRuntime = refreshedRuntime;
|
|
201
|
+
nextJwt = refreshedRuntime.dev_access_token;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
165
204
|
let profile = loadedProfile;
|
|
166
|
-
if (!profile) {
|
|
205
|
+
if (!nextJwt && !profile) {
|
|
167
206
|
try {
|
|
168
|
-
profile = getProfile(loadConfig(
|
|
207
|
+
profile = getProfile(loadConfig(), runtime.profileName);
|
|
169
208
|
} catch {
|
|
170
209
|
return false;
|
|
171
210
|
}
|
|
172
211
|
}
|
|
173
|
-
|
|
212
|
+
nextJwt = nextJwt || (
|
|
213
|
+
typeof profile?.dev_access_token === 'string' && profile.dev_access_token
|
|
214
|
+
? profile.dev_access_token
|
|
215
|
+
: null
|
|
216
|
+
);
|
|
174
217
|
if (!nextJwt || nextJwt === runtime.jwt) {
|
|
175
218
|
return false;
|
|
176
219
|
}
|
|
177
|
-
const expectedUserId = runtime.worktreeRuntime?.expected_user_id;
|
|
178
220
|
if (expectedUserId && getJwtSubject(nextJwt) !== expectedUserId) {
|
|
179
221
|
throw new CliError({
|
|
180
222
|
code: 'dev_runtime_identity_mismatch',
|
|
181
|
-
message: 'The refreshed
|
|
223
|
+
message: 'The refreshed dev credential does not belong to this worktree test user',
|
|
182
224
|
exitCode: EXIT_CODES.auth,
|
|
183
225
|
hints: [
|
|
184
226
|
{ message: 'Restart ./dev.sh to restore the approved worktree identity.' },
|
|
@@ -187,12 +229,27 @@ function reloadJwtFromConfig(runtime, loadedProfile = null) {
|
|
|
187
229
|
});
|
|
188
230
|
}
|
|
189
231
|
runtime.jwt = nextJwt;
|
|
190
|
-
runtime.credentialKind = runtime.worktreeRuntime ? 'worktree' : 'desktop';
|
|
191
|
-
runtime.desktopAppName = profile.desktop_app_name;
|
|
192
|
-
runtime.desktopPid = profile.desktop_pid;
|
|
193
232
|
return true;
|
|
194
233
|
}
|
|
195
234
|
|
|
235
|
+
/**
|
|
236
|
+
* A live worktree lease is authoritative over any stale dev metadata left in
|
|
237
|
+
* the shared profile store. Prefer its explicit expiry, then the active JWT's
|
|
238
|
+
* expiry, and deliberately fail closed when neither can be verified.
|
|
239
|
+
*/
|
|
240
|
+
export function getActiveWorktreeCredentialProfile(runtime, loadedProfile = {}) {
|
|
241
|
+
if (runtime.credentialKind !== 'worktree') {
|
|
242
|
+
return loadedProfile;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
...loadedProfile,
|
|
246
|
+
dev_access_expires_at:
|
|
247
|
+
runtime.worktreeRuntime?.dev_access_expires_at
|
|
248
|
+
?? getJwtExpiration(runtime.jwt)
|
|
249
|
+
?? loadedProfile.dev_access_expires_at,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
196
253
|
export async function httpRequest({
|
|
197
254
|
runtime,
|
|
198
255
|
method = 'POST',
|
|
@@ -201,10 +258,10 @@ export async function httpRequest({
|
|
|
201
258
|
multipart = false,
|
|
202
259
|
requireAuth = true,
|
|
203
260
|
}) {
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
261
|
+
// Refresh before spending the credential rather than after a rejection: the
|
|
262
|
+
// rotating refresh token is shared with every other `notis` process reading
|
|
263
|
+
// the same profile, so a lapsed access token is renewed once, under the
|
|
264
|
+
// config write lock, instead of racing on a 401 retry.
|
|
208
265
|
let currentProfile;
|
|
209
266
|
if (runtime.credentialKind === 'oauth') {
|
|
210
267
|
if (requireAuth && credentialIsExpired(runtime, {
|
|
@@ -212,16 +269,11 @@ export async function httpRequest({
|
|
|
212
269
|
})) {
|
|
213
270
|
await refreshOAuthCredential(runtime);
|
|
214
271
|
}
|
|
215
|
-
currentProfile = getProfile(
|
|
216
|
-
loadConfig(runtime.worktreeRuntime),
|
|
217
|
-
runtime.profileName,
|
|
218
|
-
);
|
|
272
|
+
currentProfile = getProfile(loadConfig(), runtime.profileName);
|
|
219
273
|
} else {
|
|
220
|
-
currentProfile = getProfile(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
);
|
|
224
|
-
reloadJwtFromConfig(runtime, currentProfile);
|
|
274
|
+
currentProfile = getProfile(loadConfig(), runtime.profileName);
|
|
275
|
+
reloadDevJwt(runtime, currentProfile);
|
|
276
|
+
currentProfile = getActiveWorktreeCredentialProfile(runtime, currentProfile);
|
|
225
277
|
}
|
|
226
278
|
if (requireAuth && credentialIsExpired(runtime, currentProfile)) {
|
|
227
279
|
throw createExpiredAuthError(runtime);
|
|
@@ -284,7 +336,7 @@ export async function httpRequest({
|
|
|
284
336
|
if (response.status === 401) {
|
|
285
337
|
const refreshed = runtime.credentialKind === 'oauth'
|
|
286
338
|
? await refreshOAuthCredential(runtime)
|
|
287
|
-
:
|
|
339
|
+
: reloadDevJwt(runtime) && !isJwtExpired(runtime.jwt);
|
|
288
340
|
if (refreshed) {
|
|
289
341
|
if (requireAuth && runtime.jwt) {
|
|
290
342
|
headers.Authorization = `Bearer ${runtime.jwt}`;
|
|
@@ -313,7 +365,10 @@ export async function httpRequest({
|
|
|
313
365
|
response.status === 401
|
|
314
366
|
&& credentialIsExpired(
|
|
315
367
|
runtime,
|
|
316
|
-
|
|
368
|
+
getActiveWorktreeCredentialProfile(
|
|
369
|
+
runtime,
|
|
370
|
+
getProfile(loadConfig(), runtime.profileName),
|
|
371
|
+
),
|
|
317
372
|
)
|
|
318
373
|
) {
|
|
319
374
|
throw createExpiredAuthError(runtime);
|
|
@@ -1,162 +0,0 @@
|
|
|
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
|
-
/**
|
|
28
|
-
* Recovery hints for an unusable desktop-managed credential.
|
|
29
|
-
*
|
|
30
|
-
* `mode` distinguishes the two cases an agent has to act on differently:
|
|
31
|
-
* "expired" means a session existed and the desktop app can renew it, while
|
|
32
|
-
* "missing" means this machine was never signed in — telling that caller to
|
|
33
|
-
* renew something is misleading.
|
|
34
|
-
*/
|
|
35
|
-
export function getDesktopAuthRecovery(runtime, { mode = 'expired' } = {}) {
|
|
36
|
-
const desktopRunning = isPidRunning(runtime.desktopPid);
|
|
37
|
-
const appName = runtime.desktopAppName || defaultDesktopAppName(runtime.apiBase);
|
|
38
|
-
let command = 'Start the Notis desktop app';
|
|
39
|
-
if (process.platform === 'darwin') {
|
|
40
|
-
command = `open -a ${quoteShellArgument(appName)}`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
let reason;
|
|
44
|
-
if (mode === 'missing') {
|
|
45
|
-
reason = desktopRunning
|
|
46
|
-
? `Sign in to ${appName} to authenticate the CLI on this machine, then retry`
|
|
47
|
-
: `Install and sign in to ${appName} to authenticate the CLI on this machine, then retry`;
|
|
48
|
-
} else {
|
|
49
|
-
reason = desktopRunning
|
|
50
|
-
? `Bring ${appName} forward so it can renew CLI authentication, then retry`
|
|
51
|
-
: `Start ${appName} to renew expired CLI authentication, then retry`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const hints = [{ command, reason }];
|
|
55
|
-
if (mode === 'missing') {
|
|
56
|
-
// Leads the list: a machine that was never signed in may not even have an
|
|
57
|
-
// account yet, and `notis start` covers both cases.
|
|
58
|
-
hints.unshift({
|
|
59
|
-
command: 'notis login',
|
|
60
|
-
reason: 'Sign in or create an account in the browser and authorize this machine',
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
hints.push({
|
|
64
|
-
command: 'notis doctor',
|
|
65
|
-
reason: 'Retry the auth and API checks after the desktop app is ready',
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
return { desktopRunning, appName, hints };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Block until the desktop app writes an unexpired JWT into the CLI profile.
|
|
73
|
-
*
|
|
74
|
-
* Polls the local config file only — never the server. The desktop app is what
|
|
75
|
-
* mints the credential, and `transport.js` re-reads the profile before every
|
|
76
|
-
* request, so this loop exists purely so the caller's process can wait rather
|
|
77
|
-
* than fail and make the user re-run the command.
|
|
78
|
-
*/
|
|
79
|
-
export async function waitForDesktopAuth({
|
|
80
|
-
loadConfig,
|
|
81
|
-
getJwt,
|
|
82
|
-
isJwtExpired,
|
|
83
|
-
profileName = 'default',
|
|
84
|
-
timeoutMs = 300_000,
|
|
85
|
-
intervalMs = 2_000,
|
|
86
|
-
onTick,
|
|
87
|
-
} = {}) {
|
|
88
|
-
const deadline = Date.now() + timeoutMs;
|
|
89
|
-
for (;;) {
|
|
90
|
-
const jwt = getJwt(loadConfig(), profileName);
|
|
91
|
-
if (jwt && !isJwtExpired(jwt)) {
|
|
92
|
-
return jwt;
|
|
93
|
-
}
|
|
94
|
-
if (Date.now() >= deadline) {
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
if (onTick) onTick(Math.max(0, deadline - Date.now()));
|
|
98
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function createExpiredAuthError(runtime) {
|
|
103
|
-
if (runtime.credentialKind === 'oauth') {
|
|
104
|
-
return new CliError({
|
|
105
|
-
code: 'auth_expired',
|
|
106
|
-
message: 'Notis CLI OAuth authentication has expired',
|
|
107
|
-
exitCode: EXIT_CODES.auth,
|
|
108
|
-
details: { credential_source: 'oauth' },
|
|
109
|
-
hints: [
|
|
110
|
-
{ command: 'notis login', reason: 'Authorize a new scoped CLI credential' },
|
|
111
|
-
{ command: 'notis doctor', reason: 'Inspect the active credential state' },
|
|
112
|
-
],
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
if (runtime.credentialSource === 'env') {
|
|
116
|
-
return new CliError({
|
|
117
|
-
code: 'auth_expired',
|
|
118
|
-
message: 'NOTIS_JWT is expired',
|
|
119
|
-
exitCode: EXIT_CODES.auth,
|
|
120
|
-
details: { credential_source: 'env' },
|
|
121
|
-
hints: [
|
|
122
|
-
{
|
|
123
|
-
command: 'Set NOTIS_JWT to a fresh token',
|
|
124
|
-
reason: 'The explicit environment credential overrides desktop-managed auth',
|
|
125
|
-
},
|
|
126
|
-
],
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const recovery = getDesktopAuthRecovery(runtime);
|
|
131
|
-
return new CliError({
|
|
132
|
-
code: 'auth_expired',
|
|
133
|
-
message: 'Notis CLI authentication has expired',
|
|
134
|
-
exitCode: EXIT_CODES.auth,
|
|
135
|
-
details: {
|
|
136
|
-
credential_source: 'desktop',
|
|
137
|
-
desktop_running: recovery.desktopRunning,
|
|
138
|
-
desktop_app_name: recovery.appName,
|
|
139
|
-
},
|
|
140
|
-
hints: recovery.hints,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function createInvalidAuthHints(runtime) {
|
|
145
|
-
if (runtime?.credentialKind === 'oauth') {
|
|
146
|
-
return [
|
|
147
|
-
{
|
|
148
|
-
command: 'notis login',
|
|
149
|
-
reason: 'Authorize a new scoped CLI credential',
|
|
150
|
-
},
|
|
151
|
-
];
|
|
152
|
-
}
|
|
153
|
-
if (runtime?.credentialSource === 'env') {
|
|
154
|
-
return [
|
|
155
|
-
{
|
|
156
|
-
command: 'Set NOTIS_JWT to a fresh token',
|
|
157
|
-
reason: 'The explicit environment credential was rejected',
|
|
158
|
-
},
|
|
159
|
-
];
|
|
160
|
-
}
|
|
161
|
-
return getDesktopAuthRecovery(runtime || {}).hints;
|
|
162
|
-
}
|