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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@enkryptify/cli",
"version": "0.3.0",
"version": "0.3.5",
"bin": {
"ek": "./dist/cli.js"
},
Expand Down
7 changes: 7 additions & 0 deletions scripts/build-dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,11 @@ else
fi

bun build "$PROJECT_DIR/src/cli.ts" --compile --outfile "$DEST" "${ARGS[@]}"

if [[ "$(uname -s)" == "Darwin" ]]; then
codesign --remove-signature "$DEST" >/dev/null 2>&1 || true
codesign --force --sign - "$DEST" >/dev/null 2>&1 || \
echo "⚠ codesign failed — ek-dev may be killed by AMFI on macOS 26+"
fi

echo "✓ Installed ek-dev → $DEST"
35 changes: 20 additions & 15 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ import { checkForUpdate } from "@/lib/versionCheck";
import { Command } from "commander";
import { getCompletions } from "./complete/complete";

if (process.argv[2] === "__analytics") {
const { runAnalyticsWorker } = await import("@/lib/analytics-worker");
await runAnalyticsWorker();
process.exit(0);
}

const isCompletion = process.argv[2] === "__complete";

const program = new Command();
Expand Down Expand Up @@ -44,18 +50,17 @@ if (!isCompletion && !isUpgrade) {
checkForUpdate().catch(() => {});
}

program
.parseAsync(process.argv)
.catch((error) => {
if (error instanceof CLIError) {
analytics.track("cli_error", { error_code: error.errorCode, error_message: error.message });
logger.error(error.message, { why: error.why, fix: error.fix, docs: error.docs });
} else {
analytics.track("cli_error", { error_message: error instanceof Error ? error.message : String(error) });
logger.error(error instanceof Error ? error.message : String(error));
}
process.exitCode = 1;
})
.finally(async () => {
await analytics.shutdown();
});
process.on("exit", () => {
analytics.shutdown();
});

program.parseAsync(process.argv).catch((error) => {
if (error instanceof CLIError) {
analytics.track("cli_error", { error_code: error.errorCode, error_message: error.message });
logger.error(error.message, { why: error.why, fix: error.fix, docs: error.docs });
} else {
analytics.track("cli_error", { error_message: error instanceof Error ? error.message : String(error) });
logger.error(error instanceof Error ? error.message : String(error));
}
process.exitCode = 1;
});
47 changes: 47 additions & 0 deletions src/lib/analytics-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { env } from "@/env";

export type WorkerEvent = {
distinctId: string;
event: string;
properties: Record<string, unknown>;
timestamp: string;
};

export type WorkerPayload = {
events: WorkerEvent[];
};

const REQUEST_TIMEOUT_MS = 5000;

