ticketlens 0.1.5 → 0.1.6
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/bin/ticketlens.mjs
CHANGED
|
@@ -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,45 @@ 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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
process.stderr.write(` ${s.
|
|
290
|
-
process.
|
|
291
|
-
|
|
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
|
+
process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} ${err.message}\n`);
|
|
313
|
+
process.stderr.write(`\n ${s.dim(`Try ${s.cyan('ticketlens login --manual')} to paste a token instead.`)}\n\n`);
|
|
314
|
+
process.exitCode = 1;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
292
317
|
}
|
|
293
318
|
|
|
294
|
-
//
|
|
319
|
+
// ── verify token against API (both flows) ─────────────────────────
|
|
295
320
|
process.stderr.write(`\n ${s.dim('○ Verifying token…')}\n`);
|
|
296
321
|
let res;
|
|
297
322
|
try {
|
|
@@ -299,7 +324,7 @@ switch (command) {
|
|
|
299
324
|
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
300
325
|
signal: AbortSignal.timeout(15000),
|
|
301
326
|
});
|
|
302
|
-
} catch
|
|
327
|
+
} catch {
|
|
303
328
|
process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Could not reach ${getApiBase()} — check your connection.\n`);
|
|
304
329
|
process.exitCode = 1;
|
|
305
330
|
return;
|
package/package.json
CHANGED
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
|
|
53
|
+
if (state !== expectedState) {
|
|
54
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
55
|
+
res.end('State mismatch — authorization rejected.');
|
|
56
|
+
settle(reject, new Error('State mismatch'));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!token.startsWith('tl_')) {
|
|
61
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
62
|
+
res.end('Invalid token received.');
|
|
63
|
+
settle(reject, new Error('Invalid token'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
68
|
+
res.end(
|
|
69
|
+
'<html><body style="font-family:sans-serif;text-align:center;padding:60px;background:#0d1117;color:#cdd9e5">'
|
|
70
|
+
+ '<h2 style="color:#3fb950">✓ Authorized</h2>'
|
|
71
|
+
+ '<p style="color:#8b949e">You can close this tab and return to the terminal.</p>'
|
|
72
|
+
+ '</body></html>',
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
settle(resolve, token);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
server.on('error', (err) => settle(reject, err));
|
|
79
|
+
|
|
80
|
+
const timer = setTimeout(
|
|
81
|
+
() => settle(reject, new Error('Authorization timed out after 120 seconds')),
|
|
82
|
+
timeoutMs,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
server.listen(port, '127.0.0.1');
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Full browser login flow. Opens the authorize page in the default browser,
|
|
91
|
+
* waits for the callback, and returns the plaintext CLI token.
|
|
92
|
+
*/
|
|
93
|
+
export async function browserLogin() {
|
|
94
|
+
const state = generateState();
|
|
95
|
+
const port = pickPort();
|
|
96
|
+
const hostname = osHostname();
|
|
97
|
+
|
|
98
|
+
const consoleBase = getConsoleBase();
|
|
99
|
+
const url = `${consoleBase}/console/auth/cli`
|
|
100
|
+
+ `?port=${port}`
|
|
101
|
+
+ `&state=${encodeURIComponent(state)}`
|
|
102
|
+
+ `&hostname=${encodeURIComponent(hostname)}`;
|
|
103
|
+
|
|
104
|
+
const tokenPromise = startLocalServer(port, state);
|
|
105
|
+
openBrowser(url);
|
|
106
|
+
|
|
107
|
+
return tokenPromise;
|
|
108
|
+
}
|
|
@@ -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
|
|
225
|
-
`
|
|
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.
|
|
230
|
-
` 2.
|
|
231
|
-
` 3.
|
|
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('
|
|
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
|
|
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');
|