ticketlens 0.10.3 → 0.10.18
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/README.md +2 -0
- package/bin/ticketlens.mjs +47 -117
- package/package.json +3 -2
- package/scripts/postinstall.mjs +34 -0
- package/skills/jtb/SKILL.md +2 -2
- package/skills/jtb/scripts/lib/alias-status.mjs +73 -0
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/cli.mjs +1 -1
- package/skills/jtb/scripts/lib/config.mjs +23 -9
- package/skills/jtb/scripts/lib/connection-tester.mjs +53 -0
- package/skills/jtb/scripts/lib/help.mjs +8 -6
- package/skills/jtb/scripts/lib/init-wizard.mjs +19 -38
- package/skills/jtb/scripts/lib/login-flow.mjs +98 -0
- package/skills/jtb/scripts/lib/onboarding.mjs +254 -0
- package/skills/jtb/scripts/lib/prompt-helpers.mjs +41 -2
- package/skills/jtb/scripts/lib/quick-start-panel.mjs +32 -0
- package/skills/jtb/scripts/lib/run-stats.mjs +2 -1
- package/skills/jtb/scripts/lib/select-prompt.mjs +4 -3
- package/skills/jtb/scripts/lib/setup-state.mjs +35 -0
- package/skills/jtb/scripts/lib/sync.mjs +48 -0
- package/skills/jtb/scripts/lib/wordmark.mjs +69 -0
package/README.md
CHANGED
|
@@ -55,6 +55,8 @@ npx ticketlens init
|
|
|
55
55
|
npx ticketlens CNV1-2
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config` before anything is configured also launches guided setup, no dead end. Pass `--no-input` to force non-interactive behavior even in a terminal (scripts, CI).
|
|
59
|
+
|
|
58
60
|
**Prerequisites:** Node.js >=20
|
|
59
61
|
|
|
60
62
|
---
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* ticketlens triage [--stale=N] [--status=X,Y] [--profile=NAME]
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { createRequire } from 'node:module';
|
|
11
10
|
import { parseCommand } from '../skills/jtb/scripts/lib/cli.mjs';
|
|
11
|
+
import { renderWordmark } from '../skills/jtb/scripts/lib/wordmark.mjs';
|
|
12
12
|
import { run as runFetch } from '../skills/jtb/scripts/fetch-ticket.mjs';
|
|
13
13
|
import { run as runTriage } from '../skills/jtb/scripts/fetch-my-tickets.mjs';
|
|
14
14
|
import { run as runInit } from '../skills/jtb/scripts/lib/init-wizard.mjs';
|
|
@@ -29,14 +29,13 @@ import {
|
|
|
29
29
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
30
30
|
import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
|
|
31
31
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
32
|
-
import { readCliToken,
|
|
33
|
-
import {
|
|
34
|
-
import { syncProfiles,
|
|
35
|
-
import { promptSecret, promptText } from '../skills/jtb/scripts/lib/prompt-helpers.mjs';
|
|
32
|
+
import { readCliToken, deleteCliToken } from '../skills/jtb/scripts/lib/cli-auth.mjs';
|
|
33
|
+
import { runLogin } from '../skills/jtb/scripts/lib/login-flow.mjs';
|
|
34
|
+
import { syncProfiles, reportSyncResult, getApiBase } from '../skills/jtb/scripts/lib/sync.mjs';
|
|
36
35
|
import { checkForUpdate, getUpdateHint } from '../skills/jtb/scripts/lib/update-check.mjs';
|
|
37
36
|
import { incrementInvocation, incrementCommand } from '../skills/jtb/scripts/lib/activity-counter.mjs';
|
|
38
37
|
import { DEFAULT_CONFIG_DIR } from '../skills/jtb/scripts/lib/config.mjs';
|
|
39
|
-
import {
|
|
38
|
+
import { checkTeamJiraConfigUpdate } from '../skills/jtb/scripts/lib/team-jira-sync.mjs';
|
|
40
39
|
|
|
41
40
|
const TRACKED_COMMANDS = new Set([
|
|
42
41
|
'triage', 'fetch', 'get', 'compliance', 'review', 'standup',
|
|
@@ -184,7 +183,24 @@ switch (command) {
|
|
|
184
183
|
|
|
185
184
|
const profileArg = cmdArgs.find(a => a.startsWith('--profile='));
|
|
186
185
|
const profileName = profileArg ? profileArg.split('=')[1] : undefined;
|
|
187
|
-
|
|
186
|
+
const isInteractive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !cmdArgs.includes('--no-input');
|
|
187
|
+
|
|
188
|
+
(async () => {
|
|
189
|
+
if (isInteractive) {
|
|
190
|
+
const { detectSetupState } = await import('../skills/jtb/scripts/lib/setup-state.mjs');
|
|
191
|
+
// Ready + no explicit --profile= honors the hub's own "Exit" sublabel
|
|
192
|
+
// promise ("you can rerun this any time: ticketlens config"). An
|
|
193
|
+
// explicit --profile= is a targeted, scriptable invocation — bypass
|
|
194
|
+
// the hub and edit that profile directly, unchanged from today.
|
|
195
|
+
if (detectSetupState().status !== 'ready' || !profileName) {
|
|
196
|
+
const { run: runOnboarding } = await import('../skills/jtb/scripts/lib/onboarding.mjs');
|
|
197
|
+
await runOnboarding({ stream: process.stderr });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await runConfig({ profileName });
|
|
203
|
+
})().catch(err => {
|
|
188
204
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
189
205
|
process.exitCode = 1;
|
|
190
206
|
});
|
|
@@ -329,12 +345,9 @@ switch (command) {
|
|
|
329
345
|
});
|
|
330
346
|
break;
|
|
331
347
|
|
|
332
|
-
case 'version':
|
|
333
|
-
|
|
334
|
-
const pkg = require('../package.json');
|
|
335
|
-
process.stdout.write(`ticketlens v${pkg.version}\n`);
|
|
348
|
+
case 'version':
|
|
349
|
+
process.stdout.write(renderWordmark({ stream: process.stdout }));
|
|
336
350
|
break;
|
|
337
|
-
}
|
|
338
351
|
|
|
339
352
|
case 'schedule': {
|
|
340
353
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printScheduleHelp(); break; }
|
|
@@ -430,78 +443,7 @@ switch (command) {
|
|
|
430
443
|
const useManual = cmdArgs.includes('--manual');
|
|
431
444
|
|
|
432
445
|
(async () => {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
let token;
|
|
436
|
-
|
|
437
|
-
if (useManual) {
|
|
438
|
-
// ── manual paste flow (CI / headless environments) ──────────────────
|
|
439
|
-
process.stderr.write(`\n ${s.bold('TicketLens Login')}\n`);
|
|
440
|
-
process.stderr.write(` ${s.dim('─'.repeat(44))}\n`);
|
|
441
|
-
process.stderr.write(` ${s.dim(`Generate a CLI token at ${s.cyan(`${getConsoleBase()}/console/account`)}`)}\n`);
|
|
442
|
-
process.stderr.write(` ${s.dim('then paste it below.')}\n\n`);
|
|
443
|
-
|
|
444
|
-
token = await promptSecret(`CLI Token ${s.dim('(tl_…)')}:`, { stream: process.stderr });
|
|
445
|
-
if (!token.startsWith('tl_')) {
|
|
446
|
-
process.stderr.write(` ${s.red('✖')} Token must start with ${s.dim('tl_')}\n`);
|
|
447
|
-
process.exitCode = 1;
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
} else {
|
|
451
|
-
// ── browser flow (default) ────────────────────────────────────────
|
|
452
|
-
process.stderr.write(`\n ${s.bold('TicketLens Login')}\n`);
|
|
453
|
-
process.stderr.write(` ${s.dim('─'.repeat(44))}\n`);
|
|
454
|
-
process.stderr.write(` Opening browser to authorize…\n\n`);
|
|
455
|
-
process.stderr.write(` ${s.dim('○ Waiting for authorization (120s)…')}\n`);
|
|
456
|
-
|
|
457
|
-
try {
|
|
458
|
-
token = await browserLogin();
|
|
459
|
-
} catch (err) {
|
|
460
|
-
const cancelled = err.message === 'Authorization cancelled';
|
|
461
|
-
process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} ${cancelled ? 'Login cancelled.' : err.message}\n`);
|
|
462
|
-
if (!cancelled) {
|
|
463
|
-
process.stderr.write(`\n ${s.dim(`Try ${s.cyan('ticketlens login --manual')} to paste a token instead.`)}\n\n`);
|
|
464
|
-
}
|
|
465
|
-
process.exitCode = cancelled ? 0 : 1;
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// ── verify token against API (both flows) ─────────────────────────
|
|
471
|
-
process.stderr.write(`\n ${s.dim('○ Verifying token…')}\n`);
|
|
472
|
-
let res;
|
|
473
|
-
try {
|
|
474
|
-
res = await fetch(`${getApiBase()}/v1/profiles`, {
|
|
475
|
-
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
476
|
-
signal: AbortSignal.timeout(15000),
|
|
477
|
-
});
|
|
478
|
-
} catch {
|
|
479
|
-
process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Could not reach ${getApiBase()} — check your connection.\n`);
|
|
480
|
-
process.exitCode = 1;
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (res.status === 401) {
|
|
485
|
-
process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Invalid token — check the value and try again.\n`);
|
|
486
|
-
process.exitCode = 1;
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
if (!res.ok) {
|
|
490
|
-
process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Server returned ${res.status}. Try again later.\n`);
|
|
491
|
-
process.exitCode = 1;
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
saveCliToken(token);
|
|
496
|
-
process.stderr.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Logged in.\n`);
|
|
497
|
-
|
|
498
|
-
// Flow 1: pull team Jira config for Pro/Team members (silently skipped for Free)
|
|
499
|
-
const tcLogin = await applyTeamConfigOnLogin().catch(() => null);
|
|
500
|
-
if (tcLogin?.ok) {
|
|
501
|
-
process.stderr.write(` ${s.dim(`○ Team Jira config applied for ${s.cyan(tcLogin.groupName)}.`)}\n`);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
process.stderr.write(`\n Run ${s.cyan('ticketlens sync')} to pull your connections.\n\n`);
|
|
446
|
+
await runLogin({ manual: useManual });
|
|
505
447
|
})().catch(err => {
|
|
506
448
|
process.stderr.write(`Error: ${err.message}\n`);
|
|
507
449
|
process.exitCode = 1;
|
|
@@ -525,42 +467,14 @@ switch (command) {
|
|
|
525
467
|
|
|
526
468
|
const result = await syncProfiles();
|
|
527
469
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
process.exitCode = 1;
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
if (result.error === 'unauthorized') {
|
|
534
|
-
process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} Token expired or revoked. Run ${s.cyan('ticketlens login')} to re-authenticate.\n\n`);
|
|
535
|
-
process.exitCode = 1;
|
|
536
|
-
return;
|
|
537
|
-
}
|
|
470
|
+
process.stderr.write('\x1b[A\r\x1b[2K');
|
|
471
|
+
reportSyncResult(result, { stream: process.stderr });
|
|
538
472
|
if (result.error) {
|
|
539
|
-
process.stderr.write(
|
|
473
|
+
process.stderr.write('\n');
|
|
540
474
|
process.exitCode = 1;
|
|
541
475
|
return;
|
|
542
476
|
}
|
|
543
477
|
|
|
544
|
-
const { added, updated, unchanged, needsCredentials } = result;
|
|
545
|
-
const total = added.length + updated.length + unchanged.length;
|
|
546
|
-
|
|
547
|
-
process.stderr.write(`\x1b[A\r\x1b[2K ${s.green('✔')} Sync complete`);
|
|
548
|
-
if (total === 0) {
|
|
549
|
-
process.stderr.write(` — no profiles on console yet.\n`);
|
|
550
|
-
} else {
|
|
551
|
-
process.stderr.write(`\n`);
|
|
552
|
-
if (added.length) process.stderr.write(` ${s.dim('+')} ${added.length} added: ${added.map(n => s.cyan(n)).join(', ')}\n`);
|
|
553
|
-
if (updated.length) process.stderr.write(` ${s.dim('↑')} ${updated.length} updated: ${updated.map(n => s.cyan(n)).join(', ')}\n`);
|
|
554
|
-
if (unchanged.length) process.stderr.write(` ${s.dim('○')} ${unchanged.length} unchanged\n`);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
if (needsCredentials.length > 0) {
|
|
558
|
-
process.stderr.write(`\n ${s.yellow('!')} These profiles need credentials before they can be used:\n`);
|
|
559
|
-
for (const name of needsCredentials) {
|
|
560
|
-
process.stderr.write(` ${s.dim('○')} ${s.cyan(name)} — run: ${s.bold(`ticketlens config --profile=${name}`)}\n`);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
478
|
// Flow 3: also pull team Jira config update (Pro/Team); silently skipped for Free
|
|
565
479
|
const tcSync = await checkTeamJiraConfigUpdate().catch(() => null);
|
|
566
480
|
if (tcSync?.banner) {
|
|
@@ -694,7 +608,23 @@ switch (command) {
|
|
|
694
608
|
}
|
|
695
609
|
|
|
696
610
|
case 'help':
|
|
697
|
-
default:
|
|
698
|
-
|
|
611
|
+
default: {
|
|
612
|
+
const isInteractive = args.length === 0 && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
|
|
613
|
+
|
|
614
|
+
(async () => {
|
|
615
|
+
if (isInteractive) {
|
|
616
|
+
const { detectSetupState } = await import('../skills/jtb/scripts/lib/setup-state.mjs');
|
|
617
|
+
if (detectSetupState().status !== 'ready') {
|
|
618
|
+
const { run: runOnboarding } = await import('../skills/jtb/scripts/lib/onboarding.mjs');
|
|
619
|
+
await runOnboarding({ stream: process.stderr });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
printHelp();
|
|
624
|
+
})().catch(err => {
|
|
625
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
626
|
+
process.exitCode = 1;
|
|
627
|
+
});
|
|
699
628
|
break;
|
|
629
|
+
}
|
|
700
630
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ticketlens",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.18",
|
|
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": {
|
|
7
|
-
"ticketlens": "bin/ticketlens.mjs"
|
|
7
|
+
"ticketlens": "bin/ticketlens.mjs",
|
|
8
|
+
"tl": "bin/ticketlens.mjs"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"bin/",
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { homedir } from 'node:os';
|
|
|
12
12
|
|
|
13
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const SKILL_SRC = join(__dirname, '..', 'skills', 'jtb', 'SKILL.md');
|
|
15
|
+
const OWN_BIN = join(__dirname, '..', 'bin', 'ticketlens.mjs');
|
|
15
16
|
|
|
16
17
|
if (!existsSync(SKILL_SRC)) process.exit(0);
|
|
17
18
|
|
|
@@ -60,3 +61,36 @@ if (updated === 0 && skipped === TARGETS.length) {
|
|
|
60
61
|
console.log(' ℹ /jtb skill not installed in any known location.');
|
|
61
62
|
console.log(' To install: ticketlens update-skill');
|
|
62
63
|
}
|
|
64
|
+
|
|
65
|
+
// First-run banner + tl-alias status. Best-effort: npm >=7 hides this output
|
|
66
|
+
// unless the user passes --foreground-scripts, so the guaranteed channel is
|
|
67
|
+
// the first bare `tl`/`ticketlens` run added in a later phase. Never throws —
|
|
68
|
+
// a broken banner must not break the install.
|
|
69
|
+
try {
|
|
70
|
+
const { renderWordmark } = await import('../skills/jtb/scripts/lib/wordmark.mjs');
|
|
71
|
+
const { checkAliasStatus } = await import('../skills/jtb/scripts/lib/alias-status.mjs');
|
|
72
|
+
const { loadProfiles } = await import('../skills/jtb/scripts/lib/profile-resolver.mjs');
|
|
73
|
+
|
|
74
|
+
console.log('\n' + renderWordmark({ stream: process.stdout }));
|
|
75
|
+
|
|
76
|
+
const profiles = loadProfiles();
|
|
77
|
+
const isConfigured = !!(profiles && Object.keys(profiles.profiles || {}).length > 0);
|
|
78
|
+
|
|
79
|
+
const alias = checkAliasStatus({ selfBinPath: OWN_BIN });
|
|
80
|
+
if (alias.status === 'active') {
|
|
81
|
+
console.log(' ✔ Also available as: tl');
|
|
82
|
+
} else if (alias.status === 'shadowed') {
|
|
83
|
+
console.log(` ⚠ 'tl' on this machine points to ${alias.foreignPath} — use 'ticketlens' instead.`);
|
|
84
|
+
}
|
|
85
|
+
// alias.status === 'missing' → npm bin dir not on PATH is a broader problem
|
|
86
|
+
// this script can't fix; saying nothing avoids confusing the user further.
|
|
87
|
+
|
|
88
|
+
if (isConfigured) {
|
|
89
|
+
console.log(' → Try tl triage');
|
|
90
|
+
} else {
|
|
91
|
+
console.log(' → New here? Run tl to launch the guided setup.');
|
|
92
|
+
}
|
|
93
|
+
console.log('');
|
|
94
|
+
} catch {
|
|
95
|
+
// Silent on failure — never breaks the install.
|
|
96
|
+
}
|
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.10.
|
|
1
|
+
<!-- jtb-skill-version: 0.10.18 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -59,7 +59,7 @@ TicketLens supports two connection methods — check in this order:
|
|
|
59
59
|
|
|
60
60
|
**1. Profile config (recommended):** If `~/.ticketlens/profiles.json` exists, no env vars
|
|
61
61
|
are needed. Profile resolution is automatic (by ticket prefix, project path, or `--profile`).
|
|
62
|
-
Setup via `ticketlens init
|
|
62
|
+
Setup via `ticketlens init` (`tl init` also works — `tl` is shorthand for `ticketlens`).
|
|
63
63
|
|
|
64
64
|
**2. Env var fallback:** If no profile config exists, these must be set:
|
|
65
65
|
- `JIRA_BASE_URL` — e.g. `https://yourteam.atlassian.net`
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects whether the `tl` shorthand resolves to this package's own
|
|
3
|
+
* bin/ticketlens.mjs, a foreign binary shadowing it on PATH, or nothing.
|
|
4
|
+
* Pure filesystem checks only — no subprocess spawning, no network.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, accessSync, realpathSync, constants as fsConstants } from 'node:fs';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scan PATH for the first executable matching binName. Never throws —
|
|
14
|
+
* an unreadable or missing directory is skipped, not fatal.
|
|
15
|
+
*/
|
|
16
|
+
export function findOnPath(binName, { env = process.env, platform = process.platform } = {}) {
|
|
17
|
+
const isWin = platform === 'win32';
|
|
18
|
+
// PATH delimiter depends on the target platform, not the host running this
|
|
19
|
+
// code — node:path's `delimiter` is host-bound and wrong when platform is
|
|
20
|
+
// passed explicitly (e.g. testing win32 logic from a POSIX host).
|
|
21
|
+
const dirs = (env.PATH || env.Path || '').split(isWin ? ';' : ':').filter(Boolean);
|
|
22
|
+
const candidates = isWin
|
|
23
|
+
? [...(env.PATHEXT || DEFAULT_PATHEXT).split(';').map((ext) => binName + ext), binName]
|
|
24
|
+
: [binName];
|
|
25
|
+
|
|
26
|
+
for (const dir of dirs) {
|
|
27
|
+
for (const name of candidates) {
|
|
28
|
+
try {
|
|
29
|
+
const full = join(dir, name);
|
|
30
|
+
if (!existsSync(full)) continue;
|
|
31
|
+
// POSIX: skip non-executable matches (a shell would too). Windows PATHEXT
|
|
32
|
+
// already restricts candidates to executable extensions, so no check needed.
|
|
33
|
+
if (!isWin) accessSync(full, fsConstants.X_OK);
|
|
34
|
+
return full;
|
|
35
|
+
} catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compare where `tl` resolves against our own installed ticketlens binary.
|
|
45
|
+
* POSIX: realpath comparison (npm bins are symlinks) against selfBinPath.
|
|
46
|
+
* Windows: npm .cmd/.ps1 shims aren't real symlinks, and selfBinPath (the
|
|
47
|
+
* package's internal target script under node_modules) lives in a different
|
|
48
|
+
* directory than the shims themselves — comparing against it always reads
|
|
49
|
+
* as foreign. Instead, npm always installs every bin entry from one package
|
|
50
|
+
* into the same shim directory together, so confirm ownership by checking
|
|
51
|
+
* whether a `ticketlens` shim sits alongside the found `tl` shim.
|
|
52
|
+
*/
|
|
53
|
+
export function checkAliasStatus({ selfBinPath, env = process.env, platform = process.platform } = {}) {
|
|
54
|
+
try {
|
|
55
|
+
const tlPath = findOnPath('tl', { env, platform });
|
|
56
|
+
if (!tlPath) return { status: 'missing' };
|
|
57
|
+
|
|
58
|
+
if (platform === 'win32') {
|
|
59
|
+
const ticketlensPath = findOnPath('ticketlens', { env, platform });
|
|
60
|
+
return ticketlensPath && dirname(tlPath) === dirname(ticketlensPath)
|
|
61
|
+
? { status: 'active' }
|
|
62
|
+
: { status: 'shadowed', foreignPath: tlPath };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const resolvedTl = realpathSync(tlPath);
|
|
66
|
+
const resolvedSelf = realpathSync(selfBinPath);
|
|
67
|
+
return resolvedTl === resolvedSelf
|
|
68
|
+
? { status: 'active' }
|
|
69
|
+
: { status: 'shadowed', foreignPath: tlPath };
|
|
70
|
+
} catch {
|
|
71
|
+
return { status: 'missing' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_API_BASE = 'https://api.ticketlens.app';
|
|
7
|
-
export const DEFAULT_SITE_BASE = 'https://ticketlens.
|
|
7
|
+
export const DEFAULT_SITE_BASE = 'https://ticketlens.app';
|
|
8
8
|
|
|
9
9
|
// Matches localhost, 127.0.0.1, and any hostname ending in .test or .local,
|
|
10
10
|
// with an optional port — all treated as local-only addresses.
|
|
@@ -8,7 +8,7 @@ export const TICKET_KEY_PATTERN = /^[A-Z][A-Z0-9]+-\d+$/;
|
|
|
8
8
|
export function parseCommand(args) {
|
|
9
9
|
const first = args[0];
|
|
10
10
|
|
|
11
|
-
if (args.length === 0 || first === '--help' || first === '-h') {
|
|
11
|
+
if (args.length === 0 || first === '--help' || first === '-h' || first === '--no-input') {
|
|
12
12
|
return { command: 'help', args: [] };
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -11,21 +11,35 @@ import { homedir } from 'node:os';
|
|
|
11
11
|
/** Canonical config directory: ~/.ticketlens */
|
|
12
12
|
export const DEFAULT_CONFIG_DIR = join(homedir(), '.ticketlens');
|
|
13
13
|
|
|
14
|
-
/** Read package.json
|
|
14
|
+
/** Read package.json once per process, memoized.
|
|
15
15
|
* Uses realpathSync to resolve symlinks before navigating, so the correct
|
|
16
|
-
* package.json is found whether the module is loaded via a symlink
|
|
16
|
+
* package.json is found whether the module is loaded via a symlink (e.g.
|
|
17
|
+
* the npm-managed `tl` bin) or directly.
|
|
17
18
|
*/
|
|
18
|
-
let
|
|
19
|
-
|
|
20
|
-
if (
|
|
19
|
+
let _pkg;
|
|
20
|
+
function _readPkg() {
|
|
21
|
+
if (_pkg) return _pkg;
|
|
21
22
|
try {
|
|
22
23
|
const realDir = dirname(realpathSync(fileURLToPath(import.meta.url)));
|
|
23
|
-
|
|
24
|
-
_version = pkg.version || '0.0.0';
|
|
24
|
+
_pkg = JSON.parse(readFileSync(join(realDir, '..', '..', '..', '..', 'package.json'), 'utf8'));
|
|
25
25
|
} catch {
|
|
26
|
-
|
|
26
|
+
_pkg = {};
|
|
27
27
|
}
|
|
28
|
-
return
|
|
28
|
+
return _pkg;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getVersion() {
|
|
32
|
+
return _readPkg().version || '0.0.0';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Metadata used by the first-run wordmark banner. */
|
|
36
|
+
export function getPackageMeta() {
|
|
37
|
+
const pkg = _readPkg();
|
|
38
|
+
return {
|
|
39
|
+
version: pkg.version || '0.0.0',
|
|
40
|
+
author: pkg.author || 'unknown',
|
|
41
|
+
repository: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url || ''),
|
|
42
|
+
};
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
/** Human-readable relative time from an ISO date string. */
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests every configured profile's connection, one at a time with a spinner
|
|
3
|
+
* per profile — the same pattern init-wizard.mjs/config-wizard.mjs already
|
|
4
|
+
* use for a single profile, just looped across all of them.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createSession } from './banner.mjs';
|
|
8
|
+
import { classifyError } from './error-classifier.mjs';
|
|
9
|
+
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
10
|
+
import { loadProfiles, loadCredentials } from './profile-resolver.mjs';
|
|
11
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
12
|
+
|
|
13
|
+
export async function testConnections({
|
|
14
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
15
|
+
stream = process.stderr,
|
|
16
|
+
resolveAdapterFn = resolveAdapter,
|
|
17
|
+
} = {}) {
|
|
18
|
+
const config = loadProfiles(configDir);
|
|
19
|
+
const names = config ? Object.keys(config.profiles) : [];
|
|
20
|
+
const creds = loadCredentials(configDir);
|
|
21
|
+
const results = [];
|
|
22
|
+
|
|
23
|
+
for (const name of names) {
|
|
24
|
+
const profile = config.profiles[name];
|
|
25
|
+
const profileCreds = creds[name] || {};
|
|
26
|
+
const conn = {
|
|
27
|
+
baseUrl: profile.baseUrl,
|
|
28
|
+
auth: profile.auth,
|
|
29
|
+
email: profile.email,
|
|
30
|
+
apiToken: profileCreds.apiToken,
|
|
31
|
+
pat: profileCreds.pat,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const session = createSession(
|
|
35
|
+
{ baseUrl: conn.baseUrl, profileName: name, email: conn.email || undefined, pat: conn.auth === 'pat' ? conn.pat : undefined },
|
|
36
|
+
{ stream },
|
|
37
|
+
);
|
|
38
|
+
session.spin('Testing connection...');
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await resolveAdapterFn(conn).fetchCurrentUser();
|
|
42
|
+
session.connected();
|
|
43
|
+
results.push({ name, ok: true });
|
|
44
|
+
} catch (err) {
|
|
45
|
+
session.failed();
|
|
46
|
+
const classified = classifyError(err, { baseUrl: conn.baseUrl, profileName: name });
|
|
47
|
+
session.footer(classified.message, 'error', classified.hint);
|
|
48
|
+
results.push({ name, ok: false, error: classified.message });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { results, failedCount: results.filter(r => !r.ok).length };
|
|
53
|
+
}
|
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { createStyler } from './ansi.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { renderWordmark } from './wordmark.mjs';
|
|
7
7
|
|
|
8
8
|
export function printHelp({ stream = process.stdout } = {}) {
|
|
9
9
|
const isTTY = stream.isTTY;
|
|
10
10
|
const s = createStyler({ isTTY });
|
|
11
|
-
const v = getVersion();
|
|
12
11
|
|
|
13
12
|
// Column targets (visible chars):
|
|
14
13
|
// USAGE: command portion = 36, so descriptions always start at the same column
|
|
@@ -18,12 +17,11 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
18
17
|
// ANSI codes (s.cyan, s.dim) add invisible bytes — they do NOT affect visible width.
|
|
19
18
|
|
|
20
19
|
const lines = [
|
|
21
|
-
'',
|
|
22
|
-
` ${s.bold(s.brand('◆ TicketLens'))} ${s.dim(`v${v}`)}`,
|
|
23
|
-
` ${s.dim('Stop tab-switching. Start building.')}`,
|
|
24
20
|
'',
|
|
25
21
|
` ${s.bold('USAGE')}`,
|
|
26
22
|
'',
|
|
23
|
+
` ${s.brand('tl')} ${s.dim('<anything>')} Shorthand for ${s.brand('ticketlens')}`,
|
|
24
|
+
'',
|
|
27
25
|
// Alignment targets (visible chars before description):
|
|
28
26
|
// Groups 1–2 (auth/setup): column 40 (command portion ≤ 24)
|
|
29
27
|
// Group 3 (daily use): column 42 (longest: "review [--branch=BRANCH]" = 39)
|
|
@@ -56,6 +54,10 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
56
54
|
` ${s.brand('ticketlens')} cloud-keys ${s.dim('[add|remove|list|test]')} Manage your encrypted AI provider keys ${s.dim('[Pro]')}`,
|
|
57
55
|
` ${s.brand('ticketlens')} update-skill ${s.dim('[--dry-run]')} Update /jtb skill in Claude Code and other AI assistants`,
|
|
58
56
|
'',
|
|
57
|
+
` ${s.bold('GLOBAL OPTIONS')}`,
|
|
58
|
+
'',
|
|
59
|
+
` ${s.brand('--no-input')} Force non-interactive behavior even in a terminal`,
|
|
60
|
+
'',
|
|
59
61
|
` ${s.bold('FETCH OPTIONS')}`,
|
|
60
62
|
'',
|
|
61
63
|
// visible widths: "--profile=NAME"=14, "--depth=N"=9, "--plain"=7, "--styled"=8,
|
|
@@ -125,7 +127,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
125
127
|
'',
|
|
126
128
|
];
|
|
127
129
|
|
|
128
|
-
stream.write(lines.join('\n') + '\n');
|
|
130
|
+
stream.write(renderWordmark({ stream }) + lines.join('\n') + '\n');
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
export function printFetchHelp({ stream = process.stdout } = {}) {
|
|
@@ -14,8 +14,10 @@ import { loadProfiles, saveProfile, saveDefault } from './profile-resolver.mjs';
|
|
|
14
14
|
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
15
15
|
import { promptSelect } from './select-prompt.mjs';
|
|
16
16
|
import { runSwitch } from './profile-switcher.mjs';
|
|
17
|
+
import { renderWordmark } from './wordmark.mjs';
|
|
18
|
+
import { printQuickStart } from './quick-start-panel.mjs';
|
|
17
19
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
18
|
-
import {
|
|
20
|
+
import { SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
|
|
19
21
|
|
|
20
22
|
const RETRY_OPTIONS = [
|
|
21
23
|
{ label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
|
|
@@ -51,7 +53,7 @@ async function probeProtocol(host, { stream, s }) {
|
|
|
51
53
|
|
|
52
54
|
// ── Main wizard ───────────────────────────────────────────────────────────────
|
|
53
55
|
|
|
54
|
-
export async function run({ configDir = DEFAULT_CONFIG_DIR } = {}) {
|
|
56
|
+
export async function run({ configDir = DEFAULT_CONFIG_DIR, showBanner = true, showQuickStart = true } = {}) {
|
|
55
57
|
const stream = process.stderr;
|
|
56
58
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
57
59
|
|
|
@@ -65,27 +67,22 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR } = {}) {
|
|
|
65
67
|
process.on('SIGINT', onSigint);
|
|
66
68
|
|
|
67
69
|
try {
|
|
68
|
-
await _run({ configDir, stream, s });
|
|
70
|
+
await _run({ configDir, stream, s, showBanner, showQuickStart });
|
|
69
71
|
} finally {
|
|
70
72
|
process.removeListener('SIGINT', onSigint);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
async function _run({ configDir, stream, s }) {
|
|
75
|
-
// Welcome
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
stream.write(bc('╭' + '─'.repeat(innerWidth) + '╮') + '\n');
|
|
84
|
-
for (const line of headerLines) {
|
|
85
|
-
const pad = innerWidth - visLen(line) - 1;
|
|
86
|
-
stream.write(bc('│') + ' ' + line + ' '.repeat(Math.max(0, pad)) + bc('│') + '\n');
|
|
76
|
+
async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
|
|
77
|
+
// Welcome banner — same wordmark as --help/--version, so a brand-new user's
|
|
78
|
+
// very first output looks like the same product (Phase 1's renderWordmark()
|
|
79
|
+
// was built reusable for exactly this; not duplicating the art). Suppressed
|
|
80
|
+
// when invoked from the onboarding hub, which already showed its own banner
|
|
81
|
+
// — otherwise the user sees it twice in the fresh-install flow.
|
|
82
|
+
if (showBanner) {
|
|
83
|
+
stream.write(renderWordmark({ stream }));
|
|
84
|
+
stream.write(`\n${s.dim("Let's configure your tracker connection.")}\n`);
|
|
87
85
|
}
|
|
88
|
-
stream.write(bc('╰' + '─'.repeat(innerWidth) + '╯') + '\n');
|
|
89
86
|
|
|
90
87
|
let addedCount = 0;
|
|
91
88
|
let addAnother = true;
|
|
@@ -575,25 +572,9 @@ async function _run({ configDir, stream, s }) {
|
|
|
575
572
|
const profileWord = addedCount === 1 ? '1 profile' : `${addedCount} profiles`;
|
|
576
573
|
stream.write(`\n ${s.green('✔')} ${profileWord} configured.\n\n`);
|
|
577
574
|
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
['ticketlens --help', 'Full command reference'],
|
|
584
|
-
];
|
|
585
|
-
const cmdWidth = cmds.reduce((max, [c]) => Math.max(max, c.length), 0);
|
|
586
|
-
const cmdRows = cmds.map(([cmd, desc]) =>
|
|
587
|
-
` ${s.bold(s.cyan(cmd.padEnd(cmdWidth)))} ${s.dim(desc)}`
|
|
588
|
-
);
|
|
589
|
-
const QTITLE = ' Quick start ';
|
|
590
|
-
const contentWidth = cmdRows.reduce((max, r) => Math.max(max, visLen(r)), 0);
|
|
591
|
-
const qWidth = Math.max(contentWidth + 2, QTITLE.length + 4);
|
|
592
|
-
const qPad = (r) => ' ' + r + ' '.repeat(Math.max(0, qWidth - visLen(r) - 1));
|
|
593
|
-
const qTitleFill = qWidth - 1 - QTITLE.length;
|
|
594
|
-
stream.write(bc('╭') + bc('─') + s.bold(s.cyan(QTITLE)) + bc('─'.repeat(Math.max(0, qTitleFill))) + bc('╮') + '\n');
|
|
595
|
-
stream.write(bc('│') + qPad('') + bc('│') + '\n');
|
|
596
|
-
for (const r of cmdRows) stream.write(bc('│') + qPad(r) + bc('│') + '\n');
|
|
597
|
-
stream.write(bc('│') + qPad('') + bc('│') + '\n');
|
|
598
|
-
stream.write(bc('╰') + bc('─'.repeat(qWidth)) + bc('╯') + '\n\n');
|
|
575
|
+
// Suppressed when invoked from the onboarding hub, which owns showing this
|
|
576
|
+
// exactly once after its own menu loop exits — otherwise the user sees it twice.
|
|
577
|
+
if (showQuickStart) {
|
|
578
|
+
printQuickStart({ stream, s });
|
|
579
|
+
}
|
|
599
580
|
}
|