@epure-sh/cli 0.0.0-stage → 0.1.8

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/lib/auth.js ADDED
@@ -0,0 +1,469 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const http = require('http');
5
+ const https = require('https');
6
+ const crypto = require('crypto');
7
+ const { exec } = require('child_process');
8
+ const readline = require('readline');
9
+
10
+ function getCredentialsPath() {
11
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
12
+ return path.join(configHome, 'epure', 'credentials.json');
13
+ }
14
+
15
+ function readCredentials() {
16
+ const p = getCredentialsPath();
17
+ try {
18
+ if (fs.existsSync(p)) {
19
+ const raw = fs.readFileSync(p, 'utf8');
20
+ return JSON.parse(raw);
21
+ }
22
+ } catch {}
23
+ return { current_instance: '', instances: {} };
24
+ }
25
+
26
+ function writeCredentials(data) {
27
+ const p = getCredentialsPath();
28
+ const dir = path.dirname(p);
29
+ if (!fs.existsSync(dir)) {
30
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
31
+ }
32
+ fs.writeFileSync(p, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
33
+ try {
34
+ fs.chmodSync(p, 0o600);
35
+ } catch {}
36
+ }
37
+
38
+ function normalizeUrl(url) {
39
+ if (!url) return 'https://app.epure.sh';
40
+ let u = url.trim().replace(/\/+$/, '');
41
+ if (!u.startsWith('http://') && !u.startsWith('https://')) {
42
+ u = `https://${u}`;
43
+ }
44
+ return u;
45
+ }
46
+
47
+ function getActiveCredentials(targetUrl) {
48
+ const creds = readCredentials();
49
+ const normTarget = targetUrl ? normalizeUrl(targetUrl) : '';
50
+ const current = normTarget || creds.current_instance || 'https://app.epure.sh';
51
+ const instance = creds.instances ? creds.instances[current] : null;
52
+ if (!instance && !normTarget && creds.instances) {
53
+ const keys = Object.keys(creds.instances);
54
+ if (keys.length > 0) {
55
+ return { url: keys[0], ...creds.instances[keys[0]] };
56
+ }
57
+ }
58
+ return instance ? { url: current, ...instance } : null;
59
+ }
60
+
61
+ function saveInstance(url, data) {
62
+ const normUrl = normalizeUrl(url);
63
+ const creds = readCredentials();
64
+ if (!creds.instances) creds.instances = {};
65
+ creds.instances[normUrl] = {
66
+ url: normUrl,
67
+ access_token: data.access_token,
68
+ refresh_token: data.refresh_token,
69
+ expires_at: data.expires_at,
70
+ org_id: data.org_id,
71
+ user_id: data.user_id,
72
+ scopes: data.scopes || ['read:agent', 'write:triage'],
73
+ };
74
+ creds.current_instance = normUrl;
75
+ writeCredentials(creds);
76
+ }
77
+
78
+ function switchInstance(url) {
79
+ const normUrl = normalizeUrl(url);
80
+ const creds = readCredentials();
81
+ if (!creds.instances || !creds.instances[normUrl]) {
82
+ throw new Error(`Instance "${normUrl}" not found in credentials. Run "epure login --url ${normUrl}" first.`);
83
+ }
84
+ creds.current_instance = normUrl;
85
+ writeCredentials(creds);
86
+ return normUrl;
87
+ }
88
+
89
+ function removeInstance(url) {
90
+ const creds = readCredentials();
91
+ const target = url ? normalizeUrl(url) : creds.current_instance;
92
+ if (creds.instances && creds.instances[target]) {
93
+ delete creds.instances[target];
94
+ }
95
+ if (creds.current_instance === target) {
96
+ const remaining = Object.keys(creds.instances || {});
97
+ creds.current_instance = remaining.length > 0 ? remaining[0] : '';
98
+ }
99
+ writeCredentials(creds);
100
+ return target;
101
+ }
102
+
103
+ function listInstances() {
104
+ const creds = readCredentials();
105
+ return {
106
+ current_instance: creds.current_instance || '',
107
+ instances: Object.keys(creds.instances || {}),
108
+ };
109
+ }
110
+
111
+ function base64url(buf) {
112
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
113
+ }
114
+
115
+ function openBrowser(url) {
116
+ const platform = process.platform;
117
+ let cmd;
118
+ if (platform === 'darwin') {
119
+ cmd = `open "${url}"`;
120
+ } else if (platform === 'win32') {
121
+ cmd = `start "" "${url}"`;
122
+ } else {
123
+ cmd = `xdg-open "${url}"`;
124
+ }
125
+ exec(cmd, () => {});
126
+ }
127
+
128
+ function postJson(fullUrl, body) {
129
+ return new Promise((resolve, reject) => {
130
+ const urlObj = new URL(fullUrl);
131
+ const client = urlObj.protocol === 'https:' ? https : http;
132
+ const payload = JSON.stringify(body);
133
+
134
+ const req = client.request(
135
+ fullUrl,
136
+ {
137
+ method: 'POST',
138
+ headers: {
139
+ 'Content-Type': 'application/json',
140
+ 'Content-Length': Buffer.byteLength(payload),
141
+ 'User-Agent': 'epure-auth/0.1.7',
142
+ },
143
+ },
144
+ (res) => {
145
+ let resBody = '';
146
+ res.on('data', (c) => (resBody += c));
147
+ res.on('end', () => {
148
+ try {
149
+ const data = JSON.parse(resBody);
150
+ if (res.statusCode >= 200 && res.statusCode < 300) {
151
+ resolve(data);
152
+ } else {
153
+ reject(new Error(data.error_description || data.error || `HTTP ${res.statusCode}: ${resBody}`));
154
+ }
155
+ } catch {
156
+ if (res.statusCode >= 200 && res.statusCode < 300) {
157
+ resolve({});
158
+ } else {
159
+ reject(new Error(`HTTP ${res.statusCode}: ${resBody}`));
160
+ }
161
+ }
162
+ });
163
+ }
164
+ );
165
+
166
+ req.on('error', reject);
167
+ req.write(payload);
168
+ req.end();
169
+ });
170
+ }
171
+
172
+ async function ensureValidToken(instanceData, forceRefresh = false) {
173
+ if (!instanceData || !instanceData.refresh_token) {
174
+ return instanceData?.access_token || null;
175
+ }
176
+ const nowSec = Math.floor(Date.now() / 1000);
177
+ const expiresAt = instanceData.expires_at || 0;
178
+ // Refresh if under 48 hours left or forced
179
+ if (!forceRefresh && expiresAt > nowSec + 172800) {
180
+ return instanceData.access_token;
181
+ }
182
+
183
+ try {
184
+ const res = await postJson(`${instanceData.url}/oauth/token`, {
185
+ grant_type: 'refresh_token',
186
+ refresh_token: instanceData.refresh_token,
187
+ });
188
+ if (res.access_token) {
189
+ instanceData.access_token = res.access_token;
190
+ if (res.refresh_token) {
191
+ instanceData.refresh_token = res.refresh_token;
192
+ }
193
+ instanceData.expires_at = nowSec + (res.expires_in || 2592000);
194
+ saveInstance(instanceData.url, instanceData);
195
+ return instanceData.access_token;
196
+ }
197
+ } catch (err) {
198
+ if (expiresAt > nowSec) {
199
+ return instanceData.access_token;
200
+ }
201
+ throw err;
202
+ }
203
+ return instanceData.access_token;
204
+ }
205
+
206
+ function promptLine(query) {
207
+ const rl = readline.createInterface({
208
+ input: process.stdin,
209
+ output: process.stdout,
210
+ });
211
+ return new Promise((resolve) => {
212
+ rl.question(query, (ans) => {
213
+ rl.close();
214
+ resolve(ans.trim());
215
+ });
216
+ });
217
+ }
218
+
219
+ async function selectInstanceUrl(passedUrl) {
220
+ if (passedUrl) {
221
+ return normalizeUrl(passedUrl);
222
+ }
223
+
224
+ if (!process.stdin.isTTY) {
225
+ return 'https://app.epure.sh';
226
+ }
227
+
228
+ console.log('\nSelect Epure instance:');
229
+ console.log(' 1) Epure Cloud (https://app.epure.sh) [default]');
230
+ console.log(' 2) Localhost (http://localhost:8080)');
231
+ console.log(' 3) Custom Domain / VPS');
232
+ const ans = await promptLine('Choose [1-3, default 1]: ');
233
+
234
+ if (ans === '2') {
235
+ return 'http://localhost:8080';
236
+ } else if (ans === '3') {
237
+ const custom = await promptLine('Enter custom instance URL (e.g. https://errors.mycompany.com): ');
238
+ return normalizeUrl(custom || 'https://app.epure.sh');
239
+ }
240
+ return 'https://app.epure.sh';
241
+ }
242
+
243
+ async function loginLoopback(targetUrl, clientId, clientName) {
244
+ return new Promise((resolve, reject) => {
245
+ const verifier = base64url(crypto.randomBytes(32));
246
+ const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
247
+ const state = crypto.randomBytes(16).toString('hex');
248
+
249
+ const server = http.createServer(async (req, res) => {
250
+ try {
251
+ const parsed = new URL(req.url, 'http://127.0.0.1');
252
+ if (parsed.pathname !== '/callback') {
253
+ res.writeHead(404);
254
+ res.end();
255
+ return;
256
+ }
257
+
258
+ const code = parsed.searchParams.get('code');
259
+ const returnedState = parsed.searchParams.get('state');
260
+ const error = parsed.searchParams.get('error');
261
+
262
+ if (error) {
263
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
264
+ res.end(`
265
+ <html><body style="font-family: sans-serif; background: #0c0d0e; color: #f87171; padding: 40px; text-align: center;">
266
+ <h2>Authorization Denied</h2>
267
+ <p>${parsed.searchParams.get('error_description') || error}</p>
268
+ </body></html>
269
+ `);
270
+ server.close();
271
+ return reject(new Error(`Authorization failed: ${error}`));
272
+ }
273
+
274
+ if (returnedState !== state) {
275
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
276
+ res.end('State mismatch error.');
277
+ server.close();
278
+ return reject(new Error('OAuth state mismatch'));
279
+ }
280
+
281
+ if (!code) {
282
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
283
+ res.end('Missing code parameter.');
284
+ server.close();
285
+ return reject(new Error('Missing authorization code'));
286
+ }
287
+
288
+ // Exchange code for tokens
289
+ const port = server.address().port;
290
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
291
+
292
+ const tokenRes = await postJson(`${targetUrl}/oauth/token`, {
293
+ grant_type: 'authorization_code',
294
+ code,
295
+ code_verifier: verifier,
296
+ redirect_uri: redirectUri,
297
+ client_id: clientId,
298
+ });
299
+
300
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
301
+ res.end(`
302
+ <!DOCTYPE html>
303
+ <html>
304
+ <head>
305
+ <meta charset="utf-8">
306
+ <title>Epure - Authenticated</title>
307
+ <style>
308
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0c0d0e; color: #f0f0f0; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
309
+ .box { background: #16181a; border: 1px solid #2a2d32; border-radius: 12px; padding: 40px; text-align: center; max-width: 400px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
310
+ h1 { color: #fff; font-size: 20px; margin: 0 0 10px; }
311
+ p { color: #888; font-size: 14px; margin: 0; }
312
+ .icon { color: #10b981; font-size: 40px; margin-bottom: 16px; }
313
+ </style>
314
+ </head>
315
+ <body>
316
+ <div class="box">
317
+ <div class="icon">✓</div>
318
+ <h1>Authenticated with Epure</h1>
319
+ <p>You can close this tab and return to your terminal.</p>
320
+ </div>
321
+ </body>
322
+ </html>
323
+ `);
324
+
325
+ server.close();
326
+
327
+ const nowSec = Math.floor(Date.now() / 1000);
328
+ saveInstance(targetUrl, {
329
+ access_token: tokenRes.access_token,
330
+ refresh_token: tokenRes.refresh_token,
331
+ expires_at: nowSec + (tokenRes.expires_in || 2592000),
332
+ org_id: tokenRes.org_id,
333
+ user_id: tokenRes.user_id,
334
+ scopes: (tokenRes.scope || 'read:agent write:triage').split(' '),
335
+ });
336
+
337
+ resolve({
338
+ url: targetUrl,
339
+ org_id: tokenRes.org_id,
340
+ user_id: tokenRes.user_id,
341
+ });
342
+ } catch (err) {
343
+ server.close();
344
+ reject(err);
345
+ }
346
+ });
347
+
348
+ server.listen(0, '127.0.0.1', () => {
349
+ const port = server.address().port;
350
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
351
+ const authUrl = `${targetUrl}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&client_name=${encodeURIComponent(clientName)}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256&scope=${encodeURIComponent(process.env.EPURE_SCOPES || 'read:agent write:triage')}&state=${state}`;
352
+
353
+ console.log(`\nOpening browser to authorize with Epure (${targetUrl})...`);
354
+ console.log(`If your browser does not open automatically, visit:`);
355
+ console.log(` ${authUrl}\n`);
356
+
357
+ openBrowser(authUrl);
358
+ });
359
+
360
+ server.on('error', reject);
361
+
362
+ // 2-minute timeout for authorization
363
+ const timer = setTimeout(() => {
364
+ server.close();
365
+ reject(new Error('Browser authorization timed out (120s)'));
366
+ }, 120000);
367
+
368
+ server.on('close', () => {
369
+ clearTimeout(timer);
370
+ });
371
+ });
372
+ }
373
+
374
+ async function loginDeviceCode(targetUrl, clientId, clientName) {
375
+ const initRes = await postJson(`${targetUrl}/oauth/device/code`, {
376
+ client_id: clientId,
377
+ client_name: clientName,
378
+ scope: process.env.EPURE_SCOPES || 'read:agent write:triage',
379
+ });
380
+
381
+ const { device_code, user_code, verification_uri, verification_uri_complete, interval = 5, expires_in = 600 } = initRes;
382
+
383
+ const authUrl = verification_uri_complete || `${verification_uri}?user_code=${user_code}`;
384
+ console.log(`\nTo authenticate this device, open your browser and visit:`);
385
+ console.log(` ${authUrl}`);
386
+ console.log(`Or visit ${verification_uri} and enter code:`);
387
+ console.log(` ${user_code}\n`);
388
+
389
+ openBrowser(authUrl);
390
+
391
+ const pollIntervalMs = Math.max(interval, 2) * 1000;
392
+ const deadline = Date.now() + expires_in * 1000;
393
+
394
+ process.stdout.write('Waiting for approval in browser...');
395
+
396
+ while (Date.now() < deadline) {
397
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
398
+ process.stdout.write('.');
399
+
400
+ try {
401
+ const tokenRes = await postJson(`${targetUrl}/oauth/token`, {
402
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
403
+ device_code,
404
+ client_id: clientId,
405
+ });
406
+
407
+ if (tokenRes.access_token) {
408
+ console.log(' Done!');
409
+ const nowSec = Math.floor(Date.now() / 1000);
410
+ saveInstance(targetUrl, {
411
+ access_token: tokenRes.access_token,
412
+ refresh_token: tokenRes.refresh_token,
413
+ expires_at: nowSec + (tokenRes.expires_in || 2592000),
414
+ org_id: tokenRes.org_id,
415
+ user_id: tokenRes.user_id,
416
+ scopes: (tokenRes.scope || 'read:agent write:triage').split(' '),
417
+ });
418
+
419
+ return {
420
+ url: targetUrl,
421
+ org_id: tokenRes.org_id,
422
+ user_id: tokenRes.user_id,
423
+ };
424
+ }
425
+ } catch (err) {
426
+ if (err.message.includes('authorization_pending')) {
427
+ continue;
428
+ }
429
+ console.log('');
430
+ throw err;
431
+ }
432
+ }
433
+
434
+ console.log('');
435
+ throw new Error('Device activation expired. Please run login again.');
436
+ }
437
+
438
+ async function login(options = {}) {
439
+ const targetUrl = await selectInstanceUrl(options.url);
440
+ const clientId = options.clientId || 'epure-cli';
441
+ const clientName = options.clientName || `${clientId} (${os.hostname()})`;
442
+
443
+ const useDevice = options.device || Boolean(process.env.SSH_CLIENT) || !process.stdin.isTTY;
444
+
445
+ if (useDevice) {
446
+ return loginDeviceCode(targetUrl, clientId, clientName);
447
+ }
448
+
449
+ try {
450
+ return await loginLoopback(targetUrl, clientId, clientName);
451
+ } catch (err) {
452
+ console.log(`Loopback failed (${err.message}). Falling back to Device Code Flow...`);
453
+ return await loginDeviceCode(targetUrl, clientId, clientName);
454
+ }
455
+ }
456
+
457
+ module.exports = {
458
+ getCredentialsPath,
459
+ readCredentials,
460
+ writeCredentials,
461
+ normalizeUrl,
462
+ getActiveCredentials,
463
+ saveInstance,
464
+ switchInstance,
465
+ removeInstance,
466
+ listInstances,
467
+ ensureValidToken,
468
+ login,
469
+ };
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@epure-sh/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.8",
4
+ "description": "Official CLI for Epure: error triage, issue queues, demangled stack traces, and Agent API inspection",
5
+ "bin": {
6
+ "epure": "./bin/epure-cli.js",
7
+ "epure-cli": "./bin/epure-cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18.0.0"
16
+ },
17
+ "keywords": [
18
+ "epure",
19
+ "cli",
20
+ "error-tracking",
21
+ "sentry",
22
+ "triage",
23
+ "monitoring",
24
+ "telemetry",
25
+ "developer-tools"
26
+ ],
27
+ "author": "Epure <team@epure.sh>",
28
+ "license": "Apache-2.0",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/epure-sh/epure.git",
32
+ "directory": "packages/epure-cli"
33
+ }
34
+ }