ticketlens 0.1.5 → 0.1.7

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.
@@ -26,6 +26,7 @@ import {
26
26
  } from '../skills/jtb/scripts/lib/help.mjs';
27
27
  import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
28
28
  import { readCliToken, saveCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
29
+ import { browserLogin } from '../skills/jtb/scripts/lib/browser-login.mjs';
29
30
  import { syncProfiles, getApiBase, getConsoleBase } from '../skills/jtb/scripts/lib/sync.mjs';
30
31
  import { promptSecret, promptText } from '../skills/jtb/scripts/lib/prompt-helpers.mjs';
31
32
 
@@ -277,21 +278,48 @@ switch (command) {
277
278
 
278
279
  case 'login': {
279
280
  if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printLoginHelp(); break; }
281
+
282
+ const useManual = cmdArgs.includes('--manual');
283
+
280
284
  (async () => {
281
285
  const s = createStyler({ isTTY: process.stderr.isTTY });
282
- process.stderr.write(`\n ${s.bold('TicketLens Login')}\n`);
283
- process.stderr.write(` ${s.dim('─'.repeat(44))}\n`);
284
- process.stderr.write(` ${s.dim(`Generate a CLI token at ${s.cyan(`${getConsoleBase()}/console/account`)}`)}\n`);
285
- process.stderr.write(` ${s.dim('then paste it below.')}\n\n`);
286
-
287
- const token = await promptSecret(`CLI Token ${s.dim('(tl_…)')}:`, { stream: process.stderr });
288
- if (!token.startsWith('tl_')) {
289
- process.stderr.write(` ${s.red('✖')} Token must start with ${s.dim('tl_')}\n`);
290
- process.exitCode = 1;
291
- return;
286
+
287
+ let token;
288
+
289
+ if (useManual) {
290
+ // ── manual paste flow (CI / headless environments) ──────────────────
291
+ process.stderr.write(`\n ${s.bold('TicketLens Login')}\n`);
292
+ process.stderr.write(` ${s.dim(''.repeat(44))}\n`);
293
+ process.stderr.write(` ${s.dim(`Generate a CLI token at ${s.cyan(`${getConsoleBase()}/console/account`)}`)}\n`);
294
+ process.stderr.write(` ${s.dim('then paste it below.')}\n\n`);
295
+
296
+ token = await promptSecret(`CLI Token ${s.dim('(tl_…)')}:`, { stream: process.stderr });
297
+ if (!token.startsWith('tl_')) {
298
+ process.stderr.write(` ${s.red('✖')} Token must start with ${s.dim('tl_')}\n`);
299
+ process.exitCode = 1;
300
+ return;
301
+ }
302
+ } else {
303
+ // ── browser flow (default) ────────────────────────────────────────
304
+ process.stderr.write(`\n ${s.bold('TicketLens Login')}\n`);
305
+ process.stderr.write(` ${s.dim('─'.repeat(44))}\n`);
306
+ process.stderr.write(` Opening browser to authorize…\n\n`);
307
+ process.stderr.write(` ${s.dim('○ Waiting for authorization (120s)…')}\n`);
308
+
309
+ try {
310
+ token = await browserLogin();
311
+ } catch (err) {
312
+ const cancelled = err.message === 'Authorization cancelled';
313
+ process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} ${cancelled ? 'Login cancelled.' : err.message}\n`);
314
+ if (!cancelled) {
315
+ process.stderr.write(`\n ${s.dim(`Try ${s.cyan('ticketlens login --manual')} to paste a token instead.`)}\n\n`);
316
+ }
317
+ process.exitCode = cancelled ? 0 : 1;
318
+ return;
319
+ }
292
320
  }
293
321
 
294
- // Validate against the API before saving
322
+ // ── verify token against API (both flows) ─────────────────────────
295
323
  process.stderr.write(`\n ${s.dim('○ Verifying token…')}\n`);
296
324
  let res;
297
325
  try {
@@ -299,7 +327,7 @@ switch (command) {
299
327
  headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
300
328
  signal: AbortSignal.timeout(15000),
301
329
  });
302
- } catch (err) {
330
+ } catch {
303
331
  process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Could not reach ${getApiBase()} — check your connection.\n`);
304
332
  process.exitCode = 1;
305
333
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,121 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { spawn } from 'node:child_process';
4
+ import { hostname as osHostname } from 'node:os';
5
+ import { getConsoleBase } from './sync.mjs';
6
+
7
+ const PORT_MIN = 49152;
8
+ const PORT_MAX = 65535;
9
+ const TIMEOUT_MS = 120_000;
10
+
11
+ export const generateState = () => randomBytes(16).toString('hex');
12
+
13
+ export const pickPort = () =>
14
+ Math.floor(Math.random() * (PORT_MAX - PORT_MIN + 1)) + PORT_MIN;
15
+
16
+ export function openBrowser(url) {
17
+ const cmd = process.platform === 'win32' ? 'cmd'
18
+ : process.platform === 'darwin' ? 'open'
19
+ : 'xdg-open';
20
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
21
+ spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
22
+ }
23
+
24
+ /**
25
+ * Start a one-shot local HTTP server that waits for the CLI auth callback.
26
+ * Resolves with the token string on success; rejects on state mismatch,
27
+ * missing/invalid token, or timeout.
28
+ */
29
+ export function startLocalServer(port, expectedState, timeoutMs = TIMEOUT_MS) {
30
+ return new Promise((resolve, reject) => {
31
+ let settled = false;
32
+
33
+ const settle = (fn, value) => {
34
+ if (settled) return;
35
+ settled = true;
36
+ clearTimeout(timer);
37
+ server.close();
38
+ fn(value);
39
+ };
40
+
41
+ const server = createServer((req, res) => {
42
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
43
+
44
+ if (url.pathname !== '/callback') {
45
+ res.writeHead(404);
46
+ res.end();
47
+ return;
48
+ }
49
+
50
+ const token = url.searchParams.get('token') ?? '';
51
+ const state = url.searchParams.get('state') ?? '';
52
+ const error = url.searchParams.get('error') ?? '';
53
+
54
+ if (state !== expectedState) {
55
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
56
+ res.end('State mismatch — authorization rejected.');
57
+ settle(reject, new Error('State mismatch'));
58
+ return;
59
+ }
60
+
61
+ if (error) {
62
+ res.writeHead(200, { 'Content-Type': 'text/html' });
63
+ res.end(
64
+ '<html><body style="font-family:sans-serif;text-align:center;padding:60px;background:#0d1117;color:#cdd9e5">'
65
+ + '<h2 style="color:#8b949e">Authorization cancelled</h2>'
66
+ + '<p style="color:#8b949e">You can close this tab.</p>'
67
+ + '</body></html>',
68
+ );
69
+ settle(reject, new Error('Authorization cancelled'));
70
+ return;
71
+ }
72
+
73
+ if (!token.startsWith('tl_')) {
74
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
75
+ res.end('Invalid token received.');
76
+ settle(reject, new Error('Invalid token'));
77
+ return;
78
+ }
79
+
80
+ res.writeHead(200, { 'Content-Type': 'text/html' });
81
+ res.end(
82
+ '<html><body style="font-family:sans-serif;text-align:center;padding:60px;background:#0d1117;color:#cdd9e5">'
83
+ + '<h2 style="color:#3fb950">&#10003; Authorized</h2>'
84
+ + '<p style="color:#8b949e">You can close this tab and return to the terminal.</p>'
85
+ + '</body></html>',
86
+ );
87
+
88
+ settle(resolve, token);
89
+ });
90
+
91
+ server.on('error', (err) => settle(reject, err));
92
+
93
+ const timer = setTimeout(
94
+ () => settle(reject, new Error('Authorization timed out after 120 seconds')),
95
+ timeoutMs,
96
+ );
97
+
98
+ server.listen(port, '127.0.0.1');
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Full browser login flow. Opens the authorize page in the default browser,
104
+ * waits for the callback, and returns the plaintext CLI token.
105
+ */
106
+ export async function browserLogin() {
107
+ const state = generateState();
108
+ const port = pickPort();
109
+ const hostname = osHostname();
110
+
111
+ const consoleBase = getConsoleBase();
112
+ const url = `${consoleBase}/console/auth/cli`
113
+ + `?port=${port}`
114
+ + `&state=${encodeURIComponent(state)}`
115
+ + `&hostname=${encodeURIComponent(hostname)}`;
116
+
117
+ const tokenPromise = startLocalServer(port, state);
118
+ openBrowser(url);
119
+
120
+ return tokenPromise;
121
+ }
@@ -219,29 +219,34 @@ export function printLoginHelp({ stream = process.stdout } = {}) {
219
219
  const s = createStyler({ isTTY: stream.isTTY });
220
220
  const lines = [
221
221
  '',
222
- ` ${s.bold(s.brand('ticketlens'))} ${s.bold('login')}`,
222
+ ` ${s.bold(s.brand('ticketlens'))} ${s.bold('login')} ${s.dim('[--manual]')}`,
223
223
  '',
224
- ` Connect the CLI to your TicketLens account using a CLI token.`,
225
- ` Validates the token against the API before saving it locally.`,
224
+ ` Connect the CLI to your TicketLens account.`,
225
+ ` Opens a browser window to authorize no copy-pasting required.`,
226
226
  '',
227
227
  ` ${s.bold('HOW IT WORKS')}`,
228
228
  '',
229
- ` 1. Generate a CLI token at ${s.cyan(`${s.dim('<console-url>')}/console/account`)}`,
230
- ` 2. Run ${s.cyan('ticketlens login')} and paste the token when prompted`,
231
- ` 3. Run ${s.cyan('ticketlens sync')} to pull your tracker connections`,
229
+ ` 1. Run ${s.cyan('ticketlens login')} — your browser opens the authorize page`,
230
+ ` 2. Click ${s.bold('Authorize TicketLens CLI')} while logged in to the Console`,
231
+ ` 3. The terminal confirms login automatically`,
232
+ ` 4. Run ${s.cyan('ticketlens sync')} to pull your tracker connections`,
232
233
  '',
233
234
  ` ${s.bold('OPTIONS')}`,
234
235
  '',
235
- ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
236
+ ` ${s.brand('--manual')} Paste a token instead of using the browser`,
237
+ ` ${s.dim('Useful for CI, SSH sessions, or headless environments.')}`,
238
+ ` ${s.dim(`Generate a token at ${s.cyan('<console-url>/console/account')}`)}`,
239
+ ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
236
240
  '',
237
241
  ` ${s.bold('EXAMPLES')}`,
238
242
  '',
239
- ` ${s.dim('$')} ticketlens login`,
240
- ` ${s.dim('$')} ticketlens sync ${s.dim('# after login, pull connections')}`,
243
+ ` ${s.dim('$')} ticketlens login ${s.dim('# opens browser (default)')}`,
244
+ ` ${s.dim('$')} ticketlens login --manual ${s.dim('# paste token (CI / headless)')}`,
245
+ ` ${s.dim('$')} ticketlens sync ${s.dim('# after login, pull connections')}`,
241
246
  '',
242
247
  ` ${s.bold('FILES')}`,
243
248
  '',
244
- ` ${s.dim('Token saved to:')} ~/.ticketlens/cli-token`,
249
+ ` ${s.dim('Token saved to:')} ~/.ticketlens/cli-token ${s.dim('(written by ticketlens login)')}`,
245
250
  '',
246
251
  ];
247
252
  stream.write(lines.join('\n') + '\n');