diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e81f5f92..23a65dbd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,6 +2,7 @@ import type { Manifest } from "./manifest.js"; +import { readFileSync } from "node:fs"; import { getErrorMessage, isString, toRecord } from "@openrouter/spawn-shared"; import pc from "picocolors"; import pkg from "../package.json" with { type: "json" }; @@ -38,6 +39,7 @@ import { } from "./commands/index.js"; import { expandEqualsFlags, findUnknownFlag } from "./flags.js"; import { agentKeys, cloudKeys, getCacheAge, loadManifest } from "./manifest.js"; +import { getInstallRefPath } from "./shared/paths.js"; import { asyncTryCatch, asyncTryCatchIf, isFileError, isNetworkError, tryCatch, tryCatchIf } from "./shared/result.js"; import { captureError, initTelemetry, setTelemetryContext } from "./shared/telemetry.js"; import { checkForUpdates } from "./update-check.js"; @@ -48,6 +50,16 @@ const VERSION = pkg.version; // Disabled with SPAWN_TELEMETRY=0. initTelemetry(VERSION); +// Attribution: if the user installed via a tagged URL (SPAWN_REF=reddit|x|...), +// the install script persisted the ref to ~/.config/spawn/.ref. Read it once +// and attach to every telemetry event so PostHog can segment by acquisition channel. +tryCatchIf(isFileError, () => { + const ref = readFileSync(getInstallRefPath(), "utf8").trim(); + if (ref && /^[a-zA-Z0-9_-]+$/.test(ref)) { + setTelemetryContext("ref", ref); + } +}); + function handleError(err: unknown): never { captureError("cli_error", err); const msg = getErrorMessage(err); diff --git a/packages/cli/src/shared/paths.ts b/packages/cli/src/shared/paths.ts index 97e6cd90..52d1781e 100644 --- a/packages/cli/src/shared/paths.ts +++ b/packages/cli/src/shared/paths.ts @@ -58,6 +58,11 @@ export function getSpawnPreferencesPath(): string { return join(getUserHome(), ".config", "spawn", "preferences.json"); } +/** Return the path to the install referrer file: ~/.config/spawn/.ref */ +export function getInstallRefPath(): string { + return join(getUserHome(), ".config", "spawn", ".ref"); +} + /** Return the cache directory for spawn, respecting XDG_CACHE_HOME. */ export function getCacheDir(): string { return join(process.env.XDG_CACHE_HOME || join(getUserHome(), ".cache"), "spawn"); diff --git a/sh/cli/install.sh b/sh/cli/install.sh index d45e2d53..0bb0f62f 100755 --- a/sh/cli/install.sh +++ b/sh/cli/install.sh @@ -374,3 +374,19 @@ ensure_min_bun_version log_step "Installing spawn via bun..." build_and_install + +# Persist install referrer (e.g. SPAWN_REF=reddit) so the CLI can report +# attribution on first run. Only written once — never overwritten on updates. +if [ -n "${SPAWN_REF:-}" ]; then + _ref_dir="${HOME}/.config/spawn" + _ref_file="${_ref_dir}/.ref" + if [ ! -f "${_ref_file}" ]; then + mkdir -p "${_ref_dir}" + # Sanitize: allow only alphanumeric, hyphens, underscores (no injection) + _clean_ref=$(printf '%s' "${SPAWN_REF}" | tr -cd 'a-zA-Z0-9_-' | head -c 32) + if [ -n "${_clean_ref}" ]; then + printf '%s' "${_clean_ref}" > "${_ref_file}" + log_info "Install referrer: ${_clean_ref}" + fi + fi +fi