@unifiedmemory/cli 1.3.12 → 1.3.14

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/.env.example CHANGED
@@ -37,6 +37,24 @@
37
37
  # REDIRECT_URI=http://localhost:3333/callback
38
38
  # PORT=3333
39
39
 
40
+ # ============================================
41
+ # OAuth Success Page Link
42
+ # ============================================
43
+ # Optional website link shown on the OAuth success page
44
+ # Users can visit this URL to manage their account or subscription
45
+ # Default: https://unifiedmemory.ai/oauth/callback
46
+ # OAUTH_SUCCESS_URL=https://unifiedmemory.ai/oauth/callback
47
+
48
+ # ============================================
49
+ # Frontend URL (CLI Auth Page)
50
+ # ============================================
51
+ # URL of the frontend app that hosts the CLI auth page with org picker
52
+ # During login, the browser opens this URL where users can select an organization
53
+ # before being redirected to Clerk OAuth
54
+ # Default: https://unifiedmemory.ai
55
+ # For local development: http://localhost:3000
56
+ # FRONTEND_URL=https://unifiedmemory.ai
57
+
40
58
  # ============================================
41
59
  # Clerk Client Secret (Optional)
42
60
  # ============================================
package/README.md CHANGED
@@ -106,6 +106,34 @@ Project Configuration:
106
106
  āœ“ Configured: My Project (proj_xxx)
