ticketlens 0.38.2 → 0.38.3

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 CHANGED
@@ -451,7 +451,7 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
451
451
 
452
452
  `ticketlens create` creates a new ticket with a fixed minimal field set — no arbitrary custom fields. Unlike every other write command, there's no existing ticket to target, so `--profile` (or your default profile) picks the tracker instead of a ticket key. `--project` is the Jira project key or Linear team key — required for both, ignored on GitHub since its target repo is already fixed by the profile. `--type` is Jira's issue type (e.g. `"Task"`, `"Bug"`) — required for Jira, ignored elsewhere. No `--confirm` gate, same risk tier as `update`/`assign` — but this is the highest-blast-radius command in the whole family: a bad `--project`/`--type` fabricates a real, hard-to-walk-back item in a live tracker, so an invalid value surfaces the tracker's own error rather than a silent guess.
453
453
 
454
- **A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1, ECNT.` — rather than a bare tracker error. This is reactive only: it never runs on a successful create, never auto-retries the write, and is cached locally per profile for 24h so a burst of failed attempts doesn't re-fetch every time.
454
+ **A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1, CNV2.` — rather than a bare tracker error. This is reactive only: it never runs on a successful create, never auto-retries the write, and is cached locally per profile for 24h so a burst of failed attempts doesn't re-fetch every time.
455
455
 
456
456
  All six write actions (comment/transition/assign/link/update/create) have a short local debounce (10s) against an accidental double-fire (a flaky retry, hitting enter twice), and every successful write is appended to a local, append-only audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — unlike Recall notes, ticket writes aren't naturally idempotent, so a timed-out attempt is surfaced to you instead of silently repeated. `duplicates` has neither, since nothing is written.
457
457
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.2",
3
+ "version": "0.38.3",
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,86 @@
1
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ // This module's own resolved path — its BANNED_PATTERNS regex literals
6
+ // necessarily contain the banned substrings as source text, so it must
7
+ // exclude itself from any scan or it would permanently flag itself as a
8
+ // leak. Exposed as a parameter (not hardcoded in scanForLeaks) so tests can
9
+ // verify the exclusion is path-exact, not a basename match that could also
10
+ // skip an unrelated shipped file that happens to share this name.
11
+ const SELF_PATH = resolve(fileURLToPath(import.meta.url));
12
+
13
+ // Files npm ships in every tarball regardless of package.json's `files`
14
+ // list (npm always includes these three, unconditionally).
15
+ export const ALWAYS_SHIPPED = ['package.json', 'README.md', 'LICENSE'];
16
+
17
+ export const BANNED_PATTERNS = [
18
+ // Not an English word — safe to match bare, without requiring the "-NNNN" suffix.
19
+ { name: 'Advent Jira ticket prefix', pattern: /\bECNT\b/i },
20
+ // "ASAP" is ordinary English; only match the ticket-key shape to avoid false positives.
21
+ { name: 'ASAP gateway ticket prefix', pattern: /\bASAP-\d+\b/i },
22
+ { name: 'pilot-client wrapper command name', pattern: /advent-ticket/i },
23
+ // Case-sensitive: "Advent" (proper noun, the employer) vs. lowercase "advent"
24
+ // (ordinary word, e.g. "the advent of AI-assisted development").
25
+ { name: 'employer name', pattern: /\bAdvent\b/ },
26
+ ];
27
+
28
+ function isDirEntry(absPath, entry) {
29
+ return entry.endsWith('/') || statSync(absPath).isDirectory();
30
+ }
31
+
32
+ function collectFiles(root, entry) {
33
+ const absPath = join(root, entry);
34
+ if (!existsSync(absPath)) return [];
35
+ if (!isDirEntry(absPath, entry)) return [absPath];
36
+
37
+ const files = [];
38
+ const walk = (dir) => {
39
+ for (const dirent of readdirSync(dir, { withFileTypes: true })) {
40
+ const full = join(dir, dirent.name);
41
+ if (dirent.isDirectory()) walk(full);
42
+ else files.push(full);
43
+ }
44
+ };
45
+ walk(absPath);
46
+ return files;
47
+ }
48
+
49
+ function findViolations(root, absPath) {
50
+ const relPath = relative(root, absPath);
51
+ let content;
52
+ try {
53
+ content = readFileSync(absPath, 'utf8');
54
+ } catch (err) {
55
+ return [`${relPath} — could not read file to scan for leaks: ${err.message}`];
56
+ }
57
+
58
+ const violations = [];
59
+ content.split('\n').forEach((line, index) => {
60
+ for (const { name, pattern } of BANNED_PATTERNS) {
61
+ if (pattern.test(line)) {
62
+ violations.push(`${relPath}:${index + 1} — ${name} (matched ${pattern})`);
63
+ }
64
+ }
65
+ });
66
+
67
+ return violations;
68
+ }
69
+
70
+ /**
71
+ * Scans every file npm actually ships — package.json's `files` list plus
72
+ * the README/LICENSE/package.json npm always includes regardless of `files`
73
+ * — for employer/pilot-client identifying patterns.
74
+ * @param {string} root absolute path to the package root
75
+ * @param {string[]} filesList package.json's `files` array
76
+ * @param {string} [selfPath] path to exclude from the scan (defaults to this module's own path)
77
+ * @returns {string[]} violation descriptions ("path:line — name (matched /re/)"), empty if clean
78
+ */
79
+ export function scanForLeaks(root, filesList, selfPath = SELF_PATH) {
80
+ const entries = [...new Set([...filesList, ...ALWAYS_SHIPPED])];
81
+
82
+ return entries
83
+ .flatMap(entry => collectFiles(root, entry))
84
+ .filter(absPath => resolve(absPath) !== resolve(selfPath))
85
+ .flatMap(absPath => findViolations(root, absPath));
86
+ }
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join, resolve } from 'node:path';
4
+ import { scanForLeaks } from './leak-scanner.mjs';
4
5
 
5
6
  const LOCAL_RE = /localhost|127\.0\.0\.1|\.test(:\d+)?(\/|$)/i;
6
7
 
@@ -24,23 +25,54 @@ export function checkApiBase(defaultApiBase, tag = 'latest') {
24
25
  return { ok: true, reason: `production URL looks good (${defaultApiBase})` };
25
26
  }
26
27
 
28
+ /**
29
+ * @param {string} root absolute path to the package root
30
+ * @param {string[]} filesList package.json's `files` array
31
+ * @returns {{ ok: boolean, reason: string }}
32
+ */
33
+ export function checkForLeaks(root, filesList) {
34
+ const violations = scanForLeaks(root, filesList);
35
+ if (violations.length > 0) {
36
+ return {
37
+ ok: false,
38
+ reason: `Found ${violations.length} employer/pilot-client leak(s) in shipped files:\n` +
39
+ violations.map(v => ` - ${v}`).join('\n'),
40
+ };
41
+ }
42
+ return { ok: true, reason: 'no employer/pilot-client leaks found in shipped files' };
43
+ }
44
+
45
+ /**
46
+ * Prints each check's reason. On any failure, prints failures to stderr as
47
+ * ERRORs and exits 1 (no successes are printed in that case). Only reached
48
+ * when every check passes does it print each success reason to stdout.
49
+ * @param {{ ok: boolean, reason: string }[]} checks
50
+ * @param {string} logPrefix e.g. '[preflight]' or '[publish] Preflight:'
51
+ */
52
+ export function runChecks(checks, logPrefix) {
53
+ const failed = checks.filter(c => !c.ok);
54
+ if (failed.length > 0) {
55
+ for (const { reason } of failed) process.stderr.write(`\n${logPrefix} ERROR: ${reason}\n`);
56
+ process.stderr.write('\n');
57
+ process.exit(1);
58
+ }
59
+ for (const { reason } of checks) process.stdout.write(`${logPrefix} ${reason}\n`);
60
+ }
61
+
27
62
  // Only run when invoked directly (not when imported by tests)
28
63
  const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
29
64
  if (isMain) {
30
65
  const __dir = dirname(fileURLToPath(import.meta.url));
66
+ const root = resolve(__dir, '..');
31
67
  const apiUtilsPath = join(__dir, '../skills/jtb/scripts/lib/api-utils.mjs');
32
68
  const source = readFileSync(apiUtilsPath, 'utf8');
33
69
  const match = source.match(/export const DEFAULT_API_BASE\s*=\s*'([^']+)'/);
34
70
  const defaultApiBase = match?.[1] ?? '';
71
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
35
72
 
36
73
  const tag = process.env.npm_config_tag;
37
- const { ok, reason } = checkApiBase(defaultApiBase, tag);
38
-
39
- if (!ok) {
40
- process.stderr.write(`\n[preflight] ERROR: ${reason}\n\n`);
41
- process.exit(1);
42
- }
74
+ const checks = [checkApiBase(defaultApiBase, tag), checkForLeaks(root, pkg.files)];
43
75
 
44
- process.stdout.write(`[preflight] ${reason}\n`);
76
+ runChecks(checks, '[preflight]');
45
77
  process.exit(0);
46
78
  }
@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, rmSync } from 'node:fs';
16
16
  import { execSync } from 'node:child_process';
17
17
  import { dirname, join, resolve } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
+ import { checkApiBase, checkForLeaks, runChecks } from './preflight.mjs';
19
20
 
20
21
  const __dir = dirname(fileURLToPath(import.meta.url));
21
22
  const ROOT = resolve(__dir, '..');
@@ -26,6 +27,17 @@ const args = process.argv.slice(2);
26
27
  const tag = args.find(a => a.startsWith('--tag='))?.split('=')[1] ?? 'beta';
27
28
  const prodUrl = args.find(a => a.startsWith('--prod-url='))?.split('=')[1] ?? PROD_URL;
28
29
 
30
+ // `npm publish <tarball-file>` (below) does NOT trigger npm's `prepublishOnly`
31
+ // lifecycle hook — that only fires when npm packs a directory itself, not
32
+ // when handed an already-built tarball. So these checks are run directly
33
+ // here, not left to package.json's `prepublishOnly` script, which would
34
+ // silently never execute during a real publish:beta/publish:latest run.
35
+ // checkApiBase validates `prodUrl` (the value about to be swapped in and
36
+ // shipped), not the on-disk source — the source is *supposed* to stay local.
37
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
38
+ const preflightChecks = [checkApiBase(prodUrl, tag), checkForLeaks(ROOT, pkg.files)];
39
+ runChecks(preflightChecks, '[publish] Preflight:');
40
+
29
41
  const original = readFileSync(API_UTILS, 'utf8');
30
42
  const swapped = original.replace(
31
43
  /export const DEFAULT_API_BASE\s*=\s*'[^']+'/,