mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +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
|
|
@ -2,10 +2,21 @@ import { source } from "@/lib/source";
|
||||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { baseOptions } from "@/app/layout.config";
|
import { baseOptions } from "@/app/layout.config";
|
||||||
|
import { GitHubStars } from "@/components/github-stars";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: ReactNode }) {
|
export default function Layout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
<DocsLayout
|
||||||
|
tree={source.pageTree}
|
||||||
|
{...baseOptions}
|
||||||
|
sidebar={{
|
||||||
|
banner: (
|
||||||
|
<div className="flex">
|
||||||
|
<GitHubStars />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</DocsLayout>
|
</DocsLayout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -869,6 +869,147 @@ body::after {
|
||||||
50% { opacity: 0.65; transform: scale(0.9); }
|
50% { opacity: 0.65; transform: scale(0.9); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
GitHub star widget (navbar)
|
||||||
|
Split pill: GitHub mark + "Star" │ gold star + count.
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
.ktx-stars {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-fd-border);
|
||||||
|
background: color-mix(in oklch, var(--color-fd-card) 72%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
font-family: var(--font-display), var(--font-sans), sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-fd-foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 2px rgba(27, 27, 24, 0.04);
|
||||||
|
transition:
|
||||||
|
transform 0.3s var(--ktx-ease),
|
||||||
|
box-shadow 0.3s var(--ktx-ease),
|
||||||
|
border-color 0.3s ease;
|
||||||
|
animation: ktx-stars-in 0.5s var(--ktx-ease) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ktx-stars-in {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktx-stars:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in oklch, var(--color-fd-primary) 45%, var(--color-fd-border));
|
||||||
|
box-shadow:
|
||||||
|
0 6px 18px -8px rgba(14, 116, 144, 0.28),
|
||||||
|
0 1px 2px rgba(27, 27, 24, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktx-stars:focus-visible {
|
||||||
|
outline: 2px solid var(--color-fd-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ktx-stars {
|
||||||
|
background: color-mix(in oklch, var(--color-fd-card) 60%, transparent);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ktx-stars:hover {
|
||||||
|
border-color: rgba(34, 211, 238, 0.4);
|
||||||
|
box-shadow:
|
||||||
|
0 6px 18px -8px rgba(34, 211, 238, 0.3),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktx-stars-seg {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktx-stars-seg--count {
|
||||||
|
border-left: 1px solid var(--color-fd-border);
|
||||||
|
background: color-mix(in oklch, var(--color-fd-primary) 6%, transparent);
|
||||||
|
transition: background 0.3s var(--ktx-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktx-stars:hover .ktx-stars-seg--count {
|
||||||
|
background: color-mix(in oklch, var(--color-fd-primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktx-stars-gh {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktx-stars-text {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktx-stars-star {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: #f5b301;
|
||||||
|
transition: transform 0.3s var(--ktx-ease), filter 0.3s var(--ktx-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktx-stars:hover .ktx-stars-star {
|
||||||
|
transform: scale(1.18) rotate(-8deg);
|
||||||
|
filter: drop-shadow(0 1px 4px rgba(245, 179, 1, 0.55));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktx-stars-count {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-fd-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton shown only on the rare cold (uncached) fetch */
|
||||||
|
.ktx-stars--skeleton {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ktx-stars-skeleton-bar {
|
||||||
|
display: inline-block;
|
||||||
|
width: 26px;
|
||||||
|
height: 11px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-fd-muted) 25%,
|
||||||
|
color-mix(in oklch, var(--color-fd-muted-foreground) 28%, var(--color-fd-muted)) 50%,
|
||||||
|
var(--color-fd-muted) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: ktx-stars-shimmer 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ktx-stars-shimmer {
|
||||||
|
from { background-position: 200% 0; }
|
||||||
|
to { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact on phones: drop the "Star" word, keep mark + count */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ktx-stars-text { display: none; }
|
||||||
|
.ktx-stars-seg { padding: 0 9px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ktx-stars { animation: none; transition: none; }
|
||||||
|
.ktx-stars:hover { transform: none; }
|
||||||
|
.ktx-stars:hover .ktx-stars-star { transform: none; }
|
||||||
|
.ktx-stars-skeleton-bar { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Dot grid */
|
/* Dot grid */
|
||||||
.dot-grid {
|
.dot-grid {
|
||||||
background-image: radial-gradient(
|
background-image: radial-gradient(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||||
import { GitHubIcon } from "@/components/github-icon";
|
|
||||||
import { Logo } from "@/components/logo";
|
import { Logo } from "@/components/logo";
|
||||||
import { SlackIcon } from "@/components/slack-icon";
|
import { SlackIcon } from "@/components/slack-icon";
|
||||||
|
|
||||||
|
|
@ -26,14 +25,6 @@ export const baseOptions: BaseLayoutProps = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "icon",
|
|
||||||
label: "GitHub",
|
|
||||||
icon: <GitHubIcon />,
|
|
||||||
text: "GitHub",
|
|
||||||
url: "https://github.com/kaelio/ktx",
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "icon",
|
type: "icon",
|
||||||
label: "Join the ktx Slack community",
|
label: "Join the ktx Slack community",
|
||||||
|
|
|
||||||
93
docs-site/components/github-stars.tsx
Normal file
93
docs-site/components/github-stars.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { GitHubIcon } from "@/components/github-icon";
|
||||||
|
|
||||||
|
const REPO = "kaelio/ktx";
|
||||||
|
const REPO_URL = `https://github.com/${REPO}`;
|
||||||
|
const API_URL = `https://api.github.com/repos/${REPO}`;
|
||||||
|
|
||||||
|
async function fetchStarCount(): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_URL, {
|
||||||
|
headers: { Accept: "application/vnd.github+json" },
|
||||||
|
// Revalidate hourly. GitHub's unauthenticated REST limit is 60 req/h per
|
||||||
|
// IP, so a single cached server-side fetch keeps the count fresh while
|
||||||
|
// never exposing visitors to rate limits or layout shift.
|
||||||
|
next: { revalidate: 3600 },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as { stargazers_count?: unknown };
|
||||||
|
return typeof data.stargazers_count === "number"
|
||||||
|
? data.stargazers_count
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact, GitHub-style count: 847 → "847", 1234 → "1.2k", 12345 → "12.3k". */
|
||||||
|
function formatStars(count: number): string {
|
||||||
|
if (count < 1000) return count.toLocaleString("en-US");
|
||||||
|
const thousands = count / 1000;
|
||||||
|
const rounded =
|
||||||
|
thousands >= 100 ? Math.round(thousands) : Math.round(thousands * 10) / 10;
|
||||||
|
return `${rounded}k`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarGlyph() {
|
||||||
|
return (
|
||||||
|
<svg className="ktx-stars-star" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 2.6l2.9 5.88 6.49.95-4.7 4.57 1.11 6.46L12 17.4l-5.8 3.06 1.11-6.46-4.7-4.57 6.49-.95z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function StarsContent() {
|
||||||
|
const count = await fetchStarCount();
|
||||||
|
const label =
|
||||||
|
count === null
|
||||||
|
? "Star ktx on GitHub"
|
||||||
|
: `Star ktx on GitHub — ${count.toLocaleString("en-US")} stars`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={REPO_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={label}
|
||||||
|
className="ktx-stars"
|
||||||
|
>
|
||||||
|
<span className="ktx-stars-seg ktx-stars-seg--label">
|
||||||
|
<GitHubIcon className="ktx-stars-gh" />
|
||||||
|
<span className="ktx-stars-text">Star</span>
|
||||||
|
</span>
|
||||||
|
{count !== null && (
|
||||||
|
<span className="ktx-stars-seg ktx-stars-seg--count">
|
||||||
|
<StarGlyph />
|
||||||
|
<span className="ktx-stars-count">{formatStars(count)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarsSkeleton() {
|
||||||
|
return (
|
||||||
|
<span className="ktx-stars ktx-stars--skeleton" aria-hidden="true">
|
||||||
|
<span className="ktx-stars-seg ktx-stars-seg--label">
|
||||||
|
<GitHubIcon className="ktx-stars-gh" />
|
||||||
|
<span className="ktx-stars-text">Star</span>
|
||||||
|
</span>
|
||||||
|
<span className="ktx-stars-seg ktx-stars-seg--count">
|
||||||
|
<span className="ktx-stars-skeleton-bar" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GitHubStars() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<StarsSkeleton />}>
|
||||||
|
<StarsContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -112,6 +112,18 @@ pnpm add -g @kaelio/ktx
|
||||||
yarn global add @kaelio/ktx
|
yarn global add @kaelio/ktx
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Build-view star prompt
|
||||||
|
|
||||||
|
During an interactive context build, `ktx setup` and `ktx ingest` can show a dim
|
||||||
|
GitHub star reminder above the `Ctrl+C to stop` hint. **ktx** skips this prompt
|
||||||
|
for CI, non-TTY output, and `DO_NOT_TRACK=1`.
|
||||||
|
|
||||||
|
To suppress only this prompt while keeping other notices enabled, set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KTX_NO_STAR=1
|
||||||
|
```
|
||||||
|
|
||||||
## Project resolution
|
## Project resolution
|
||||||
|
|
||||||
Most commands are project-aware. Pass `--project-dir <path>` when scripting or
|
Most commands are project-aware. Pass `--project-dir <path>` when scripting or
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ import { buildPublicIngestPlan, executePublicIngestTarget, publicProgressMessage
|
||||||
import { createAggregateProgressPort } from './progress-port-adapter.js';
|
import { createAggregateProgressPort } from './progress-port-adapter.js';
|
||||||
import { formatDuration } from './demo-metrics.js';
|
import { formatDuration } from './demo-metrics.js';
|
||||||
import { profileMark } from './startup-profile.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');
|
profileMark('module:context-build-view');
|
||||||
|
|
||||||
|
|
@ -79,6 +86,7 @@ export interface ContextBuildViewState {
|
||||||
frame: number;
|
frame: number;
|
||||||
startedAt: number | null;
|
startedAt: number | null;
|
||||||
totalElapsedMs: number;
|
totalElapsedMs: number;
|
||||||
|
starCount: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContextBuildArgs {
|
export interface ContextBuildArgs {
|
||||||
|
|
@ -121,6 +129,8 @@ interface CompletedItemName {
|
||||||
interface ContextBuildRenderOptions {
|
interface ContextBuildRenderOptions {
|
||||||
styled?: boolean;
|
styled?: boolean;
|
||||||
showHint?: boolean;
|
showHint?: boolean;
|
||||||
|
showStarPrompt?: boolean;
|
||||||
|
columns?: number;
|
||||||
hintText?: string;
|
hintText?: string;
|
||||||
projectDir?: string;
|
projectDir?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
@ -138,6 +148,15 @@ export interface ContextBuildDeps {
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
onSourceProgress?: (sources: ContextBuildSourceProgressUpdate[]) => void;
|
onSourceProgress?: (sources: ContextBuildSourceProgressUpdate[]) => void;
|
||||||
sourceProgressThrottleMs?: number;
|
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 ---
|
// --- Rendering ---
|
||||||
|
|
@ -427,6 +446,14 @@ export function renderContextBuildView(
|
||||||
lines.push('');
|
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) {
|
if (options.showHint && hasActive) {
|
||||||
const hintContent = options.hintText ?? 'Ctrl+C to stop';
|
const hintContent = options.hintText ?? 'Ctrl+C to stop';
|
||||||
const hint = ` ${hintContent}`;
|
const hint = ` ${hintContent}`;
|
||||||
|
|
@ -584,6 +611,7 @@ export function viewStateFromSourceProgress(
|
||||||
frame: 0,
|
frame: 0,
|
||||||
startedAt: startedAtMs ?? null,
|
startedAt: startedAtMs ?? null,
|
||||||
totalElapsedMs: startedAtMs ? now - startedAtMs : 0,
|
totalElapsedMs: startedAtMs ? now - startedAtMs : 0,
|
||||||
|
starCount: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -631,6 +659,9 @@ export function createRepainter(io: KtxCliIo) {
|
||||||
hasPainted = true;
|
hasPainted = true;
|
||||||
lastCursorUpRows = cursorUpRowsAfterWrite(content);
|
lastCursorUpRows = cursorUpRowsAfterWrite(content);
|
||||||
},
|
},
|
||||||
|
columns() {
|
||||||
|
return terminalColumns();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -806,6 +837,7 @@ export function initViewState(targets: KtxPublicIngestPlanTarget[]): ContextBuil
|
||||||
frame: 0,
|
frame: 0,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
totalElapsedMs: 0,
|
totalElapsedMs: 0,
|
||||||
|
starCount: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -817,6 +849,50 @@ function formatProgressDetail(
|
||||||
return `[${percent}%] ${publicProgressMessage(update.message, target)}`;
|
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(
|
export async function runContextBuild(
|
||||||
project: KtxPublicIngestProject,
|
project: KtxPublicIngestProject,
|
||||||
args: ContextBuildArgs,
|
args: ContextBuildArgs,
|
||||||
|
|
@ -838,13 +914,31 @@ export async function runContextBuild(
|
||||||
state.startedAt = nowFn();
|
state.startedAt = nowFn();
|
||||||
|
|
||||||
const repainter = isTTY ? createRepainter(io) : null;
|
const repainter = isTTY ? createRepainter(io) : null;
|
||||||
|
const starPromptEnabled = repainter !== null && !shouldSuppressStarPrompt(deps.starPromptEnv ?? process.env);
|
||||||
const viewOpts = {
|
const viewOpts = {
|
||||||
styled: true,
|
styled: true,
|
||||||
projectDir: args.projectDir,
|
projectDir: args.projectDir,
|
||||||
notices: plan.notices ?? [],
|
notices: plan.notices ?? [],
|
||||||
warnings: plan.warnings,
|
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);
|
paint(true);
|
||||||
|
|
||||||
let spinnerInterval: ReturnType<typeof setInterval> | null = null;
|
let spinnerInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ const unicode = detectUnicodeSupport();
|
||||||
export const SYMBOLS = {
|
export const SYMBOLS = {
|
||||||
middot: unicode ? '·' : '-',
|
middot: unicode ? '·' : '-',
|
||||||
emDash: unicode ? '—' : '--',
|
emDash: unicode ? '—' : '--',
|
||||||
|
star: unicode ? '★' : '*',
|
||||||
|
rightArrow: unicode ? '→' : '->',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function dim(text: string): string {
|
export function dim(text: string): string {
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,7 @@ async function runDemoContextReplay(
|
||||||
frame: 0,
|
frame: 0,
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
totalElapsedMs: 0,
|
totalElapsedMs: 0,
|
||||||
|
starCount: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const allTargets = [...allPrimary, ...allContext];
|
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}`;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js';
|
import { buildDefaultKtxProjectConfig, type KtxProjectConfig } from '../src/context/project/config.js';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { KtxPublicIngestProject, KtxPublicIngestTargetResult } from '../src/public-ingest.js';
|
import type { KtxPublicIngestProject, KtxPublicIngestTargetResult } from '../src/public-ingest.js';
|
||||||
import {
|
import {
|
||||||
type ContextBuildTargetState,
|
type ContextBuildTargetState,
|
||||||
|
|
@ -12,6 +15,7 @@ import {
|
||||||
runContextBuild,
|
runContextBuild,
|
||||||
viewStateFromSourceProgress,
|
viewStateFromSourceProgress,
|
||||||
} from '../src/context-build-view.js';
|
} from '../src/context-build-view.js';
|
||||||
|
import { writeStarCountCache } from '../src/star-prompt/cache.js';
|
||||||
|
|
||||||
function makeIo(options: { isTTY?: boolean; columns?: number } = {}) {
|
function makeIo(options: { isTTY?: boolean; columns?: number } = {}) {
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
|
|
@ -426,6 +430,64 @@ describe('renderContextBuildView', () => {
|
||||||
expect(rendered).not.toContain('resume');
|
expect(rendered).not.toContain('resume');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the star prompt directly above the stop hint while active', () => {
|
||||||
|
const state = initViewState([
|
||||||
|
{ connectionId: 'warehouse', driver: 'postgres', operation: 'database-ingest', debugCommand: '', steps: ['database-schema'] },
|
||||||
|
]);
|
||||||
|
state.primarySources[0].status = 'running';
|
||||||
|
state.starCount = 1234;
|
||||||
|
|
||||||
|
const rendered = renderContextBuildView(state, {
|
||||||
|
styled: false,
|
||||||
|
showHint: true,
|
||||||
|
showStarPrompt: true,
|
||||||
|
columns: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rendered).toContain(
|
||||||
|
' ★ This takes a few minutes - mind giving ktx a star while you wait? github.com/Kaelio/ktx · 1,234 ★',
|
||||||
|
);
|
||||||
|
expect(rendered.indexOf('mind giving ktx a star')).toBeLessThan(rendered.indexOf('Ctrl+C to stop'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the star prompt without a count while active', () => {
|
||||||
|
const state = initViewState([
|
||||||
|
{ connectionId: 'warehouse', driver: 'postgres', operation: 'database-ingest', debugCommand: '', steps: ['database-schema'] },
|
||||||
|
]);
|
||||||
|
state.primarySources[0].status = 'running';
|
||||||
|
|
||||||
|
const rendered = renderContextBuildView(state, {
|
||||||
|
styled: false,
|
||||||
|
showHint: true,
|
||||||
|
showStarPrompt: true,
|
||||||
|
columns: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rendered).toContain(
|
||||||
|
' ★ This takes a few minutes - mind giving ktx a star while you wait? github.com/Kaelio/ktx',
|
||||||
|
);
|
||||||
|
expect(rendered).not.toContain('1,234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the star prompt after the build finishes', () => {
|
||||||
|
const state = initViewState([
|
||||||
|
{ connectionId: 'warehouse', driver: 'postgres', operation: 'database-ingest', debugCommand: '', steps: ['database-schema'] },
|
||||||
|
]);
|
||||||
|
state.primarySources[0].status = 'done';
|
||||||
|
state.totalElapsedMs = 5000;
|
||||||
|
state.starCount = 1234;
|
||||||
|
|
||||||
|
const rendered = renderContextBuildView(state, {
|
||||||
|
styled: false,
|
||||||
|
showHint: true,
|
||||||
|
showStarPrompt: true,
|
||||||
|
columns: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rendered).not.toContain('mind giving ktx a star');
|
||||||
|
expect(rendered).not.toContain('Ctrl+C to stop');
|
||||||
|
});
|
||||||
|
|
||||||
it('omits detach hint when all targets are done', () => {
|
it('omits detach hint when all targets are done', () => {
|
||||||
const state = initViewState([
|
const state = initViewState([
|
||||||
{ connectionId: 'warehouse', driver: 'postgres', operation: 'database-ingest', debugCommand: '', steps: ['database-schema'] },
|
{ connectionId: 'warehouse', driver: 'postgres', operation: 'database-ingest', debugCommand: '', steps: ['database-schema'] },
|
||||||
|
|
@ -608,9 +670,140 @@ describe('createRepainter', () => {
|
||||||
const cursorMoves = [...io.stdout().matchAll(/\[(\d+)A/g)].map((m) => Number(m[1]));
|
const cursorMoves = [...io.stdout().matchAll(/\[(\d+)A/g)].map((m) => Number(m[1]));
|
||||||
expect(cursorMoves).toEqual([2]);
|
expect(cursorMoves).toEqual([2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes the same terminal columns used for visual row calculations', () => {
|
||||||
|
const io = makeIo({ isTTY: true, columns: 44 });
|
||||||
|
const repainter = createRepainter(io.io);
|
||||||
|
|
||||||
|
expect(repainter.columns()).toBe(44);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('runContextBuild', () => {
|
describe('runContextBuild', () => {
|
||||||
|
let starPromptHomeDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
starPromptHomeDir = await mkdtemp(join(tmpdir(), 'ktx-star-prompt-run-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(starPromptHomeDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the interactive star prompt from a fresh cached count without fetching', async () => {
|
||||||
|
await writeStarCountCache(
|
||||||
|
{ count: 1234, fetchedAt: '2026-06-08T10:00:00.000Z' },
|
||||||
|
{ homeDir: starPromptHomeDir },
|
||||||
|
);
|
||||||
|
const io = makeIo({ isTTY: true, columns: 120 });
|
||||||
|
const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
|
||||||
|
const fetchStarCount = vi.fn(async () => 9999);
|
||||||
|
const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation));
|
||||||
|
|
||||||
|
await runContextBuild(
|
||||||
|
project,
|
||||||
|
{ projectDir: '/tmp/project', inputMode: 'disabled' },
|
||||||
|
io.io,
|
||||||
|
{
|
||||||
|
executeTarget,
|
||||||
|
fetchStarCount,
|
||||||
|
now: () => Date.parse('2026-06-08T11:00:00.000Z'),
|
||||||
|
starPromptEnv: {},
|
||||||
|
starPromptHomeDir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchStarCount).not.toHaveBeenCalled();
|
||||||
|
expect(io.stdout()).toContain('mind giving ktx a star');
|
||||||
|
expect(io.stdout()).toContain('1,234 ★');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes a stale cached count while the interactive build is active', async () => {
|
||||||
|
await writeStarCountCache(
|
||||||
|
{ count: 1234, fetchedAt: '2026-06-06T10:00:00.000Z' },
|
||||||
|
{ homeDir: starPromptHomeDir },
|
||||||
|
);
|
||||||
|
const io = makeIo({ isTTY: true, columns: 120 });
|
||||||
|
const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
|
||||||
|
const fetchStarCount = vi.fn(async () => 5678);
|
||||||
|
let finishTarget!: () => void;
|
||||||
|
const targetFinished = new Promise<KtxPublicIngestTargetResult>((resolve) => {
|
||||||
|
finishTarget = () => {
|
||||||
|
resolve(successResult('warehouse', 'postgres', 'database-ingest'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const executeTarget = vi.fn(async () => targetFinished);
|
||||||
|
|
||||||
|
const run = runContextBuild(
|
||||||
|
project,
|
||||||
|
{ projectDir: '/tmp/project', inputMode: 'disabled' },
|
||||||
|
io.io,
|
||||||
|
{
|
||||||
|
executeTarget,
|
||||||
|
fetchStarCount,
|
||||||
|
now: () => Date.parse('2026-06-08T11:00:00.000Z'),
|
||||||
|
starPromptEnv: {},
|
||||||
|
starPromptHomeDir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetchStarCount).toHaveBeenCalledTimes(1);
|
||||||
|
expect(io.stdout()).toContain('5,678 ★');
|
||||||
|
});
|
||||||
|
finishTarget();
|
||||||
|
await expect(run).resolves.toMatchObject({ exitCode: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['DO_NOT_TRACK', { DO_NOT_TRACK: '1' }],
|
||||||
|
['KTX_NO_STAR', { KTX_NO_STAR: '1' }],
|
||||||
|
['CI', { CI: '1' }],
|
||||||
|
])('suppresses the star prompt and fetch for %s', async (_name, starPromptEnv) => {
|
||||||
|
const io = makeIo({ isTTY: true, columns: 120 });
|
||||||
|
const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
|
||||||
|
const fetchStarCount = vi.fn(async () => 1234);
|
||||||
|
const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation));
|
||||||
|
|
||||||
|
await runContextBuild(
|
||||||
|
project,
|
||||||
|
{ projectDir: '/tmp/project', inputMode: 'disabled' },
|
||||||
|
io.io,
|
||||||
|
{
|
||||||
|
executeTarget,
|
||||||
|
fetchStarCount,
|
||||||
|
now: () => Date.parse('2026-06-08T11:00:00.000Z'),
|
||||||
|
starPromptEnv,
|
||||||
|
starPromptHomeDir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchStarCount).not.toHaveBeenCalled();
|
||||||
|
expect(io.stdout()).not.toContain('mind giving ktx a star');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses the star prompt and fetch for non-TTY output', async () => {
|
||||||
|
const io = makeIo({ isTTY: false, columns: 120 });
|
||||||
|
const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
|
||||||
|
const fetchStarCount = vi.fn(async () => 1234);
|
||||||
|
const executeTarget = vi.fn(async (target) => successResult(target.connectionId, target.driver, target.operation));
|
||||||
|
|
||||||
|
await runContextBuild(
|
||||||
|
project,
|
||||||
|
{ projectDir: '/tmp/project', inputMode: 'disabled' },
|
||||||
|
io.io,
|
||||||
|
{
|
||||||
|
executeTarget,
|
||||||
|
fetchStarCount,
|
||||||
|
now: () => Date.parse('2026-06-08T11:00:00.000Z'),
|
||||||
|
starPromptHomeDir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchStarCount).not.toHaveBeenCalled();
|
||||||
|
expect(io.stdout()).not.toContain('mind giving ktx a star');
|
||||||
|
});
|
||||||
|
|
||||||
it('executes scan targets before source-ingest targets', async () => {
|
it('executes scan targets before source-ingest targets', async () => {
|
||||||
const io = makeIo();
|
const io = makeIo();
|
||||||
const project = projectWithConnections({
|
const project = projectWithConnections({
|
||||||
|
|
|
||||||
73
packages/cli/test/star-prompt/cache.test.ts
Normal file
73
packages/cli/test/star-prompt/cache.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isFreshStarCountCache,
|
||||||
|
readStarCountCache,
|
||||||
|
starCountCachePath,
|
||||||
|
writeStarCountCache,
|
||||||
|
} from '../../src/star-prompt/cache.js';
|
||||||
|
|
||||||
|
describe('star prompt cache', () => {
|
||||||
|
let homeDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
homeDir = await mkdtemp(join(tmpdir(), 'ktx-star-count-cache-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(homeDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses ~/.ktx/star-count.json', () => {
|
||||||
|
expect(starCountCachePath(homeDir)).toBe(join(homeDir, '.ktx', 'star-count.json'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips strict cache data', async () => {
|
||||||
|
await writeStarCountCache({ count: 1234, fetchedAt: '2026-06-08T10:00:00.000Z' }, { homeDir });
|
||||||
|
|
||||||
|
expect(readStarCountCache({ homeDir })).toEqual({
|
||||||
|
count: 1234,
|
||||||
|
fetchedAt: '2026-06-08T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for missing, corrupt, or unknown-field cache files', async () => {
|
||||||
|
expect(readStarCountCache({ homeDir })).toBeNull();
|
||||||
|
|
||||||
|
await mkdir(join(homeDir, '.ktx'), { recursive: true });
|
||||||
|
await writeFile(starCountCachePath(homeDir), '{bad json', 'utf-8');
|
||||||
|
expect(readStarCountCache({ homeDir })).toBeNull();
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
starCountCachePath(homeDir),
|
||||||
|
JSON.stringify({ count: 1234, fetchedAt: '2026-06-08T10:00:00.000Z', extra: true }),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
expect(readStarCountCache({ homeDir })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes formatted JSON with a trailing newline', async () => {
|
||||||
|
await writeStarCountCache({ count: 9876, fetchedAt: '2026-06-08T10:00:00.000Z' }, { homeDir });
|
||||||
|
|
||||||
|
const raw = await readFile(starCountCachePath(homeDir), 'utf-8');
|
||||||
|
expect(raw).toContain('"count": 9876');
|
||||||
|
expect(raw.endsWith('\n')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects fresh and stale cache entries', () => {
|
||||||
|
const now = new Date('2026-06-08T12:00:00.000Z');
|
||||||
|
const ttlMs = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isFreshStarCountCache({ count: 1, fetchedAt: '2026-06-07T12:00:01.000Z' }, now, ttlMs),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isFreshStarCountCache({ count: 1, fetchedAt: '2026-06-07T11:59:59.000Z' }, now, ttlMs),
|
||||||
|
).toBe(false);
|
||||||
|
expect(isFreshStarCountCache({ count: 1, fetchedAt: 'not-a-date' }, now, ttlMs)).toBe(false);
|
||||||
|
expect(isFreshStarCountCache(null, now, ttlMs)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
packages/cli/test/star-prompt/star-count.test.ts
Normal file
90
packages/cli/test/star-prompt/star-count.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const requestMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock('node:https', () => ({
|
||||||
|
request: requestMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type MockResponse = EventEmitter & { statusCode?: number };
|
||||||
|
type MockRequest = EventEmitter & {
|
||||||
|
destroy: ReturnType<typeof vi.fn>;
|
||||||
|
end: () => void;
|
||||||
|
setTimeout: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockHttpsResponse(statusCode: number, body: string): { socket: { unref: ReturnType<typeof vi.fn> } } {
|
||||||
|
const socket = { unref: vi.fn() };
|
||||||
|
requestMock.mockImplementation((_url: unknown, _options: unknown, callback: (response: MockResponse) => void) => {
|
||||||
|
const request = new EventEmitter() as MockRequest;
|
||||||
|
request.destroy = vi.fn();
|
||||||
|
request.setTimeout = vi.fn();
|
||||||
|
request.end = () => {
|
||||||
|
request.emit('socket', socket);
|
||||||
|
const response = new EventEmitter() as MockResponse;
|
||||||
|
response.statusCode = statusCode;
|
||||||
|
callback(response);
|
||||||
|
response.emit('data', Buffer.from(body));
|
||||||
|
response.emit('end');
|
||||||
|
};
|
||||||
|
return request;
|
||||||
|
});
|
||||||
|
return { socket };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('fetchGitHubStarCount', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
requestMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the Kaelio/ktx repository star count and unrefs the socket', async () => {
|
||||||
|
const { socket } = mockHttpsResponse(200, JSON.stringify({ stargazers_count: 1234, name: 'ktx' }));
|
||||||
|
const { fetchGitHubStarCount } = await import('../../src/star-prompt/star-count.js');
|
||||||
|
|
||||||
|
await expect(fetchGitHubStarCount()).resolves.toBe(1234);
|
||||||
|
|
||||||
|
expect(requestMock).toHaveBeenCalledWith(
|
||||||
|
expect.any(URL),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
accept: 'application/vnd.github+json',
|
||||||
|
'user-agent': 'ktx-star-prompt',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
const [url] = requestMock.mock.calls[0] as [URL];
|
||||||
|
expect(url.toString()).toBe('https://api.github.com/repos/Kaelio/ktx');
|
||||||
|
expect(socket.unref).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-2xx, invalid JSON, and invalid payloads', async () => {
|
||||||
|
const { fetchGitHubStarCount } = await import('../../src/star-prompt/star-count.js');
|
||||||
|
|
||||||
|
mockHttpsResponse(503, 'GitHub unavailable');
|
||||||
|
await expect(fetchGitHubStarCount()).resolves.toBeNull();
|
||||||
|
|
||||||
|
mockHttpsResponse(200, '{bad json');
|
||||||
|
await expect(fetchGitHubStarCount()).resolves.toBeNull();
|
||||||
|
|
||||||
|
mockHttpsResponse(200, JSON.stringify({ stargazers_count: '1234' }));
|
||||||
|
await expect(fetchGitHubStarCount()).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroys the request and returns null on timeout', async () => {
|
||||||
|
const request = new EventEmitter() as MockRequest;
|
||||||
|
request.destroy = vi.fn();
|
||||||
|
request.end = vi.fn();
|
||||||
|
request.setTimeout = vi.fn((_ms: number, callback: () => void) => {
|
||||||
|
callback();
|
||||||
|
return request;
|
||||||
|
});
|
||||||
|
requestMock.mockReturnValue(request);
|
||||||
|
const { fetchGitHubStarCount } = await import('../../src/star-prompt/star-count.js');
|
||||||
|
|
||||||
|
await expect(fetchGitHubStarCount({ timeoutMs: 5 })).resolves.toBeNull();
|
||||||
|
expect(request.destroy).toHaveBeenCalledWith(expect.any(Error));
|
||||||
|
});
|
||||||
|
});
|
||||||
47
packages/cli/test/star-prompt/star-line.test.ts
Normal file
47
packages/cli/test/star-prompt/star-line.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { renderStarPromptLine } from '../../src/star-prompt/star-line.js';
|
||||||
|
|
||||||
|
const unicodeSymbols = {
|
||||||
|
star: '★',
|
||||||
|
middot: '·',
|
||||||
|
rightArrow: '→',
|
||||||
|
};
|
||||||
|
|
||||||
|
const asciiSymbols = {
|
||||||
|
star: '*',
|
||||||
|
middot: '-',
|
||||||
|
rightArrow: '->',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('renderStarPromptLine', () => {
|
||||||
|
it('renders the full prompt with a formatted count when it fits', () => {
|
||||||
|
expect(renderStarPromptLine({ count: 1234, columns: 120, symbols: unicodeSymbols })).toBe(
|
||||||
|
' ★ This takes a few minutes - mind giving ktx a star while you wait? github.com/Kaelio/ktx · 1,234 ★',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the full prompt without a count when the count is unavailable', () => {
|
||||||
|
expect(renderStarPromptLine({ count: null, columns: 120, symbols: unicodeSymbols })).toBe(
|
||||||
|
' ★ This takes a few minutes - mind giving ktx a star while you wait? github.com/Kaelio/ktx',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the count segment before shortening the sentence', () => {
|
||||||
|
expect(renderStarPromptLine({ count: 1234, columns: 102, symbols: unicodeSymbols })).toBe(
|
||||||
|
' ★ This takes a few minutes - mind giving ktx a star while you wait? github.com/Kaelio/ktx',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the narrow fallback on compact terminals', () => {
|
||||||
|
expect(renderStarPromptLine({ count: 1234, columns: 92, symbols: unicodeSymbols })).toBe(
|
||||||
|
' ★ Star ktx → github.com/Kaelio/ktx',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports ASCII fallback symbols', () => {
|
||||||
|
expect(renderStarPromptLine({ count: 1234, columns: 120, symbols: asciiSymbols })).toBe(
|
||||||
|
' * This takes a few minutes - mind giving ktx a star while you wait? github.com/Kaelio/ktx - 1,234 *',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue