@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.
@@ -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
- getProfile,
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 './desktop-auth.js';
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
- function reloadJwtFromConfig(runtime, loadedProfile = null) {
161
- if (runtime.credentialKind === 'env' || runtime.credentialKind === 'oauth') {
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
- const profileName = runtime.profileName || DEFAULT_PROFILE;
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(runtime.worktreeRuntime), profileName);
207
+ profile = getProfile(loadConfig(), runtime.profileName);
169
208
  } catch {
170
209
  return false;
171
210
  }
172
211
  }
173
- const nextJwt = typeof profile.jwt === 'string' && profile.jwt ? profile.jwt : null;
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 scoped dev credential does not belong to this worktree test user',
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
- // The desktop renderer is the single owner of the rotating Supabase refresh
205
- // token. Each CLI request only consumes the newest access token it synced to
206
- // disk, avoiding refresh-token races between independent CLI processes and
207
- // the running desktop session.
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
- loadConfig(runtime.worktreeRuntime),
222
- runtime.profileName,
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
- : reloadJwtFromConfig(runtime) && !isJwtExpired(runtime.jwt);
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
- getProfile(loadConfig(runtime.worktreeRuntime), runtime.profileName),
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
- }