107
107
  ```
108
108
 
109
+ ### `um usage`
110
+ Check your current usage and quota allowances.
111
+
112
+ ```bash
113
+ um usage
114
+ ```
115
+
116
+ **Example output:**
117
+ ```
118
+ šŸ“Š Usage & Quota
119
+
120
+ Account:
121
+ Organization: My Team
122
+
123
+ Monthly Queries:
124
+ 450 / 1,000 (45.0%)
125
+ [ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘ā–‘]
126
+ Remaining: 550 queries
127
+ Resets: 2/1/2026
128
+ ```
129
+
130
+ Context-aware: Shows personal or organization quota based on current context (`um org switch`).
131
+
132
+ **Color indicators:**
133
+ - 🟢 Green: <70% usage
134
+ - 🟔 Yellow: 70-89% usage
135
+ - šŸ”“ Red: ≄90% usage (with upgrade prompt)
136
+
109
137
  ### `um org switch`
110
138
  Switch between your organizations or personal account.
111
139
 
@@ -0,0 +1,67 @@
1
+ import chalk from 'chalk';
2
+ import { getToken, getOrgContext } from '../lib/token-storage.js';
3
+ import { isTokenExpired } from '../lib/token-refresh.js';
4
+
5
+ /**
6
+ * Debug authentication and token issues
7
+ * Displays comprehensive diagnostics about the current token state
8
+ * JWT is the single source of truth for org context
9
+ */
10
+ export async function debugAuth() {
11
+ console.log(chalk.blue('\nšŸ” Token Diagnostics\n'));
12
+
13
+ const tokenData = getToken();
14
+
15
+ if (!tokenData) {
16
+ console.log(chalk.red('āŒ No token found'));
17
+ console.log(chalk.gray(' Run: um login'));
18
+ return;
19
+ }
20
+
21
+ // Check expiration
22
+ const expired = isTokenExpired(tokenData);
23
+ console.log(chalk.yellow('Token Expiration:'));
24
+ console.log(expired ? chalk.red(' āŒ Expired') : chalk.green(' āœ“ Valid'));
25
+ if (tokenData.decoded?.exp) {
26
+ const expDate = new Date(tokenData.decoded.exp * 1000);
27
+ console.log(chalk.gray(` Expires: ${expDate.toLocaleString()}`));
28
+ }
29
+
30
+ // Check org context from JWT claims (single source of truth)
31
+ console.log(chalk.yellow('\nOrganization Context (from JWT):'));
32
+ const orgContext = getOrgContext();
33
+
34
+ if (orgContext) {
35
+ console.log(chalk.green(` āœ“ Organization: ${orgContext.name} (${orgContext.id})`));
36
+ console.log(chalk.gray(` Role: ${orgContext.role || 'member'}`));
37
+ if (orgContext.slug) {
38
+ console.log(chalk.gray(` Slug: ${orgContext.slug}`));
39
+ }
40
+ } else {
41
+ console.log(chalk.cyan(' Personal Account (no org claims in JWT)'));
42
+ }
43
+
44
+ // Check session ID
45
+ console.log(chalk.yellow('\nSession ID:'));
46
+ const hasSid = tokenData.decoded?.sid;
47
+ console.log(hasSid ? chalk.green(' āœ“ Present') : chalk.yellow(' ⚠ Missing'));
48
+ if (hasSid) {
49
+ console.log(chalk.gray(` SID: ${tokenData.decoded.sid}`));
50
+ } else {
51
+ console.log(chalk.gray(' Note: Some org-scoped tokens may not have sid'));
52
+ }
53
+
54
+ // Check user ID
55
+ console.log(chalk.yellow('\nUser ID:'));
56
+ if (tokenData.decoded?.sub) {
57
+ console.log(chalk.green(` āœ“ ${tokenData.decoded.sub}`));
58
+ } else {
59
+ console.log(chalk.red(' āŒ Missing'));
60
+ }
61
+
62
+ // Check refresh token
63
+ console.log(chalk.yellow('\nRefresh Token:'));
64
+ console.log(tokenData.refresh_token ? chalk.green(' āœ“ Present') : chalk.red(' āŒ Missing'));
65
+
66
+ console.log('');
67
+ }
package/commands/init.js CHANGED
@@ -65,8 +65,9 @@ export async function init(options = {}) {
65
65
  }
66
66
 
67
67
  console.log(chalk.green(`āœ“ Logged in as: ${authData.user_id}`));
68
- if (authData.org_id) {
69
- console.log(chalk.green(`āœ“ Organization: ${authData.org_id}`));
68
+ if (authData.org_id && authData.org_id !== authData.user_id) {
69
+ const orgDisplay = authData.org_name || authData.org_id;
70
+ console.log(chalk.green(`āœ“ Organization: ${orgDisplay}`));
70
71
  }
71
72
 
72
73
  // Step 1.5: Check for existing config
@@ -166,14 +167,17 @@ async function ensureAuthenticated(options) {
166
167
  if (stored && stored.decoded) {
167
168
  console.log(chalk.gray('Using saved session'));
168
169
 
169
- // Extract user_id and org_id from token
170
+ // Extract user_id and org_id from JWT claims, with selectedOrgId fallback
170
171
  const userId = stored.decoded.sub;
171
- const orgId = stored.selectedOrg?.id || userId; // Fallback to user_id if no org
172
+ // Use org from JWT o.* claims, fallback to selectedOrgId from login, then userId for personal context
173
+ const orgId = stored.decoded.o?.o_id || stored.selectedOrgId || userId;
174
+ const orgName = stored.decoded.o?.o_name || stored.selectedOrgName || null;
172
175
  const expirationTime = stored.decoded.exp * 1000;
173
176
 
174
177
  return {
175
178
  user_id: userId,
176
179
  org_id: orgId,
180
+ org_name: orgName,
177
181
  access_token: stored.idToken || stored.accessToken,
178
182
  api_url: 'https://rose-asp-main-1c0b114.d2.zuplo.dev',
179
183
  expires_at: expirationTime,
@@ -189,14 +193,17 @@ async function ensureAuthenticated(options) {
189
193
  return null;
190
194
  }
191
195
 
192
- // Extract from saved token
196
+ // Extract from saved token (JWT claims with selectedOrgId fallback)
193
197
  const savedToken = getToken();
194
198
  const userId = savedToken?.decoded?.sub;
195
- const orgId = savedToken?.selectedOrg?.id || userId;
199
+ // Use org from JWT o.* claims, fallback to selectedOrgId from login, then userId for personal context
200
+ const orgId = savedToken?.decoded?.o?.o_id || savedToken?.selectedOrgId || userId;
201
+ const orgName = savedToken?.decoded?.o?.o_name || savedToken?.selectedOrgName || null;
196
202
 
197
203
  return {
198
204
  user_id: userId,
199
205
  org_id: orgId,
206
+ org_name: orgName,
200
207
  access_token: savedToken.idToken || savedToken.accessToken,
201
208
  api_url: 'https://rose-asp-main-1c0b114.d2.zuplo.dev',
202
209
  expires_at: savedToken.decoded?.exp * 1000,
@@ -244,10 +251,12 @@ async function selectOrCreateProject(authData, options) {
244
251
  return null;
245
252
  }
246
253
 
247
- // Update authData with fresh credentials
254
+ // Update authData with fresh credentials (JWT claims with selectedOrgId fallback)
248
255
  const savedToken = getToken();
249
256
  authData.user_id = savedToken.decoded.sub;
250
- authData.org_id = savedToken.selectedOrg?.id || authData.user_id;
257
+ // Use org from JWT o.* claims, fallback to selectedOrgId from login, then userId for personal context
258
+ authData.org_id = savedToken.decoded?.o?.o_id || savedToken?.selectedOrgId || authData.user_id;
259
+ authData.org_name = savedToken.decoded?.o?.o_name || savedToken?.selectedOrgName || null;
251
260
  authData.access_token = savedToken.idToken || savedToken.accessToken;
252
261
  authData.expires_at = savedToken.decoded?.exp * 1000;
253
262
 
package/commands/login.js CHANGED
@@ -3,12 +3,9 @@ import { URL } from 'url';
3
3
  import open from 'open';
4
4
  import chalk from 'chalk';
5
5
  import crypto from 'crypto';
6
- import inquirer from 'inquirer';
7
6
  import { config, validateConfig } from '../lib/config.js';
8
- import { saveToken, updateSelectedOrg } from '../lib/token-storage.js';
9
- import { getUserOrganizations, getOrganizationsFromToken, getOrgScopedToken } from '../lib/clerk-api.js';
7
+ import { saveToken } from '../lib/token-storage.js';
10
8
  import { parseJWT } from '../lib/jwt-utils.js';
11
- import { promptOrganizationSelection, displayOrganizationSelection } from '../lib/org-selection-ui.js';
12
9
 
13
10
  function generateRandomState() {
14
11
  // Use cryptographically secure random bytes for CSRF protection
@@ -38,11 +35,10 @@ export async function login() {
38
35
  const state = generateRandomState();
39
36
  const pkce = generatePKCE();
40
37
 
41
- // Build OAuth2 authorization URL with PKCE
42
- const authUrl = new URL(`https://${config.clerkDomain}/oauth/authorize`);
38
+ // Build URL to frontend CLI auth page (which shows org picker before Clerk OAuth)
39
+ const authUrl = new URL(`${config.frontendUrl}/cli-auth`);
43
40
  authUrl.searchParams.append('client_id', config.clerkClientId);
