feat: count successful OpenClaw plugin installs (#3242)

This commit is contained in:
Patrick Erichsen
2026-07-23 12:12:58 -07:00
committed by GitHub
parent c8066fe89c
commit fec0f5bd23
17 changed files with 549 additions and 40 deletions
+2
View File
@@ -98,6 +98,7 @@ import type * as lib_packageArtifacts from "../lib/packageArtifacts.js";
import type * as lib_packageRegistry from "../lib/packageRegistry.js";
import type * as lib_packageSearchDigest from "../lib/packageSearchDigest.js";
import type * as lib_packageSecurity from "../lib/packageSecurity.js";
import type * as lib_packageStatEvents from "../lib/packageStatEvents.js";
import type * as lib_public from "../lib/public.js";
import type * as lib_publicBrowse from "../lib/publicBrowse.js";
import type * as lib_publicRouteReservations from "../lib/publicRouteReservations.js";
@@ -278,6 +279,7 @@ declare const fullApi: ApiFromModules<{
"lib/packageRegistry": typeof lib_packageRegistry;
"lib/packageSearchDigest": typeof lib_packageSearchDigest;
"lib/packageSecurity": typeof lib_packageSecurity;
"lib/packageStatEvents": typeof lib_packageStatEvents;
"lib/public": typeof lib_public;
"lib/publicBrowse": typeof lib_publicBrowse;
"lib/publicRouteReservations": typeof lib_publicRouteReservations;
+60
View File
@@ -345,6 +345,66 @@ describe("httpApi handlers", () => {
});
});
it("cliTelemetryInstallHttp forwards a successful plugin install", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
const runMutation = vi.fn().mockResolvedValue(null);
const response = await __handlers.cliTelemetryInstallHandler(
makeCtx({ runMutation }),
new Request("https://x/api/cli/telemetry/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event: "plugin_install",
packageName: "@openclaw/voice-call",
version: "2026.7.23",
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
userId: "users:1",
packageName: "@openclaw/voice-call",
version: "2026.7.23",
});
});
it("cliTelemetryInstallHttp rejects malformed plugin install reports", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
const runMutation = vi.fn();
const response = await __handlers.cliTelemetryInstallHandler(
makeCtx({ runMutation }),
new Request("https://x/api/cli/telemetry/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event: "plugin_install", version: "2026.7.23" }),
}),
);
expect(response.status).toBe(400);
expect(runMutation).not.toHaveBeenCalled();
});
it("cliTelemetryInstallHttp accepts unknown plugin packages as telemetry no-ops", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
const runMutation = vi.fn().mockResolvedValue(null);
const response = await __handlers.cliTelemetryInstallHandler(
makeCtx({ runMutation }),
new Request("https://x/api/cli/telemetry/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event: "plugin_install",
packageName: "@missing/plugin",
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
});
it("cliTelemetryInstallHttp accepts legacy roots snapshots", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: "users:1" } as never);
const runMutation = vi.fn().mockResolvedValue(null);
+6
View File
@@ -327,6 +327,12 @@ async function cliTelemetryInstallHandler(ctx: ActionCtx, request: Request) {
});
}
}
} else if (args.event === "plugin_install") {
await ctx.runMutation(internal.telemetry.reportCliPluginInstallInternal, {
userId,
packageName: args.packageName,
version: args.version,
});
} else {
await ctx.runMutation(internal.telemetry.reportCliInstallInternal, {
userId,
+18
View File
@@ -0,0 +1,18 @@
import type { Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
export async function insertPackageInstallStatEvent(
ctx: Pick<MutationCtx, "db">,
params: {
packageId: Id<"packages">;
kind?: "install" | "install_clear";
occurredAt?: number;
},
) {
await ctx.db.insert("packageStatEvents", {
packageId: params.packageId,
kind: params.kind ?? "install",
occurredAt: params.occurredAt ?? Date.now(),
processedAt: undefined,
});
}
+1
View File
@@ -299,6 +299,7 @@ export const RETENTION_POLICIES = {
registryArtifactBackupSyncState: permanent("Legacy registry artifact backup cursor state."),
registryArtifactBackupJobs: permanent("Legacy registry artifact backup job history."),
userSkillInstalls: permanent("Current user install records."),
userPackageInstalls: permanent("Current user package install records."),
skillOwnershipTransfers: ephemeral("Ownership transfer invitations expire.", {
expirationField: "expiresAt",
prune: "usage-time validation plus pending retention cleanup",
+10 -4
View File
@@ -242,6 +242,12 @@ describe("package stat events", () => {
kind: "download",
occurredAt: dayStart * 2,
},
{
_id: "packageStatEvents:5",
packageId: "packages:one",
kind: "install_clear",
occurredAt: dayStart,
},
];
const insert = vi.fn();
const patch = vi.fn();
@@ -282,14 +288,14 @@ describe("package stat events", () => {
const result = await processStatsHandler(ctx, { batchSize: 10 });
expect(result).toEqual({ processed: 4, packagesUpdated: 2 });
expect(result).toEqual({ processed: 5, packagesUpdated: 2 });
expect(insert).toHaveBeenCalledWith(
"packageDailyStats",
expect.objectContaining({
packageId: "packages:one",
day: 1,
downloads: 1,
installs: 1,
installs: 0,
}),
);
expect(insert).toHaveBeenCalledWith(
@@ -316,7 +322,7 @@ describe("package stat events", () => {
stats: expect.objectContaining({ downloads: 12 }),
recommendedScore: computeRecommendationScore({
downloads: 12,
installs: 2,
installs: 1,
stars: 2,
}),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
@@ -325,7 +331,7 @@ describe("package stat events", () => {
expect(patch).toHaveBeenCalledWith(
"packages:one",
expect.objectContaining({
stats: expect.objectContaining({ installs: 2 }),
stats: expect.objectContaining({ installs: 1 }),
}),
);
expect(patch).toHaveBeenCalledWith(
+7 -5
View File
@@ -88,6 +88,7 @@ import {
normalizePackageScanStatus,
resolvePackageReleaseScanStatus,
} from "./lib/packageSecurity";
import { insertPackageInstallStatEvent } from "./lib/packageStatEvents";
import { toPublicPublisher } from "./lib/public";
import {
assertCanManageOwnedResource,
@@ -4538,11 +4539,9 @@ export const recordPackageInstallInternal = internalMutation({
});
}
await ctx.db.insert("packageStatEvents", {
await insertPackageInstallStatEvent(ctx, {
packageId: args.packageId,
kind: "install",
occurredAt: args.occurredAt ?? Date.now(),
processedAt: undefined,
occurredAt: args.occurredAt,
});
},
});
@@ -4630,6 +4629,9 @@ export const processPackageStatEventsInternal = internalMutation({
if (event.kind === "install") {
stats.installs += 1;
dailyStats.installs += 1;
} else if (event.kind === "install_clear") {
stats.installs -= 1;
dailyStats.installs -= 1;
} else {
stats.downloads += 1;
dailyStats.downloads += 1;
@@ -4646,7 +4648,7 @@ export const processPackageStatEventsInternal = internalMutation({
}
const nextStats = {
downloads: (pkg.stats?.downloads ?? 0) + stats.downloads,
installs: (pkg.stats?.installs ?? 0) + stats.installs,
installs: Math.max(0, (pkg.stats?.installs ?? 0) + stats.installs),
stars: pkg.stats?.stars ?? 0,
versions: pkg.stats?.versions ?? 0,
};
+15 -1
View File
@@ -1982,7 +1982,7 @@ const skillCardGenerationJobs = defineTable({
const packageStatEvents = defineTable({
packageId: v.id("packages"),
kind: v.union(v.literal("download"), v.literal("install")),
kind: v.union(v.literal("download"), v.literal("install"), v.literal("install_clear")),
occurredAt: v.number(),
processedAt: v.optional(v.number()),
})
@@ -3557,6 +3557,19 @@ const userSkillInstalls = defineTable({
.index("by_user_skill", ["userId", "skillId"])
.index("by_skill", ["skillId"]);
const userPackageInstalls = defineTable({
userId: v.id("users"),
packageId: v.id("packages"),
firstSeenAt: v.number(),
lastSeenAt: v.number(),
lastVersion: v.optional(v.string()),
metricRecordedAt: v.optional(v.number()),
})
.index("by_user", ["userId"])
.index("by_user_lastSeenAt", ["userId", "lastSeenAt"])
.index("by_user_package", ["userId", "packageId"])
.index("by_package", ["packageId"]);
const skillOwnershipTransfers = defineTable({
skillId: v.id("skills"),
fromUserId: v.id("users"),
@@ -3669,5 +3682,6 @@ export default defineSchema({
registryArtifactBackupSyncState,
registryArtifactBackupJobs,
userSkillInstalls,
userPackageInstalls,
skillOwnershipTransfers,
});
+273 -1
View File
@@ -30,6 +30,7 @@ const {
pruneInstallTelemetryDedupesInternal,
reportCliInstallInternal,
reportCliLegacyInstallBatchInternal,
reportCliPluginInstallInternal,
} = await import("./telemetry");
const reportCliInstallHandler = (
@@ -59,6 +60,19 @@ const reportCliLegacyInstallBatchHandler = (
}
)._handler;
const reportCliPluginInstallHandler = (
reportCliPluginInstallInternal as unknown as {
_handler: (
ctx: unknown,
args: {
userId: string;
packageName: string;
version?: string;
},
) => Promise<void>;
}
)._handler;
const clearUserTelemetryHandler = (
clearUserTelemetryInternal as unknown as {
_handler: (ctx: unknown, args: { userId: string; clearStartedAt?: number }) => Promise<void>;
@@ -193,6 +207,235 @@ describe("telemetry install events", () => {
);
});
it("records the first plugin install for a canonical scoped package", async () => {
const insert = vi.fn(async (table: string) =>
table === "userPackageInstalls" ? "userPackageInstalls:one" : "packageStatEvents:one",
);
const patch = vi.fn();
const packageDoc = {
_id: "packages:voice-call",
normalizedName: "@openclaw/voice-call",
};
const ctx = {
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn(
(indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
callback(makeIndexBuilder());
if (table === "packages" && indexName === "by_name") {
return { unique: async () => packageDoc };
}
if (table === "userPackageInstalls" && indexName === "by_user_package") {
return { unique: async () => null };
}
throw new Error(`unexpected query ${table}.${indexName}`);
},
),
})),
insert,
patch,
},
};
await reportCliPluginInstallHandler(ctx, {
userId: "users:one",
packageName: "@OpenClaw/Voice-Call",
version: "2026.7.23",
});
expect(insert).toHaveBeenCalledWith("userPackageInstalls", {
userId: "users:one",
packageId: "packages:voice-call",
firstSeenAt: expect.any(Number),
lastSeenAt: expect.any(Number),
lastVersion: "2026.7.23",
});
expect(insert).toHaveBeenCalledWith("packageStatEvents", {
packageId: "packages:voice-call",
kind: "install",
occurredAt: expect.any(Number),
processedAt: undefined,
});
expect(patch).toHaveBeenCalledWith("userPackageInstalls:one", {
metricRecordedAt: expect.any(Number),
});
});
it("updates repeated plugin installs without incrementing package metrics", async () => {
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 === "packages" && indexName === "by_name") {
return {
unique: async () => ({
_id: "packages:voice-call",
normalizedName: "@openclaw/voice-call",
}),
};
}
if (table === "userPackageInstalls" && indexName === "by_user_package") {
return {
unique: async () => ({
_id: "userPackageInstalls:one",
lastVersion: "2026.7.22",
metricRecordedAt: 123,
}),
};
}
throw new Error(`unexpected query ${table}.${indexName}`);
},
),
})),
insert,
patch,
},
};
await reportCliPluginInstallHandler(ctx, {
userId: "users:one",
packageName: "@openclaw/voice-call",
version: "2026.7.23",
});
expect(patch).toHaveBeenCalledWith("userPackageInstalls:one", {
lastSeenAt: expect.any(Number),
lastVersion: "2026.7.23",
});
expect(insert).not.toHaveBeenCalled();
});
it("retries a pending package metric on a repeated plugin install", async () => {
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 === "packages" && indexName === "by_name") {
return {
unique: async () => ({
_id: "packages:voice-call",
normalizedName: "@openclaw/voice-call",
}),
};
}
if (table === "userPackageInstalls" && indexName === "by_user_package") {
return {
unique: async () => ({
_id: "userPackageInstalls:one",
lastVersion: "2026.7.22",
}),
};
}
throw new Error(`unexpected query ${table}.${indexName}`);
},
),
})),
insert,
patch,
},
};
await reportCliPluginInstallHandler(ctx, {
userId: "users:one",
packageName: "@openclaw/voice-call",
version: "2026.7.23",
});
expect(insert).toHaveBeenCalledWith("packageStatEvents", {
packageId: "packages:voice-call",
kind: "install",
occurredAt: expect.any(Number),
processedAt: undefined,
});
expect(patch).toHaveBeenCalledWith("userPackageInstalls:one", {
metricRecordedAt: expect.any(Number),
});
});
it("ignores unknown or deleted plugin packages", async () => {
const insert = vi.fn();
const packages = [
null,
{
_id: "packages:deleted",
normalizedName: "@openclaw/deleted",
softDeletedAt: 123,
},
];
const ctx = {
db: {
query: vi.fn(() => ({
withIndex: vi.fn(
(_indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
callback(makeIndexBuilder());
return { unique: async () => packages.shift() ?? null };
},
),
})),
insert,
patch: vi.fn(),
},
};
await reportCliPluginInstallHandler(ctx, {
userId: "users:one",
packageName: "@openclaw/missing",
});
await reportCliPluginInstallHandler(ctx, {
userId: "users:one",
packageName: "@openclaw/deleted",
});
expect(insert).not.toHaveBeenCalled();
});
it("keeps package metric queueing best-effort after persisting the install relationship", async () => {
const insert = vi.fn(async (table: string) => {
if (table === "packageStatEvents") {
throw new Error("metrics unavailable");
}
return "userPackageInstalls:one";
});
const ctx = {
db: {
query: vi.fn((table: string) => ({
withIndex: vi.fn(
(_indexName: string, callback: (q: ReturnType<typeof makeIndexBuilder>) => unknown) => {
callback(makeIndexBuilder());
return {
unique: async () =>
table === "packages"
? { _id: "packages:voice-call", normalizedName: "@openclaw/voice-call" }
: null,
};
},
),
})),
insert,
patch: vi.fn(),
},
};
await expect(
reportCliPluginInstallHandler(ctx, {
userId: "users:one",
packageName: "@openclaw/voice-call",
}),
).resolves.toBeUndefined();
expect(insert).toHaveBeenCalledWith(
"userPackageInstalls",
expect.objectContaining({ packageId: "packages:voice-call" }),
);
});
it("does not attribute an unclaimed skills.sh install to a same-slug native skill", async () => {
const query = vi.fn();
const insert = vi.fn();
@@ -476,6 +719,17 @@ describe("telemetry install events", () => {
{ _id: "installs:one", skillId: "skills:one" },
{ _id: "installs:two", skillId: "skills:two" },
];
const packageInstalls = [
{
_id: "userPackageInstalls:one",
packageId: "packages:one",
metricRecordedAt: 86_500_000,
},
{
_id: "userPackageInstalls:pending",
packageId: "packages:pending",
},
];
const dedupes = [{ _id: "installTelemetryDedupes:one" }];
const ctx = {
db: {
@@ -486,6 +740,9 @@ describe("telemetry install events", () => {
if (table === "userSkillInstalls" && indexName === "by_user_lastSeenAt") {
return { take: async () => installs };
}
if (table === "userPackageInstalls" && indexName === "by_user_lastSeenAt") {
return { take: async () => packageInstalls };
}
if (table === "installTelemetryDedupes" && indexName === "by_user_createdAt") {
return { take: async () => dedupes };
}
@@ -518,7 +775,16 @@ describe("telemetry install events", () => {
delta: { allTime: -1, current: -1 },
}),
);
expect(deleteDoc).toHaveBeenCalledTimes(3);
expect(insert).toHaveBeenCalledWith("packageStatEvents", {
packageId: "packages:one",
kind: "install_clear",
occurredAt: 86_500_000,
processedAt: undefined,
});
expect(insert.mock.calls.filter(([table]) => table === "packageStatEvents")).toHaveLength(1);
expect(deleteDoc).toHaveBeenCalledTimes(5);
expect(deleteDoc).toHaveBeenCalledWith("userPackageInstalls:one");
expect(deleteDoc).toHaveBeenCalledWith("userPackageInstalls:pending");
expect(deleteDoc).toHaveBeenCalledWith("installTelemetryDedupes:one");
});
@@ -527,6 +793,12 @@ describe("telemetry install events", () => {
table: "userSkillInstalls",
indexName: "by_user_lastSeenAt",
batchSize: 5_000,
laterTables: ["userPackageInstalls", "installTelemetryDedupes"],
},
{
table: "userPackageInstalls",
indexName: "by_user_lastSeenAt",
batchSize: 5_000,
laterTables: ["installTelemetryDedupes"],
},
{
+85
View File
@@ -4,6 +4,8 @@ import type { Id } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { internalMutation, mutation } from "./functions";
import { requireUser } from "./lib/access";
import { normalizePackageName } from "./lib/packageRegistry";
import { insertPackageInstallStatEvent } from "./lib/packageStatEvents";
import { RETENTION_STANDARD_BATCH_SIZE } from "./lib/retentionPolicy";
import {
getSkillBySlugForPublisher,
@@ -60,6 +62,68 @@ export const reportCliLegacyInstallBatchInternal = internalMutation({
},
});
export const reportCliPluginInstallInternal = internalMutation({
args: {
userId: v.id("users"),
packageName: v.string(),
version: v.optional(v.string()),
},
handler: async (ctx, args) => {
const packageName = normalizePackageName(args.packageName);
if (!packageName) return;
const pkg = await ctx.db
.query("packages")
.withIndex("by_name", (q) => q.eq("normalizedName", packageName))
.unique();
if (!pkg || pkg.softDeletedAt) return;
const now = Date.now();
const version = args.version?.trim() || undefined;
const existing = await ctx.db
.query("userPackageInstalls")
.withIndex("by_user_package", (q) => q.eq("userId", args.userId).eq("packageId", pkg._id))
.unique();
if (existing) {
await ctx.db.patch(existing._id, {
lastSeenAt: now,
lastVersion: version ?? existing.lastVersion,
});
if (existing.metricRecordedAt === undefined) {
try {
await insertPackageInstallStatEvent(ctx, {
packageId: pkg._id,
occurredAt: now,
});
} catch {
// A later successful report retries the aggregate metric.
return;
}
await ctx.db.patch(existing._id, { metricRecordedAt: now });
}
return;
}
const installId = await ctx.db.insert("userPackageInstalls", {
userId: args.userId,
packageId: pkg._id,
firstSeenAt: now,
lastSeenAt: now,
lastVersion: version,
});
try {
await insertPackageInstallStatEvent(ctx, {
packageId: pkg._id,
occurredAt: now,
});
} catch {
// The durable install relationship is authoritative; aggregate metric
// processing is best-effort and will retry on a later successful report.
return;
}
await ctx.db.patch(installId, { metricRecordedAt: now });
},
});
export const pruneInstallTelemetryDedupesInternal = internalMutation({
args: {},
handler: async (ctx) => {
@@ -203,6 +267,27 @@ async function clearTelemetryForUser(
return;
}
const packageInstalls = await ctx.db
.query("userPackageInstalls")
.withIndex("by_user_lastSeenAt", (q) =>
q.eq("userId", params.userId).lte("lastSeenAt", params.clearStartedAt),
)
.take(CLEAR_INSTALLS_BATCH_SIZE);
for (const entry of packageInstalls) {
if (entry.metricRecordedAt !== undefined) {
await insertPackageInstallStatEvent(ctx, {
packageId: entry.packageId,
kind: "install_clear",
occurredAt: entry.metricRecordedAt,
});
}
await ctx.db.delete(entry._id);
}
if (packageInstalls.length === CLEAR_INSTALLS_BATCH_SIZE) {
await scheduleClearUserTelemetry(ctx, params.userId, params.clearStartedAt);
return;
}
const dedupes = await ctx.db
.query("installTelemetryDedupes")
.withIndex("by_user_createdAt", (q) =>
+13 -6
View File
@@ -7,25 +7,27 @@ read_when:
# Telemetry
ClawHub uses minimal CLI telemetry to compute aggregate install counts.
ClawHub uses minimal CLI telemetry to compute aggregate skill and plugin install counts.
## When telemetry is collected
Telemetry is only sent when:
- You are logged in in the CLI.
- You run `clawhub install <slug>`.
- You run `clawhub install <slug>`, or complete an authenticated
`openclaw plugins install clawhub:<package>` install.
- Telemetry is **not disabled** (see “How to disable” below).
If you are not logged in, nothing is reported.
## What we collect
On each reported `clawhub install`, the CLI sends one best-effort install event.
After a skill or plugin has installed and its local install record has been persisted, the CLI
sends one best-effort install event.
The event includes:
- `slug`: the installed skill slug.
- The installed skill slug or canonical plugin package name.
- `version`: the installed version, when known.
### What we do _not_ collect
@@ -36,17 +38,22 @@ The event includes:
## Install counts
ClawHub maintains aggregate counters per skill:
For skills, ClawHub maintains:
- `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.
For plugins, ClawHub counts the first successful install reported by each user and package.
Repeated installs and updates refresh the recorded version without increasing the aggregate
install count.
## Transparency + user controls
Everyone only sees **aggregated install counters**.
Deleting your account also deletes your telemetry data.
Deleting your account also deletes your telemetry data and removes its contribution from install
counters.
## How to disable telemetry
+16 -10
View File
@@ -209,17 +209,23 @@ export const CliTelemetryInstallRequestSchema = type({
// Deprecated compatibility fields accepted and ignored by the backend.
rootId: "string?",
rootLabel: "string?",
}).or({
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
roots: type({
rootId: "string",
label: "string",
skills: type({
slug: "string",
version: "string|null?",
})
.or({
event: '"plugin_install"',
packageName: "string",
version: "string?",
})
.or({
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
roots: type({
rootId: "string",
label: "string",
skills: type({
slug: "string",
version: "string|null?",
}).array(),
}).array(),
}).array(),
});
});
export type CliTelemetryInstallRequest = (typeof CliTelemetryInstallRequestSchema)[inferred];
export const ApiCliTelemetryInstallResponseSchema = type({
+4
View File
@@ -204,6 +204,10 @@ export declare const CliTelemetryInstallRequestSchema: import("arktype/internal/
version?: string | undefined;
rootId?: string | undefined;
rootLabel?: string | undefined;
} | {
event: "plugin_install";
packageName: string;
version?: string | undefined;
} | {
roots: {
rootId: string;
+7 -1
View File
@@ -182,7 +182,13 @@ export const CliTelemetryInstallRequestSchema = type({
// Deprecated compatibility fields accepted and ignored by the backend.
rootId: "string?",
rootLabel: "string?",
}).or({
})
.or({
event: '"plugin_install"',
packageName: "string",
version: "string?",
})
.or({
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
roots: type({
rootId: "string",
File diff suppressed because one or more lines are too long
+15 -1
View File
@@ -228,7 +228,7 @@ describe("clawhub-schema", () => {
expect(blocked.ok).toBe(false);
});
it("accepts current and legacy install telemetry payloads", () => {
it("accepts skill, plugin, and legacy install telemetry payloads", () => {
const current = parseArk(
CliTelemetryInstallRequestSchema,
{
@@ -240,6 +240,15 @@ describe("clawhub-schema", () => {
},
"Install telemetry",
);
const plugin = parseArk(
CliTelemetryInstallRequestSchema,
{
event: "plugin_install",
packageName: "@openclaw/voice-call",
version: "2026.7.23",
},
"Install telemetry",
);
const legacy = parseArk(
CliTelemetryInstallRequestSchema,
{
@@ -260,6 +269,11 @@ describe("clawhub-schema", () => {
ownerHandle: "alice",
sourceRef: "skills-sh/alice/skills/demo",
});
expect(plugin).toEqual({
event: "plugin_install",
packageName: "@openclaw/voice-call",
version: "2026.7.23",
});
expect(legacy).toMatchObject({ roots: [{ rootId: "root" }] });
});
+16 -10
View File
@@ -210,17 +210,23 @@ export const CliTelemetryInstallRequestSchema = type({
// Deprecated compatibility fields accepted and ignored by the backend.
rootId: "string?",
rootLabel: "string?",
}).or({
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
roots: type({
rootId: "string",
label: "string",
skills: type({
slug: "string",
version: "string|null?",
})
.or({
event: '"plugin_install"',
packageName: "string",
version: "string?",
})
.or({
// Legacy bulk snapshots remain accepted while older CLIs are in circulation.
roots: type({
rootId: "string",
label: "string",
skills: type({
slug: "string",
version: "string|null?",
}).array(),
}).array(),
}).array(),
});
});
export type CliTelemetryInstallRequest = (typeof CliTelemetryInstallRequestSchema)[inferred];
export const ApiCliTelemetryInstallResponseSchema = type({