commitshow 0.2.5 → 0.2.11

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.
@@ -2,6 +2,7 @@ import { resolveTarget, TargetError } from '../lib/target.js';
2
2
  import { findProjectByGithubUrl, fetchLatestSnapshot, fetchStanding, runPreviewAudit, waitForPreviewSnapshot, } from '../lib/api.js';
3
3
  import { renderAudit, renderMarkdown, renderJson, renderUpsell, renderQuotaFooter, renderRateLimitDeny, renderAuditError, writeAuditMarkdown, writeAuditJson, } from '../lib/render.js';
4
4
  import { c } from '../lib/colors.js';
5
+ import { Spinner } from '../lib/spinner.js';
5
6
  export async function audit(args) {
6
7
  const asJson = args.includes('--json');
7
8
  const force = args.includes('--refresh') || args.includes('--force');
@@ -73,7 +74,7 @@ export async function audit(args) {
73
74
  console.log(renderAudit(view));
74
75
  if (project.status === 'preview') {
75
76
  console.log('');
76
- console.log(renderUpsell());
77
+ console.log(renderUpsell(project.github_url ?? target.github_url));
77
78
  }
78
79
  console.log('');
79
80
  }
@@ -129,13 +130,24 @@ export async function audit(args) {
129
130
  emitError(asJson, err.error, err.message ?? 'Preview audit failed.', target.github_url);
130
131
  return 1;
131
132
  }
132
- // Background job — poll until the snapshot lands.
133
+ // Background job — poll until the snapshot lands. While polling, run
134
+ // a TTY spinner with phase labels + elapsed time so the user sees
135
+ // continuous feedback during the 60-90s wait. Skipped in --json mode
136
+ // so machine consumers see clean output. Falls back to phase-boundary
137
+ // line prints in non-TTY contexts (CI · redirected stderr).
133
138
  let envelope;
134
139
  if ('status' in result && result.status === 'running') {
135
140
  const pending = result;
141
+ const spinner = new Spinner();
136
142
  if (!asJson)
137
- console.log(c.dim(' This runs the full Claude audit · ~60-90 seconds. Hang tight.'));
138
- const waited = await waitForPreviewSnapshot(pending.project_id, refreshBaseline);
143
+ spinner.start(`Auditing ${target.slug}`);
144
+ let waited;
145
+ try {
146
+ waited = await waitForPreviewSnapshot(pending.project_id, refreshBaseline);
147
+ }
148
+ finally {
149
+ spinner.stop();
150
+ }
139
151
  if (!waited) {
140
152
  emitError(asJson, 'timeout', 'Preview audit is taking longer than expected. Try `commitshow status <repo>` in a minute.', target.github_url);
141
153
  return 1;
@@ -188,7 +200,7 @@ export async function audit(args) {
188
200
  console.log(renderQuotaFooter(envelope.quota));
189
201
  console.log('');
190
202
  }
191
- console.log(renderUpsell());
203
+ console.log(renderUpsell(envelope.project.github_url ?? target.github_url));
192
204
  console.log('');
193
205
  }
194
206
  if (target.kind === 'local') {
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ import { status } from './commands/status.js';
5
5
  import { login } from './commands/login.js';
6
6
  import { whoami } from './commands/whoami.js';
7
7
  import { c } from './lib/colors.js';
8
- const VERSION = '0.2.0';
8
+ import { checkLatestVersion, formatUpdateBanner } from './lib/version-check.js';
9
+ const VERSION = '0.2.11';
9
10
  const USAGE = `
10
11
  ${c.bold(c.gold('commit.show'))} ${c.dim(`v${VERSION}`)} ${c.muted('—')} ${c.cream('audit any vibe-coded project from your terminal.')}
11
12
  ${c.muted('the')} ${c.gold('walk-on')} ${c.muted('lane: drop in, get scored, leave · no signup, no audition, no league entry.')}
@@ -41,6 +42,12 @@ ${c.muted('LEARN MORE')}
41
42
  `;
42
43
  export async function main(argv) {
43
44
  const [cmd, ...rest] = argv;
45
+ // Background version check · 24h cached · 2s timeout · failure-tolerant.
46
+ // Runs in parallel with the actual command so a slow npm registry
47
+ // doesn't gate audit results. Banner prints to stderr (won't pollute
48
+ // --json stdout) AFTER the command finishes.
49
+ const isJson = rest.includes('--json');
50
+ const versionCheck = checkLatestVersion('commitshow', VERSION).catch(() => null);
44
51
  let code = 0;
45
52
  try {
46
53
  switch (cmd) {
@@ -84,5 +91,18 @@ export async function main(argv) {
84
91
  console.error(c.scarlet(`\n ${msg}`));
85
92
  code = 1;
86
93
  }
94
+ // Print update banner LAST so the audit output stays the focal point.
95
+ // Skip in --json mode so machine consumers see clean JSON.
96
+ if (!isJson) {
97
+ try {
98
+ const ck = await versionCheck;
99
+ if (ck && ck.outdated) {
100
+ process.stderr.write(formatUpdateBanner(ck));
101
+ }
102
+ }
103
+ catch {
104
+ // never block exit on version-check failure
105
+ }
106
+ }
87
107
  process.exit(code);
88
108
  }
@@ -149,8 +149,10 @@ export function renderAudit(view) {
149
149
  lines.push(' ' + ' '.repeat(leftPad) + c.goldDeep(row));
150
150
  }
151
151
  // Caption · small "/ 100 · band" · band tinted so the signal lives there.
152
- // Walk-on track gets an extra middle segment so the score is read in the
153
- // right context (88 walk-on ≠ 88 league).
152
+ // Walk-on track gets an extra middle segment + a sub-line surfacing the
153
+ // 95 max so users read the score in the right context (88 walk-on ≠ 88
154
+ // league · perfect walk-on caps at 95 because Scout+Community pillars
155
+ // structurally unevaluated).
154
156
  const band = total >= 75 ? 'strong' : total >= 50 ? 'mid' : 'weak';
155
157
  const bandTone = scoreTone(total);
156
158
  const captionVisible = isWalkOn
@@ -160,6 +162,10 @@ export function renderAudit(view) {
160
162
  if (isWalkOn) {
161
163
  lines.push(' ' + ' '.repeat(capPad)
162
164
  + c.muted('/ 100 · ') + c.gold('walk-on') + c.muted(' · ') + bandTone(band));
165
+ // Sub-caption explaining the 5pt headroom · centered, dim
166
+ const subVisible = 'audition unlocks final 5 · max walk-on score 95';
167
+ const subPad = Math.floor((58 - subVisible.length) / 2);
168
+ lines.push(' ' + ' '.repeat(subPad) + c.muted(subVisible));
163
169
  }
164
170
  else {
165
171
  lines.push(' ' + ' '.repeat(capPad) + c.muted('/ 100 · ') + bandTone(band));
@@ -204,6 +210,39 @@ export function renderAudit(view) {
204
210
  lines.push(' ' + boxBottom());
205
211
  lines.push('');
206
212
  }
213
+ // ─── Vibe Coder Checklist · 7-category framework ───
214
+ // Render only the categories that produced an actionable status (fail /
215
+ // warn / pass when meaningful). N/A categories are dropped to keep the
216
+ // terminal output compact. Helps beginners see "the 7 things AI-coded
217
+ // projects miss" framework directly in the report.
218
+ const vc = snapshot?.github_signals?.vibe_concerns;
219
+ if (vc) {
220
+ const items = vibeChecklistLines(vc);
221
+ const actionable = items.filter(i => i.status !== 'na');
222
+ if (actionable.length > 0) {
223
+ lines.push(' ' + boxTop());
224
+ lines.push(' ' + boxRow('Vibe Coder Checklist · 7 things AI-coded projects miss'.length, c.bold(c.gold('Vibe Coder Checklist')) + c.muted(' · 7 things AI-coded projects miss')));
225
+ lines.push(' ' + boxBlank());
226
+ for (const it of actionable.slice(0, 7)) {
227
+ const tone = it.status === 'fail' ? c.scarlet : it.status === 'warn' ? c.gold : c.teal;
228
+ const dot = it.status === 'fail' ? '✕' : it.status === 'warn' ? '⚠' : '✓';
229
+ const labelVisible = `${dot} ${it.label}`;
230
+ const detailVisible = it.detail;
231
+ // First line: dot + label (status colored)
232
+ lines.push(' ' + boxRow(labelVisible.length, tone(`${dot} `) + c.cream(it.label)));
233
+ // Second line: indented detail
234
+ const detailTrunc = truncate(detailVisible, 50);
235
+ lines.push(' ' + boxRow(2 + detailTrunc.length, c.muted(' ') + c.muted(detailTrunc)));
236
+ // Third line: evidence (if any) · only on fail/warn so success cases stay compact
237
+ if (it.evidence && (it.status === 'fail' || it.status === 'warn')) {
238
+ const evTrunc = truncate(it.evidence, 50);
239
+ lines.push(' ' + boxRow(2 + evTrunc.length, c.muted(' → ') + c.muted(evTrunc)));
240
+ }
241
+ }
242
+ lines.push(' ' + boxBottom());
243
+ lines.push('');
244
+ }
245
+ }
207
246
  // Standings + delta
208
247
  if (standing) {
209
248
  const rank = `#${standing.rank} of ${standing.total_in_season}`;
@@ -225,6 +264,110 @@ export function renderAudit(view) {
225
264
  lines.push(' '.repeat(footerPad) + c.gold('commit.show'));
226
265
  return lines.join('\n');
227
266
  }
267
+ function vibeChecklistLines(vc) {
268
+ const out = [];
269
+ // 1. Webhook idempotency
270
+ {
271
+ const w = vc?.webhook_idempotency;
272
+ if (w && w.handlers_seen > 0) {
273
+ const ev = w.sample_files?.[0];
274
+ out.push(w.gap
275
+ ? { key: 'webhook', status: 'fail', label: 'Webhook idempotency', detail: `${w.handlers_seen} handler${w.handlers_seen > 1 ? 's' : ''} · 0 idempotency-key check found`, evidence: ev }
276
+ : { key: 'webhook', status: 'pass', label: 'Webhook idempotency', detail: `${w.idempotency_signal_seen}/${w.handlers_seen} handler${w.handlers_seen > 1 ? 's' : ''} dedupe by event id`, evidence: ev });
277
+ }
278
+ else {
279
+ out.push({ key: 'webhook', status: 'na', label: 'Webhook idempotency', detail: 'no webhook handler files detected' });
280
+ }
281
+ }
282
+ // 2. RLS gaps
283
+ {
284
+ const r = vc?.rls_gaps;
285
+ if (r && r.tables > 0) {
286
+ const evList = r.tables_uncovered ?? [];
287
+ const ev = evList.length > 0 ? `tables: ${evList.slice(0, 3).join(', ')}${evList.length > 3 ? ` +${evList.length - 3}` : ''}` : undefined;
288
+ if (!r.has_rls_intent)
289
+ out.push({ key: 'rls', status: 'fail', label: 'RLS coverage', detail: `${r.tables} tables · 0 row-level-security policies`, evidence: ev });
290
+ else if (r.gap_estimate >= 3)
291
+ out.push({ key: 'rls', status: 'warn', label: 'RLS coverage', detail: `${r.tables} tables · ${r.policies} policies · ${r.gap_estimate} uncovered`, evidence: ev });
292
+ else if (r.gap_estimate > 0)
293
+ out.push({ key: 'rls', status: 'warn', label: 'RLS coverage', detail: `${r.tables} tables · ${r.policies} policies · ${r.gap_estimate} uncovered`, evidence: ev });
294
+ else
295
+ out.push({ key: 'rls', status: 'pass', label: 'RLS coverage', detail: `${r.tables} tables · ${r.policies} policies · all covered` });
296
+ }
297
+ else {
298
+ out.push({ key: 'rls', status: 'na', label: 'RLS coverage', detail: 'no SQL migrations detected' });
299
+ }
300
+ }
301
+ // 3. Secret exposure
302
+ {
303
+ const s = vc?.secret_exposure;
304
+ if (s && s.total > 0) {
305
+ const first = s.client_violations[0];
306
+ const ev = first ? `${first.file} · ${first.reason ?? first.pattern}` : undefined;
307
+ out.push({ key: 'secrets', status: 'fail', label: 'Secret in client code', detail: `${s.total} file${s.total > 1 ? 's' : ''} · e.g. ${first?.pattern ?? '?'}`, evidence: ev });
308
+ }
309
+ else {
310
+ out.push({ key: 'secrets', status: 'pass', label: 'Secret in client code', detail: 'no service-role keys in client paths' });
311
+ }
312
+ }
313
+ // 4. DB indexes
314
+ {
315
+ const d = vc?.db_indexes;
316
+ if (d && d.fk_columns_seen > 0) {
317
+ const sample = d.unindexed_samples?.[0];
318
+ const ev = sample
319
+ ? (sample.references ? `${sample.file} · ${sample.column} → ${sample.references}` : `${sample.file} · ${sample.column}`)
320
+ : undefined;
321
+ if (d.gap_estimate >= 3)
322
+ out.push({ key: 'indexes', status: 'warn', label: 'Database indexes', detail: `${d.fk_columns_seen} FK columns · ${d.indexes_seen} indexes · ${d.gap_estimate} unindexed`, evidence: ev });
323
+ else if (d.gap_estimate > 0)
324
+ out.push({ key: 'indexes', status: 'warn', label: 'Database indexes', detail: `${d.fk_columns_seen} FK · ${d.indexes_seen} idx · ${d.gap_estimate} unindexed`, evidence: ev });
325
+ else
326
+ out.push({ key: 'indexes', status: 'pass', label: 'Database indexes', detail: `${d.fk_columns_seen} FK columns · ${d.indexes_seen} indexes · healthy` });
327
+ }
328
+ else {
329
+ out.push({ key: 'indexes', status: 'na', label: 'Database indexes', detail: 'no SQL migrations detected' });
330
+ }
331
+ }
332
+ // 5. Observability
333
+ {
334
+ const o = vc?.observability;
335
+ if (o?.detected)
336
+ out.push({ key: 'observability', status: 'pass', label: 'Error tracking', detail: o.libs.join(' · ') });
337
+ else
338
+ out.push({ key: 'observability', status: 'fail', label: 'Error tracking', detail: 'no sentry / datadog / pino / winston / otel lib in package.json' });
339
+ }
340
+ // 6. Rate limiting
341
+ {
342
+ const r = vc?.rate_limit;
343
+ if (r) {
344
+ if (!r.has_api_routes)
345
+ out.push({ key: 'rate_limit', status: 'na', label: 'API rate limiting', detail: 'no API routes detected' });
346
+ else if (r.lib_detected)
347
+ out.push({ key: 'rate_limit', status: 'pass', label: 'API rate limiting', detail: r.lib_detected });
348
+ else if (r.middleware_detected)
349
+ out.push({ key: 'rate_limit', status: 'pass', label: 'API rate limiting', detail: 'custom middleware detected' });
350
+ else if (r.needs_attention)
351
+ out.push({ key: 'rate_limit', status: 'fail', label: 'API rate limiting', detail: 'API routes · 0 rate-limit lib or middleware' });
352
+ }
353
+ else {
354
+ out.push({ key: 'rate_limit', status: 'na', label: 'API rate limiting', detail: 'unknown' });
355
+ }
356
+ }
357
+ // 7. Prompt injection
358
+ {
359
+ const p = vc?.prompt_injection;
360
+ if (!p?.uses_ai_sdk)
361
+ out.push({ key: 'prompt_injection', status: 'na', label: 'Prompt injection risk', detail: 'no AI SDK detected' });
362
+ else if (p.suspicious)
363
+ out.push({ key: 'prompt_injection', status: 'warn', label: 'Prompt injection risk', detail: `${p.raw_input_to_prompt_files.length} file${p.raw_input_to_prompt_files.length > 1 ? 's' : ''} pipe user input into prompt` });
364
+ else
365
+ out.push({ key: 'prompt_injection', status: 'pass', label: 'Prompt injection risk', detail: 'AI SDK in use · no obvious raw-input patterns' });
366
+ }
367
+ // Sort fail → warn → pass → na
368
+ const order = { fail: 0, warn: 1, pass: 2, na: 3 };
369
+ return out.sort((a, b) => order[a.status] - order[b.status]);
370
+ }
228
371
  function truncate(s, w) {
229
372
  const vl = s.length;
230
373
  if (vl <= w)
@@ -537,15 +680,23 @@ function wrapText(s, width) {
537
680
  out.push(line.trim());
538
681
  return out;
539
682
  }
540
- export function renderUpsell() {
683
+ export function renderUpsell(githubUrl) {
541
684
  const lines = [];
542
685
  // "Walk-on" — anyone running CLI without auditioning. Theatre-coherent
543
686
  // (Audition / Audit / Stage / Backstage / Walk-on) · friendlier than
544
687
  // "preview" (which doubles as our DB status) · positions audition as the
545
688
  // upgrade path without making the walk-on tier feel lesser.
689
+ //
690
+ // CTA carries the repo URL as a query param so /submit pre-fills the
691
+ // GitHub input — one-click conversion from the terminal to the form.
692
+ // Slug form (github.com/owner/repo) keeps the URL short.
693
+ const repoSlug = githubUrl ? githubUrl.replace(/^https?:\/\//, '').replace(/\/$/, '') : null;
694
+ const submitUrl = repoSlug
695
+ ? `https://commit.show/submit?repo=${encodeURIComponent(repoSlug)}`
696
+ : 'https://commit.show/submit';
546
697
  const titleVisible = 'Walk-on · drop-in audit, no audition yet';
547
698
  const headVisible = 'Audition to unlock:';
548
- const ctaVisible = '→ https://commit.show/submit';
699
+ const ctaVisible = `→ ${submitUrl}`;
549
700
  lines.push(' ' + boxTop());
550
701
  lines.push(' ' + boxRow(titleVisible.length, c.bold(c.gold('Walk-on')) + c.muted(' · ') + c.cream('drop-in audit, no audition yet')));
551
702
  lines.push(' ' + boxBlank());
@@ -0,0 +1,116 @@
1
+ // Spinner — simple progress indicator for long-running CLI ops (the
2
+ // preview audit poll, ~60-90s of Claude time). Renders to stderr so
3
+ // stdout stays clean for --json / pipe consumers. Animates only when
4
+ // stderr is a real TTY; in CI / redirected output we fall back to
5
+ // phase-change line prints (one line per ~15s milestone) so logs
6
+ // don't drown in spinner frames but still show progress.
7
+ //
8
+ // Phase heuristic is time-based (server doesn't currently expose
9
+ // staged progress over HTTP). The labels approximate what's actually
10
+ // happening server-side:
11
+ // 0–12 s fetching repo signals (GitHub API · package.json · paths)
12
+ // 12–25 s probing live URL · Lighthouse · completeness signals
13
+ // 25–55 s running Claude audit · scout brief generation
14
+ // 55–90 s finalizing report
15
+ // 90 s+ waiting on retry / Anthropic rate-limit recovery
16
+ import { c } from './colors.js';
17
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
18
+ const DEFAULT_PHASES = [
19
+ { until: 12, label: 'fetching repo signals' },
20
+ { until: 25, label: 'probing live URL · Lighthouse' },
21
+ { until: 55, label: 'running audit · scout brief' },
22
+ { until: 90, label: 'finalizing report' },
23
+ { until: Infinity, label: 'still cooking · larger repos can take 2–3 min' },
24
+ ];
25
+ export class Spinner {
26
+ frame = 0;
27
+ startMs = 0;
28
+ timer = null;
29
+ label = '';
30
+ isTty = false;
31
+ active = false;
32
+ // Track the last phase index we printed in non-TTY mode so we only
33
+ // emit a new line on phase boundary (not every interval tick).
34
+ lastNonTtyPhase = -1;
35
+ start(label) {
36
+ if (this.active)
37
+ return;
38
+ this.label = label;
39
+ this.startMs = Date.now();
40
+ this.isTty = !!process.stderr.isTTY;
41
+ this.active = true;
42
+ if (this.isTty) {
43
+ // Hide cursor for cleaner animation. Restored on stop().
44
+ process.stderr.write('\x1b[?25l');
45
+ this.timer = setInterval(() => this.tickTty(), 90);
46
+ }
47
+ else {
48
+ // Non-TTY: print phase boundaries on a slower pulse so CI logs
49
+ // get a heartbeat without spinner spam.
50
+ this.timer = setInterval(() => this.tickPlain(), 1000);
51
+ }
52
+ }
53
+ /** Update the static label (e.g., switching from 'Auditing X' to
54
+ * 'Polling for snapshot…'). Phase label keeps its own clock. */
55
+ setLabel(label) {
56
+ this.label = label;
57
+ }
58
+ elapsedSec() {
59
+ return Math.floor((Date.now() - this.startMs) / 1000);
60
+ }
61
+ phaseFor(s) {
62
+ for (let i = 0; i < DEFAULT_PHASES.length; i++) {
63
+ if (s < DEFAULT_PHASES[i].until)
64
+ return { idx: i, label: DEFAULT_PHASES[i].label };
65
+ }
66
+ return { idx: DEFAULT_PHASES.length - 1, label: DEFAULT_PHASES[DEFAULT_PHASES.length - 1].label };
67
+ }
68
+ tickTty() {
69
+ const s = this.elapsedSec();
70
+ const ph = this.phaseFor(s);
71
+ const f = FRAMES[this.frame % FRAMES.length];
72
+ this.frame++;
73
+ // \r returns to column 0 · \x1b[K erases to end of line. The trailing
74
+ // erase is needed so a previous longer line doesn't leave a tail.
75
+ const line = `\r ${c.gold(f)} ${c.cream(this.label)} ${c.muted('·')} ${c.muted(ph.label)} ${c.dim(`${s}s`)}\x1b[K`;
76
+ process.stderr.write(line);
77
+ }
78
+ tickPlain() {
79
+ const s = this.elapsedSec();
80
+ const ph = this.phaseFor(s);
81
+ if (ph.idx !== this.lastNonTtyPhase) {
82
+ this.lastNonTtyPhase = ph.idx;
83
+ process.stderr.write(` · ${ph.label}${s > 0 ? ` (${s}s)` : ''}\n`);
84
+ }
85
+ }
86
+ /** Stop the spinner and (optionally) replace the line with a final
87
+ * status. Final text is printed without the spinner glyph and ends
88
+ * with a newline so subsequent output starts on a clean line. */
89
+ stop(finalText) {
90
+ if (!this.active)
91
+ return;
92
+ if (this.timer)
93
+ clearInterval(this.timer);
94
+ this.timer = null;
95
+ if (this.isTty) {
96
+ // Erase line, restore cursor.
97
+ process.stderr.write('\r\x1b[K\x1b[?25h');
98
+ }
99
+ if (finalText)
100
+ process.stderr.write(` ${finalText}\n`);
101
+ this.active = false;
102
+ }
103
+ }
104
+ // Module-level helper · ensure we always restore the cursor even if
105
+ // the user hits Ctrl+C while the spinner is animating. Idempotent.
106
+ let sigintWired = false;
107
+ function wireSigintCleanup() {
108
+ if (sigintWired)
109
+ return;
110
+ sigintWired = true;
111
+ process.on('SIGINT', () => {
112
+ process.stderr.write('\r\x1b[K\x1b[?25h');
113
+ process.exit(130);
114
+ });
115
+ }
116
+ wireSigintCleanup();
@@ -0,0 +1,106 @@
1
+ // CLI auto-update detection — npx caches the resolved package version
2
+ // for up to 24h+, so users running `npx commitshow audit` keep getting
3
+ // a stale binary even after we publish 0.2.X+1. Asking them to clear
4
+ // `~/.npm/_npx/` is a UX failure. Instead we check the npm registry
5
+ // in the background, cache the answer for 24h, and surface a single
6
+ // clear warning line + update command.
7
+ //
8
+ // We never block · never auto-update · just inform.
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ const CACHE_DIR = join(homedir(), '.commitshow');
13
+ const CACHE_PATH = join(CACHE_DIR, 'version-cache.json');
14
+ const TTL_MS = 24 * 60 * 60 * 1000;
15
+ function readCache() {
16
+ if (!existsSync(CACHE_PATH))
17
+ return null;
18
+ try {
19
+ const raw = readFileSync(CACHE_PATH, 'utf8');
20
+ return JSON.parse(raw);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ function writeCache(c) {
27
+ try {
28
+ if (!existsSync(CACHE_DIR))
29
+ mkdirSync(CACHE_DIR, { recursive: true, mode: 0o700 });
30
+ writeFileSync(CACHE_PATH, JSON.stringify(c), { mode: 0o600 });
31
+ }
32
+ catch {
33
+ // Cache write failures are non-fatal — version check just runs
34
+ // every invocation instead of once per 24h.
35
+ }
36
+ }
37
+ /** Compare semver-ish strings · returns >0 if a > b, 0 if equal, <0 if a < b.
38
+ * Tolerates pre-release suffixes by ignoring them (best-effort, not full semver). */
39
+ function semverCompare(a, b) {
40
+ const pa = a.split('.').map(s => parseInt(s, 10) || 0);
41
+ const pb = b.split('.').map(s => parseInt(s, 10) || 0);
42
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
43
+ const ai = pa[i] ?? 0;
44
+ const bi = pb[i] ?? 0;
45
+ if (ai !== bi)
46
+ return ai - bi;
47
+ }
48
+ return 0;
49
+ }
50
+ /** Fetch latest version from npm registry · returns null on any failure
51
+ * (offline · DNS · timeout · 404). Non-blocking — caller decides if it
52
+ * matters. 2s timeout cap so a slow registry doesn't gate the audit. */
53
+ async function fetchLatestFromNpm(packageName) {
54
+ try {
55
+ const ctrl = new AbortController();
56
+ const timer = setTimeout(() => ctrl.abort(), 2000);
57
+ const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
58
+ signal: ctrl.signal,
59
+ headers: { accept: 'application/json' },
60
+ });
61
+ clearTimeout(timer);
62
+ if (!res.ok)
63
+ return null;
64
+ const j = await res.json();
65
+ return typeof j.version === 'string' ? j.version : null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /** Check whether the running CLI is the latest published version.
72
+ * Hits cache first (24h TTL) · falls back to npm registry GET.
73
+ * Returns synchronously-ish from cache · async-fetches in the background
74
+ * ONLY when cache is stale.
75
+ *
76
+ * Caller pattern:
77
+ * const ck = await checkLatestVersion('commitshow', '0.2.9')
78
+ * if (ck.outdated) console.error(formatUpdateBanner(ck))
79
+ */
80
+ export async function checkLatestVersion(packageName, currentVersion) {
81
+ const cache = readCache();
82
+ const fresh = cache && (Date.now() - cache.checked_at) < TTL_MS;
83
+ let latest = fresh ? (cache?.latest ?? null) : null;
84
+ if (!fresh) {
85
+ latest = await fetchLatestFromNpm(packageName);
86
+ if (latest)
87
+ writeCache({ checked_at: Date.now(), latest });
88
+ }
89
+ const outdated = !!(latest && semverCompare(currentVersion, latest) < 0);
90
+ return { current: currentVersion, latest, outdated };
91
+ }
92
+ /** Single-line warning banner for stale-CLI users. Goes to stderr so
93
+ * it doesn't pollute --json stdout. */
94
+ export function formatUpdateBanner(ck) {
95
+ if (!ck.outdated || !ck.latest)
96
+ return '';
97
+ const lines = [
98
+ '',
99
+ `⚠ A newer commitshow is available · ${ck.current} → ${ck.latest}`,
100
+ ` Update with: npm install -g commitshow@latest`,
101
+ ` Or run once: npx commitshow@latest <command>`,
102
+ ` (npx caches the binary for ~24h · @latest forces a fresh resolve)`,
103
+ '',
104
+ ];
105
+ return lines.join('\n');
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commitshow",
3
- "version": "0.2.5",
3
+ "version": "0.2.11",
4
4
  "description": "commit.show CLI — audit any vibe-coded project from your terminal.",
5
5
  "type": "module",
6
6
  "bin": {