async function postEvent(event: WorkerEvent): Promise<void> {
try {
await fetch(`${env.POSTHOG_HOST}/i/v0/e/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: env.POSTHOG_API_KEY,
distinct_id: event.distinctId,
event: event.event,
properties: event.properties,
timestamp: event.timestamp,
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch {
// Best-effort — never throw from the analytics worker
}
}

export async function runAnalyticsWorker(): Promise<void> {
try {
const raw = process.argv[3];
if (!raw) return;

const payload = JSON.parse(raw) as WorkerPayload;
if (!Array.isArray(payload.events) || payload.events.length === 0) return;

await Promise.allSettled(payload.events.map(postEvent));
} catch {
// Worker must never crash visibly
}
}
107 changes: 52 additions & 55 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { keyring } from "@/lib/keyring";
import { logger } from "@/lib/logger";
import { CLIError } from "@/lib/errors";
import { loadConfig, saveConfig } from "@/lib/config";
import { PostHog } from "posthog-node";
import { randomUUID } from "crypto";

type StoredAuthData = {
Expand All @@ -27,12 +26,19 @@ export type CommandTracker = {
error(error: unknown): void;
};

let posthog: PostHog | null = null;
type QueuedEvent = {
distinctId: string;
event: string;
properties: Record<string, unknown>;
timestamp: string;
};

let enabled = false;
let distinctId: string | null = null;
let anonymousId: string | null = null;
let superProperties: Record<string, unknown> = {};
let noticeShown = false;
const pendingEvents: QueuedEvent[] = [];

function isOptedOut(): boolean {
const telemetryEnv = process.env.EK_TELEMETRY;
Expand Down Expand Up @@ -84,6 +90,19 @@ async function showFirstRunNotice(): Promise<void> {
}
}

function getSelfSpawnCommand(): string[] {
const exec = process.execPath;
const arg1 = process.argv[1];

// Compiled Bun binary: argv[1] is a virtual /$bunfs/ path inside the binary,
// not a real file we can re-exec — just run the binary again.
if (!arg1 || arg1.startsWith("/$bunfs/")) {
return [exec];
}

return [exec, arg1];
}

export const analytics = {
async init(): Promise<void> {
if (isTestEnvironment() || isOptedOut()) {
Expand All @@ -92,14 +111,12 @@ export const analytics = {
}

try {
// Check config-level opt-out
const config = await loadConfig();
if (config.settings?.telemetry === "false") {
enabled = false;
return;
}

// Get or create anonymous ID
anonymousId = config.settings?.anonymousId ?? null;
if (!anonymousId) {
anonymousId = randomUUID();
Expand All @@ -108,10 +125,8 @@ export const analytics = {
await saveConfig(config);
}

// Default distinct ID is the anonymous ID
distinctId = anonymousId;

// Try to read user identity from keyring
try {
const authDataString = await keyring.get("enkryptify");
if (authDataString) {
Expand All @@ -124,7 +139,6 @@ export const analytics = {
// Best-effort, continue with anonymous ID
}

// Build super properties
superProperties = {
cli_version: env.CLI_VERSION,
os: process.platform,
Expand All @@ -133,35 +147,6 @@ export const analytics = {
is_ci: detectCI(),
};

// Initialize PostHog client
posthog = new PostHog(env.POSTHOG_API_KEY, {
host: env.POSTHOG_HOST,
flushAt: 10,
flushInterval: 5000,
requestTimeout: 3000,
disabled: false,
});

// Identify user if we have a real user ID (not anonymous)
if (distinctId !== anonymousId) {
try {
const authDataString = await keyring.get("enkryptify");
if (authDataString) {
const parsed: unknown = JSON.parse(authDataString);
if (isValidStoredAuthData(parsed)) {
posthog.identify({
distinctId: parsed.userId,
properties: {
email: parsed.email,
},
});
}
}
} catch {
// Best-effort
}
}

enabled = true;
} catch {
// Analytics initialization should never break the CLI
Expand All @@ -170,44 +155,46 @@ export const analytics = {
},

identify(userId: string, email: string): void {
if (!enabled || !posthog) return;
if (!enabled) return;

try {
// If we were using an anonymous ID, alias it to the real user
const identifyProps: Record<string, unknown> = {
...superProperties,
$set: { email },
};

// Link the prior anonymous ID to the user when transitioning from anonymous
if (anonymousId && distinctId === anonymousId) {
posthog.alias({
distinctId: userId,
alias: anonymousId,
});
identifyProps.$anon_distinct_id = anonymousId;
}

distinctId = userId;

posthog.identify({
pendingEvents.push({
distinctId: userId,
properties: {
email,
},
event: "$identify",
properties: identifyProps,
timestamp: new Date().toISOString(),
});

distinctId = userId;
} catch {
// Best-effort
}
},

track(event: string, properties?: Record<string, unknown>): void {
if (!enabled || !posthog || !distinctId) return;
if (!enabled || !distinctId) return;

try {
// Show first-run notice (fire-and-forget)
void showFirstRunNotice();

posthog.capture({
pendingEvents.push({
distinctId,
event,
properties: {
...superProperties,
...properties,
},
timestamp: new Date().toISOString(),
});
} catch {
// Never throw from analytics
Expand Down Expand Up @@ -242,13 +229,23 @@ export const analytics = {
};
},

async shutdown(): Promise<void> {
if (!enabled || !posthog) return;
shutdown(): void {
if (!enabled || pendingEvents.length === 0) return;

try {
await Promise.race([posthog.shutdown(), new Promise((resolve) => setTimeout(resolve, 2000))]);
const payload = JSON.stringify({ events: pendingEvents });
const cmd = getSelfSpawnCommand();
const proc = Bun.spawn([...cmd, "__analytics", payload], {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
// Detach so the worker survives the parent exiting
detached: true,
});
proc.unref();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
SiebeBaree marked this conversation as resolved.
pendingEvents.length = 0;
} catch {
// Never throw from analytics shutdown
// Never throw from analytics shutdown; leave pendingEvents intact
}
},

Expand Down
Loading