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.
- package/dist/commands/audit.js +17 -5
- package/dist/index.js +21 -1
- package/dist/lib/render.js +155 -4
- package/dist/lib/spinner.js +116 -0
- package/dist/lib/version-check.js +106 -0
- package/package.json +1 -1
package/dist/commands/audit.js
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/render.js
CHANGED
|
@@ -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
|
|
153
|
-
// right context (88 walk-on ≠ 88
|
|
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 =
|
|
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
|
+
}
|