diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 5825730d58..c1524e7310 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -140,10 +140,13 @@ A single `first_launch` event is sent containing only: - The installed version (e.g., "0.5.9") - Whether this is a fresh install or upgrade (boolean) +- Which installer was used (`curl`, `powershell`, or `npm`) - Your anonymous machine ID (random UUID) No code, queries, file paths, or personal information is included. This event helps us understand adoption and is fully opt-out-able. +The install scripts (`altimate.sh/install`, `install.ps1`) and the npm postinstall send nothing themselves and contact no telemetry endpoint. They only record the version and installer name to a local file that the CLI reads on its next run, so the opt-out above still decides whether anything is ever transmitted. + ## What happens when I authenticate via a well-known URL? When you run `altimate auth login `, the CLI fetches `/.well-known/altimate-code` to discover the server's auth command. Before executing anything: diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index d28a484475..9c43e97382 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -38,7 +38,7 @@ We collect the following categories of events: | `feature_suggestion` | A post-connection feature suggestion is shown (suggestion_type, suggestions_shown, warehouse_type — no user input) | | `sql_execute_failure` | A SQL execution fails (warehouse type, query type, error message, PII-masked SQL — no raw values) | | `core_failure` | An internal tool error occurs (tool name, category, error class, truncated error message, PII-safe input signature, and optionally masked arguments — no raw values or credentials) | -| `first_launch` | Fired once on first CLI run after installation. Contains version and is_upgrade flag. No PII. | +| `first_launch` | Fired once on the first CLI run after an install or upgrade, triggered by a marker file the installer wrote — the installers themselves send nothing. Contains the installed version, `is_upgrade` (whether this machine had ever run altimate-code before), and `install_method` (`curl`, `powershell`, `npm`, or `unknown` for markers written before the field existed). A brand-new install is `is_upgrade: false`. No PII. | | `task_outcome_signal` | Behavioral quality signal at session end — accepted, error, abandoned, or cancelled. Includes tool count, step count, duration, and last tool category. No user content. | | `task_classified` | Intent classification of the first user message using keyword matching — category (e.g. `debug_dbt`, `write_sql`, `optimize_query`), confidence score, and detected warehouse type. No user text is sent — only the classified category. | | `tool_chain_outcome` | Aggregated tool execution sequence at session end — ordered tool names (capped at 50), error count, recovery count, final outcome, duration, and cost. No tool arguments or outputs. | diff --git a/install b/install index e2962f2f49..9b4d1ca1c7 100755 --- a/install +++ b/install @@ -487,6 +487,29 @@ install_from_binary() { chmod 755 "$dest_path" } +# Write the same post-install marker that npm's postinstall.mjs writes, so the +# CLI emits its `first_launch` telemetry event on the next run. Without this the +# curl install path — the one advertised at altimate.sh/install — produces no +# install event at all, and every curl user is invisible in install metrics. +# +# The path MUST match welcome.ts's data-dir resolution ($XDG_DATA_HOME, falling +# back to ~/.local/share) on every platform, including Windows: the CLI reads it +# via Node's os.homedir() and never consults %LOCALAPPDATA%. +# +# No network call and no identifier is written here — this only hands the CLI the +# version it was installed at. Whether anything is ever sent remains entirely up +# to the CLI's existing telemetry opt-out gates. +write_install_marker() { + local data_dir="${XDG_DATA_HOME:-$HOME/.local/share}/altimate-code" + # An empty marker is deleted unread by the CLI, so fall back to "unknown" + # rather than losing the install: $specific_version is empty whenever the + # GitHub API could not be reached (see check_version). + local marker_version="${specific_version:-unknown}" + mkdir -p "$data_dir" 2>/dev/null || return 0 + printf '%s' "${marker_version#v}" > "$data_dir/.installed-version" 2>/dev/null || return 0 + printf '%s' "curl" > "$data_dir/.install-source" 2>/dev/null || return 0 +} + if [ -n "$binary_path" ]; then install_from_binary else @@ -494,6 +517,10 @@ else download_and_install fi +# Only reached when an install actually happened: check_version exits 0 early +# when the requested version is already present. +write_install_marker + add_to_path() { local config_file=$1 diff --git a/install.ps1 b/install.ps1 index 886c2c1be0..c34e7aec50 100644 --- a/install.ps1 +++ b/install.ps1 @@ -304,6 +304,40 @@ if (-not $needsBaseline) { } } +# --------------------------------------------------------------------------- +# Post-install marker (install telemetry) +# --------------------------------------------------------------------------- +# Mirrors npm's postinstall.mjs so the CLI emits its `first_launch` event on the +# next run; without it this install path is invisible in install metrics. +# +# The directory MUST match welcome.ts's resolution - $XDG_DATA_HOME, else +# \.local\share - because the CLI reads it through Node's os.homedir() and +# never looks at %LOCALAPPDATA%. Writing to LOCALAPPDATA here would be silently +# ignored at read time. +# +# No network call and no identifier is written; only the installed version is +# recorded. The CLI's existing telemetry opt-out gates still decide whether +# anything is ever sent. +$dataRoot = if ($env:XDG_DATA_HOME) { $env:XDG_DATA_HOME } else { Join-Path $env:USERPROFILE ".local\share" } +$dataDir = Join-Path $dataRoot "altimate-code" +try { + New-Item -ItemType Directory -Force -Path $dataDir | Out-Null + # The CLI deletes an empty marker without reporting, so fall back to "unknown" + # when the version could not be resolved (GitHub API unreachable). + $markerVersion = if ($specificVersion) { $specificVersion -replace '^v', '' } else { "unknown" } + # -NoNewline: the CLI trims, but keep the file byte-identical to the npm path. + # + # -Encoding ascii, not utf8: the documented entrypoint is `powershell -c "irm ... | iex"`, + # i.e. Windows PowerShell 5.1, where `-Encoding utf8` prepends a UTF-8 BOM. Both values are + # ASCII by construction, so ascii is lossless here and cannot emit one. The CLI's .trim() + # happens to strip a leading BOM (U+FEFF is JS whitespace), but the install-source value is + # matched against a fixed allowlist and must not depend on that. + Set-Content -Path (Join-Path $dataDir ".installed-version") -Value $markerVersion -NoNewline -Encoding ascii + Set-Content -Path (Join-Path $dataDir ".install-source") -Value "powershell" -NoNewline -Encoding ascii +} catch { + # Non-fatal - a missing marker only costs us the install event. +} + # --------------------------------------------------------------------------- # PATH (user scope, via registry + broadcast) # --------------------------------------------------------------------------- diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index ee22894c42..67f231afe1 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -239,6 +239,9 @@ function writeUpgradeMarker(version) { const dataDir = path.join(xdgData, "altimate-code") fs.mkdirSync(dataDir, { recursive: true }) fs.writeFileSync(path.join(dataDir, ".installed-version"), version.replace(/^v/, "")) + // Record the installer so first_launch can distinguish npm from the curl / + // PowerShell install scripts, which write the same marker. + fs.writeFileSync(path.join(dataDir, ".install-source"), "npm") } catch { // Non-fatal — the CLI just won't show a welcome banner } diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index e7f14db537..8dcdbe85bb 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -474,6 +474,11 @@ export namespace Telemetry { session_id: string version: string is_upgrade: boolean + // altimate_change — which installer wrote the marker. Recorded by the + // installer itself; "unknown" when the marker predates this field or the + // source file was unreadable. Without it, curl and npm installs are + // indistinguishable in the same metric. + install_method: "curl" | "powershell" | "npm" | "unknown" } // altimate_change end // altimate_change start — telemetry for skill management operations diff --git a/packages/opencode/src/cli/welcome.ts b/packages/opencode/src/cli/welcome.ts index dab265049a..dbbec52cc1 100644 --- a/packages/opencode/src/cli/welcome.ts +++ b/packages/opencode/src/cli/welcome.ts @@ -9,6 +9,32 @@ import { Telemetry } from "../altimate/telemetry" const APP_NAME = "altimate-code" const MARKER_FILE = ".installed-version" +// altimate_change start — written alongside MARKER_FILE by whichever installer ran +// (postinstall.mjs, install, install.ps1) so first_launch can attribute the install. +const SOURCE_FILE = ".install-source" +const INSTALL_METHODS = ["curl", "powershell", "npm"] as const +type InstallMethod = (typeof INSTALL_METHODS)[number] | "unknown" + +/** + * Read the installer that wrote the marker, then remove the file so it stays in + * lockstep with MARKER_FILE — a stale value must never be attributed to a later + * install whose installer did not write one. + * + * Returns "unknown" for a missing, unreadable, or unrecognized value: the marker + * predates this field on upgrade from an older version, and an unrecognized + * string must not reach the event as a free-form value. + */ +function readInstallMethod(dataDir: string): InstallMethod { + const sourcePath = path.join(dataDir, SOURCE_FILE) + try { + const raw = fs.readFileSync(sourcePath, "utf-8").trim() + fs.unlinkSync(sourcePath) + return (INSTALL_METHODS as readonly string[]).includes(raw) ? (raw as InstallMethod) : "unknown" + } catch { + return "unknown" + } +} +// altimate_change end /** Resolve the data directory at call time (respects XDG_DATA_HOME changes in tests). */ function getDataDir(): string { @@ -27,12 +53,18 @@ function getDataDir(): string { */ export function showWelcomeBannerIfNeeded(): void { try { - const markerPath = path.join(getDataDir(), MARKER_FILE) + const dataDir = getDataDir() + const markerPath = path.join(dataDir, MARKER_FILE) if (!fs.existsSync(markerPath)) return const installedVersion = fs.readFileSync(markerPath, "utf-8").trim() if (!installedVersion) { fs.unlinkSync(markerPath) + // altimate_change — clear the companion file too, so an orphaned source value + // cannot be attributed to a later install. Both install scripts write "unknown" + // rather than an empty version, so this path should now only be reachable from + // a truncated or hand-edited marker. + readInstallMethod(dataDir) return } @@ -64,6 +96,7 @@ export function showWelcomeBannerIfNeeded(): void { session_id: "", version: installedVersion, is_upgrade: isUpgrade, + install_method: readInstallMethod(dataDir), }) // altimate_change end diff --git a/packages/opencode/test/cli/welcome.test.ts b/packages/opencode/test/cli/welcome.test.ts index 0c87cf0bba..63753aeb4c 100644 --- a/packages/opencode/test/cli/welcome.test.ts +++ b/packages/opencode/test/cli/welcome.test.ts @@ -1,7 +1,8 @@ -import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" +import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test" import fs from "fs" import path from "path" import os from "os" +import { Telemetry } from "@/altimate/telemetry" describe("showWelcomeBannerIfNeeded", () => { let tmpDir: string @@ -70,4 +71,122 @@ describe("showWelcomeBannerIfNeeded", () => { const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome") expect(() => showWelcomeBannerIfNeeded()).not.toThrow() }) + + // altimate_change start — first_launch is the only install metric, and after AI-8448 the curl and + // PowerShell installers feed it too. These assert the two fields the install dashboard reads. + describe("first_launch event", () => { + const dataFiles = (version = "1.2.3", source?: string) => { + const dir = path.join(tmpDir, "altimate-code") + fs.writeFileSync(path.join(dir, ".installed-version"), version) + if (source !== undefined) fs.writeFileSync(path.join(dir, ".install-source"), source) + return dir + } + + /** + * The machine-id probe reads os.homedir(), which must be stubbed rather than + * driven through $HOME: Bun resolves homedir() once at startup and ignores + * later mutation of process.env.HOME. Without this the result depends on + * whether the developer running the suite has ever launched the CLI. + */ + function withHome(home: string, fn: () => T): T { + const spy = spyOn(os, "homedir").mockImplementation(() => home) + try { + return fn() + } finally { + spy.mockRestore() + } + } + + function captureEvents() { + const events: Telemetry.Event[] = [] + spyOn(Telemetry, "track").mockImplementation((e: Telemetry.Event) => { + events.push(e) + }) + return events + } + + afterEach(() => mock.restore()) + + test("a machine with no prior identity reports is_upgrade false — the brand-new-install signal", async () => { + dataFiles() + const events = captureEvents() + const cleanHome = fs.mkdtempSync(path.join(os.tmpdir(), "welcome-home-")) + + const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome") + withHome(cleanHome, () => showWelcomeBannerIfNeeded()) + + const e = events[0] as any + expect(e.type).toBe("first_launch") + expect(e.is_upgrade).toBe(false) + expect(e.version).toBe("1.2.3") + fs.rmSync(cleanHome, { recursive: true, force: true }) + }) + + test("a pre-existing machine-id reports is_upgrade true", async () => { + dataFiles() + const events = captureEvents() + const usedHome = fs.mkdtempSync(path.join(os.tmpdir(), "welcome-home-")) + fs.mkdirSync(path.join(usedHome, ".altimate"), { recursive: true }) + fs.writeFileSync(path.join(usedHome, ".altimate", "machine-id"), "8f1c0c4e-0a5e-4f4e-9c1a-2b3c4d5e6f70") + + const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome") + withHome(usedHome, () => showWelcomeBannerIfNeeded()) + + expect((events[0] as any).is_upgrade).toBe(true) + fs.rmSync(usedHome, { recursive: true, force: true }) + }) + + test("attributes the installer that wrote the marker and consumes the source file", async () => { + const dir = dataFiles("1.2.3", "curl") + const events = captureEvents() + const cleanHome = fs.mkdtempSync(path.join(os.tmpdir(), "welcome-home-")) + + const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome") + withHome(cleanHome, () => showWelcomeBannerIfNeeded()) + + expect((events[0] as any).install_method).toBe("curl") + // Left behind, it would be attributed to the next install whose installer wrote none. + expect(fs.existsSync(path.join(dir, ".install-source"))).toBe(false) + fs.rmSync(cleanHome, { recursive: true, force: true }) + }) + + test("an absent source file reports unknown rather than dropping the event", async () => { + dataFiles("1.2.3") + const events = captureEvents() + const cleanHome = fs.mkdtempSync(path.join(os.tmpdir(), "welcome-home-")) + + const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome") + withHome(cleanHome, () => showWelcomeBannerIfNeeded()) + + // Upgrades from a version whose installer predates the source file land here. + expect((events[0] as any).install_method).toBe("unknown") + fs.rmSync(cleanHome, { recursive: true, force: true }) + }) + + test("an unrecognised source value cannot mint a new dimension", async () => { + dataFiles("1.2.3", "hand-edited-nonsense") + const events = captureEvents() + const cleanHome = fs.mkdtempSync(path.join(os.tmpdir(), "welcome-home-")) + + const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome") + withHome(cleanHome, () => showWelcomeBannerIfNeeded()) + + expect((events[0] as any).install_method).toBe("unknown") + fs.rmSync(cleanHome, { recursive: true, force: true }) + }) + + test("an empty marker emits nothing and clears the orphaned source file", async () => { + const dir = dataFiles("", "curl") + const events = captureEvents() + const cleanHome = fs.mkdtempSync(path.join(os.tmpdir(), "welcome-home-")) + + const { showWelcomeBannerIfNeeded } = await import("../../src/cli/welcome") + withHome(cleanHome, () => showWelcomeBannerIfNeeded()) + + expect(events).toHaveLength(0) + expect(fs.existsSync(path.join(dir, ".install-source"))).toBe(false) + fs.rmSync(cleanHome, { recursive: true, force: true }) + }) + }) + // altimate_change end }) diff --git a/packages/opencode/test/install/install-telemetry.test.ts b/packages/opencode/test/install/install-telemetry.test.ts new file mode 100644 index 0000000000..2fe5c613c9 --- /dev/null +++ b/packages/opencode/test/install/install-telemetry.test.ts @@ -0,0 +1,153 @@ +/** + * altimate_change — install telemetry (AI-8448). + * + * `first_launch` is the only install metric, and it is triggered by a marker file rather than by + * the installer talking to the network. Before this, only npm's postinstall wrote that marker, so + * moving the advertised install path to altimate.sh/install took installs out of instrumentation + * and showed up as a dip in the dashboard. + * + * The shell installers are asserted at the source level, matching windows-install.test.ts: running + * them would download a release. What matters is that they write the marker where the CLI actually + * looks, which is the part that fails silently. + */ +import { describe, expect, test, afterEach, spyOn, mock } from "bun:test" +import { readFileSync, existsSync, mkdtempSync, rmSync } from "node:fs" +import { join } from "node:path" +import os from "os" +import path from "path" +import { Telemetry } from "@/altimate/telemetry" + +const REPO_ROOT = join(import.meta.dir, "..", "..", "..", "..") +const INSTALL_SH = readFileSync(join(REPO_ROOT, "install"), "utf-8") +const INSTALL_PS1 = readFileSync(join(REPO_ROOT, "install.ps1"), "utf-8") +const WELCOME_SRC = readFileSync(join(REPO_ROOT, "packages/opencode/src/cli/welcome.ts"), "utf-8") + +describe("install — post-install marker", () => { + test("writes the marker the CLI reads", () => { + expect(INSTALL_SH).toMatch(/\.installed-version/) + expect(INSTALL_SH).toMatch(/write_install_marker/) + }) + + test("resolves the data dir exactly as welcome.ts does", () => { + // welcome.ts: XDG_DATA_HOME, else /.local/share, then /altimate-code. + expect(INSTALL_SH).toMatch(/\$\{XDG_DATA_HOME:-\$HOME\/\.local\/share\}\/altimate-code/) + expect(WELCOME_SRC).toMatch(/XDG_DATA_HOME \|\| path\.join\(os\.homedir\(\), "\.local", "share"\)/) + }) + + test("falls back to a non-empty version when the release could not be resolved", () => { + // An empty marker is deleted unread (welcome.ts), so an unresolved version would + // otherwise lose the install entirely — the exact case check_version leaves empty. + expect(INSTALL_SH).toMatch(/specific_version:-unknown/) + }) + + test("attributes itself as curl", () => { + expect(INSTALL_SH).toMatch(/\.install-source/) + expect(INSTALL_SH).toMatch(/printf '%s' "curl"/) + }) + + test("marker is written after the install actually happened, not before", () => { + // Ordering matters twice: check_version exits 0 early when the requested version is + // already present (no install, so no event), and a marker written ahead of a failed + // download would report an install that never landed. + const dispatch = INSTALL_SH.indexOf(" download_and_install") + const markerCall = INSTALL_SH.lastIndexOf("\nwrite_install_marker") + expect(dispatch).toBeGreaterThan(0) + expect(markerCall).toBeGreaterThan(dispatch) + }) + + test("marker failures cannot abort the install", () => { + // A read-only or absent $HOME must cost the event, never the install. + const start = INSTALL_SH.indexOf("write_install_marker() {") + const fn = INSTALL_SH.slice(start, INSTALL_SH.indexOf("\n}", start)) + expect(fn).toMatch(/mkdir -p "\$data_dir" 2>\/dev\/null \|\| return 0/) + expect(fn.match(/\|\| return 0/g)?.length).toBeGreaterThanOrEqual(3) + }) +}) + +describe("install.ps1 — post-install marker", () => { + const markerBlock = INSTALL_PS1.slice( + INSTALL_PS1.indexOf("Post-install marker"), + INSTALL_PS1.indexOf("PATH (user scope"), + ) + // Comments in this block deliberately name %LOCALAPPDATA% to explain why it is wrong, + // so the "never LOCALAPPDATA" assertion has to look at code only. + const markerCode = markerBlock + .split("\n") + .filter((l) => !l.trim().startsWith("#")) + .join("\n") + + test("writes the marker and attributes itself as powershell", () => { + expect(markerCode).toMatch(/\.installed-version/) + expect(markerCode).toMatch(/"powershell"/) + }) + + test("uses the XDG/.local\\share path, never LOCALAPPDATA", () => { + // The CLI reads the data dir through Node's os.homedir() and never consults + // %LOCALAPPDATA%, so a marker written there would be silently ignored at read time. + expect(markerCode).toMatch(/XDG_DATA_HOME/) + expect(markerCode).toMatch(/\.local\\share/) + expect(markerCode).not.toMatch(/LOCALAPPDATA/) + }) + + test("falls back to a non-empty version and cannot abort the install", () => { + expect(markerCode).toMatch(/"unknown"/) + expect(markerCode).toMatch(/} catch \{/) + }) + + test("writes without a BOM", () => { + // The documented entrypoint is `powershell -c "irm ... | iex"` — Windows PowerShell 5.1, + // where `-Encoding utf8` prepends a UTF-8 BOM. install-source is matched against a fixed + // allowlist, so a BOM would silently degrade every PowerShell install to "unknown". + expect(markerCode).not.toMatch(/-Encoding utf8/) + expect(markerCode.match(/-Encoding ascii/g)).toHaveLength(2) + }) +}) + +describe("is_upgrade ordering invariant", () => { + afterEach(() => mock.restore()) + + test("an unawaited Telemetry.init() has not minted a machine-id when the banner runs", async () => { + // src/index.ts fires Telemetry.init() WITHOUT awaiting it, then calls + // showWelcomeBannerIfNeeded() synchronously on the next line. is_upgrade is only + // meaningful because doInit() reaches its first await (Config.get) before minting the + // machine-id — so the banner's existsSync still sees pre-launch state. + // + // Add an await ahead of that mint, or make it synchronous, and this invariant flips: + // every install would then report is_upgrade: true and brand-new installs would vanish + // from the metric without a single test failing. Hence this test. + const origCs = process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + const origDisabled = process.env.ALTIMATE_TELEMETRY_DISABLED + const tmpHome = mkdtempSync(join(os.tmpdir(), "install-telemetry-home-")) + spyOn(os, "homedir").mockImplementation(() => tmpHome) + spyOn(global, "fetch").mockImplementation((async () => new Response("", { status: 200 })) as any) + + try { + // The baked-in sink is refused under a test runner, and doInit returns before minting + // when it has no connection string — which would make this test vacuously pass. + delete process.env.ALTIMATE_TELEMETRY_DISABLED + process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = + "InstrumentationKey=k;IngestionEndpoint=https://example.invalid" + // init() is `initPromise ??= doInit()`; shutdown() is the only seam that clears it, so + // an earlier init in this process would otherwise be handed back already resolved. + await Telemetry.shutdown() + + const pending = Telemetry.init() + const machineIdPath = path.join(tmpHome, ".altimate", "machine-id") + + // The instant that matters — the same turn of the event loop in which index.ts calls + // showWelcomeBannerIfNeeded(). + expect(existsSync(machineIdPath)).toBe(false) + + await pending + // Proves the assertion above is not vacuous: this path really does mint, just later. + expect(existsSync(machineIdPath)).toBe(true) + } finally { + await Telemetry.shutdown() + if (origCs !== undefined) process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = origCs + else delete process.env.APPLICATIONINSIGHTS_CONNECTION_STRING + if (origDisabled !== undefined) process.env.ALTIMATE_TELEMETRY_DISABLED = origDisabled + else delete process.env.ALTIMATE_TELEMETRY_DISABLED + rmSync(tmpHome, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/opencode/test/install/postinstall.test.ts b/packages/opencode/test/install/postinstall.test.ts index 9a027abd95..7f90f48a91 100644 --- a/packages/opencode/test/install/postinstall.test.ts +++ b/packages/opencode/test/install/postinstall.test.ts @@ -107,6 +107,10 @@ describe("postinstall.mjs", () => { const markerPath = path.join(dataDir, "altimate-code", ".installed-version") expect(fs.existsSync(markerPath)).toBe(true) expect(fs.readFileSync(markerPath, "utf-8")).toBe("2.5.0") + // altimate_change — the curl and PowerShell installers write the same marker, so + // first_launch can only separate npm volume from theirs via this companion file. + const sourcePath = path.join(dataDir, "altimate-code", ".install-source") + expect(fs.readFileSync(sourcePath, "utf-8")).toBe("npm") }) test("upgrade marker strips v prefix from version", () => { diff --git a/packages/opencode/test/telemetry/telemetry.test.ts b/packages/opencode/test/telemetry/telemetry.test.ts index a9490888e8..437f559bc9 100644 --- a/packages/opencode/test/telemetry/telemetry.test.ts +++ b/packages/opencode/test/telemetry/telemetry.test.ts @@ -1629,6 +1629,7 @@ describe("telemetry.memory", () => { session_id: "", version: "0.5.9", is_upgrade: false, + install_method: "curl", }) }).not.toThrow() })