diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 4c71dca..654bb72 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -10,6 +10,7 @@ import { } from "@clack/prompts"; import type { Command } from "commander"; import pc from "picocolors"; +import { emitSetupBeacon } from "../core/beacon.js"; import { styledCommand } from "../core/help.js"; import { isJson, isNonInteractive, jsonErr, jsonOut } from "../core/output.js"; import { installSkills, SKILLS_CMD } from "../core/skills.js"; @@ -137,8 +138,24 @@ async function interactiveSetup(opts: SetupOpts, cmd: Command) { for (const stack of toInstall) { if (!json) log.step(`Setting up ${stack.name}...`); + + emitSetupBeacon(stack.id, { + mode: "interactive", + dryRun: !!opts.dryRun, + status: "initiated", + }); + const result = await runStack(stack, !!opts.dryRun, json); results.push(result); + + const interactiveFinal = + result.status === "failed" ? "failed" : "succeeded"; + emitSetupBeacon(stack.id, { + mode: "interactive", + dryRun: !!opts.dryRun, + status: interactiveFinal, + }); + if (!json && result.status !== "failed") { log.success(`${stack.name} — done`); } @@ -150,8 +167,29 @@ async function interactiveSetup(opts: SetupOpts, cmd: Command) { if (installSkillsSelected) { if (opts.dryRun) { if (!json) log.info(`Would run: ${SKILLS_CMD}`); + emitSetupBeacon("skills", { + mode: "interactive", + dryRun: true, + status: "initiated", + }); + emitSetupBeacon("skills", { + mode: "interactive", + dryRun: true, + status: "succeeded", + }); } else if (nonInteractive) { + emitSetupBeacon("skills", { + mode: "interactive", + dryRun: !!opts.dryRun, + status: "initiated", + }); skillsInstalled = await runSkillsInstall(); + const skillsFinal = skillsInstalled ? "succeeded" : "failed"; + emitSetupBeacon("skills", { + mode: "interactive", + dryRun: !!opts.dryRun, + status: skillsFinal, + }); } else { const action = await select({ message: "How do you want to install Scalekit skills (from Authstack)?", @@ -170,7 +208,18 @@ async function interactiveSetup(opts: SetupOpts, cmd: Command) { }); if (!isCancel(action) && action === "auto") { + emitSetupBeacon("skills", { + mode: "interactive", + dryRun: !!opts.dryRun, + status: "initiated", + }); skillsInstalled = await runSkillsInstall(); + const skillsFinal = skillsInstalled ? "succeeded" : "failed"; + emitSetupBeacon("skills", { + mode: "interactive", + dryRun: !!opts.dryRun, + status: skillsFinal, + }); } else { log.info(""); log.info("Run this to install Scalekit skills from Authstack:"); @@ -259,8 +308,22 @@ async function directSetup(stackId: string, opts: SetupOpts, cmd: Command) { } if (!json) log.step(`Setting up ${stack.name}...`); + + emitSetupBeacon(stack.id, { + mode: "direct", + dryRun: !!opts.dryRun, + status: "initiated", + }); + const result = await runStack(stack, !!opts.dryRun, json); + const directFinal = result.status === "failed" ? "failed" : "succeeded"; + emitSetupBeacon(stack.id, { + mode: "direct", + dryRun: !!opts.dryRun, + status: directFinal, + }); + if (json) { if (result.status === "failed") { jsonErr(`${stack.name} failed: ${result.error}`); diff --git a/src/core/beacon.ts b/src/core/beacon.ts new file mode 100644 index 0000000..89cef03 --- /dev/null +++ b/src/core/beacon.ts @@ -0,0 +1,107 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +const ID_FILE = join(homedir(), ".scalekit", "anonymous_id"); +const INGEST = "https://ph.scalekit.com/i/v0/e/"; +const TOKEN = + process.env.SCALEKIT_BEACON_TOKEN || + "phc_85pLP8gwYvRCQdxgLQP24iqXHPRGaLgEw4S4dgZHJZ"; + +async function getOrCreateDistinctId(): Promise { + try { + return (await readFile(ID_FILE, "utf-8")).trim(); + } catch { + const id = randomUUID(); + await mkdir(dirname(ID_FILE), { recursive: true }); + await writeFile(ID_FILE, id, "utf-8"); + return id; + } +} + +function getCliVersion(): string { + try { + const req = createRequire(import.meta.url); + return (req("../package.json") as { version?: string }).version || "0.0.0"; + } catch { + return "0.0.0"; + } +} + +function isEnabled(): boolean { + if (process.env.DO_NOT_TRACK || process.env.SCALEKIT_TELEMETRY === "0") { + return false; + } + return true; +} + +export async function emitBeacon( + event: string, + properties: Record, +): Promise { + if (!isEnabled()) return; + + try { + const distinct_id = await getOrCreateDistinctId(); + const body = { + token: TOKEN, + event, + distinct_id, + properties: { + ...properties, + cli_version: getCliVersion(), + }, + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 4000); + + fetch(INGEST, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }) + .catch(() => {}) + .finally(() => clearTimeout(timeout)); + } catch { + // never throw, never block + } +} + +// Standard coding agent names (matching existing runtime beacon usage) +const CODING_AGENT: Record = { + cursor: "cursor", + claude: "claude_code", + "claude-code": "claude_code", + cc: "claude_code", + copilot: "copilot", + "github-copilot": "copilot", + ghcp: "copilot", + codex: "codex", + opencode: "codex", +}; + +export function emitSetupBeacon( + stack: string, + data: { + mode: "direct" | "interactive"; + dryRun: boolean; + status: "initiated" | "succeeded" | "failed"; + }, +): void { + const coding_agent = CODING_AGENT[stack] || stack; + const plugins = stack === "skills" ? [] : ["agentkit", "saaskit"]; + + void emitBeacon("plugin_installed", { + stack, + coding_agent, + plugins, + mode: data.mode, + dry_run: data.dryRun, + status: data.status, + source: "setup", + }); +} diff --git a/test/commands/setup.test.ts b/test/commands/setup.test.ts index 6da6516..e7b5faa 100644 --- a/test/commands/setup.test.ts +++ b/test/commands/setup.test.ts @@ -18,6 +18,10 @@ vi.mock("../../src/core/skills.js", () => ({ SKILLS_CMD: `npx skills add ${AUTHSTACK_REPO} --all`, })); +vi.mock("../../src/core/beacon.js", () => ({ + emitSetupBeacon: vi.fn(), +})); + import { cancel, confirm, @@ -27,6 +31,7 @@ import { select, } from "@clack/prompts"; import { setupCommand } from "../../src/commands/setup.js"; +import { emitSetupBeacon } from "../../src/core/beacon.js"; import { installSkills } from "../../src/core/skills.js"; import { stacks } from "../../src/stacks/registry.js"; @@ -36,6 +41,7 @@ const mockSelect = vi.mocked(select); const mockConfirm = vi.mocked(confirm); const mockIsCancel = vi.mocked(isCancel); const mockInstallSkills = vi.mocked(installSkills); +const mockEmitSetupBeacon = vi.mocked(emitSetupBeacon); function stubStacks(opts: { detect?: boolean; installError?: Error } = {}) { for (const stack of stacks) { @@ -57,6 +63,7 @@ async function run(args: string[]) { beforeEach(() => { vi.clearAllMocks(); mockIsCancel.mockReturnValue(false); + mockEmitSetupBeacon.mockClear(); vi.spyOn(process, "exit").mockImplementation((code?: number) => { throw new Error(`process.exit(${code})`); }); @@ -93,6 +100,24 @@ describe("setup --yes", () => { expect(claude.install).toHaveBeenCalled(); expect(codex.install).not.toHaveBeenCalled(); expect(copilot.install).not.toHaveBeenCalled(); + + // Beacon should be emitted for chosen stacks (initiated + final) + expect(mockEmitSetupBeacon).toHaveBeenCalledWith( + "cursor", + expect.objectContaining({ status: "initiated" }), + ); + expect(mockEmitSetupBeacon).toHaveBeenCalledWith( + "cursor", + expect.objectContaining({ status: "succeeded" }), + ); + expect(mockEmitSetupBeacon).toHaveBeenCalledWith( + "claude", + expect.objectContaining({ status: "initiated" }), + ); + expect(mockEmitSetupBeacon).toHaveBeenCalledWith( + "claude", + expect.objectContaining({ status: "succeeded" }), + ); }); it("installs all stacks when none detected", async () => { @@ -113,6 +138,23 @@ describe("setup --dry-run", () => { for (const cmd of cursor.commands) { expect(mockLog.info).toHaveBeenCalledWith(`Would run: ${cmd}`); } + + expect(mockEmitSetupBeacon).toHaveBeenCalledWith( + "cursor", + expect.objectContaining({ + mode: "direct", + dryRun: true, + status: "initiated", + }), + ); + expect(mockEmitSetupBeacon).toHaveBeenCalledWith( + "cursor", + expect.objectContaining({ + mode: "direct", + dryRun: true, + status: "succeeded", + }), + ); }); }); @@ -200,6 +242,12 @@ describe("setup with aliases", () => { for (const cmd of claude.commands) { expect(mockLog.info).toHaveBeenCalledWith(`Would run: ${cmd}`); } + + // Beacon uses resolved stack id (not the alias) + expect(mockEmitSetupBeacon).toHaveBeenCalledWith( + "claude", + expect.objectContaining({ mode: "direct", dryRun: true }), + ); }); it("setup opencode --dry-run resolves codex alias", async () => {