44
41
  authUrl.searchParams.append('redirect_uri', config.redirectUri);
45
- authUrl.searchParams.append('response_type', 'code');
46
42
  authUrl.searchParams.append('scope', 'openid profile email');
47
43
  authUrl.searchParams.append('state', state);
48
44
  authUrl.searchParams.append('code_challenge', pkce.challenge);
@@ -76,7 +72,23 @@ export async function login() {
76
72
  return;
77
73
  }
78
74
 
79
- if (returnedState !== state) {
75
+ // Parse state to extract CSRF token, org_id, and org_name
76
+ // State format: base64({ csrf: string, org_id: string|null, org_name: string|null })
77
+ let csrfState = returnedState;
78
+ let selectedOrgId = null;
79
+ let selectedOrgName = null;
80
+
81
+ try {
82
+ const stateData = JSON.parse(atob(returnedState));
83
+ csrfState = stateData.csrf;
84
+ selectedOrgId = stateData.org_id;
85
+ selectedOrgName = stateData.org_name;
86
+ } catch (e) {
87
+ // Old format - state is just the CSRF token
88
+ csrfState = returnedState;
89
+ }
90
+
91
+ if (csrfState !== state) {
80
92
  res.writeHead(400, { 'Content-Type': 'text/html' });
81
93
  res.end(`
82
94
  <html>
@@ -143,24 +155,111 @@ export async function login() {
143
155
  // Debug: Log the token response structure
144
156
  console.log(chalk.gray('\nToken response keys:'), Object.keys(tokenData));
145
157
 
146
- // Send success response to browser first
158
+ // Send success response to browser
147
159
  res.writeHead(200, { 'Content-Type': 'text/html' });
148
160
  res.end(`
149
- <html>
150
- <body style="font-family: system-ui; padding: 2rem; text-align: center;">
151
- <h1 style="color: #10B981;">āœ… Authentication Successful!</h1>
152
- <p>You have successfully authenticated with Clerk.</p>
153
- <p>You can close this window and return to the CLI.</p>
154
- <script>setTimeout(() => window.close(), 3000);</script>
161
+ <!DOCTYPE html>
162
+ <html lang="en">
163
+ <head>
164
+ <meta charset="UTF-8">
165
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
166
+ <title>Login Successful</title>
167
+ <link rel="preconnect" href="https://fonts.googleapis.com">
168
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
169
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
170
+ <style>
171
+ * {
172
+ margin: 0;
173
+ padding: 0;
174
+ box-sizing: border-box;
175
+ }
176
+ body {
177
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
178
+ background: hsl(220, 30%, 8%);
179
+ color: hsl(0, 0%, 100%);
180
+ min-height: 100vh;
181
+ display: flex;
182
+ align-items: center;
183
+ justify-content: center;
184
+ padding: 2rem;
185
+ }
186
+ .card {
187
+ background: hsl(220, 25%, 12%);
188
+ border: 1px solid hsl(220, 20%, 20%);
189
+ border-radius: 0.5rem;
190
+ padding: 2rem;
191
+ text-align: center;
192
+ max-width: 32rem;
193
+ width: 100%;
194
+ }
195
+ .logo {
196
+ width: 64px;
197
+ height: 64px;
198
+ margin: 0 auto 1.5rem;
199
+ display: block;
200
+ }
201
+ h1 {
202
+ font-size: 1.875rem;
203
+ font-weight: 700;
204
+ margin-bottom: 0.5rem;
205
+ color: hsl(0, 0%, 100%);
206
+ }
207
+ .description {
208
+ font-size: 1.125rem;
209
+ color: hsl(0, 0%, 65%);
210
+ margin-bottom: 1.5rem;
211
+ }
212
+ .message {
213
+ color: hsl(0, 0%, 65%);
214
+ line-height: 1.6;
215
+ }
216
+ .optional-link {
217
+ margin-top: 2rem;
218
+ padding-top: 1.5rem;
219
+ border-top: 1px solid hsl(220, 20%, 20%);
220
+ font-size: 0.8125rem;
221
+ color: hsl(0, 0%, 50%);
222
+ line-height: 1.5;
223
+ }
224
+ .optional-link a {
225
+ color: hsl(0, 0%, 55%);
226
+ text-decoration: none;
227
+ border-bottom: 1px solid transparent;
228
+ transition: border-color 0.2s;
229
+ }
230
+ .optional-link a:hover {
231
+ border-bottom-color: hsl(0, 0%, 55%);
232
+ }
233
+ </style>
234
+ </head>
235
+ <body>
236
+ <div class="card">
237
+ <img src="https://unifiedmemory.ai/images/theme/axolotl-logo.png" alt="UnifiedMemory.ai Logo" class="logo" />
238
+ <h1>Login Successful</h1>
239
+ <p class="description">Your authentication is complete.</p>
240
+ <p class="message">You may now close this tab and return to the terminal to continue using the CLI.</p>
241
+ <div class="optional-link">
242
+ You can manage your account or subscription on the web at<br>
243
+ <a href="https://unifiedmemory.ai/account">unifiedmemory.ai</a>
244
+ </div>
245
+ </div>
246
+ <script>
247
+ // Auto-close window after 10 seconds
248
+ setTimeout(() => {
249
+ window.close();
250
+ }, 10000);
251
+ </script>
155
252
  </body>
156
253
  </html>
157
254
  `);
158
255
 
159
256
  // Parse JWT for user info - do not log tokens
257
+ // The id_token should already have org claims because the frontend
258
+ // called setActive() before OAuth redirect
160
259
  const tokenToParse = tokenData.id_token || tokenData.access_token;
161
260
  const decoded = parseJWT(tokenToParse);
162
261
 
163
- // Save token (save both access_token and id_token if available)
262
+ // Save token with selected org from state (since JWT doesn't have org claims)
164
263
  saveToken({
165
264
  accessToken: tokenData.access_token,
166
265
  idToken: tokenData.id_token,
@@ -168,7 +267,10 @@ export async function login() {
168
267
  tokenType: tokenData.token_type || 'Bearer',
169
268
  expiresIn: tokenData.expires_in,
170
269
  receivedAt: Date.now(),
171
- decoded: decoded
270
+ decoded: decoded,
271
+ sessionId: decoded?.sid,
272
+ selectedOrgId: selectedOrgId, // From state parameter
273
+ selectedOrgName: selectedOrgName, // From state parameter
172
274
  });
173
275
 
174
276
  console.log(chalk.green('\nāœ… Authentication successful!'));
@@ -184,95 +286,30 @@ export async function login() {
184
286
  }
185
287
  }
186
288
 
187
- // Close server first
188
- server.close(async () => {
289
+ // Close server and display org context
290
+ server.close(() => {
189
291
  console.log(chalk.gray('āœ“ Callback server closed'));
190
292
 
191
- // Prompt for organization selection
192
- try {
193
- const userId = decoded?.sub;
194
- if (userId) {
195
- // First try to get organizations from JWT token
196
- let memberships = getOrganizationsFromToken(decoded);
197
-
198
- // If not in JWT, fetch from Clerk Frontend API
199
- if (memberships.length === 0) {
200
- const sessionToken = tokenData.id_token || tokenData.access_token;
201
- memberships = await getUserOrganizations(userId, sessionToken);
202
- }
203
-
204
- console.log(chalk.blue('\nšŸ” Checking for organizations...'));
205
- const selectedOrg = await promptOrganizationSelection(memberships);
206
- displayOrganizationSelection(selectedOrg);
207
-
208
- if (selectedOrg) {
209
- // Get org-scoped JWT from Clerk
210
- try {
211
- console.log(chalk.cyan('\nšŸ”„ Getting organization-scoped token...'));
212
-
213
- const sessionId = decoded.sid;
214
- if (!sessionId) {
215
- throw new Error('No session ID found in token');
216
- }
217
-
218
- const orgToken = await getOrgScopedToken(
219
- sessionId,
220
- selectedOrg.id,
221
- tokenData.id_token
222
- );
223
-
224
- // Update saved token with org-scoped version
225
- // Preserve originalSid for recovery purposes (org-scoped token won't have sid)
226
- saveToken({
227
- accessToken: tokenData.access_token,
228
- idToken: orgToken.jwt,
229
- refresh_token: tokenData.refresh_token,
230
- tokenType: 'Bearer',
231
- expiresIn: tokenData.expires_in,
232
- receivedAt: Date.now(),
233
- decoded: parseJWT(orgToken.jwt),
234
- selectedOrg: selectedOrg,
235
- originalSid: decoded.sid // Preserve session ID from original OAuth token
236
- });
237
-
238
- console.log(chalk.green(`\nāœ… Using organization context: ${chalk.bold(selectedOrg.name)}`));
239
- console.log(chalk.gray(` Organization ID: ${selectedOrg.id}`));
240
- console.log(chalk.gray(` Your role: ${selectedOrg.role}`));
241
- console.log(chalk.gray(' āœ“ Token updated with organization context'));
242
- } catch (error) {
243
- console.error(chalk.yellow('\nāš ļø Failed to get org-scoped token:'), error.message);
244
- console.log(chalk.gray(' Continuing with original token (may have limited org access)'));
245
-
246
- // Save token with selectedOrg AND originalSid for recovery
247
- // This allows token-validation.js to retry getting org-scoped token later
248
- saveToken({
249
- accessToken: tokenData.access_token,
250
- idToken: tokenData.id_token,
251
- refresh_token: tokenData.refresh_token,
252
- tokenType: 'Bearer',
253
- expiresIn: tokenData.expires_in,
254
- receivedAt: Date.now(),
255
- decoded: decoded,
256
- selectedOrg: selectedOrg,
257
- originalSid: decoded.sid // Preserve session ID for recovery
258
- });
259
-
260
- console.log(chalk.green(`\nāœ… Using organization context: ${chalk.bold(selectedOrg.name)}`));
261
- console.log(chalk.gray(` Organization ID: ${selectedOrg.id}`));
262
- console.log(chalk.gray(` Your role: ${selectedOrg.role}`));
263
- console.log(chalk.yellow(' āš ļø Token lacks org claims - recovery will be attempted on next use'));
264
- }
265
- } else {
266
- console.log(chalk.green('\nāœ… Using personal account context'));
267
- }
268
-
269
- console.log(chalk.gray('\nYou can switch organizations anytime with: um org switch'));
293
+ // Display organization context - prefer JWT claims, fall back to selected org from state
294
+ // Support both flat claims (org_id) and nested claims (o.o_id) from Clerk JWT templates
295
+ const jwtOrgId = decoded?.org_id || decoded?.o?.o_id;
296
+ const orgId = jwtOrgId || selectedOrgId;
297
+ const orgName = decoded?.org_name || decoded?.o?.o_name || selectedOrgName;
298
+ const orgSlug = decoded?.org_slug || decoded?.o?.o_slug;
299
+ const orgRole = decoded?.org_role || decoded?.o?.o_role;
300
+
301
+ if (orgId) {
302
+ console.log(chalk.green(`\nāœ… Organization context: ${chalk.bold(orgName || orgSlug || orgId)}`));
303
+ console.log(chalk.gray(` Organization ID: ${orgId}`));
304
+ if (orgRole) {
305
+ console.log(chalk.gray(` Your role: ${orgRole}`));
270
306
  }
271
- } catch (error) {
272
- console.log(chalk.yellow('\nāš ļø Could not fetch organizations. Continuing with personal account context.'));
273
- console.log(chalk.gray(`Error: ${error.message}`));
307
+ } else {
308
+ console.log(chalk.green('\nāœ… Using personal account context'));
274
309
  }
275
310
 
311
+ console.log(chalk.gray('\nYou can switch organizations anytime with: um org switch'));
312
+
276
313
  resolve(tokenData);
277
314
  });
278
315
  } catch (error) {