Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`);
}
Expand All @@ -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)?",
Expand All @@ -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:");
Expand Down Expand Up @@ -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}`);
Expand Down
107 changes: 107 additions & 0 deletions src/core/beacon.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, unknown>,
): Promise<void> {
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<string, string> = {
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",
});
}
48 changes: 48 additions & 0 deletions test/commands/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";

Expand All @@ -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) {
Expand All @@ -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})`);
});
Expand Down Expand Up @@ -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 () => {
Expand All @@ -113,6 +138,23 @@ describe("setup <stack> --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",
}),
);
});
});

Expand Down Expand Up @@ -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 () => {
Expand Down
Loading