mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: rank skills by installs and dedupe install telemetry (#2535)
This commit is contained in:
@@ -74,7 +74,7 @@ Docs: [`docs/quickstart.md`](docs/quickstart.md), [`docs/cli.md`](docs/cli.md).
|
||||
|
||||
## Telemetry
|
||||
|
||||
ClawHub tracks minimal **install telemetry** (to compute install counts) when you run `clawhub sync` while logged in.
|
||||
ClawHub tracks minimal **install telemetry** (to compute install counts) when you run `clawhub install` while logged in.
|
||||
Disable via:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
cliSkillDeleteHttp,
|
||||
cliSkillUndeleteHttp,
|
||||
cliTelemetryInstallHttp,
|
||||
cliTelemetrySyncHttp,
|
||||
cliUploadUrlHttp,
|
||||
cliWhoamiHttp,
|
||||
getSkillHttp,
|
||||
@@ -399,12 +398,6 @@ http.route({
|
||||
handler: cliTelemetryInstallHttp,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: LegacyApiRoutes.cliTelemetrySync,
|
||||
method: "POST",
|
||||
handler: cliTelemetrySyncHttp,
|
||||
});
|
||||
|
||||
http.route({
|
||||
path: LegacyApiRoutes.cliSkillDelete,
|
||||
method: "POST",
|
||||
|
||||
@@ -267,7 +267,7 @@ describe("httpApi handlers", () => {
|
||||
expect(json.user.handle).toBe("p");
|
||||
});
|
||||
|
||||
it("cliTelemetryInstallHttp forwards roots and returns ok", async () => {
|
||||
it("cliTelemetryInstallHttp forwards one install event and returns ok", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(null);
|
||||
const response = await __handlers.cliTelemetryInstallHandler(
|
||||
@@ -276,56 +276,31 @@ describe("httpApi handlers", () => {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
roots: [
|
||||
{
|
||||
rootId: "abc",
|
||||
label: "~/skills",
|
||||
skills: [{ slug: "weather", version: null }],
|
||||
},
|
||||
],
|
||||
event: "install",
|
||||
slug: "weather",
|
||||
version: "1.0.0",
|
||||
rootId: "abc",
|
||||
rootLabel: "~/skills",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ ok: true });
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
userId: "users:1",
|
||||
slug: "weather",
|
||||
version: "1.0.0",
|
||||
rootId: "abc",
|
||||
rootLabel: "~/skills",
|
||||
});
|
||||
});
|
||||
|
||||
it("cliTelemetrySyncHttp remains a backwards-compatible alias", async () => {
|
||||
it("cliTelemetryInstallHttp rejects sync-shaped roots snapshots", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(null);
|
||||
const response = await __handlers.cliTelemetrySyncHandler(
|
||||
const response = await __handlers.cliTelemetryInstallHandler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://x/api/cli/telemetry/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roots: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runMutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cliTelemetrySyncHttp returns 400 on invalid payload", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const response = await __handlers.cliTelemetrySyncHandler(
|
||||
makeCtx({ runMutation: vi.fn() }),
|
||||
new Request("https://x/api/cli/telemetry/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roots: "nope" }),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("cliTelemetrySyncHttp forwards skill versions when provided", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(null);
|
||||
await __handlers.cliTelemetrySyncHandler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://x/api/cli/telemetry/sync", {
|
||||
new Request("https://x/api/cli/telemetry/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -339,31 +314,9 @@ describe("httpApi handlers", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
userId: "users:1",
|
||||
roots: [
|
||||
{ rootId: "abc", label: "~/skills", skills: [{ slug: "weather", version: "1.0.0" }] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("cliTelemetrySyncHttp returns 400 on invalid json", async () => {
|
||||
const request = new Request("https://x/api/cli/telemetry/sync", { method: "POST", body: "{" });
|
||||
const response = await __handlers.cliTelemetrySyncHandler(makeCtx({}), request);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("cliTelemetrySyncHttp returns 401 when unauthorized", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized"));
|
||||
const response = await __handlers.cliTelemetrySyncHandler(
|
||||
makeCtx({}),
|
||||
new Request("https://x/api/cli/telemetry/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roots: [] }),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cliDeviceCodeHttp rate limits and creates a device code", async () => {
|
||||
|
||||
+9
-16
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
ApiCliSkillDeleteResponseSchema,
|
||||
ApiCliTelemetrySyncResponseSchema,
|
||||
ApiCliTelemetryInstallResponseSchema,
|
||||
CliTelemetryInstallRequestSchema,
|
||||
CliPublishRequestSchema,
|
||||
CliSkillDeleteRequestSchema,
|
||||
CliTelemetrySyncRequestSchema,
|
||||
parseArk,
|
||||
} from "clawhub-schema";
|
||||
import { api, internal } from "./_generated/api";
|
||||
@@ -237,19 +237,15 @@ async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
|
||||
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request);
|
||||
const args = parseArk(CliTelemetrySyncRequestSchema, body, "Telemetry payload");
|
||||
await ctx.runMutation(internal.telemetry.reportCliSyncInternal, {
|
||||
const args = parseArk(CliTelemetryInstallRequestSchema, body, "Install telemetry payload");
|
||||
await ctx.runMutation(internal.telemetry.reportCliInstallInternal, {
|
||||
userId,
|
||||
roots: args.roots.map((root) => ({
|
||||
rootId: root.rootId,
|
||||
label: root.label,
|
||||
skills: root.skills.map((skill) => ({
|
||||
slug: skill.slug,
|
||||
version: skill.version ?? undefined,
|
||||
})),
|
||||
})),
|
||||
slug: args.slug,
|
||||
version: args.version,
|
||||
rootId: args.rootId,
|
||||
rootLabel: args.rootLabel,
|
||||
});
|
||||
const ok = parseArk(ApiCliTelemetrySyncResponseSchema, { ok: true }, "Telemetry response");
|
||||
const ok = parseArk(ApiCliTelemetryInstallResponseSchema, { ok: true }, "Telemetry response");
|
||||
return json(ok);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Telemetry failed";
|
||||
@@ -258,9 +254,7 @@ async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const cliTelemetrySyncHandler = cliTelemetryInstallHandler;
|
||||
export const cliTelemetryInstallHttp = httpAction(cliTelemetryInstallHandler);
|
||||
export const cliTelemetrySyncHttp = httpAction(cliTelemetryInstallHandler);
|
||||
|
||||
async function cliDeviceCodeHandler(ctx: ActionCtx, request: Request) {
|
||||
if (request.method !== "POST") return text("Method not allowed", 405);
|
||||
@@ -395,7 +389,6 @@ export const __handlers = {
|
||||
cliPublishHandler,
|
||||
cliSkillDeleteHandler,
|
||||
cliTelemetryInstallHandler,
|
||||
cliTelemetrySyncHandler,
|
||||
cliDeviceCodeHandler,
|
||||
cliDeviceTokenHandler,
|
||||
};
|
||||
|
||||
@@ -213,6 +213,74 @@ describe("publisher abuse scoring", () => {
|
||||
expect(score.reasonCodes).toContain("temporal_sustained_downloads_flat_installs");
|
||||
});
|
||||
|
||||
it("flags high-volume installs that track downloads too closely", () => {
|
||||
const todayDay = 100;
|
||||
const score = computeCurrentSkillTemporalAbuseScore({
|
||||
todayDay,
|
||||
dailyStats: dailyRange(94, 7, { downloads: 200, installs: 180 }),
|
||||
});
|
||||
|
||||
expect(score.nearConversion).toBe(true);
|
||||
expect(score.recent7Downloads).toBe(1_400);
|
||||
expect(score.recent7Installs).toBe(1_260);
|
||||
expect(score.installDownloadRatio7).toBeCloseTo(0.9);
|
||||
expect(score.reasonCodes).toContain("temporal_installs_track_downloads");
|
||||
});
|
||||
|
||||
it("keeps low-volume one-to-one install traffic below close-ratio thresholds", () => {
|
||||
const todayDay = 100;
|
||||
const score = computeCurrentSkillTemporalAbuseScore({
|
||||
todayDay,
|
||||
dailyStats: dailyRange(94, 7, { downloads: 1, installs: 1 }),
|
||||
});
|
||||
|
||||
expect(score.nearConversion).toBe(false);
|
||||
expect(score.reasonCodes).not.toContain("temporal_installs_track_downloads");
|
||||
});
|
||||
|
||||
it("keeps observed high-end install ratios below close-ratio thresholds", () => {
|
||||
const todayDay = 100;
|
||||
const score = computeCurrentSkillTemporalAbuseScore({
|
||||
todayDay,
|
||||
dailyStats: dailyRange(94, 7, { downloads: 20, installs: 1 }),
|
||||
});
|
||||
|
||||
expect(score.recent7Downloads).toBe(140);
|
||||
expect(score.recent7Installs).toBe(7);
|
||||
expect(score.installDownloadRatio7).toBeCloseTo(0.05);
|
||||
expect(score.nearConversion).toBe(false);
|
||||
expect(score.reasonCodes).not.toContain("temporal_installs_track_downloads");
|
||||
});
|
||||
|
||||
it("requires installs to be close to downloads, not just statistically elevated", () => {
|
||||
const todayDay = 100;
|
||||
const score = computeCurrentSkillTemporalAbuseScore({
|
||||
todayDay,
|
||||
dailyStats: dailyRange(94, 7, { downloads: 300, installs: 15 }),
|
||||
});
|
||||
|
||||
expect(score.recent7Downloads).toBe(2_100);
|
||||
expect(score.recent7Installs).toBe(105);
|
||||
expect(score.installDownloadRatio7).toBeCloseTo(0.05);
|
||||
expect(score.installDownloadExcessZScore7).toBeGreaterThan(10);
|
||||
expect(score.nearConversion).toBe(false);
|
||||
expect(score.reasonCodes).not.toContain("temporal_installs_track_downloads");
|
||||
});
|
||||
|
||||
it("reports a 30-day close-ratio window when the 7-day threshold is not met", () => {
|
||||
const todayDay = 100;
|
||||
const score = computeCurrentSkillTemporalAbuseScore({
|
||||
todayDay,
|
||||
dailyStats: dailyRange(71, 30, { downloads: 100, installs: 80 }),
|
||||
});
|
||||
|
||||
expect(score.nearConversion).toBe(true);
|
||||
expect(score.installDownloadRatio7).toBeCloseTo(0.8);
|
||||
expect(score.installDownloadRatio30).toBeCloseTo(0.8);
|
||||
expect(score.nearConversionWindowStartDay).toBe(71);
|
||||
expect(score.nearConversionWindowEndDay).toBe(100);
|
||||
});
|
||||
|
||||
it("keeps ordinary steady download traffic below temporal thresholds", () => {
|
||||
const todayDay = 100;
|
||||
const score = computeCurrentSkillTemporalAbuseScore({
|
||||
|
||||
@@ -60,6 +60,7 @@ export type SkillTemporalAbuseDailyStat = {
|
||||
export type SkillTemporalAbuseScore = {
|
||||
spike: boolean;
|
||||
sustained: boolean;
|
||||
nearConversion: boolean;
|
||||
pressure: number;
|
||||
recent7Downloads: number;
|
||||
recent7Installs: number;
|
||||
@@ -73,10 +74,16 @@ export type SkillTemporalAbuseScore = {
|
||||
spikeMultiplierCohortBand?: "p95" | "p99";
|
||||
downloads30dVsPeerP95?: number;
|
||||
spikeMultiplierVsPeerP95?: number;
|
||||
installDownloadRatio7: number;
|
||||
installDownloadRatio30: number;
|
||||
installDownloadExcessZScore7: number;
|
||||
installDownloadExcessZScore30: number;
|
||||
spikeWindowStartDay?: number;
|
||||
spikeWindowEndDay?: number;
|
||||
sustainedWindowStartDay?: number;
|
||||
sustainedWindowEndDay?: number;
|
||||
nearConversionWindowStartDay?: number;
|
||||
nearConversionWindowEndDay?: number;
|
||||
reasonCodes: string[];
|
||||
};
|
||||
|
||||
@@ -116,6 +123,12 @@ const TEMPORAL_SUSTAINED_DAYS = 30;
|
||||
const TEMPORAL_MAX_SPIKE_INSTALLS = 2;
|
||||
const TEMPORAL_MAX_SUSTAINED_INSTALLS = 5;
|
||||
const TEMPORAL_MIN_BASELINE_7_DOWNLOADS = 100;
|
||||
const TEMPORAL_MIN_NEAR_CONVERSION_7_DOWNLOADS = 1_000;
|
||||
const TEMPORAL_MIN_NEAR_CONVERSION_30_DOWNLOADS = 2_000;
|
||||
const TEMPORAL_MIN_NEAR_CONVERSION_INSTALLS = 500;
|
||||
const TEMPORAL_EXPECTED_INSTALL_DOWNLOAD_RATIO = 0.012;
|
||||
const TEMPORAL_MIN_INSTALL_DOWNLOAD_RATIO = 0.5;
|
||||
const TEMPORAL_MIN_INSTALL_DOWNLOAD_EXCESS_Z_SCORE = 50;
|
||||
|
||||
export function labelForPublisherAbuseZScore(
|
||||
zScore: number,
|
||||
@@ -291,6 +304,7 @@ export function computeHistoricalSkillTemporalAbuseScore(input: {
|
||||
const maxDay = Math.max(...days);
|
||||
let bestSpike = emptySkillTemporalAbuseScore();
|
||||
let bestSustained = emptySkillTemporalAbuseScore();
|
||||
let bestNearConversion = emptySkillTemporalAbuseScore();
|
||||
|
||||
for (let startDay = minDay; startDay <= maxDay; startDay += 1) {
|
||||
if (startDay + TEMPORAL_SPIKE_RECENT_DAYS - 1 <= maxDay) {
|
||||
@@ -305,6 +319,13 @@ export function computeHistoricalSkillTemporalAbuseScore(input: {
|
||||
if (score.spike && score.spikeMultiplier > bestSpike.spikeMultiplier) {
|
||||
bestSpike = score;
|
||||
}
|
||||
if (
|
||||
score.nearConversion &&
|
||||
score.nearConversionWindowEndDay === startDay + TEMPORAL_SPIKE_RECENT_DAYS - 1 &&
|
||||
score.installDownloadRatio7 > bestNearConversion.installDownloadRatio7
|
||||
) {
|
||||
bestNearConversion = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (startDay + TEMPORAL_SUSTAINED_DAYS - 1 <= maxDay) {
|
||||
@@ -319,10 +340,17 @@ export function computeHistoricalSkillTemporalAbuseScore(input: {
|
||||
if (score.sustained && score.recent30Downloads > bestSustained.recent30Downloads) {
|
||||
bestSustained = score;
|
||||
}
|
||||
if (
|
||||
score.nearConversion &&
|
||||
score.nearConversionWindowEndDay === startDay + TEMPORAL_SUSTAINED_DAYS - 1 &&
|
||||
score.installDownloadRatio30 > bestNearConversion.installDownloadRatio30
|
||||
) {
|
||||
bestNearConversion = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeTemporalAbuseWindowScores(bestSpike, bestSustained);
|
||||
return mergeTemporalAbuseWindowScores(bestSpike, bestSustained, bestNearConversion);
|
||||
}
|
||||
|
||||
export function labelForTemporalPublisherAbuse(input: {
|
||||
@@ -379,15 +407,25 @@ export function classifySkillTemporalAbuseScore(
|
||||
: undefined;
|
||||
const spike = Boolean(spikeMultiplierCohortBand);
|
||||
const sustained = Boolean(downloads30dCohortBand);
|
||||
const nearConversion = score.nearConversion;
|
||||
const nearConversionPressure = nearConversion
|
||||
? Math.max(score.installDownloadExcessZScore7, score.installDownloadExcessZScore30)
|
||||
: 0;
|
||||
const reasonCodes: string[] = [];
|
||||
if (spike) reasonCodes.push("temporal_download_spike_flat_installs");
|
||||
if (sustained) reasonCodes.push("temporal_sustained_downloads_flat_installs");
|
||||
if (nearConversion) reasonCodes.push("temporal_installs_track_downloads");
|
||||
|
||||
return {
|
||||
...score,
|
||||
spike,
|
||||
sustained,
|
||||
pressure: Math.max(spike ? spikeMultiplierVsPeerP95 : 0, sustained ? downloads30dVsPeerP95 : 0),
|
||||
nearConversion,
|
||||
pressure: Math.max(
|
||||
spike ? spikeMultiplierVsPeerP95 : 0,
|
||||
sustained ? downloads30dVsPeerP95 : 0,
|
||||
nearConversionPressure,
|
||||
),
|
||||
downloads30dCohortBand,
|
||||
spikeMultiplierCohortBand,
|
||||
downloads30dVsPeerP95,
|
||||
@@ -396,6 +434,8 @@ export function classifySkillTemporalAbuseScore(
|
||||
spikeWindowEndDay: spike ? score.spikeWindowEndDay : undefined,
|
||||
sustainedWindowStartDay: sustained ? score.sustainedWindowStartDay : undefined,
|
||||
sustainedWindowEndDay: sustained ? score.sustainedWindowEndDay : undefined,
|
||||
nearConversionWindowStartDay: nearConversion ? score.nearConversionWindowStartDay : undefined,
|
||||
nearConversionWindowEndDay: nearConversion ? score.nearConversionWindowEndDay : undefined,
|
||||
reasonCodes,
|
||||
};
|
||||
}
|
||||
@@ -449,10 +489,37 @@ function computeSkillTemporalAbuseScoreForWindows(input: {
|
||||
);
|
||||
const spikeMultiplier = baseline7Downloads > 0 ? recent7.downloads / baseline7Downloads : 0;
|
||||
const downloadInstallRatio30 = recent30.downloads / Math.max(1, recent30.installs);
|
||||
const installDownloadRatio7 = recent7.installs / Math.max(1, recent7.downloads);
|
||||
const installDownloadRatio30 = recent30.installs / Math.max(1, recent30.downloads);
|
||||
const installDownloadExcessZScore7 = installDownloadExcessZScore({
|
||||
downloads: recent7.downloads,
|
||||
installs: recent7.installs,
|
||||
});
|
||||
const installDownloadExcessZScore30 = installDownloadExcessZScore({
|
||||
downloads: recent30.downloads,
|
||||
installs: recent30.installs,
|
||||
});
|
||||
const nearConversion7 =
|
||||
recent7.downloads >= TEMPORAL_MIN_NEAR_CONVERSION_7_DOWNLOADS &&
|
||||
recent7.installs >= TEMPORAL_MIN_NEAR_CONVERSION_INSTALLS &&
|
||||
installDownloadRatio7 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_RATIO &&
|
||||
installDownloadExcessZScore7 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_EXCESS_Z_SCORE;
|
||||
const nearConversion30 =
|
||||
recent30.downloads >= TEMPORAL_MIN_NEAR_CONVERSION_30_DOWNLOADS &&
|
||||
recent30.installs >= TEMPORAL_MIN_NEAR_CONVERSION_INSTALLS &&
|
||||
installDownloadRatio30 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_RATIO &&
|
||||
installDownloadExcessZScore30 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_EXCESS_Z_SCORE;
|
||||
const nearConversion = nearConversion7 || nearConversion30;
|
||||
const reasonCodes: string[] = [];
|
||||
if (nearConversion) reasonCodes.push("temporal_installs_track_downloads");
|
||||
|
||||
return {
|
||||
spike: false,
|
||||
sustained: false,
|
||||
pressure: 0,
|
||||
nearConversion,
|
||||
pressure: nearConversion
|
||||
? Math.max(installDownloadExcessZScore7, installDownloadExcessZScore30)
|
||||
: 0,
|
||||
recent7Downloads: recent7.downloads,
|
||||
recent7Installs: recent7.installs,
|
||||
previous30Downloads: previous30.downloads,
|
||||
@@ -461,35 +528,74 @@ function computeSkillTemporalAbuseScoreForWindows(input: {
|
||||
recent30Downloads: recent30.downloads,
|
||||
recent30Installs: recent30.installs,
|
||||
downloadInstallRatio30,
|
||||
installDownloadRatio7,
|
||||
installDownloadRatio30,
|
||||
installDownloadExcessZScore7,
|
||||
installDownloadExcessZScore30,
|
||||
spikeWindowStartDay: input.spikeStartDay,
|
||||
spikeWindowEndDay: spikeEndDay,
|
||||
sustainedWindowStartDay: input.sustainedStartDay,
|
||||
sustainedWindowEndDay: sustainedEndDay,
|
||||
reasonCodes: [],
|
||||
nearConversionWindowStartDay: nearConversion7
|
||||
? input.spikeStartDay
|
||||
: nearConversion30
|
||||
? input.sustainedStartDay
|
||||
: undefined,
|
||||
nearConversionWindowEndDay: nearConversion7
|
||||
? spikeEndDay
|
||||
: nearConversion30
|
||||
? sustainedEndDay
|
||||
: undefined,
|
||||
reasonCodes,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeTemporalAbuseWindowScores(
|
||||
bestSpike: SkillTemporalAbuseScore,
|
||||
bestSustained: SkillTemporalAbuseScore,
|
||||
bestNearConversion: SkillTemporalAbuseScore,
|
||||
): SkillTemporalAbuseScore {
|
||||
if (!bestSpike.spike && !bestSustained.sustained) return emptySkillTemporalAbuseScore();
|
||||
if (!bestSpike.spike && !bestSustained.sustained && !bestNearConversion.nearConversion) {
|
||||
return emptySkillTemporalAbuseScore();
|
||||
}
|
||||
const reasonCodes: string[] = [];
|
||||
if (bestSpike.spike) reasonCodes.push("temporal_download_spike_flat_installs");
|
||||
if (bestSustained.sustained) reasonCodes.push("temporal_sustained_downloads_flat_installs");
|
||||
if (bestNearConversion.nearConversion) reasonCodes.push("temporal_installs_track_downloads");
|
||||
|
||||
return {
|
||||
spike: bestSpike.spike,
|
||||
sustained: bestSustained.sustained,
|
||||
pressure: Math.max(bestSpike.pressure, bestSustained.pressure),
|
||||
recent7Downloads: bestSpike.recent7Downloads,
|
||||
recent7Installs: bestSpike.recent7Installs,
|
||||
previous30Downloads: bestSpike.previous30Downloads,
|
||||
baseline7Downloads: bestSpike.baseline7Downloads,
|
||||
spikeMultiplier: bestSpike.spikeMultiplier,
|
||||
recent30Downloads: bestSustained.recent30Downloads,
|
||||
recent30Installs: bestSustained.recent30Installs,
|
||||
downloadInstallRatio30: bestSustained.downloadInstallRatio30,
|
||||
nearConversion: bestNearConversion.nearConversion,
|
||||
pressure: Math.max(bestSpike.pressure, bestSustained.pressure, bestNearConversion.pressure),
|
||||
recent7Downloads: bestSpike.spike
|
||||
? bestSpike.recent7Downloads
|
||||
: bestNearConversion.recent7Downloads,
|
||||
recent7Installs: bestSpike.spike
|
||||
? bestSpike.recent7Installs
|
||||
: bestNearConversion.recent7Installs,
|
||||
previous30Downloads: bestSpike.spike
|
||||
? bestSpike.previous30Downloads
|
||||
: bestNearConversion.previous30Downloads,
|
||||
baseline7Downloads: bestSpike.spike
|
||||
? bestSpike.baseline7Downloads
|
||||
: bestNearConversion.baseline7Downloads,
|
||||
spikeMultiplier: bestSpike.spike
|
||||
? bestSpike.spikeMultiplier
|
||||
: bestNearConversion.spikeMultiplier,
|
||||
recent30Downloads: bestSustained.sustained
|
||||
? bestSustained.recent30Downloads
|
||||
: bestNearConversion.recent30Downloads,
|
||||
recent30Installs: bestSustained.sustained
|
||||
? bestSustained.recent30Installs
|
||||
: bestNearConversion.recent30Installs,
|
||||
downloadInstallRatio30: bestSustained.sustained
|
||||
? bestSustained.downloadInstallRatio30
|
||||
: bestNearConversion.downloadInstallRatio30,
|
||||
installDownloadRatio7: bestNearConversion.installDownloadRatio7,
|
||||
installDownloadRatio30: bestNearConversion.installDownloadRatio30,
|
||||
installDownloadExcessZScore7: bestNearConversion.installDownloadExcessZScore7,
|
||||
installDownloadExcessZScore30: bestNearConversion.installDownloadExcessZScore30,
|
||||
downloads30dCohortBand: bestSustained.downloads30dCohortBand,
|
||||
spikeMultiplierCohortBand: bestSpike.spikeMultiplierCohortBand,
|
||||
downloads30dVsPeerP95: bestSustained.downloads30dVsPeerP95,
|
||||
@@ -498,6 +604,8 @@ function mergeTemporalAbuseWindowScores(
|
||||
spikeWindowEndDay: bestSpike.spikeWindowEndDay,
|
||||
sustainedWindowStartDay: bestSustained.sustainedWindowStartDay,
|
||||
sustainedWindowEndDay: bestSustained.sustainedWindowEndDay,
|
||||
nearConversionWindowStartDay: bestNearConversion.nearConversionWindowStartDay,
|
||||
nearConversionWindowEndDay: bestNearConversion.nearConversionWindowEndDay,
|
||||
reasonCodes,
|
||||
};
|
||||
}
|
||||
@@ -535,6 +643,7 @@ function emptySkillTemporalAbuseScore(): SkillTemporalAbuseScore {
|
||||
return {
|
||||
spike: false,
|
||||
sustained: false,
|
||||
nearConversion: false,
|
||||
pressure: 0,
|
||||
recent7Downloads: 0,
|
||||
recent7Installs: 0,
|
||||
@@ -544,10 +653,25 @@ function emptySkillTemporalAbuseScore(): SkillTemporalAbuseScore {
|
||||
recent30Downloads: 0,
|
||||
recent30Installs: 0,
|
||||
downloadInstallRatio30: 0,
|
||||
installDownloadRatio7: 0,
|
||||
installDownloadRatio30: 0,
|
||||
installDownloadExcessZScore7: 0,
|
||||
installDownloadExcessZScore30: 0,
|
||||
reasonCodes: [],
|
||||
};
|
||||
}
|
||||
|
||||
function installDownloadExcessZScore(input: { downloads: number; installs: number }) {
|
||||
if (input.downloads <= 0) return 0;
|
||||
const expected = input.downloads * TEMPORAL_EXPECTED_INSTALL_DOWNLOAD_RATIO;
|
||||
const variance =
|
||||
input.downloads *
|
||||
TEMPORAL_EXPECTED_INSTALL_DOWNLOAD_RATIO *
|
||||
(1 - TEMPORAL_EXPECTED_INSTALL_DOWNLOAD_RATIO);
|
||||
const stdDev = Math.sqrt(Math.max(variance, 1));
|
||||
return (input.installs - expected) / stdDev;
|
||||
}
|
||||
|
||||
function nonNegative(value: number) {
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ const persistTemporalHandler = (
|
||||
temporalScore: {
|
||||
spike: boolean;
|
||||
sustained: boolean;
|
||||
nearConversion: boolean;
|
||||
pressure: number;
|
||||
recent7Downloads: number;
|
||||
recent7Installs: number;
|
||||
@@ -167,6 +168,10 @@ const persistTemporalHandler = (
|
||||
recent30Downloads: number;
|
||||
recent30Installs: number;
|
||||
downloadInstallRatio30: number;
|
||||
installDownloadRatio7: number;
|
||||
installDownloadRatio30: number;
|
||||
installDownloadExcessZScore7: number;
|
||||
installDownloadExcessZScore30: number;
|
||||
spikeWindowStartDay?: number;
|
||||
spikeWindowEndDay?: number;
|
||||
sustainedWindowStartDay?: number;
|
||||
@@ -3947,6 +3952,105 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
expect(ctx.db.get).toHaveBeenCalledWith("publishers:quiet");
|
||||
});
|
||||
|
||||
it("keeps near-conversion-only temporal candidates", async () => {
|
||||
const indexBuilder = {
|
||||
eq: vi.fn(() => indexBuilder),
|
||||
gte: vi.fn(() => indexBuilder),
|
||||
lte: vi.fn(() => indexBuilder),
|
||||
};
|
||||
const publisher = {
|
||||
_id: "publishers:pollyreach",
|
||||
kind: "user",
|
||||
handle: "pollyreach",
|
||||
linkedUserId: "users:joel",
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === publisher._id) return publisher;
|
||||
throw new Error(`unexpected get ${id}`);
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "skills") {
|
||||
return {
|
||||
withIndex: (indexName: string, callback: (q: typeof indexBuilder) => unknown) => {
|
||||
expect(indexName).toBe("by_active_stats_downloads");
|
||||
callback(indexBuilder);
|
||||
return {
|
||||
order: () => ({
|
||||
paginate: async () => ({
|
||||
page: [
|
||||
{
|
||||
_id: "skills:tracked-installs",
|
||||
ownerPublisherId: publisher._id,
|
||||
slug: "tracked-installs",
|
||||
displayName: "Tracked Installs",
|
||||
softDeletedAt: undefined,
|
||||
statsDownloads: 1_400,
|
||||
statsInstallsAllTime: 1_190,
|
||||
stats: {
|
||||
downloads: 1_400,
|
||||
stars: 0,
|
||||
installsCurrent: 1_190,
|
||||
installsAllTime: 1_190,
|
||||
},
|
||||
},
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "skillDailyStats") {
|
||||
return {
|
||||
withIndex: (indexName: string, callback: (q: typeof indexBuilder) => unknown) => {
|
||||
expect(indexName).toBe("by_skill_day");
|
||||
callback(indexBuilder);
|
||||
return {
|
||||
take: async () =>
|
||||
Array.from({ length: 7 }, (_, index) => ({
|
||||
skillId: "skills:tracked-installs",
|
||||
day: 94 + index,
|
||||
downloads: 200,
|
||||
installs: 170,
|
||||
updatedAt: 1,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
collectTemporalHandler(ctx, {
|
||||
mode: "current",
|
||||
batchSize: 1,
|
||||
todayDay: 100,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
cursor: undefined,
|
||||
isDone: true,
|
||||
scannedSkills: 1,
|
||||
candidates: [
|
||||
{
|
||||
slug: "tracked-installs",
|
||||
temporalScore: {
|
||||
spike: false,
|
||||
sustained: false,
|
||||
nearConversion: true,
|
||||
reasonCodes: ["temporal_installs_track_downloads"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips official org publishers during temporal candidate collection", async () => {
|
||||
const indexBuilder = {
|
||||
eq: vi.fn(() => indexBuilder),
|
||||
@@ -4357,6 +4461,7 @@ function temporalCandidate(skillId: string, skill: { slug: string; displayName:
|
||||
temporalScore: {
|
||||
spike: true,
|
||||
sustained: false,
|
||||
nearConversion: false,
|
||||
pressure: 20,
|
||||
recent7Downloads: 2_000,
|
||||
recent7Installs: 0,
|
||||
@@ -4366,6 +4471,10 @@ function temporalCandidate(skillId: string, skill: { slug: string; displayName:
|
||||
recent30Downloads: 2_000,
|
||||
recent30Installs: 0,
|
||||
downloadInstallRatio30: 2_000,
|
||||
installDownloadRatio7: 0,
|
||||
installDownloadRatio30: 0,
|
||||
installDownloadExcessZScore7: 0,
|
||||
installDownloadExcessZScore30: 0,
|
||||
spikeWindowStartDay: 94,
|
||||
spikeWindowEndDay: 100,
|
||||
reasonCodes: ["temporal_download_spike_flat_installs"],
|
||||
|
||||
@@ -151,6 +151,7 @@ const temporalAbuseCohortBenchmarkValidator = v.object({
|
||||
const temporalScoreValidator = v.object({
|
||||
spike: v.boolean(),
|
||||
sustained: v.boolean(),
|
||||
nearConversion: v.boolean(),
|
||||
pressure: v.number(),
|
||||
recent7Downloads: v.number(),
|
||||
recent7Installs: v.number(),
|
||||
@@ -164,10 +165,16 @@ const temporalScoreValidator = v.object({
|
||||
spikeMultiplierCohortBand: v.optional(temporalCohortBandValidator),
|
||||
downloads30dVsPeerP95: v.optional(v.number()),
|
||||
spikeMultiplierVsPeerP95: v.optional(v.number()),
|
||||
installDownloadRatio7: v.number(),
|
||||
installDownloadRatio30: v.number(),
|
||||
installDownloadExcessZScore7: v.number(),
|
||||
installDownloadExcessZScore30: v.number(),
|
||||
spikeWindowStartDay: v.optional(v.number()),
|
||||
spikeWindowEndDay: v.optional(v.number()),
|
||||
sustainedWindowStartDay: v.optional(v.number()),
|
||||
sustainedWindowEndDay: v.optional(v.number()),
|
||||
nearConversionWindowStartDay: v.optional(v.number()),
|
||||
nearConversionWindowEndDay: v.optional(v.number()),
|
||||
reasonCodes: v.array(v.string()),
|
||||
});
|
||||
|
||||
@@ -1049,7 +1056,12 @@ export async function runTemporalPublisherAbuseScanInternalHandler(
|
||||
...candidate,
|
||||
temporalScore: classifySkillTemporalAbuseScore(candidate.temporalScore, benchmark),
|
||||
}))
|
||||
.filter((candidate) => candidate.temporalScore.spike || candidate.temporalScore.sustained);
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.temporalScore.spike ||
|
||||
candidate.temporalScore.sustained ||
|
||||
candidate.temporalScore.nearConversion,
|
||||
);
|
||||
|
||||
const flaggedPublishers = aggregateTemporalPublisherCandidates(highTemporalCandidates).length;
|
||||
if (dryRun || (mode !== "current" && highTemporalCandidates.length === 0)) {
|
||||
@@ -1267,6 +1279,7 @@ function temporalEvidenceFromCandidate(candidate: TemporalSkillCandidate) {
|
||||
displayName: candidate.displayName,
|
||||
spike: candidate.temporalScore.spike,
|
||||
sustained: candidate.temporalScore.sustained,
|
||||
nearConversion: candidate.temporalScore.nearConversion,
|
||||
pressure: candidate.temporalScore.pressure,
|
||||
recent7Downloads: candidate.temporalScore.recent7Downloads,
|
||||
recent7Installs: candidate.temporalScore.recent7Installs,
|
||||
@@ -1280,10 +1293,16 @@ function temporalEvidenceFromCandidate(candidate: TemporalSkillCandidate) {
|
||||
spikeMultiplierCohortBand: candidate.temporalScore.spikeMultiplierCohortBand,
|
||||
downloads30dVsPeerP95: candidate.temporalScore.downloads30dVsPeerP95,
|
||||
spikeMultiplierVsPeerP95: candidate.temporalScore.spikeMultiplierVsPeerP95,
|
||||
installDownloadRatio7: candidate.temporalScore.installDownloadRatio7,
|
||||
installDownloadRatio30: candidate.temporalScore.installDownloadRatio30,
|
||||
installDownloadExcessZScore7: candidate.temporalScore.installDownloadExcessZScore7,
|
||||
installDownloadExcessZScore30: candidate.temporalScore.installDownloadExcessZScore30,
|
||||
spikeWindowStartDay: candidate.temporalScore.spikeWindowStartDay,
|
||||
spikeWindowEndDay: candidate.temporalScore.spikeWindowEndDay,
|
||||
sustainedWindowStartDay: candidate.temporalScore.sustainedWindowStartDay,
|
||||
sustainedWindowEndDay: candidate.temporalScore.sustainedWindowEndDay,
|
||||
nearConversionWindowStartDay: candidate.temporalScore.nearConversionWindowStartDay,
|
||||
nearConversionWindowEndDay: candidate.temporalScore.nearConversionWindowEndDay,
|
||||
reasonCodes: candidate.temporalScore.reasonCodes,
|
||||
};
|
||||
}
|
||||
|
||||
+7
-2
@@ -1098,7 +1098,6 @@ const skillSearchDigest = defineTable({
|
||||
.index("by_active_recommended_rank", [
|
||||
"softDeletedAt",
|
||||
"statsStars",
|
||||
"statsInstallsAllTime",
|
||||
"statsDownloads",
|
||||
"updatedAt",
|
||||
])
|
||||
@@ -1138,7 +1137,6 @@ const skillSearchDigest = defineTable({
|
||||
"softDeletedAt",
|
||||
"isSuspicious",
|
||||
"statsStars",
|
||||
"statsInstallsAllTime",
|
||||
"statsDownloads",
|
||||
"updatedAt",
|
||||
])
|
||||
@@ -2246,6 +2244,7 @@ const publisherAbuseScores = defineTable({
|
||||
displayName: v.string(),
|
||||
spike: v.boolean(),
|
||||
sustained: v.boolean(),
|
||||
nearConversion: v.optional(v.boolean()),
|
||||
pressure: v.number(),
|
||||
recent7Downloads: v.number(),
|
||||
recent7Installs: v.number(),
|
||||
@@ -2259,10 +2258,16 @@ const publisherAbuseScores = defineTable({
|
||||
spikeMultiplierCohortBand: v.optional(v.union(v.literal("p95"), v.literal("p99"))),
|
||||
downloads30dVsPeerP95: v.optional(v.number()),
|
||||
spikeMultiplierVsPeerP95: v.optional(v.number()),
|
||||
installDownloadRatio7: v.optional(v.number()),
|
||||
installDownloadRatio30: v.optional(v.number()),
|
||||
installDownloadExcessZScore7: v.optional(v.number()),
|
||||
installDownloadExcessZScore30: v.optional(v.number()),
|
||||
spikeWindowStartDay: v.optional(v.number()),
|
||||
spikeWindowEndDay: v.optional(v.number()),
|
||||
sustainedWindowStartDay: v.optional(v.number()),
|
||||
sustainedWindowEndDay: v.optional(v.number()),
|
||||
nearConversionWindowStartDay: v.optional(v.number()),
|
||||
nearConversionWindowEndDay: v.optional(v.number()),
|
||||
reasonCodes: v.array(v.string()),
|
||||
}),
|
||||
),
|
||||
|
||||
+11
-11
@@ -1210,7 +1210,7 @@ describe("search helpers", () => {
|
||||
expect(nameMatchScore).toBeGreaterThan(popularVectorScore);
|
||||
});
|
||||
|
||||
it("adds a stars and installs popularity prior for equally relevant matches", () => {
|
||||
it("adds stars and downloads popularity but ignores installs for equally relevant matches", () => {
|
||||
const queryTokens = tokenize("notion");
|
||||
const highDownloadsOnly = __test.scoreSkillResult(
|
||||
queryTokens,
|
||||
@@ -1219,22 +1219,22 @@ describe("search helpers", () => {
|
||||
"notion-helper",
|
||||
{ downloads: 1000, installsAllTime: 0, stars: 0 },
|
||||
);
|
||||
const trustedUsage = __test.scoreSkillResult(
|
||||
const highInstallsOnly = __test.scoreSkillResult(
|
||||
queryTokens,
|
||||
0.5,
|
||||
"Notion Helper",
|
||||
"notion-helper",
|
||||
{ downloads: 0, installsAllTime: 20, stars: 5 },
|
||||
{ downloads: 0, installsAllTime: 1000, stars: 0 },
|
||||
);
|
||||
expect(trustedUsage).toBeGreaterThan(highDownloadsOnly);
|
||||
expect(highDownloadsOnly).toBeGreaterThan(highInstallsOnly);
|
||||
});
|
||||
|
||||
it("breaks capped popularity ties by stars and installs before downloads", async () => {
|
||||
it("breaks capped popularity ties by stars and downloads before installs", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
const trustedUsage = {
|
||||
const installedOnly = {
|
||||
skill: makePublicSkill({
|
||||
id: "skills:trusted",
|
||||
slug: "tool-trusted",
|
||||
id: "skills:installed",
|
||||
slug: "tool-installed",
|
||||
displayName: "Tool",
|
||||
downloads: 0,
|
||||
installsAllTime: 1_000,
|
||||
@@ -1251,7 +1251,7 @@ describe("search helpers", () => {
|
||||
displayName: "Tool",
|
||||
downloads: 1_000_000_000,
|
||||
installsAllTime: 0,
|
||||
stars: 0,
|
||||
stars: 1_000,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
@@ -1261,7 +1261,7 @@ describe("search helpers", () => {
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
|
||||
.mockResolvedValueOnce([]) // directPrefixSkillMatches
|
||||
.mockResolvedValueOnce([downloadedOnly, trustedUsage]); // lexicalFallbackSkills
|
||||
.mockResolvedValueOnce([installedOnly, downloadedOnly]); // lexicalFallbackSkills
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
@@ -1271,7 +1271,7 @@ describe("search helpers", () => {
|
||||
{ query: "tool", limit: 2 },
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["tool-trusted", "tool-downloaded"]);
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["tool-downloaded", "tool-installed"]);
|
||||
});
|
||||
|
||||
it("uses digest doc instead of full skill doc in hydrateResults but revalidates the owner", async () => {
|
||||
|
||||
+2
-8
@@ -76,7 +76,6 @@ const SLUG_PREFIX_BOOST = 0.8;
|
||||
const NAME_EXACT_BOOST = 1.1;
|
||||
const NAME_PREFIX_BOOST = 0.6;
|
||||
const STAR_POPULARITY_WEIGHT = 0.12;
|
||||
const INSTALL_POPULARITY_WEIGHT = 0.04;
|
||||
const DOWNLOAD_POPULARITY_WEIGHT = 0.005;
|
||||
const MAX_POPULARITY_BOOST = 0.09;
|
||||
const FALLBACK_SCAN_LIMIT = 2000;
|
||||
@@ -124,6 +123,7 @@ function getLexicalBoost(queryTokens: string[], displayName: string, slug: strin
|
||||
|
||||
type PopularityStats = {
|
||||
downloads: number;
|
||||
/** Accepted for compatibility with existing callers, but ignored for ranking. */
|
||||
installsAllTime?: number;
|
||||
stars: number;
|
||||
};
|
||||
@@ -131,7 +131,6 @@ type PopularityStats = {
|
||||
function getPopularityBoost(stats: PopularityStats) {
|
||||
const rawBoost =
|
||||
Math.log1p(Math.max(stats.stars, 0)) * STAR_POPULARITY_WEIGHT +
|
||||
Math.log1p(Math.max(stats.installsAllTime ?? 0, 0)) * INSTALL_POPULARITY_WEIGHT +
|
||||
Math.log1p(Math.max(stats.downloads, 0)) * DOWNLOAD_POPULARITY_WEIGHT;
|
||||
return Math.min(rawBoost, MAX_POPULARITY_BOOST);
|
||||
}
|
||||
@@ -197,11 +196,7 @@ function classifySkillMatch(
|
||||
}
|
||||
|
||||
function comparePopularityStats(a: PopularityStats, b: PopularityStats) {
|
||||
return (
|
||||
b.stars - a.stars ||
|
||||
(b.installsAllTime ?? 0) - (a.installsAllTime ?? 0) ||
|
||||
b.downloads - a.downloads
|
||||
);
|
||||
return b.stars - a.stars || b.downloads - a.downloads;
|
||||
}
|
||||
|
||||
function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearchEntry[]) {
|
||||
@@ -380,7 +375,6 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
entry.skill.slug,
|
||||
{
|
||||
downloads: entry.skill.stats.downloads,
|
||||
installsAllTime: entry.skill.stats.installsAllTime,
|
||||
stars: entry.skill.stats.stars,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -23,7 +23,6 @@ describe("skills.listPublicPageV4", () => {
|
||||
expect(getSkillSearchDigestIndexFields("by_active_recommended_rank")).toEqual([
|
||||
"softDeletedAt",
|
||||
"statsStars",
|
||||
"statsInstallsAllTime",
|
||||
"statsDownloads",
|
||||
"updatedAt",
|
||||
]);
|
||||
@@ -31,7 +30,6 @@ describe("skills.listPublicPageV4", () => {
|
||||
"softDeletedAt",
|
||||
"isSuspicious",
|
||||
"statsStars",
|
||||
"statsInstallsAllTime",
|
||||
"statsDownloads",
|
||||
"updatedAt",
|
||||
]);
|
||||
@@ -74,7 +72,7 @@ describe("skills.listPublicPageV4", () => {
|
||||
).toBe("updated");
|
||||
expect(
|
||||
__test.resolveRecommendedPublicListSort({
|
||||
decodedCursor: [undefined, 10, 20, 30, 123, 456, "skillSearchDigest:recommended"],
|
||||
decodedCursor: [undefined, 10, 20, 123, 456, "skillSearchDigest:recommended"],
|
||||
hasMissingRankStats: true,
|
||||
}),
|
||||
).toBe("recommended");
|
||||
@@ -85,7 +83,6 @@ describe("skills.listPublicPageV4", () => {
|
||||
false,
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
123,
|
||||
456,
|
||||
"skillSearchDigest:nonsuspicious-recommended",
|
||||
@@ -95,7 +92,7 @@ describe("skills.listPublicPageV4", () => {
|
||||
).toBe("recommended");
|
||||
});
|
||||
|
||||
it("sorts highlighted recommended results by stars, installs, downloads, then updatedAt", async () => {
|
||||
it("sorts highlighted recommended results by stars, downloads, then updatedAt", async () => {
|
||||
const result = await listPublicPageV4Handler(
|
||||
makeHighlightedCtx([
|
||||
makeDigest({
|
||||
@@ -136,9 +133,9 @@ describe("skills.listPublicPageV4", () => {
|
||||
|
||||
expect(result.page.map((entry) => entry.skill.slug)).toEqual([
|
||||
"stars-skill",
|
||||
"installs-skill",
|
||||
"downloads-skill",
|
||||
"updated-skill",
|
||||
"installs-skill",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,7 +181,6 @@ describe("public skill list deterministic cursors", () => {
|
||||
|
||||
expect(withIndex.mock.calls.map(([indexName]) => indexName)).toEqual([
|
||||
"by_active_stats_stars",
|
||||
"by_active_stats_installs_all_time",
|
||||
"by_active_stats_downloads",
|
||||
]);
|
||||
expect(getPageMock).toHaveBeenCalledTimes(1);
|
||||
@@ -204,7 +203,6 @@ describe("public skill list deterministic cursors", () => {
|
||||
|
||||
expect(withIndex.mock.calls.map(([indexName]) => indexName)).toEqual([
|
||||
"by_nonsuspicious_stars",
|
||||
"by_nonsuspicious_installs",
|
||||
"by_nonsuspicious_downloads",
|
||||
]);
|
||||
expect(getPageMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
+6
-23
@@ -4931,7 +4931,7 @@ export const listPublicPageV3 = query({
|
||||
type PublicListSort = keyof typeof SORT_INDEXES;
|
||||
|
||||
const SORT_INDEX_FIELD_COUNTS: Record<PublicListSort, number> = {
|
||||
recommended: 5,
|
||||
recommended: 4,
|
||||
newest: 2,
|
||||
updated: 2,
|
||||
name: 2,
|
||||
@@ -4941,7 +4941,7 @@ const SORT_INDEX_FIELD_COUNTS: Record<PublicListSort, number> = {
|
||||
};
|
||||
|
||||
const NONSUSPICIOUS_SORT_INDEX_FIELD_COUNTS: Record<PublicListSort, number> = {
|
||||
recommended: 6,
|
||||
recommended: 5,
|
||||
newest: 3,
|
||||
updated: 3,
|
||||
name: 3,
|
||||
@@ -6131,22 +6131,13 @@ async function hasMissingRecommendedRankStats(
|
||||
) {
|
||||
if (decodedCursor) return false;
|
||||
if (nonSuspiciousOnly) {
|
||||
const [missingStars, missingInstalls, missingDownloads] = await Promise.all([
|
||||
const [missingStars, missingDownloads] = await Promise.all([
|
||||
ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_stars", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("isSuspicious", false).eq("statsStars", undefined),
|
||||
)
|
||||
.first(),
|
||||
ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_installs", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false)
|
||||
.eq("statsInstallsAllTime", undefined),
|
||||
)
|
||||
.first(),
|
||||
ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_downloads", (q) =>
|
||||
@@ -6157,22 +6148,16 @@ async function hasMissingRecommendedRankStats(
|
||||
)
|
||||
.first(),
|
||||
]);
|
||||
return Boolean(missingStars || missingInstalls || missingDownloads);
|
||||
return Boolean(missingStars || missingDownloads);
|
||||
}
|
||||
|
||||
const [missingStars, missingInstalls, missingDownloads] = await Promise.all([
|
||||
const [missingStars, missingDownloads] = await Promise.all([
|
||||
ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_stats_stars", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("statsStars", undefined),
|
||||
)
|
||||
.first(),
|
||||
ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_stats_installs_all_time", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("statsInstallsAllTime", undefined),
|
||||
)
|
||||
.first(),
|
||||
ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_stats_downloads", (q) =>
|
||||
@@ -6180,7 +6165,7 @@ async function hasMissingRecommendedRankStats(
|
||||
)
|
||||
.first(),
|
||||
]);
|
||||
return Boolean(missingStars || missingInstalls || missingDownloads);
|
||||
return Boolean(missingStars || missingDownloads);
|
||||
}
|
||||
|
||||
function readDigestRankStat(
|
||||
@@ -6246,8 +6231,6 @@ async function fetchHighlightedPage(
|
||||
case "recommended":
|
||||
return (
|
||||
(readDigestRankStat(a, "stars") - readDigestRankStat(b, "stars")) * multiplier ||
|
||||
(readDigestRankStat(a, "installsAllTime") - readDigestRankStat(b, "installsAllTime")) *
|
||||
multiplier ||
|
||||
(readDigestRankStat(a, "downloads") - readDigestRankStat(b, "downloads")) * multiplier ||
|
||||
(a.updatedAt - b.updatedAt) * multiplier
|
||||
);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./functions", () => ({
|
||||
internalAction: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
||||
internalMutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
||||
internalQuery: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
||||
mutation: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
||||
query: (def: { handler: unknown }) => ({ _handler: def.handler }),
|
||||
}));
|
||||
|
||||
vi.mock("./_generated/api", () => ({
|
||||
internal: {
|
||||
skillStatEvents: {
|
||||
processSkillStatEventsAction: Symbol("processSkillStatEventsAction"),
|
||||
processSkillStatEventsInternal: Symbol("processSkillStatEventsInternal"),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { reportCliInstallInternal } = await import("./telemetry");
|
||||
|
||||
const reportCliInstallHandler = (
|
||||
reportCliInstallInternal as unknown as {
|
||||
_handler: (
|
||||
ctx: unknown,
|
||||
args: {
|
||||
userId: string;
|
||||
slug: string;
|
||||
version?: string;
|
||||
rootId?: string;
|
||||
rootLabel?: string;
|
||||
},
|
||||
) => Promise<void>;
|
||||
}
|
||||
)._handler;
|
||||
|
||||
function makeIndexBuilder() {
|
||||
const builder = {
|
||||
eq: vi.fn(() => builder),
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
describe("telemetry install events", () => {
|
||||
it("records the first CLI install as an install stat event", async () => {
|
||||
const skill = { _id: "skills:demo", slug: "demo" };
|
||||
const insert = vi.fn();
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn(
|
||||
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
||||
callback(makeIndexBuilder());
|
||||
if (table === "skills" && indexName === "by_slug") {
|
||||
return { unique: async () => skill };
|
||||
}
|
||||
if (table === "userSyncRoots" && indexName === "by_user_root") {
|
||||
return { unique: async () => null };
|
||||
}
|
||||
if (table === "userSkillRootInstalls" && indexName === "by_user_root_skill") {
|
||||
return { unique: async () => null };
|
||||
}
|
||||
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
|
||||
return { unique: async () => null };
|
||||
}
|
||||
throw new Error(`unexpected query ${table}.${indexName}`);
|
||||
},
|
||||
),
|
||||
})),
|
||||
insert,
|
||||
patch: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await reportCliInstallHandler(ctx, {
|
||||
userId: "users:one",
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
rootId: "root",
|
||||
rootLabel: "~/skills",
|
||||
});
|
||||
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"userSkillInstalls",
|
||||
expect.objectContaining({
|
||||
userId: "users:one",
|
||||
skillId: "skills:demo",
|
||||
activeRoots: 1,
|
||||
lastVersion: "1.0.0",
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"userSyncRoots",
|
||||
expect.objectContaining({
|
||||
userId: "users:one",
|
||||
rootId: "root",
|
||||
label: "~/skills",
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"userSkillRootInstalls",
|
||||
expect.objectContaining({
|
||||
userId: "users:one",
|
||||
rootId: "root",
|
||||
skillId: "skills:demo",
|
||||
lastVersion: "1.0.0",
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillStatEvents",
|
||||
expect.objectContaining({
|
||||
skillId: "skills:demo",
|
||||
kind: "install_new",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps repeated CLI install events idempotent per user and skill", async () => {
|
||||
const skill = { _id: "skills:demo", slug: "demo" };
|
||||
const existingInstall = {
|
||||
_id: "userSkillInstalls:one",
|
||||
userId: "users:one",
|
||||
skillId: "skills:demo",
|
||||
activeRoots: 1,
|
||||
lastVersion: "1.0.0",
|
||||
};
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn(
|
||||
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
||||
callback(makeIndexBuilder());
|
||||
if (table === "skills" && indexName === "by_slug") {
|
||||
return { unique: async () => skill };
|
||||
}
|
||||
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
|
||||
return { unique: async () => existingInstall };
|
||||
}
|
||||
throw new Error(`unexpected query ${table}.${indexName}`);
|
||||
},
|
||||
),
|
||||
})),
|
||||
insert,
|
||||
patch,
|
||||
},
|
||||
};
|
||||
|
||||
await reportCliInstallHandler(ctx, {
|
||||
userId: "users:one",
|
||||
slug: "demo",
|
||||
version: "1.0.1",
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"userSkillInstalls:one",
|
||||
expect.objectContaining({ activeRoots: 1, lastVersion: "1.0.1" }),
|
||||
);
|
||||
expect(insert).not.toHaveBeenCalledWith("skillStatEvents", expect.anything());
|
||||
});
|
||||
|
||||
it("reactivates an inactive CLI install for current install counts", async () => {
|
||||
const skill = { _id: "skills:demo", slug: "demo" };
|
||||
const existingInstall = {
|
||||
_id: "userSkillInstalls:one",
|
||||
userId: "users:one",
|
||||
skillId: "skills:demo",
|
||||
activeRoots: 0,
|
||||
lastVersion: "1.0.0",
|
||||
};
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn(
|
||||
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
|
||||
callback(makeIndexBuilder());
|
||||
if (table === "skills" && indexName === "by_slug") {
|
||||
return { unique: async () => skill };
|
||||
}
|
||||
if (table === "userSkillInstalls" && indexName === "by_user_skill") {
|
||||
return { unique: async () => existingInstall };
|
||||
}
|
||||
throw new Error(`unexpected query ${table}.${indexName}`);
|
||||
},
|
||||
),
|
||||
})),
|
||||
insert,
|
||||
patch,
|
||||
},
|
||||
};
|
||||
|
||||
await reportCliInstallHandler(ctx, {
|
||||
userId: "users:one",
|
||||
slug: "demo",
|
||||
version: "1.0.1",
|
||||
});
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"userSkillInstalls:one",
|
||||
expect.objectContaining({ activeRoots: 1, lastVersion: "1.0.1" }),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"skillStatEvents",
|
||||
expect.objectContaining({
|
||||
skillId: "skills:demo",
|
||||
kind: "install_reactivate",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+105
-194
@@ -1,53 +1,69 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { v } from "convex/values";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { internalMutation, mutation, query } from "./functions";
|
||||
import { requireUser } from "./lib/access";
|
||||
import { insertStatEvent } from "./skillStatEvents";
|
||||
|
||||
const TELEMETRY_STALE_MS = 120 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type RootPayload = {
|
||||
rootId: string;
|
||||
label: string;
|
||||
skills: Array<{ slug: string; version?: string | null }>;
|
||||
};
|
||||
|
||||
export const reportCliSyncInternal = internalMutation({
|
||||
export const reportCliInstallInternal = internalMutation({
|
||||
args: {
|
||||
userId: v.id("users"),
|
||||
roots: v.array(
|
||||
v.object({
|
||||
rootId: v.string(),
|
||||
label: v.string(),
|
||||
skills: v.array(
|
||||
v.object({
|
||||
slug: v.string(),
|
||||
version: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
slug: v.string(),
|
||||
version: v.optional(v.string()),
|
||||
rootId: v.optional(v.string()),
|
||||
rootLabel: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const slug = args.slug.trim().toLowerCase();
|
||||
if (!slug) return;
|
||||
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.unique();
|
||||
if (!skill || skill.softDeletedAt) return;
|
||||
|
||||
const now = Date.now();
|
||||
const stalenessCutoff = now - TELEMETRY_STALE_MS;
|
||||
|
||||
await expireStaleRoots(ctx, { userId: args.userId, stalenessCutoff, now });
|
||||
|
||||
const roots = normalizeRoots(args.roots);
|
||||
const skillsBySlug = await resolveSkillsBySlug(ctx, roots);
|
||||
|
||||
for (const root of roots) {
|
||||
await upsertRoot(ctx, { userId: args.userId, rootId: root.rootId, now, label: root.label });
|
||||
await applyRootReport(ctx, {
|
||||
const rootId = args.rootId?.trim();
|
||||
if (rootId) {
|
||||
await upsertSingleRootInstall(ctx, {
|
||||
userId: args.userId,
|
||||
root,
|
||||
skillsBySlug,
|
||||
skillId: skill._id,
|
||||
rootId,
|
||||
label: args.rootLabel?.trim() || "Unknown",
|
||||
now,
|
||||
version: args.version,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("userSkillInstalls")
|
||||
.withIndex("by_user_skill", (q) => q.eq("userId", args.userId).eq("skillId", skill._id))
|
||||
.unique();
|
||||
if (existing) {
|
||||
const wasInactive = existing.activeRoots <= 0;
|
||||
await ctx.db.patch(existing._id, {
|
||||
lastSeenAt: now,
|
||||
activeRoots: Math.max(1, existing.activeRoots),
|
||||
lastVersion: args.version,
|
||||
});
|
||||
if (wasInactive) {
|
||||
await insertStatEvent(ctx, { skillId: skill._id, kind: "install_reactivate" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.db.insert("userSkillInstalls", {
|
||||
userId: args.userId,
|
||||
skillId: skill._id,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
activeRoots: 1,
|
||||
lastVersion: args.version,
|
||||
});
|
||||
await insertStatEvent(ctx, { skillId: skill._id, kind: "install_new" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -199,26 +215,63 @@ async function clearTelemetryForUser(ctx: MutationCtx, params: { userId: Id<"use
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRoots(roots: RootPayload[]): RootPayload[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: RootPayload[] = [];
|
||||
for (const root of roots) {
|
||||
const id = root.rootId.trim();
|
||||
if (!id) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
unique.push({
|
||||
rootId: id,
|
||||
label: root.label.trim() || "Unknown",
|
||||
skills: root.skills
|
||||
.map((skill) => ({
|
||||
slug: skill.slug.trim().toLowerCase(),
|
||||
version: skill.version ?? null,
|
||||
}))
|
||||
.filter((skill) => Boolean(skill.slug)),
|
||||
async function upsertSingleRootInstall(
|
||||
ctx: MutationCtx,
|
||||
params: {
|
||||
userId: Id<"users">;
|
||||
skillId: Id<"skills">;
|
||||
rootId: string;
|
||||
label: string;
|
||||
now: number;
|
||||
version?: string;
|
||||
},
|
||||
) {
|
||||
await upsertRoot(ctx, {
|
||||
userId: params.userId,
|
||||
rootId: params.rootId,
|
||||
label: params.label,
|
||||
now: params.now,
|
||||
});
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("userSkillRootInstalls")
|
||||
.withIndex("by_user_root_skill", (q) =>
|
||||
q.eq("userId", params.userId).eq("rootId", params.rootId).eq("skillId", params.skillId),
|
||||
)
|
||||
.unique();
|
||||
|
||||
if (existing) {
|
||||
const wasRemoved = Boolean(existing.removedAt);
|
||||
await ctx.db.patch(existing._id, {
|
||||
lastSeenAt: params.now,
|
||||
lastVersion: params.version ?? existing.lastVersion,
|
||||
removedAt: undefined,
|
||||
});
|
||||
if (wasRemoved) {
|
||||
await incrementActiveRoots(ctx, {
|
||||
userId: params.userId,
|
||||
skillId: params.skillId,
|
||||
now: params.now,
|
||||
version: params.version,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
return unique;
|
||||
|
||||
await ctx.db.insert("userSkillRootInstalls", {
|
||||
userId: params.userId,
|
||||
rootId: params.rootId,
|
||||
skillId: params.skillId,
|
||||
firstSeenAt: params.now,
|
||||
lastSeenAt: params.now,
|
||||
lastVersion: params.version,
|
||||
});
|
||||
await incrementActiveRoots(ctx, {
|
||||
userId: params.userId,
|
||||
skillId: params.skillId,
|
||||
now: params.now,
|
||||
version: params.version,
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertRoot(
|
||||
@@ -247,85 +300,6 @@ async function upsertRoot(
|
||||
});
|
||||
}
|
||||
|
||||
async function applyRootReport(
|
||||
ctx: MutationCtx,
|
||||
params: {
|
||||
userId: Id<"users">;
|
||||
root: RootPayload;
|
||||
skillsBySlug: Map<string, { skillId: Id<"skills"> }>;
|
||||
now: number;
|
||||
},
|
||||
) {
|
||||
const expected = new Set<Id<"skills">>();
|
||||
const versionsBySkill = new Map<Id<"skills">, string | undefined>();
|
||||
for (const entry of params.root.skills) {
|
||||
const resolved = params.skillsBySlug.get(entry.slug);
|
||||
if (!resolved) continue;
|
||||
expected.add(resolved.skillId);
|
||||
const version = entry.version?.trim() || undefined;
|
||||
if (version) versionsBySkill.set(resolved.skillId, version);
|
||||
}
|
||||
|
||||
const previous = await ctx.db
|
||||
.query("userSkillRootInstalls")
|
||||
.withIndex("by_user_root", (q) =>
|
||||
q.eq("userId", params.userId).eq("rootId", params.root.rootId),
|
||||
)
|
||||
.take(5000);
|
||||
|
||||
const active = previous.filter((entry) => !entry.removedAt);
|
||||
|
||||
for (const skillId of expected) {
|
||||
const existing = await ctx.db
|
||||
.query("userSkillRootInstalls")
|
||||
.withIndex("by_user_root_skill", (q) =>
|
||||
q.eq("userId", params.userId).eq("rootId", params.root.rootId).eq("skillId", skillId),
|
||||
)
|
||||
.unique();
|
||||
|
||||
const reportedVersion = versionsBySkill.get(skillId);
|
||||
|
||||
if (existing) {
|
||||
const wasRemoved = Boolean(existing.removedAt);
|
||||
await ctx.db.patch(existing._id, {
|
||||
lastSeenAt: params.now,
|
||||
lastVersion: reportedVersion ?? existing.lastVersion,
|
||||
removedAt: undefined,
|
||||
});
|
||||
if (wasRemoved) {
|
||||
await incrementActiveRoots(ctx, {
|
||||
userId: params.userId,
|
||||
skillId,
|
||||
now: params.now,
|
||||
version: reportedVersion,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await ctx.db.insert("userSkillRootInstalls", {
|
||||
userId: params.userId,
|
||||
rootId: params.root.rootId,
|
||||
skillId,
|
||||
firstSeenAt: params.now,
|
||||
lastSeenAt: params.now,
|
||||
lastVersion: reportedVersion,
|
||||
});
|
||||
await incrementActiveRoots(ctx, {
|
||||
userId: params.userId,
|
||||
skillId,
|
||||
now: params.now,
|
||||
version: reportedVersion,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of active) {
|
||||
if (expected.has(entry.skillId)) continue;
|
||||
await ctx.db.patch(entry._id, { removedAt: params.now });
|
||||
await decrementActiveRoots(ctx, { userId: params.userId, skillId: entry.skillId });
|
||||
}
|
||||
}
|
||||
|
||||
async function incrementActiveRoots(
|
||||
ctx: MutationCtx,
|
||||
params: { userId: Id<"users">; skillId: Id<"skills">; now: number; version?: string },
|
||||
@@ -367,27 +341,6 @@ async function incrementActiveRoots(
|
||||
}
|
||||
}
|
||||
|
||||
async function decrementActiveRoots(
|
||||
ctx: MutationCtx,
|
||||
params: { userId: Id<"users">; skillId: Id<"skills"> },
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
.query("userSkillInstalls")
|
||||
.withIndex("by_user_skill", (q) => q.eq("userId", params.userId).eq("skillId", params.skillId))
|
||||
.unique();
|
||||
if (!existing) return;
|
||||
|
||||
const nextActive = Math.max(0, (existing.activeRoots ?? 0) - 1);
|
||||
await ctx.db.patch(existing._id, { activeRoots: nextActive });
|
||||
if ((existing.activeRoots ?? 0) > 0 && nextActive === 0) {
|
||||
await bumpSkillInstallCounts(ctx, {
|
||||
skillId: params.skillId,
|
||||
deltaAllTime: 0,
|
||||
deltaCurrent: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function bumpSkillInstallCounts(
|
||||
ctx: MutationCtx,
|
||||
params: { skillId: Id<"skills">; deltaAllTime: number; deltaCurrent: number },
|
||||
@@ -396,47 +349,5 @@ async function bumpSkillInstallCounts(
|
||||
await insertStatEvent(ctx, { skillId: params.skillId, kind: "install_new" });
|
||||
} else if (params.deltaAllTime === 0 && params.deltaCurrent === 1) {
|
||||
await insertStatEvent(ctx, { skillId: params.skillId, kind: "install_reactivate" });
|
||||
} else if (params.deltaAllTime === 0 && params.deltaCurrent === -1) {
|
||||
await insertStatEvent(ctx, { skillId: params.skillId, kind: "install_deactivate" });
|
||||
}
|
||||
}
|
||||
|
||||
async function expireStaleRoots(
|
||||
ctx: MutationCtx,
|
||||
params: { userId: Id<"users">; stalenessCutoff: number; now: number },
|
||||
) {
|
||||
const roots = await ctx.db
|
||||
.query("userSyncRoots")
|
||||
.withIndex("by_user", (q) => q.eq("userId", params.userId))
|
||||
.take(5000);
|
||||
|
||||
const stale = roots.filter((root) => !root.expiredAt && root.lastSeenAt < params.stalenessCutoff);
|
||||
for (const root of stale) {
|
||||
await ctx.db.patch(root._id, { expiredAt: params.now });
|
||||
const installs = await ctx.db
|
||||
.query("userSkillRootInstalls")
|
||||
.withIndex("by_user_root", (q) => q.eq("userId", params.userId).eq("rootId", root.rootId))
|
||||
.take(5000);
|
||||
for (const entry of installs) {
|
||||
if (entry.removedAt) continue;
|
||||
await ctx.db.patch(entry._id, { removedAt: params.now });
|
||||
await decrementActiveRoots(ctx, { userId: params.userId, skillId: entry.skillId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSkillsBySlug(ctx: QueryCtx | MutationCtx, roots: RootPayload[]) {
|
||||
const slugs = new Set<string>();
|
||||
for (const root of roots) {
|
||||
for (const entry of root.skills) slugs.add(entry.slug);
|
||||
}
|
||||
const map = new Map<string, { skillId: Id<"skills"> }>();
|
||||
for (const slug of slugs) {
|
||||
const skill = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_slug", (q) => q.eq("slug", slug))
|
||||
.unique();
|
||||
if (skill && !skill.softDeletedAt) map.set(slug, { skillId: skill._id });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ Reading order:
|
||||
6. `docs/skill-format.md`: skill bundle metadata and package shape.
|
||||
7. `docs/soul-format.md`: SOUL.md bundle format.
|
||||
8. `docs/auth.md`: GitHub OAuth, API tokens, and CLI login.
|
||||
9. `docs/telemetry.md`: what `clawhub sync` reports and how to opt out.
|
||||
9. `docs/telemetry.md`: install telemetry and how to opt out.
|
||||
10. `docs/troubleshooting.md`: user-facing CLI, install, publish, sync, update, and API fixes.
|
||||
|
||||
Policy, API, and trust docs:
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ Public read:
|
||||
- Optional filter: `nonSuspiciousOnly=true`
|
||||
- Legacy alias: `nonSuspicious=true`
|
||||
- With `nonSuspiciousOnly=true`, cursor-based pages may contain fewer than `limit` items; use `nextCursor` to continue.
|
||||
- `recommended` ranks by stars, then all-time installs, then downloads, then `updatedAt`.
|
||||
- `recommended` ranks by stars, then downloads, then `updatedAt`.
|
||||
- `GET /api/v1/skills/{slug}`
|
||||
- `GET /api/v1/skills/{slug}/moderation`
|
||||
- `GET /api/v1/skills/{slug}/versions?limit=&cursor=`
|
||||
|
||||
+4
-4
@@ -14,7 +14,7 @@ sidebarTitle: "ClawHub"
|
||||
ClawHub is the public registry for OpenClaw skills and plugins.
|
||||
|
||||
- Use native `openclaw` commands to search, install, and update skills and to install plugins from ClawHub.
|
||||
- Use the separate `clawhub` CLI for registry auth, publishing, delete/undelete, and sync workflows.
|
||||
- Use the separate `clawhub` CLI for registry auth, publishing, sync, and delete/undelete workflows.
|
||||
|
||||
Site: [clawhub.ai](https://clawhub.ai)
|
||||
|
||||
@@ -151,8 +151,8 @@ hide or restore content, and ban abusive accounts. See
|
||||
|
||||
## Telemetry and environment
|
||||
|
||||
When you run `clawhub sync` while logged in, the CLI sends a minimal snapshot so
|
||||
ClawHub can compute install counts. Disable this with:
|
||||
When you run `clawhub install` while logged in, the CLI may send a best-effort
|
||||
install event so ClawHub can compute aggregate install counts. Disable this with:
|
||||
|
||||
```bash
|
||||
export CLAWHUB_DISABLE_TELEMETRY=1
|
||||
@@ -166,7 +166,7 @@ Useful environment overrides:
|
||||
| `CLAWHUB_REGISTRY` | Override the registry API URL. |
|
||||
| `CLAWHUB_CONFIG_PATH` | Override where the CLI stores token/config state. |
|
||||
| `CLAWHUB_WORKDIR` | Override the default working directory. |
|
||||
| `CLAWHUB_DISABLE_TELEMETRY=1` | Disable telemetry on `sync`. |
|
||||
| `CLAWHUB_DISABLE_TELEMETRY=1` | Disable install telemetry. |
|
||||
|
||||
See [Telemetry](./telemetry.md), [HTTP API](./http-api.md), and
|
||||
[Troubleshooting](./troubleshooting.md) for deeper reference material.
|
||||
|
||||
+11
-4
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "CLI reference: commands, flags, config, lockfile, sync behavior."
|
||||
summary: "CLI reference: commands, flags, config, lockfile, and sync behavior."
|
||||
read_when:
|
||||
- Using the ClawHub CLI
|
||||
- Debugging install, update, publish, or sync
|
||||
@@ -141,6 +141,8 @@ Stores your API token + cached registry URL.
|
||||
### `uninstall <slug>`
|
||||
|
||||
- Removes `<workdir>/<dir>/<slug>` and deletes the lockfile entry.
|
||||
- Sends best-effort telemetry while logged in so current install counts can be
|
||||
deactivated.
|
||||
- Interactive: asks for confirmation.
|
||||
- Non-interactive (`--no-input`): requires `--yes`.
|
||||
|
||||
@@ -717,10 +719,15 @@ Notes:
|
||||
- `--bump patch|minor|major` (default: patch)
|
||||
- `--changelog <text>` (non-interactive)
|
||||
- `--tags a,b,c` (default: latest)
|
||||
- `--concurrency <n>` (default: 4)
|
||||
- `--concurrency <n>`
|
||||
- `--source-repo <repo>`, `--source-commit <sha>`, `--source-ref <ref>` for GitHub provenance
|
||||
|
||||
Telemetry:
|
||||
`sync` does not report install telemetry.
|
||||
|
||||
- Sent during `sync` when logged in, unless `CLAWHUB_DISABLE_TELEMETRY=1` (legacy `CLAWDHUB_DISABLE_TELEMETRY=1`).
|
||||
### Install telemetry
|
||||
|
||||
- Sent after `clawhub install <slug>` when logged in, unless
|
||||
`CLAWHUB_DISABLE_TELEMETRY=1` is set.
|
||||
- Reporting is best-effort. Install commands do not fail if telemetry is
|
||||
unavailable.
|
||||
- Details: `docs/telemetry.md`.
|
||||
|
||||
+4
-4
@@ -123,10 +123,10 @@ Response:
|
||||
|
||||
Notes:
|
||||
|
||||
- Results are returned in relevance order (embedding similarity + exact slug/name token boosts + a small popularity prior from stars, all-time installs, and downloads).
|
||||
- Results are returned in relevance order (embedding similarity + exact slug/name token boosts + a small popularity prior from stars and downloads).
|
||||
- Relevance is stronger than popularity. A precise slug or display-name token match can outrank a looser match with many more downloads.
|
||||
- ASCII text is tokenized on word and punctuation boundaries. For example, `personal-map` contains a standalone `map` token, while `amap-jsapi-skill` contains `amap`, `jsapi`, and `skill`; searching for `map` therefore gives `personal-map` a stronger lexical match than `amap-jsapi-skill`.
|
||||
- Popularity is log-scaled and capped. Stars carry the strongest weight, all-time installs carry a smaller weight, and downloads are only a tiny fallback signal. High-download skills can rank lower when the query text is a weaker match.
|
||||
- Popularity is log-scaled and capped. Stars carry the strongest weight, and downloads are the fallback popularity signal. High-download skills can rank lower when the query text is a weaker match.
|
||||
- Suspicious or hidden moderation state can remove a skill from public search depending on caller filters and current moderation status.
|
||||
|
||||
Publisher discoverability guidance:
|
||||
@@ -150,7 +150,7 @@ Invalid `sort` values return `400`.
|
||||
|
||||
Notes:
|
||||
|
||||
- `recommended` ranks by stars, then all-time installs, then downloads, then `updatedAt`.
|
||||
- `recommended` ranks by stars, then downloads, then `updatedAt`.
|
||||
- `trending` ranks by installs in the last 7 days (telemetry-based).
|
||||
- `createdAt` is stable for new-skill crawls; `updated` changes when existing skills are republished.
|
||||
- When `nonSuspiciousOnly=true`, cursor-based sorts may return fewer than `limit` items on a page because suspicious skills are filtered after page retrieval.
|
||||
@@ -1540,7 +1540,7 @@ Still supported for older CLI versions:
|
||||
- `GET /api/cli/whoami`
|
||||
- `POST /api/cli/upload-url`
|
||||
- `POST /api/cli/publish`
|
||||
- `POST /api/cli/telemetry/sync`
|
||||
- `POST /api/cli/telemetry/install`
|
||||
- `POST /api/cli/skill/delete`
|
||||
- `POST /api/cli/skill/undelete`
|
||||
|
||||
|
||||
+16
-8
@@ -18,23 +18,31 @@ normal install and download surfaces until review finishes.
|
||||
|
||||
## Skills
|
||||
|
||||
The simplest publishing path is the CLI. Sign in, preview the sync plan, then
|
||||
publish the new or changed skills:
|
||||
The simplest publishing path is the CLI. Sign in, then publish a local skill
|
||||
folder:
|
||||
|
||||
```bash
|
||||
clawhub login
|
||||
clawhub skill publish ./my-skill \
|
||||
--slug my-skill \
|
||||
--name "My Skill" \
|
||||
--version 1.0.0 \
|
||||
--owner <owner>
|
||||
```
|
||||
|
||||
Use `--owner <handle>` when publishing to an org owner. Omit it to publish as
|
||||
the authenticated user.
|
||||
|
||||
For catalog repos, use `sync` to scan folders containing `SKILL.md` and publish
|
||||
new or changed skills:
|
||||
|
||||
```bash
|
||||
clawhub sync --dry-run --owner <owner>
|
||||
clawhub sync --all --owner <owner>
|
||||
```
|
||||
|
||||
`sync` scans for folders containing `SKILL.md` and compares them with ClawHub.
|
||||
When you run it without `--dry-run`, it publishes anything new or changed.
|
||||
|
||||
Use `--dry-run` first to see the plan without uploading.
|
||||
|
||||
Use `--owner <handle>` when publishing to an org owner. Omit it to publish as
|
||||
the authenticated user.
|
||||
|
||||
### GitHub Actions for Skills
|
||||
|
||||
If you want to run skill publishing from CI, call ClawHub's reusable
|
||||
|
||||
@@ -138,10 +138,6 @@ jobs:
|
||||
dry_run: true
|
||||
```
|
||||
|
||||
When you are signed in, `sync` may also send a minimal install snapshot for
|
||||
aggregate install counts. See [Telemetry](./telemetry.md) for what is reported
|
||||
and how to opt out.
|
||||
|
||||
## Inspect before installing
|
||||
|
||||
Before installing, use the ClawHub web page or CLI detail commands to inspect
|
||||
|
||||
+16
-40
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Install telemetry collected via `clawhub sync` + opt-out."
|
||||
summary: "Install telemetry collected by the ClawHub CLI and how to opt out."
|
||||
read_when:
|
||||
- Working on telemetry / privacy controls
|
||||
- Questions about what data is collected
|
||||
@@ -7,76 +7,52 @@ read_when:
|
||||
|
||||
# Telemetry
|
||||
|
||||
ClawHub uses **minimal telemetry** to compute **install counts** (what’s actually in use) and to power better sorting/filtering.
|
||||
This is based on the CLI `clawhub sync` command.
|
||||
ClawHub uses minimal CLI telemetry to compute aggregate install counts.
|
||||
|
||||
## When telemetry is collected
|
||||
|
||||
Telemetry is only sent when:
|
||||
|
||||
- You are **logged in** in the CLI (we already require auth for sync/publish flows).
|
||||
- You run `clawhub sync`.
|
||||
- You are logged in in the CLI.
|
||||
- You run `clawhub install <slug>`.
|
||||
- Telemetry is **not disabled** (see “How to disable” below).
|
||||
|
||||
If you are not logged in, nothing is reported.
|
||||
|
||||
## What we collect
|
||||
|
||||
On each `clawhub sync`, the CLI reports a **full snapshot** of what it found, grouped by scan root (“folder/root”).
|
||||
On each reported `clawhub install`, the CLI sends one best-effort install event.
|
||||
|
||||
For each root we store:
|
||||
The event includes:
|
||||
|
||||
- `rootId`: a **SHA-256 hash** of the canonical root path (server never sees the raw path).
|
||||
- `label`: a human-readable label derived from the last two path segments (home paths are shown with `~`).
|
||||
- `firstSeenAt`, `lastSeenAt`, optional `expiredAt`.
|
||||
|
||||
For each skill found under a root we store:
|
||||
|
||||
- `skillId` (resolved by slug; only skills that exist in the registry are tracked).
|
||||
- `firstSeenAt`, `lastSeenAt`.
|
||||
- `lastVersion` (best-effort; currently the registry-matched version if known).
|
||||
- optional `removedAt` when a previously-reported install disappears from a root.
|
||||
- `rootLabel`: a short label derived from the last two path segments (home paths are shown with `~`).
|
||||
- `slug`: the installed skill slug.
|
||||
- `version`: the installed version, when known.
|
||||
|
||||
### What we do _not_ collect
|
||||
|
||||
- No raw absolute folder paths (only hashed `rootId` + a short display label).
|
||||
- No file contents.
|
||||
- No per-run logs, prompts, or other CLI output.
|
||||
- No tracking for skills that aren’t uploaded to the registry (unknown slugs are ignored).
|
||||
|
||||
## Install counts
|
||||
|
||||
We maintain two counters per skill:
|
||||
ClawHub maintains aggregate counters per skill:
|
||||
|
||||
- `installsCurrent`: unique users who currently have the skill installed in at least one active root.
|
||||
- `installsAllTime`: unique users who have ever reported the skill installed.
|
||||
|
||||
### Multiple roots
|
||||
|
||||
If you sync from multiple folders, we treat each scan root independently. A skill is “currently installed” if it exists in **any** active root.
|
||||
|
||||
### Uninstall detection
|
||||
|
||||
Because `sync` reports the full set per root:
|
||||
|
||||
- If a skill disappears from a root on the next sync, we mark it removed for that root.
|
||||
- If the skill is removed from all of your roots, it no longer counts toward `installsCurrent`.
|
||||
- `installsAllTime` never decreases unless you delete telemetry (see below).
|
||||
|
||||
### Staleness (120 days)
|
||||
|
||||
Roots that don’t report telemetry for **120 days** are marked stale and their installs stop counting toward `installsCurrent`.
|
||||
This is evaluated lazily (on the next telemetry report) to avoid background jobs.
|
||||
- `installsAllTime`: unique users who have reported at least one CLI install for the skill.
|
||||
- `installsCurrent`: unique users who have reported an install and have not deleted their
|
||||
telemetry.
|
||||
|
||||
## Transparency + user controls
|
||||
|
||||
ClawHub provides a private “Installed” tab on your own profile:
|
||||
|
||||
- Shows the exact roots + installed skills we store.
|
||||
- Shows install telemetry associated with your account.
|
||||
- Includes a **JSON export** view.
|
||||
- Includes a **Delete telemetry** action to remove all stored telemetry for your account.
|
||||
|
||||
Everyone else only sees **aggregated install counters**; no one else can see your roots/folders.
|
||||
Everyone else only sees **aggregated install counters**.
|
||||
|
||||
Deleting your account also deletes your telemetry data.
|
||||
|
||||
@@ -88,4 +64,4 @@ Set the environment variable:
|
||||
export CLAWHUB_DISABLE_TELEMETRY=1
|
||||
```
|
||||
|
||||
With this set, the CLI will not send telemetry during `clawhub sync`.
|
||||
With this set, the CLI will not send install telemetry.
|
||||
|
||||
@@ -806,17 +806,23 @@ registerCommand(program, ["sync"])
|
||||
.option("--bump <type>", "Version bump for updates (patch|minor|major)", "patch")
|
||||
.option("--changelog <text>", "Changelog to use for updates (non-interactive)")
|
||||
.option("--tags <tags>", "Comma-separated tags", "latest")
|
||||
.option("--concurrency <n>", "Concurrent registry checks (default: 4)", "4")
|
||||
.option("--no-clawdbot-roots", "Only scan the configured workdir/dir and --root values")
|
||||
.option("--source-repo <repo>", "GitHub repo (owner/repo or URL)")
|
||||
.option("--source-commit <sha>", "Git commit SHA")
|
||||
.option("--source-ref <ref>", "Git ref/tag/branch")
|
||||
.option("--concurrency <n>", "Concurrent registry/file checks", (value) =>
|
||||
Number.parseInt(value, 10),
|
||||
)
|
||||
.option("--source-repo <repo>", "GitHub repo URL or owner/name for source provenance")
|
||||
.option("--source-commit <sha>", "Git commit SHA for source provenance")
|
||||
.option("--source-ref <ref>", "Git ref for source provenance")
|
||||
.addOption(
|
||||
new Option("--clawdbot-roots", "Include Clawdbot-configured roots").default(true, "enabled"),
|
||||
)
|
||||
.addOption(new Option("--no-clawdbot-roots", "Disable Clawdbot-configured roots"))
|
||||
.action(async (options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
const bump = String(options.bump ?? "patch") as "patch" | "minor" | "major";
|
||||
if (!["patch", "minor", "major"].includes(bump)) fail("--bump must be patch|minor|major");
|
||||
const concurrencyRaw = Number(options.concurrency ?? 4);
|
||||
const concurrency = Number.isFinite(concurrencyRaw) ? Math.round(concurrencyRaw) : 4;
|
||||
const bump =
|
||||
options.bump === "patch" || options.bump === "minor" || options.bump === "major"
|
||||
? options.bump
|
||||
: fail("--bump must be patch, minor, or major");
|
||||
const concurrency = options.concurrency ?? 6;
|
||||
if (concurrency < 1 || concurrency > 32) fail("--concurrency must be between 1 and 32");
|
||||
await cmdSync(
|
||||
opts,
|
||||
|
||||
@@ -970,13 +970,11 @@ describe("cmdInstall", () => {
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
token: "tkn",
|
||||
body: {
|
||||
roots: [
|
||||
{
|
||||
rootId: expect.any(String),
|
||||
label: expect.any(String),
|
||||
skills: [{ slug: "demo", version: "1.0.0" }],
|
||||
},
|
||||
],
|
||||
event: "install",
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
rootId: expect.any(String),
|
||||
rootLabel: expect.any(String),
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
|
||||
@@ -259,7 +259,8 @@ export async function cmdInstall(
|
||||
token,
|
||||
registry,
|
||||
root: opts.dir,
|
||||
skills: lock.skills,
|
||||
slug: skillMeta.skill?.slug ?? trimmed,
|
||||
version: resolvedVersion,
|
||||
});
|
||||
spinner.succeed(`OK. Installed ${trimmed} -> ${target}`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -779,9 +779,8 @@ describe("cmdSync", () => {
|
||||
expect(mockCmdPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips telemetry when CLAWHUB_DISABLE_TELEMETRY is set", async () => {
|
||||
it("does not report install telemetry from sync", async () => {
|
||||
interactive = false;
|
||||
process.env.CLAWHUB_DISABLE_TELEMETRY = "1";
|
||||
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
|
||||
if (args.path === "/api/v1/whoami") return { user: { handle: "steipete" } };
|
||||
if (args.path.startsWith("/api/v1/resolve?")) {
|
||||
@@ -794,6 +793,5 @@ describe("cmdSync", () => {
|
||||
expect(
|
||||
mockApiRequest.mock.calls.some((call) => call[1]?.path === "/api/cli/telemetry/install"),
|
||||
).toBe(false);
|
||||
delete process.env.CLAWHUB_DISABLE_TELEMETRY;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,10 +21,8 @@ import {
|
||||
formatSyncedSummary,
|
||||
getRegistryWithAuth,
|
||||
mapWithConcurrency,
|
||||
mergeScan,
|
||||
normalizeConcurrency,
|
||||
printSection,
|
||||
reportTelemetryIfEnabled,
|
||||
resolvePublishMeta,
|
||||
scanRootsWithLabels,
|
||||
selectToUpload,
|
||||
@@ -54,7 +52,6 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
|
||||
const spinner = jsonMode ? null : createSpinner("Scanning for local skills");
|
||||
const primaryScan = await scanRootsWithLabels(combinedRoots, clawdbotRoots.labels);
|
||||
let scan = primaryScan;
|
||||
let telemetryScan = primaryScan;
|
||||
if (primaryScan.skills.length === 0) {
|
||||
if (!includeClawdbotRoots) {
|
||||
fail("No skills found (checked configured roots)");
|
||||
@@ -62,7 +59,6 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
|
||||
const fallback = getFallbackSkillRoots(opts.workdir);
|
||||
const fallbackScan = await scanRootsWithLabels(fallback);
|
||||
spinner?.stop();
|
||||
telemetryScan = mergeScan(primaryScan, fallbackScan);
|
||||
scan = fallbackScan;
|
||||
if (fallbackScan.skills.length === 0)
|
||||
fail("No skills found (checked workdir and known Clawdis/Clawd locations)");
|
||||
@@ -137,15 +133,6 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
|
||||
candidatesSpinner?.stop();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
await reportTelemetryIfEnabled({
|
||||
token,
|
||||
registry,
|
||||
scan: telemetryScan,
|
||||
candidates,
|
||||
});
|
||||
}
|
||||
|
||||
const synced = candidates.filter((candidate) => candidate.status === "synced");
|
||||
const actionable = candidates.filter((candidate) => candidate.status !== "synced");
|
||||
const bump = options.bump ?? "patch";
|
||||
|
||||
@@ -6,7 +6,7 @@ import semver from "semver";
|
||||
import { resolveHome } from "../../homedir.js";
|
||||
import { apiRequest, downloadZip } from "../../http.js";
|
||||
import {
|
||||
ApiCliTelemetrySyncResponseSchema,
|
||||
ApiCliTelemetryInstallResponseSchema,
|
||||
ApiRoutes,
|
||||
ApiV1SkillResolveResponseSchema,
|
||||
ApiV1SkillResponseSchema,
|
||||
@@ -20,56 +20,16 @@ import type { GlobalOpts } from "../types.js";
|
||||
import { fail, formatError } from "../ui.js";
|
||||
import type { Candidate, LocalSkill } from "./syncTypes.js";
|
||||
|
||||
export async function reportTelemetryIfEnabled(params: {
|
||||
token: string;
|
||||
registry: string;
|
||||
scan: { roots: string[]; skillsByRoot: Record<string, SkillFolder[]> };
|
||||
candidates: Candidate[];
|
||||
}) {
|
||||
if (isTelemetryDisabled()) return;
|
||||
const versionBySlug = new Map<string, string | null>();
|
||||
for (const candidate of params.candidates) {
|
||||
versionBySlug.set(candidate.slug, candidate.matchVersion ?? null);
|
||||
}
|
||||
|
||||
const roots = params.scan.roots.map((root) => ({
|
||||
rootId: rootTelemetryId(root),
|
||||
label: formatRootLabel(root),
|
||||
skills: (params.scan.skillsByRoot[root] ?? []).map((skill) => ({
|
||||
slug: skill.slug,
|
||||
version: versionBySlug.get(skill.slug) ?? null,
|
||||
})),
|
||||
}));
|
||||
|
||||
try {
|
||||
await apiRequest(
|
||||
params.registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
token: params.token,
|
||||
body: { roots },
|
||||
},
|
||||
ApiCliTelemetrySyncResponseSchema,
|
||||
);
|
||||
} catch {
|
||||
// ignore telemetry failures
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportInstalledSkillsTelemetryIfEnabled(params: {
|
||||
token: string | undefined;
|
||||
registry: string;
|
||||
root: string;
|
||||
skills: Record<string, { version?: string | null }>;
|
||||
slug: string;
|
||||
version?: string | null;
|
||||
}) {
|
||||
if (!params.token || isTelemetryDisabled()) return;
|
||||
const skills = Object.entries(params.skills)
|
||||
.map(([slug, entry]) => ({
|
||||
slug,
|
||||
version: entry.version ?? null,
|
||||
}))
|
||||
.filter((skill) => Boolean(skill.slug));
|
||||
const slug = params.slug.trim();
|
||||
if (!slug) return;
|
||||
|
||||
try {
|
||||
await apiRequest(
|
||||
@@ -79,16 +39,14 @@ export async function reportInstalledSkillsTelemetryIfEnabled(params: {
|
||||
path: LegacyApiRoutes.cliTelemetryInstall,
|
||||
token: params.token,
|
||||
body: {
|
||||
roots: [
|
||||
{
|
||||
rootId: rootTelemetryId(params.root),
|
||||
label: formatRootLabel(params.root),
|
||||
skills,
|
||||
},
|
||||
],
|
||||
event: "install",
|
||||
slug,
|
||||
version: params.version ?? undefined,
|
||||
rootId: rootTelemetryId(params.root),
|
||||
rootLabel: formatRootLabel(params.root),
|
||||
},
|
||||
},
|
||||
ApiCliTelemetrySyncResponseSchema,
|
||||
ApiCliTelemetryInstallResponseSchema,
|
||||
);
|
||||
} catch {
|
||||
// Install telemetry is best-effort; local installs must not fail because
|
||||
@@ -242,37 +200,6 @@ export async function scanRootsWithLabels(roots: string[], labels?: Record<strin
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeScan(
|
||||
left: {
|
||||
roots: string[];
|
||||
skillsByRoot: Record<string, SkillFolder[]>;
|
||||
skills: SkillFolder[];
|
||||
rootsWithSkills: string[];
|
||||
rootLabels: Record<string, string>;
|
||||
},
|
||||
right: {
|
||||
roots: string[];
|
||||
skillsByRoot: Record<string, SkillFolder[]>;
|
||||
skills: SkillFolder[];
|
||||
rootsWithSkills: string[];
|
||||
rootLabels: Record<string, string>;
|
||||
},
|
||||
) {
|
||||
const mergedRoots = Array.from(new Set([...left.roots, ...right.roots]));
|
||||
const skillsByRoot: Record<string, SkillFolder[]> = {};
|
||||
for (const root of mergedRoots) {
|
||||
skillsByRoot[root] = right.skillsByRoot[root] ?? left.skillsByRoot[root] ?? [];
|
||||
}
|
||||
const rootLabels: Record<string, string> = { ...left.rootLabels, ...right.rootLabels };
|
||||
const byFolder = new Map<string, SkillFolder>();
|
||||
for (const entry of [...left.skills, ...right.skills]) {
|
||||
byFolder.set(entry.folder, entry);
|
||||
}
|
||||
const skills = Array.from(byFolder.values());
|
||||
const rootsWithSkills = mergedRoots.filter((root) => (skillsByRoot[root]?.length ?? 0) > 0);
|
||||
return { roots: mergedRoots, skillsByRoot, skills, rootsWithSkills, rootLabels };
|
||||
}
|
||||
|
||||
async function dedupeRoots(roots: string[]) {
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
|
||||
@@ -7,7 +7,6 @@ export const LegacyApiRoutes = {
|
||||
cliUploadUrl: "/api/cli/upload-url",
|
||||
cliPublish: "/api/cli/publish",
|
||||
cliTelemetryInstall: "/api/cli/telemetry/install",
|
||||
cliTelemetrySync: "/api/cli/telemetry/sync",
|
||||
cliSkillDelete: "/api/cli/skill/delete",
|
||||
cliSkillUndelete: "/api/cli/skill/undelete",
|
||||
} as const;
|
||||
|
||||
@@ -152,19 +152,16 @@ export const ApiV1SkillInstallResolveResponseSchema = type({
|
||||
export type ApiV1SkillInstallResolveResponse =
|
||||
(typeof ApiV1SkillInstallResolveResponseSchema)[inferred];
|
||||
|
||||
export const CliTelemetrySyncRequestSchema = type({
|
||||
roots: type({
|
||||
rootId: "string",
|
||||
label: "string",
|
||||
skills: type({
|
||||
slug: "string",
|
||||
version: "string|null?",
|
||||
}).array(),
|
||||
}).array(),
|
||||
export const CliTelemetryInstallRequestSchema = type({
|
||||
event: '"install"',
|
||||
slug: "string",
|
||||
version: "string?",
|
||||
rootId: "string?",
|
||||
rootLabel: "string?",
|
||||
});
|
||||
export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred];
|
||||
export type CliTelemetryInstallRequest = (typeof CliTelemetryInstallRequestSchema)[inferred];
|
||||
|
||||
export const ApiCliTelemetrySyncResponseSchema = type({
|
||||
export const ApiCliTelemetryInstallResponseSchema = type({
|
||||
ok: "true",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { strToU8, zipSync } from "fflate";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const packageRoot = resolve(import.meta.dirname, "..");
|
||||
@@ -12,6 +15,7 @@ const binPath = join(packageRoot, "bin", "clawdhub.js");
|
||||
const distCliPath = join(packageRoot, "dist", "cli.js");
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const servers: Server[] = [];
|
||||
|
||||
async function makeTmpDir(prefix: string) {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix));
|
||||
@@ -19,15 +23,44 @@ async function makeTmpDir(prefix: string) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
function runNode(args: string[]) {
|
||||
function runNode(args: string[], envOverrides: NodeJS.ProcessEnv = {}) {
|
||||
const { FORCE_COLOR: _forceColor, ...env } = process.env;
|
||||
return spawnSync("node", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
env,
|
||||
env: { ...env, ...envOverrides },
|
||||
});
|
||||
}
|
||||
|
||||
async function runNodeAsync(args: string[], envOverrides: NodeJS.ProcessEnv = {}) {
|
||||
const { FORCE_COLOR: _forceColor, ...env } = process.env;
|
||||
const child = spawn("node", args, {
|
||||
cwd: repoRoot,
|
||||
env: { ...env, ...envOverrides },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => child.kill("SIGTERM"), 20_000);
|
||||
const result = await new Promise<{ status: number | null; signal: NodeJS.Signals | null }>(
|
||||
(resolveExit, rejectExit) => {
|
||||
child.on("error", rejectExit);
|
||||
child.on("exit", (status, signal) => resolveExit({ status, signal }));
|
||||
},
|
||||
);
|
||||
clearTimeout(timeout);
|
||||
return { ...result, stdout, stderr };
|
||||
}
|
||||
|
||||
function runGit(cwd: string, args: string[]) {
|
||||
const result = spawnSync("git", ["-C", cwd, ...args], {
|
||||
encoding: "utf8",
|
||||
@@ -40,11 +73,155 @@ function runGit(cwd: string, args: string[]) {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (servers.length > 0) {
|
||||
await new Promise<void>((resolveClose, rejectClose) => {
|
||||
const server = servers.pop()!;
|
||||
server.closeAllConnections();
|
||||
server.close((error) => (error ? rejectClose(error) : resolveClose()));
|
||||
});
|
||||
}
|
||||
while (tempDirs.length > 0) {
|
||||
await rm(tempDirs.pop()!, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
type RecordedRequest = {
|
||||
method: string;
|
||||
path: string;
|
||||
authorization?: string;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
async function readRequestBody(request: IncomingMessage) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
function writeJson(response: ServerResponse, status: number, body: unknown) {
|
||||
response.writeHead(status, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
async function startLocalRegistry() {
|
||||
const requests: RecordedRequest[] = [];
|
||||
const skillZip = zipSync({
|
||||
"SKILL.md": strToU8("# Demo\n\nA local registry fixture.\n"),
|
||||
});
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
const bodyText = await readRequestBody(request);
|
||||
const recorded: RecordedRequest = {
|
||||
method: request.method ?? "GET",
|
||||
path: `${url.pathname}${url.search}`,
|
||||
authorization: request.headers.authorization,
|
||||
};
|
||||
if (bodyText) recorded.body = JSON.parse(bodyText) as unknown;
|
||||
requests.push(recorded);
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/whoami") {
|
||||
writeJson(response, 200, {
|
||||
user: { handle: "artifact-user", displayName: "Artifact User", role: "user" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/skills/demo") {
|
||||
writeJson(response, 200, {
|
||||
skill: {
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
summary: "Local fixture",
|
||||
tags: {},
|
||||
stats: {},
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: {
|
||||
version: "1.0.0",
|
||||
createdAt: 2,
|
||||
changelog: "Initial",
|
||||
license: "MIT-0",
|
||||
},
|
||||
owner: null,
|
||||
moderation: {
|
||||
isSuspicious: false,
|
||||
isMalwareBlocked: false,
|
||||
verdict: "clean",
|
||||
reasonCodes: [],
|
||||
updatedAt: null,
|
||||
engineVersion: null,
|
||||
summary: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/skills/demo/versions/1.0.0") {
|
||||
writeJson(response, 200, {
|
||||
version: {
|
||||
version: "1.0.0",
|
||||
createdAt: 2,
|
||||
changelog: "Initial",
|
||||
changelogSource: "user",
|
||||
license: "MIT-0",
|
||||
files: [],
|
||||
},
|
||||
skill: {
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/download") {
|
||||
expect(url.searchParams.get("slug")).toBe("demo");
|
||||
expect(url.searchParams.get("version")).toBe("1.0.0");
|
||||
response.writeHead(200, { "Content-Type": "application/zip" });
|
||||
response.end(Buffer.from(skillZip));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/resolve") {
|
||||
writeJson(response, 200, {
|
||||
match: { version: "1.0.0" },
|
||||
latestVersion: { version: "1.0.0" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/api/cli/telemetry/install") {
|
||||
writeJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(response, 404, { error: `Unhandled ${request.method} ${url.pathname}` });
|
||||
});
|
||||
|
||||
await new Promise<void>((resolveListen, rejectListen) => {
|
||||
server.once("error", rejectListen);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", rejectListen);
|
||||
resolveListen();
|
||||
});
|
||||
});
|
||||
servers.push(server);
|
||||
|
||||
const address = server.address() as AddressInfo;
|
||||
return {
|
||||
registry: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeConfigWithToken(root: string, registry: string) {
|
||||
const configPath = join(root, "config.json");
|
||||
await writeFile(configPath, JSON.stringify({ registry, token: "test-token" }), "utf8");
|
||||
return configPath;
|
||||
}
|
||||
|
||||
describe("built CLI artifact", () => {
|
||||
it("runs help from the published bin entrypoint", async () => {
|
||||
const result = runNode([binPath, "--help"]);
|
||||
@@ -54,6 +231,45 @@ describe("built CLI artifact", () => {
|
||||
expect(result.stdout).toContain("ClawHub CLI");
|
||||
});
|
||||
|
||||
it("prints help by default", async () => {
|
||||
const result = runNode([binPath]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toContain("Usage: clawhub");
|
||||
expect(result.stdout).toContain("sync");
|
||||
});
|
||||
|
||||
it("runs sync for bare logged-in invocations", async () => {
|
||||
const { registry, requests } = await startLocalRegistry();
|
||||
const workdir = await makeTmpDir("clawhub-artifact-bare-sync-");
|
||||
const configPath = await writeConfigWithToken(workdir, registry);
|
||||
const skillDir = join(workdir, "skills", "demo");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Demo\n\nA local sync fixture.\n", "utf8");
|
||||
|
||||
const result = await runNodeAsync(
|
||||
[binPath, "--workdir", workdir, "--registry", registry, "--no-input"],
|
||||
{ CLAWHUB_CONFIG_PATH: configPath },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).not.toContain("Usage: clawhub");
|
||||
expect(requests.map((request) => request.path)).toContain("/api/v1/whoami");
|
||||
expect(requests.map((request) => request.path)).toContainEqual(
|
||||
expect.stringMatching(/^\/api\/v1\/resolve\?slug=demo&hash=/),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes sync help for reusable publishing workflows", async () => {
|
||||
const result = runNode([binPath, "sync", "--help"]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toContain("Usage: clawhub sync");
|
||||
expect(result.stdout).toContain("Scan local skills and publish new/updated ones");
|
||||
});
|
||||
|
||||
it("reports unknown top-level commands clearly", async () => {
|
||||
const result = runNode([binPath, "nope"]);
|
||||
|
||||
@@ -70,6 +286,24 @@ describe("built CLI artifact", () => {
|
||||
expect(result.stderr).not.toContain("too many arguments");
|
||||
});
|
||||
|
||||
it("rejects invalid sync bump values before scanning", async () => {
|
||||
const workdir = await makeTmpDir("clawhub-artifact-invalid-bump-");
|
||||
const result = runNode([
|
||||
binPath,
|
||||
"--workdir",
|
||||
workdir,
|
||||
"sync",
|
||||
"--bump",
|
||||
"banana",
|
||||
"--dry-run",
|
||||
"--no-clawdbot-roots",
|
||||
]);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("--bump must be patch, minor, or major");
|
||||
expect(result.stderr).not.toContain("No skills found");
|
||||
});
|
||||
|
||||
it("does not mask unknown global options", async () => {
|
||||
const result = runNode([binPath, "--bad", "nope"]);
|
||||
|
||||
@@ -161,6 +395,91 @@ describe("built CLI artifact", () => {
|
||||
expect(output.commit).toBeTypeOf("string");
|
||||
});
|
||||
|
||||
it("sends one explicit install telemetry event from the built install command", async () => {
|
||||
const { registry, requests } = await startLocalRegistry();
|
||||
const workdir = await makeTmpDir("clawhub-artifact-install-");
|
||||
const configPath = await writeConfigWithToken(workdir, registry);
|
||||
|
||||
const result = await runNodeAsync(
|
||||
[
|
||||
binPath,
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--registry",
|
||||
registry,
|
||||
"install",
|
||||
"demo",
|
||||
"--version",
|
||||
"1.0.0",
|
||||
],
|
||||
{ CLAWHUB_CONFIG_PATH: configPath },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain("OK. Installed demo");
|
||||
|
||||
const telemetryRequests = requests.filter(
|
||||
(request) => request.path === "/api/cli/telemetry/install",
|
||||
);
|
||||
expect(telemetryRequests).toHaveLength(1);
|
||||
expect(telemetryRequests[0]).toEqual({
|
||||
method: "POST",
|
||||
path: "/api/cli/telemetry/install",
|
||||
authorization: "Bearer test-token",
|
||||
body: {
|
||||
event: "install",
|
||||
slug: "demo",
|
||||
version: "1.0.0",
|
||||
rootId: expect.any(String),
|
||||
rootLabel: expect.stringContaining("skills"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(requests.map((request) => request.path)).toEqual([
|
||||
"/api/v1/skills/demo",
|
||||
"/api/v1/skills/demo/versions/1.0.0",
|
||||
"/api/v1/download?slug=demo&version=1.0.0",
|
||||
"/api/cli/telemetry/install",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not send install telemetry from the built sync command", async () => {
|
||||
const { registry, requests } = await startLocalRegistry();
|
||||
const workdir = await makeTmpDir("clawhub-artifact-sync-");
|
||||
const configPath = await writeConfigWithToken(workdir, registry);
|
||||
const skillDir = join(workdir, "skills", "demo");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(join(skillDir, "SKILL.md"), "# Demo\n\nA local sync fixture.\n", "utf8");
|
||||
|
||||
const result = await runNodeAsync(
|
||||
[
|
||||
binPath,
|
||||
"--workdir",
|
||||
workdir,
|
||||
"--registry",
|
||||
registry,
|
||||
"sync",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--no-clawdbot-roots",
|
||||
],
|
||||
{ CLAWHUB_CONFIG_PATH: configPath },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const output = JSON.parse(result.stdout.trim()) as { ok: boolean; dryRun: boolean };
|
||||
expect(output).toMatchObject({ ok: true, dryRun: true });
|
||||
|
||||
expect(
|
||||
requests.filter((request) => request.path.startsWith("/api/cli/telemetry/")),
|
||||
).toHaveLength(0);
|
||||
expect(requests.map((request) => request.path)).toEqual([
|
||||
"/api/v1/whoami",
|
||||
expect.stringMatching(/^\/api\/v1\/resolve\?slug=demo&hash=/),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the built dist free of compiled test files", async () => {
|
||||
expect(dirname(distCliPath)).toBe(join(packageRoot, "dist"));
|
||||
const result = runNode([
|
||||
|
||||
Vendored
-1
@@ -7,7 +7,6 @@ export declare const LegacyApiRoutes: {
|
||||
readonly cliUploadUrl: "/api/cli/upload-url";
|
||||
readonly cliPublish: "/api/cli/publish";
|
||||
readonly cliTelemetryInstall: "/api/cli/telemetry/install";
|
||||
readonly cliTelemetrySync: "/api/cli/telemetry/sync";
|
||||
readonly cliSkillDelete: "/api/cli/skill/delete";
|
||||
readonly cliSkillUndelete: "/api/cli/skill/undelete";
|
||||
};
|
||||
|
||||
Vendored
-1
@@ -7,7 +7,6 @@ export const LegacyApiRoutes = {
|
||||
cliUploadUrl: "/api/cli/upload-url",
|
||||
cliPublish: "/api/cli/publish",
|
||||
cliTelemetryInstall: "/api/cli/telemetry/install",
|
||||
cliTelemetrySync: "/api/cli/telemetry/sync",
|
||||
cliSkillDelete: "/api/cli/skill/delete",
|
||||
cliSkillUndelete: "/api/cli/skill/undelete",
|
||||
};
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,gBAAgB,EAAE,yBAAyB;IAC3C,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,MAAM,EAAE,gBAAgB;IACxB,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,KAAK,EAAE,eAAe;IACtB,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"}
|
||||
Vendored
+8
-11
@@ -149,18 +149,15 @@ export declare const ApiV1SkillInstallResolveResponseSchema: import("arktype/int
|
||||
status: number;
|
||||
}, {}>;
|
||||
export type ApiV1SkillInstallResolveResponse = (typeof ApiV1SkillInstallResolveResponseSchema)[inferred];
|
||||
export declare const CliTelemetrySyncRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
roots: {
|
||||
rootId: string;
|
||||
label: string;
|
||||
skills: {
|
||||
slug: string;
|
||||
version?: string | null | undefined;
|
||||
}[];
|
||||
}[];
|
||||
export declare const CliTelemetryInstallRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
event: "install";
|
||||
slug: string;
|
||||
version?: string | undefined;
|
||||
rootId?: string | undefined;
|
||||
rootLabel?: string | undefined;
|
||||
}, {}>;
|
||||
export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred];
|
||||
export declare const ApiCliTelemetrySyncResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
export type CliTelemetryInstallRequest = (typeof CliTelemetryInstallRequestSchema)[inferred];
|
||||
export declare const ApiCliTelemetryInstallResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
ok: true;
|
||||
}, {}>;
|
||||
export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
||||
|
||||
Vendored
+7
-10
@@ -127,17 +127,14 @@ export const ApiV1SkillInstallResolveResponseSchema = type({
|
||||
message: "string",
|
||||
status: "number",
|
||||
});
|
||||
export const CliTelemetrySyncRequestSchema = type({
|
||||
roots: type({
|
||||
rootId: "string",
|
||||
label: "string",
|
||||
skills: type({
|
||||
slug: "string",
|
||||
version: "string|null?",
|
||||
}).array(),
|
||||
}).array(),
|
||||
export const CliTelemetryInstallRequestSchema = type({
|
||||
event: '"install"',
|
||||
slug: "string",
|
||||
version: "string?",
|
||||
rootId: "string?",
|
||||
rootLabel: "string?",
|
||||
});
|
||||
export const ApiCliTelemetrySyncResponseSchema = type({
|
||||
export const ApiCliTelemetryInstallResponseSchema = type({
|
||||
ok: "true",
|
||||
});
|
||||
export const ApiV1WhoamiResponseSchema = type({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -7,7 +7,6 @@ export const LegacyApiRoutes = {
|
||||
cliUploadUrl: "/api/cli/upload-url",
|
||||
cliPublish: "/api/cli/publish",
|
||||
cliTelemetryInstall: "/api/cli/telemetry/install",
|
||||
cliTelemetrySync: "/api/cli/telemetry/sync",
|
||||
cliSkillDelete: "/api/cli/skill/delete",
|
||||
cliSkillUndelete: "/api/cli/skill/undelete",
|
||||
} as const;
|
||||
|
||||
@@ -153,19 +153,16 @@ export const ApiV1SkillInstallResolveResponseSchema = type({
|
||||
export type ApiV1SkillInstallResolveResponse =
|
||||
(typeof ApiV1SkillInstallResolveResponseSchema)[inferred];
|
||||
|
||||
export const CliTelemetrySyncRequestSchema = type({
|
||||
roots: type({
|
||||
rootId: "string",
|
||||
label: "string",
|
||||
skills: type({
|
||||
slug: "string",
|
||||
version: "string|null?",
|
||||
}).array(),
|
||||
}).array(),
|
||||
export const CliTelemetryInstallRequestSchema = type({
|
||||
event: '"install"',
|
||||
slug: "string",
|
||||
version: "string?",
|
||||
rootId: "string?",
|
||||
rootLabel: "string?",
|
||||
});
|
||||
export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred];
|
||||
export type CliTelemetryInstallRequest = (typeof CliTelemetryInstallRequestSchema)[inferred];
|
||||
|
||||
export const ApiCliTelemetrySyncResponseSchema = type({
|
||||
export const ApiCliTelemetryInstallResponseSchema = type({
|
||||
ok: "true",
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user