commitshow 0.2.6 → 0.3.0

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');
@@ -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;
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
  }
@@ -138,10 +138,40 @@ export function renderAudit(view) {
138
138
  const slug = p.github_url?.replace(/^https?:\/\//, '') ?? '';
139
139
  lines.push(' ' + c.bold(c.cream(name)) + ' ' + c.muted(slug));
140
140
  lines.push('');
141
+ // ── 3 strengths + 2 concerns box · errors-first reorder (2026-04-30) ──
142
+ // CONCERNS render before STRENGTHS · the value prop is "what your AI
143
+ // missed", so they lead. Score follows as the receipt below.
144
+ const strengths = asStringArray(snapshot?.rich_analysis?.scout_brief?.strengths, 3);
145
+ const concerns = asStringArray(snapshot?.rich_analysis?.scout_brief?.weaknesses, 2);
146
+ if (strengths.length > 0 || concerns.length > 0) {
147
+ const bulletWidth = CONTENT_W - 2;
148
+ lines.push(' ' + boxTop());
149
+ // Heading row inside the box · "What this build missed" lead.
150
+ if (concerns.length > 0) {
151
+ const heading = 'What this build missed';
152
+ lines.push(' ' + boxRow(heading.length, c.bold(c.scarlet(heading))));
153
+ }
154
+ for (const s of concerns) {
155
+ const txt = truncate(s, bulletWidth);
156
+ lines.push(' ' + boxRow(2 + txt.length, c.scarlet('↓ ') + c.cream(txt)));
157
+ }
158
+ if (strengths.length > 0) {
159
+ if (concerns.length > 0)
160
+ lines.push(' ' + boxBlank());
161
+ const heading = 'What it got right';
162
+ lines.push(' ' + boxRow(heading.length, c.bold(c.teal(heading))));
163
+ }
164
+ for (const s of strengths) {
165
+ const txt = truncate(s, bulletWidth);
166
+ lines.push(' ' + boxRow(2 + txt.length, c.teal('↑ ') + c.cream(txt)));
167
+ }
168
+ lines.push(' ' + boxBottom());
169
+ lines.push('');
170
+ }
141
171
  // Hero score · big-digit ASCII for X-share screenshots.
142
- // Always brand gold (slightly deeper tone for screenshot legibility) so
143
- // the wordmark + score read as one cohesive brand mark. Band info is
144
- // surfaced in the small caption underneath instead of via color.
172
+ // Now positioned AFTER concerns/strengths · the score is the receipt
173
+ // for the findings above, not the lead. Always brand gold for cohesive
174
+ // wordmark + score brand mark.
145
175
  const bigRows = bigText(String(total));
146
176
  const bigWidth = bigRows[0].length;
147
177
  const leftPad = Math.floor((58 - bigWidth) / 2);
@@ -149,8 +179,10 @@ export function renderAudit(view) {
149
179
  lines.push(' ' + ' '.repeat(leftPad) + c.goldDeep(row));
150
180
  }
151
181
  // 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).
182
+ // Walk-on track gets an extra middle segment + a sub-line surfacing the
183
+ // 95 max so users read the score in the right context (88 walk-on ≠ 88
184
+ // league · perfect walk-on caps at 95 because Scout+Community pillars
185
+ // structurally unevaluated).
154
186
  const band = total >= 75 ? 'strong' : total >= 50 ? 'mid' : 'weak';
155
187
  const bandTone = scoreTone(total);
156
188
  const captionVisible = isWalkOn
@@ -160,6 +192,10 @@ export function renderAudit(view) {
160
192
  if (isWalkOn) {
161
193
  lines.push(' ' + ' '.repeat(capPad)
162
194
  + c.muted('/ 100 · ') + c.gold('walk-on') + c.muted(' · ') + bandTone(band));
195
+ // Sub-caption explaining the 5pt headroom · centered, dim
196
+ const subVisible = 'audition unlocks final 5 · max walk-on score 95';
197
+ const subPad = Math.floor((58 - subVisible.length) / 2);
198
+ lines.push(' ' + ' '.repeat(subPad) + c.muted(subVisible));
163
199
  }
164
200
  else {
165
201
  lines.push(' ' + ' '.repeat(capPad) + c.muted('/ 100 · ') + bandTone(band));
@@ -183,26 +219,39 @@ export function renderAudit(view) {
183
219
  lines.push(' ' + ` Comm. ${pad(`${p.score_community}/20`, 7)} ${scoreBar(p.score_community, 20)}`);
184
220
  }
185
221
  lines.push('');
186
- // 3 strengths + 2 concerns from scout_brief · §15-C.2 content contract.
187
- // Web surfaces the full 5+3; the CLI keeps it tight for terminal screenshots.
188
- const strengths = asStringArray(snapshot?.rich_analysis?.scout_brief?.strengths, 3);
189
- const concerns = asStringArray(snapshot?.rich_analysis?.scout_brief?.weaknesses, 2);
190
- if (strengths.length > 0 || concerns.length > 0) {
191
- // strengths/concerns each render as `↑ ` (2 visible) + truncated line.
192
- // Total visible-line budget inside the box is CONTENT_W chars; reserve
193
- // 2 for the arrow + space, leaving CONTENT_W - 2 for the bullet text.
194
- const bulletWidth = CONTENT_W - 2;
195
- lines.push(' ' + boxTop());
196
- for (const s of strengths) {
197
- const txt = truncate(s, bulletWidth);
198
- lines.push(' ' + boxRow(2 + txt.length, c.teal(' ') + c.cream(txt)));
199
- }
200
- for (const s of concerns) {
201
- const txt = truncate(s, bulletWidth);
202
- lines.push(' ' + boxRow(2 + txt.length, c.scarlet(' ') + c.cream(txt)));
222
+ // (concerns/strengths block moved above the score · errors-first 2026-04-30)
223
+ // ─── Vibe Coder Checklist · 7-category framework ───
224
+ // Render only the categories that produced an actionable status (fail /
225
+ // warn / pass when meaningful). N/A categories are dropped to keep the
226
+ // terminal output compact. Helps beginners see "the 7 things AI-coded
227
+ // projects miss" framework directly in the report.
228
+ const vc = snapshot?.github_signals?.vibe_concerns;
229
+ if (vc) {
230
+ const items = vibeChecklistLines(vc);
231
+ const actionable = items.filter(i => i.status !== 'na');
232
+ if (actionable.length > 0) {
233
+ lines.push(' ' + boxTop());
234
+ 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')));
235
+ lines.push(' ' + boxBlank());
236
+ for (const it of actionable.slice(0, 7)) {
237
+ const tone = it.status === 'fail' ? c.scarlet : it.status === 'warn' ? c.gold : c.teal;
238
+ const dot = it.status === 'fail' ? '✕' : it.status === 'warn' ? '⚠' : '✓';
239
+ const labelVisible = `${dot} ${it.label}`;
240
+ const detailVisible = it.detail;
241
+ // First line: dot + label (status colored)
242
+ lines.push(' ' + boxRow(labelVisible.length, tone(`${dot} `) + c.cream(it.label)));
243
+ // Second line: indented detail
244
+ const detailTrunc = truncate(detailVisible, 50);
245
+ lines.push(' ' + boxRow(2 + detailTrunc.length, c.muted(' ') + c.muted(detailTrunc)));
246
+ // Third line: evidence (if any) · only on fail/warn so success cases stay compact
247
+ if (it.evidence && (it.status === 'fail' || it.status === 'warn')) {
248
+ const evTrunc = truncate(it.evidence, 50);
249
+ lines.push(' ' + boxRow(2 + evTrunc.length, c.muted(' → ') + c.muted(evTrunc)));
250
+ }
251
+ }
252
+ lines.push(' ' + boxBottom());
253
+ lines.push('');
203
254
  }
204
- lines.push(' ' + boxBottom());
205
- lines.push('');
206
255
  }
207
256
  // Standings + delta
208
257
  if (standing) {
@@ -225,6 +274,110 @@ export function renderAudit(view) {
225
274
  lines.push(' '.repeat(footerPad) + c.gold('commit.show'));
226
275
  return lines.join('\n');
227
276
  }
277
+ function vibeChecklistLines(vc) {
278
+ const out = [];
279
+ // 1. Webhook idempotency
280
+ {
281
+ const w = vc?.webhook_idempotency;
282
+ if (w && w.handlers_seen > 0) {
283
+ const ev = w.sample_files?.[0];
284
+ out.push(w.gap
285
+ ? { key: 'webhook', status: 'fail', label: 'Webhook idempotency', detail: `${w.handlers_seen} handler${w.handlers_seen > 1 ? 's' : ''} · 0 idempotency-key check found`, evidence: ev }
286
+ : { 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 });
287
+ }
288
+ else {
289
+ out.push({ key: 'webhook', status: 'na', label: 'Webhook idempotency', detail: 'no webhook handler files detected' });
290
+ }
291
+ }
292
+ // 2. RLS gaps
293
+ {
294
+ const r = vc?.rls_gaps;
295
+ if (r && r.tables > 0) {
296
+ const evList = r.tables_uncovered ?? [];
297
+ const ev = evList.length > 0 ? `tables: ${evList.slice(0, 3).join(', ')}${evList.length > 3 ? ` +${evList.length - 3}` : ''}` : undefined;
298
+ if (!r.has_rls_intent)
299
+ out.push({ key: 'rls', status: 'fail', label: 'RLS coverage', detail: `${r.tables} tables · 0 row-level-security policies`, evidence: ev });
300
+ else if (r.gap_estimate >= 3)
301
+ out.push({ key: 'rls', status: 'warn', label: 'RLS coverage', detail: `${r.tables} tables · ${r.policies} policies · ${r.gap_estimate} uncovered`, evidence: ev });
302
+ else if (r.gap_estimate > 0)
303
+ out.push({ key: 'rls', status: 'warn', label: 'RLS coverage', detail: `${r.tables} tables · ${r.policies} policies · ${r.gap_estimate} uncovered`, evidence: ev });
304
+ else
305
+ out.push({ key: 'rls', status: 'pass', label: 'RLS coverage', detail: `${r.tables} tables · ${r.policies} policies · all covered` });
306
+ }
307
+ else {
308
+ out.push({ key: 'rls', status: 'na', label: 'RLS coverage', detail: 'no SQL migrations detected' });
309
+ }
310
+ }
311
+ // 3. Secret exposure
312
+ {
313
+ const s = vc?.secret_exposure;
314
+ if (s && s.total > 0) {
315
+ const first = s.client_violations[0];
316
+ const ev = first ? `${first.file} · ${first.reason ?? first.pattern}` : undefined;
317
+ 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 });
318
+ }
319
+ else {
320
+ out.push({ key: 'secrets', status: 'pass', label: 'Secret in client code', detail: 'no service-role keys in client paths' });
321
+ }
322
+ }
323
+ // 4. DB indexes
324
+ {
325
+ const d = vc?.db_indexes;
326
+ if (d && d.fk_columns_seen > 0) {
327
+ const sample = d.unindexed_samples?.[0];
328
+ const ev = sample
329
+ ? (sample.references ? `${sample.file} · ${sample.column} → ${sample.references}` : `${sample.file} · ${sample.column}`)
330
+ : undefined;
331
+ if (d.gap_estimate >= 3)
332
+ 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 });
333
+ else if (d.gap_estimate > 0)
334
+ 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 });
335
+ else
336
+ out.push({ key: 'indexes', status: 'pass', label: 'Database indexes', detail: `${d.fk_columns_seen} FK columns · ${d.indexes_seen} indexes · healthy` });
337
+ }
338
+ else {
339
+ out.push({ key: 'indexes', status: 'na', label: 'Database indexes', detail: 'no SQL migrations detected' });
340
+ }
341
+ }
342
+ // 5. Observability
343
+ {
344
+ const o = vc?.observability;
345
+ if (o?.detected)
346
+ out.push({ key: 'observability', status: 'pass', label: 'Error tracking', detail: o.libs.join(' · ') });
347
+ else
348
+ out.push({ key: 'observability', status: 'fail', label: 'Error tracking', detail: 'no sentry / datadog / pino / winston / otel lib in package.json' });
349
+ }
350
+ // 6. Rate limiting
351
+ {
352
+ const r = vc?.rate_limit;
353
+ if (r) {
354
+ if (!r.has_api_routes)
355
+ out.push({ key: 'rate_limit', status: 'na', label: 'API rate limiting', detail: 'no API routes detected' });
356
+ else if (r.lib_detected)
357
+ out.push({ key: 'rate_limit', status: 'pass', label: 'API rate limiting', detail: r.lib_detected });
358
+ else if (r.middleware_detected)
359
+ out.push({ key: 'rate_limit', status: 'pass', label: 'API rate limiting', detail: 'custom middleware detected' });
360
+ else if (r.needs_attention)
361
+ out.push({ key: 'rate_limit', status: 'fail', label: 'API rate limiting', detail: 'API routes · 0 rate-limit lib or middleware' });
362
+ }
363
+ else {
364
+ out.push({ key: 'rate_limit', status: 'na', label: 'API rate limiting', detail: 'unknown' });
365
+ }
366
+ }
367
+ // 7. Prompt injection
368
+ {
369
+ const p = vc?.prompt_injection;
370
+ if (!p?.uses_ai_sdk)
371
+ out.push({ key: 'prompt_injection', status: 'na', label: 'Prompt injection risk', detail: 'no AI SDK detected' });
372
+ else if (p.suspicious)
373
+ 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` });
374
+ else
375
+ out.push({ key: 'prompt_injection', status: 'pass', label: 'Prompt injection risk', detail: 'AI SDK in use · no obvious raw-input patterns' });
376
+ }
377
+ // Sort fail → warn → pass → na
378
+ const order = { fail: 0, warn: 1, pass: 2, na: 3 };
379
+ return out.sort((a, b) => order[a.status] - order[b.status]);
380
+ }
228
381
  function truncate(s, w) {
229
382
  const vl = s.length;
230
383
  if (vl <= w)
@@ -255,6 +408,21 @@ export function renderMarkdown(view) {
255
408
  if (p.github_url)
256
409
  lines.push(`_${p.github_url}_`);
257
410
  lines.push('');
411
+ // errors-first markdown order (2026-04-30) · concerns + strengths
412
+ // BEFORE the score so the AI agent reading audit.md picks up the
413
+ // actionable items in its first pass.
414
+ if (concerns.length > 0) {
415
+ lines.push(`## What this build missed`);
416
+ for (const s of concerns)
417
+ lines.push(`- ${s}`);
418
+ lines.push('');
419
+ }
420
+ if (strengths.length > 0) {
421
+ lines.push(`## What it got right`);
422
+ for (const s of strengths)
423
+ lines.push(`- ${s}`);
424
+ lines.push('');
425
+ }
258
426
  lines.push(`## Score · ${p.score_total} / 100`);
259
427
  lines.push('');
260
428
  lines.push(`- Audit: ${p.score_auto}/50`);
@@ -267,20 +435,8 @@ export function renderMarkdown(view) {
267
435
  lines.push(`- Ranked #${standing.rank} of ${standing.total_in_season} — projected **${standing.projected_tier ?? '—'}** (top ${Math.round(standing.percentile)}%)`);
268
436
  }
269
437
  lines.push('');
270
- if (strengths.length > 0) {
271
- lines.push(`## Strengths`);
272
- for (const s of strengths)
273
- lines.push(`- ${s}`);
274
- lines.push('');
275
- }
276
- if (concerns.length > 0) {
277
- lines.push(`## Concerns`);
278
- for (const s of concerns)
279
- lines.push(`- ${s}`);
280
- lines.push('');
281
- }
282
438
  lines.push(`---`);
283
- lines.push(`Auditioned on commit.show · https://commit.show/projects/${p.id}`);
439
+ lines.push(`Audited on commit.show · https://commit.show/projects/${p.id}`);
284
440
  lines.push('');
285
441
  return lines.join('\n');
286
442
  }
@@ -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.6",
3
+ "version": "0.3.0",
4
4
  "description": "commit.show CLI — audit any vibe-coded project from your terminal.",
5
5
  "type": "module",
6
6
  "bin": {