mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-19 08:28:06 +02:00
feat: add GitHub star nudges to CLI build view and docs sidebar (#271)
* feat: load star count during context builds * docs: document star prompt opt-out * fix: initialize demo context star count * feat(docs-site): add GitHub star count widget to docs sidebar * test: isolate star-prompt build-view tests from ambient CI env
This commit is contained in:
parent
5232578d44
commit
795a97485a
15 changed files with 928 additions and 12 deletions
|
|
@ -12,6 +12,13 @@ import { buildPublicIngestPlan, executePublicIngestTarget, publicProgressMessage
|
|||
import { createAggregateProgressPort } from './progress-port-adapter.js';
|
||||
import { formatDuration } from './demo-metrics.js';
|
||||
import { profileMark } from './startup-profile.js';
|
||||
import {
|
||||
isFreshStarCountCache,
|
||||
readStarCountCache,
|
||||
writeStarCountCache,
|
||||
} from './star-prompt/cache.js';
|
||||
import { fetchGitHubStarCount as defaultFetchGitHubStarCount } from './star-prompt/star-count.js';
|
||||
import { renderStarPromptLine } from './star-prompt/star-line.js';
|
||||
|
||||
profileMark('module:context-build-view');
|
||||
|
||||
|
|
@ -79,6 +86,7 @@ export interface ContextBuildViewState {
|
|||
frame: number;
|
||||
startedAt: number | null;
|
||||
totalElapsedMs: number;
|
||||
starCount: number | null;
|
||||
}
|
||||
|
||||
export interface ContextBuildArgs {
|
||||
|
|
@ -121,6 +129,8 @@ interface CompletedItemName {
|
|||
interface ContextBuildRenderOptions {
|
||||
styled?: boolean;
|
||||
showHint?: boolean;
|
||||
showStarPrompt?: boolean;
|
||||
columns?: number;
|
||||
hintText?: string;
|
||||
projectDir?: string;
|
||||
title?: string;
|
||||
|
|
@ -138,6 +148,15 @@ export interface ContextBuildDeps {
|
|||
now?: () => number;
|
||||
onSourceProgress?: (sources: ContextBuildSourceProgressUpdate[]) => void;
|
||||
sourceProgressThrottleMs?: number;
|
||||
fetchStarCount?: typeof defaultFetchGitHubStarCount;
|
||||
starPromptEnv?: StarPromptEnv;
|
||||
starPromptHomeDir?: string;
|
||||
}
|
||||
|
||||
interface StarPromptEnv extends NodeJS.ProcessEnv {
|
||||
CI?: string;
|
||||
DO_NOT_TRACK?: string;
|
||||
KTX_NO_STAR?: string;
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
|
|
@ -427,6 +446,14 @@ export function renderContextBuildView(
|
|||
lines.push('');
|
||||
}
|
||||
|
||||
if (options.showStarPrompt && hasActive) {
|
||||
const starPrompt = renderStarPromptLine({
|
||||
count: state.starCount,
|
||||
columns: options.columns ?? 80,
|
||||
});
|
||||
lines.push(styled ? dim(starPrompt) : starPrompt);
|
||||
}
|
||||
|
||||
if (options.showHint && hasActive) {
|
||||
const hintContent = options.hintText ?? 'Ctrl+C to stop';
|
||||
const hint = ` ${hintContent}`;
|
||||
|
|
@ -584,6 +611,7 @@ export function viewStateFromSourceProgress(
|
|||
frame: 0,
|
||||
startedAt: startedAtMs ?? null,
|
||||
totalElapsedMs: startedAtMs ? now - startedAtMs : 0,
|
||||
starCount: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -631,6 +659,9 @@ export function createRepainter(io: KtxCliIo) {
|
|||
hasPainted = true;
|
||||
lastCursorUpRows = cursorUpRowsAfterWrite(content);
|
||||
},
|
||||
columns() {
|
||||
return terminalColumns();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -806,6 +837,7 @@ export function initViewState(targets: KtxPublicIngestPlanTarget[]): ContextBuil
|
|||
frame: 0,
|
||||
startedAt: null,
|
||||
totalElapsedMs: 0,
|
||||
starCount: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -817,6 +849,50 @@ function formatProgressDetail(
|
|||
return `[${percent}%] ${publicProgressMessage(update.message, target)}`;
|
||||
}
|
||||
|
||||
const STAR_COUNT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function envFlag(value: string | undefined): boolean {
|
||||
return value !== undefined && value !== '' && value !== '0' && value !== 'false';
|
||||
}
|
||||
|
||||
function shouldSuppressStarPrompt(env: StarPromptEnv): boolean {
|
||||
return envFlag(env.CI) || envFlag(env.DO_NOT_TRACK) || envFlag(env.KTX_NO_STAR);
|
||||
}
|
||||
|
||||
function startStarPromptCountRefresh(input: {
|
||||
fetchStarCount: typeof defaultFetchGitHubStarCount;
|
||||
homeDir?: string;
|
||||
now: () => number;
|
||||
paint: () => void;
|
||||
state: ContextBuildViewState;
|
||||
}): void {
|
||||
const cached = readStarCountCache({ homeDir: input.homeDir });
|
||||
if (cached) {
|
||||
input.state.starCount = cached.count;
|
||||
}
|
||||
|
||||
if (isFreshStarCountCache(cached, new Date(input.now()), STAR_COUNT_CACHE_TTL_MS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void input.fetchStarCount()
|
||||
.then((count) => {
|
||||
if (typeof count !== 'number' || !Number.isFinite(count)) {
|
||||
return;
|
||||
}
|
||||
input.state.starCount = count;
|
||||
input.paint();
|
||||
void writeStarCountCache(
|
||||
{
|
||||
count,
|
||||
fetchedAt: new Date(input.now()).toISOString(),
|
||||
},
|
||||
{ homeDir: input.homeDir },
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function runContextBuild(
|
||||
project: KtxPublicIngestProject,
|
||||
args: ContextBuildArgs,
|
||||
|
|
@ -838,13 +914,31 @@ export async function runContextBuild(
|
|||
state.startedAt = nowFn();
|
||||
|
||||
const repainter = isTTY ? createRepainter(io) : null;
|
||||
const starPromptEnabled = repainter !== null && !shouldSuppressStarPrompt(deps.starPromptEnv ?? process.env);
|
||||
const viewOpts = {
|
||||
styled: true,
|
||||
projectDir: args.projectDir,
|
||||
notices: plan.notices ?? [],
|
||||
warnings: plan.warnings,
|
||||
};
|
||||
const paint = (hint: boolean) => repainter?.paint(renderContextBuildView(state, { ...viewOpts, showHint: hint }));
|
||||
const paint = (hint: boolean) =>
|
||||
repainter?.paint(
|
||||
renderContextBuildView(state, {
|
||||
...viewOpts,
|
||||
showHint: hint,
|
||||
showStarPrompt: starPromptEnabled && hint,
|
||||
columns: repainter.columns(),
|
||||
}),
|
||||
);
|
||||
if (starPromptEnabled) {
|
||||
startStarPromptCountRefresh({
|
||||
fetchStarCount: deps.fetchStarCount ?? defaultFetchGitHubStarCount,
|
||||
homeDir: deps.starPromptHomeDir,
|
||||
now: nowFn,
|
||||
paint: () => paint(true),
|
||||
state,
|
||||
});
|
||||
}
|
||||
paint(true);
|
||||
|
||||
let spinnerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ const unicode = detectUnicodeSupport();
|
|||
export const SYMBOLS = {
|
||||
middot: unicode ? '·' : '-',
|
||||
emDash: unicode ? '—' : '--',
|
||||
star: unicode ? '★' : '*',
|
||||
rightArrow: unicode ? '→' : '->',
|
||||
} as const;
|
||||
|
||||
export function dim(text: string): string {
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ async function runDemoContextReplay(
|
|||
frame: 0,
|
||||
startedAt: Date.now(),
|
||||
totalElapsedMs: 0,
|
||||
starCount: null,
|
||||
};
|
||||
|
||||
const allTargets = [...allPrimary, ...allContext];
|
||||
|
|
|
|||
50
packages/cli/src/star-prompt/cache.ts
Normal file
50
packages/cli/src/star-prompt/cache.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const starCountCacheSchema = z
|
||||
.object({
|
||||
count: z.number().int().nonnegative(),
|
||||
fetchedAt: z.string(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type StarCountCache = z.infer<typeof starCountCacheSchema>;
|
||||
|
||||
/** @internal */
|
||||
export function starCountCachePath(homeDir = homedir()): string {
|
||||
return join(homeDir, '.ktx', 'star-count.json');
|
||||
}
|
||||
|
||||
export function readStarCountCache(options: { homeDir?: string } = {}): StarCountCache | null {
|
||||
try {
|
||||
return starCountCacheSchema.parse(JSON.parse(readFileSync(starCountCachePath(options.homeDir), 'utf-8')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeStarCountCache(value: StarCountCache, options: { homeDir?: string } = {}): Promise<void> {
|
||||
try {
|
||||
const path = starCountCachePath(options.homeDir);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
|
||||
renameSync(tempPath, path);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function isFreshStarCountCache(cache: StarCountCache | null, now: Date, ttlMs: number): boolean {
|
||||
if (!cache) {
|
||||
return false;
|
||||
}
|
||||
const fetchedAtMs = Date.parse(cache.fetchedAt);
|
||||
if (Number.isNaN(fetchedAtMs)) {
|
||||
return false;
|
||||
}
|
||||
return now.getTime() - fetchedAtMs < ttlMs;
|
||||
}
|
||||
76
packages/cli/src/star-prompt/star-count.ts
Normal file
76
packages/cli/src/star-prompt/star-count.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { request as httpsRequest } from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
import { z } from 'zod';
|
||||
|
||||
const GITHUB_REPO_URL = new URL('https://api.github.com/repos/Kaelio/ktx');
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
const githubRepoSchema = z.object({
|
||||
stargazers_count: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
type HttpsRequest = typeof httpsRequest;
|
||||
|
||||
function parseStarCount(raw: string): number {
|
||||
return githubRepoSchema.parse(JSON.parse(raw)).stargazers_count;
|
||||
}
|
||||
|
||||
export function fetchGitHubStarCount(options: { request?: HttpsRequest; timeoutMs?: number } = {}): Promise<number | null> {
|
||||
const requestImpl = options.request ?? httpsRequest;
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (count: number | null): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(count);
|
||||
};
|
||||
|
||||
try {
|
||||
const request = requestImpl(
|
||||
GITHUB_REPO_URL,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'ktx-star-prompt',
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer | string) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
response.on('end', () => {
|
||||
const statusCode = response.statusCode ?? 0;
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
finish(parseStarCount(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.on('socket', (socket) => {
|
||||
socket.unref();
|
||||
});
|
||||
request.on('error', () => {
|
||||
finish(null);
|
||||
});
|
||||
request.setTimeout(timeoutMs, () => {
|
||||
request.destroy(new Error('GitHub star count request timed out'));
|
||||
finish(null);
|
||||
});
|
||||
request.end();
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
42
packages/cli/src/star-prompt/star-line.ts
Normal file
42
packages/cli/src/star-prompt/star-line.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { SYMBOLS } from '../io/symbols.js';
|
||||
|
||||
const STAR_PROMPT_URL = 'github.com/Kaelio/ktx';
|
||||
const STAR_PROMPT_TEXT = 'This takes a few minutes - mind giving ktx a star while you wait?';
|
||||
|
||||
interface StarPromptSymbols {
|
||||
star: string;
|
||||
middot: string;
|
||||
rightArrow: string;
|
||||
}
|
||||
|
||||
export interface RenderStarPromptLineOptions {
|
||||
columns: number;
|
||||
count?: number | null;
|
||||
symbols?: StarPromptSymbols;
|
||||
}
|
||||
|
||||
function usableColumns(columns: number): number {
|
||||
return Number.isFinite(columns) && columns > 0 ? Math.floor(columns) : 80;
|
||||
}
|
||||
|
||||
function starCountSegment(count: number | null | undefined, symbols: StarPromptSymbols): string {
|
||||
if (typeof count !== 'number' || !Number.isFinite(count)) {
|
||||
return '';
|
||||
}
|
||||
const formatted = new Intl.NumberFormat('en-US').format(count);
|
||||
return ` ${symbols.middot} ${formatted} ${symbols.star}`;
|
||||
}
|
||||
|
||||
export function renderStarPromptLine(options: RenderStarPromptLineOptions): string {
|
||||
const symbols = options.symbols ?? SYMBOLS;
|
||||
const columns = usableColumns(options.columns);
|
||||
const base = ` ${symbols.star} ${STAR_PROMPT_TEXT} ${STAR_PROMPT_URL}`;
|
||||
const withCount = `${base}${starCountSegment(options.count, symbols)}`;
|
||||
if (withCount.length <= columns) {
|
||||
return withCount;
|
||||
}
|
||||
if (base.length <= columns) {
|
||||
return base;
|
||||
}
|
||||
return ` ${symbols.star} Star ktx ${symbols.rightArrow} ${STAR_PROMPT_URL}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue