From ad9c70fe73a1615eb0dbf2933228413661eb5d2c Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 20 Mar 2026 21:46:29 +0100 Subject: [PATCH 1/2] feat: use GitHub Releases for all version checks, add proto and local upgrade support - Switch upgrade command and background update-check from npm registry to GitHub Releases API so all install methods get accurate version info - Add proto toolchain detection: when archgate is installed via proto, `archgate upgrade` runs `proto install archgate latest --pin` - Add local dev dependency detection: when running from node_modules, detect the project's package manager via lockfile and run the appropriate update command (e.g. `bun add -d archgate@latest`) - DRY up the upgrade flows by extracting shared version check into the command action and reusing runExternalUpgrade for all external paths - Reuse fetchLatestGitHubVersion from binary-upgrade helper in update-check to eliminate duplicated fetch logic --- src/commands/upgrade.ts | 200 ++++++++++++++++------------- src/helpers/update-check.ts | 24 +--- tests/helpers/update-check.test.ts | 8 +- 3 files changed, 120 insertions(+), 112 deletions(-) diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 96046efb..641bfbd8 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -1,3 +1,6 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + import type { Command } from "@commander-js/extra-typings"; import { semver } from "bun"; @@ -12,18 +15,14 @@ import { logError } from "../helpers/log"; import { internalPath } from "../helpers/paths"; import { getPlatformInfo, resolveCommand } from "../helpers/platform"; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const NPM_REGISTRY = "https://registry.npmjs.org/archgate/latest"; - // --------------------------------------------------------------------------- // Install method detection // --------------------------------------------------------------------------- type InstallMethod = | { type: "binary"; binaryPath: string } + | { type: "proto"; protoCmd: string } + | { type: "local"; cmd: string; args: string[]; manualHint: string } | { type: "package-manager"; cmd: string; @@ -65,6 +64,56 @@ function isBinaryInstall(): boolean { return process.execPath.startsWith(binDir); } +function getProtoHome(): string { + const home = process.env.HOME ?? process.env.USERPROFILE ?? "~"; + return process.env.PROTO_HOME ?? join(home, ".proto"); +} + +function isProtoInstall(): boolean { + const protoToolDir = join(getProtoHome(), "tools", "archgate"); + return process.execPath.startsWith(protoToolDir); +} + +function isLocalInstall(): boolean { + return process.execPath.includes("node_modules"); +} + +function findProjectRoot(): string | null { + let dir = dirname(process.execPath); + while (true) { + if (existsSync(join(dir, "package.json"))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +const LOCKFILE_TO_PM: [string, string, string[]][] = [ + ["bun.lock", "bun", ["add", "-d", "archgate@latest"]], + ["bun.lockb", "bun", ["add", "-d", "archgate@latest"]], + ["pnpm-lock.yaml", "pnpm", ["add", "-D", "archgate@latest"]], + ["yarn.lock", "yarn", ["add", "-D", "archgate@latest"]], + ["package-lock.json", "npm", ["install", "-D", "archgate@latest"]], +]; + +async function detectLocalPm(): Promise<{ + cmd: string; + args: string[]; + manualHint: string; +} | null> { + const root = findProjectRoot(); + if (!root) return null; + + const match = LOCKFILE_TO_PM.find(([lockfile]) => + existsSync(join(root, lockfile)) + ); + if (!match) return null; + + const [, name, args] = match; + const resolved = (await resolveCommand(name)) ?? name; + return { cmd: resolved, args, manualHint: `${name} ${args.join(" ")}` }; +} + async function getGlobalBinDir(cmd: string[]): Promise { try { const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); @@ -82,6 +131,16 @@ async function detectInstallMethod(): Promise { return { type: "binary", binaryPath: process.execPath }; } + if (isProtoInstall()) { + const protoCmd = (await resolveCommand("proto")) ?? "proto"; + return { type: "proto", protoCmd }; + } + + if (isLocalInstall()) { + const local = await detectLocalPm(); + if (local) return { type: "local", ...local }; + } + const binaryPath = process.execPath; const candidates = await Promise.all( @@ -117,33 +176,11 @@ async function detectInstallMethod(): Promise { }; } -// --------------------------------------------------------------------------- -// Version fetching (npm) -// --------------------------------------------------------------------------- - -async function fetchLatestNpmVersion(): Promise { - const response = await fetch(NPM_REGISTRY, { - headers: { "User-Agent": "archgate-cli" }, - signal: AbortSignal.timeout(15000), - }); - - if (!response.ok) { - logError( - "Failed to fetch release info from npm registry.", - `HTTP ${response.status}. Check your network connection.` - ); - return null; - } - - const data = (await response.json()) as { version?: string }; - return data.version ?? null; -} - // --------------------------------------------------------------------------- // Upgrade flows // --------------------------------------------------------------------------- -async function upgradeBinaryInstall(currentVersion: string): Promise { +async function upgradeBinary(tag: string): Promise { const artifact = getArtifactInfo(); if (!artifact) { logError( @@ -153,32 +190,6 @@ async function upgradeBinaryInstall(currentVersion: string): Promise { process.exit(2); } - const tag = await fetchLatestGitHubVersion(); - if (!tag) { - logError( - "Failed to fetch release info from GitHub.", - "Check your network connection." - ); - process.exit(1); - } - - const latestVersion = tag.replace(/^v/, ""); - const order = semver.order(currentVersion, latestVersion); - - if (order === null) { - logError( - `Could not compare versions: ${currentVersion} vs ${latestVersion}` - ); - process.exit(2); - } - - if (order >= 0) { - console.log(`Archgate is already up-to-date (${currentVersion}).`); - process.exit(0); - } - - console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); - const hint = getManualInstallHint(); let newBinaryPath: string; try { @@ -200,50 +211,22 @@ async function upgradeBinaryInstall(currentVersion: string): Promise { ); process.exit(1); } - - console.log(`Archgate upgraded to ${latestVersion} successfully.`); } -async function upgradePackageManager( - currentVersion: string, - method: Extract +async function runExternalUpgrade( + cmd: string[], + manualHint: string ): Promise { - const latestVersion = await fetchLatestNpmVersion(); - if (!latestVersion) { - process.exit(1); - } - - const order = semver.order(currentVersion, latestVersion); - - if (order === null) { - logError( - `Could not compare versions: ${currentVersion} vs ${latestVersion}` - ); - process.exit(2); - } - - if (order >= 0) { - console.log(`Archgate is already up-to-date (${currentVersion}).`); - process.exit(0); - } - - console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); - - const proc = Bun.spawn([method.cmd, ...method.args], { - stdout: "inherit", - stderr: "inherit", - }); + const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" }); const exitCode = await proc.exited; if (exitCode !== 0) { logError( "Failed to install the latest version.", - `Try running \`${method.manualHint}\` manually.` + `Try running \`${manualHint}\` manually.` ); process.exit(1); } - - console.log(`Archgate upgraded to ${latestVersion} successfully.`); } // --------------------------------------------------------------------------- @@ -257,16 +240,51 @@ export function registerUpgradeCommand(program: Command) { .action(async () => { console.log("Checking for latest Archgate release..."); + const tag = await fetchLatestGitHubVersion(); + if (!tag) { + logError( + "Failed to fetch release info from GitHub.", + "Check your network connection." + ); + process.exit(1); + } + const packageJson = await import("../../package.json"); const currentVersion = packageJson.default.version; + const latestVersion = tag.replace(/^v/, ""); + const order = semver.order(currentVersion, latestVersion); + + if (order === null) { + logError( + `Could not compare versions: ${currentVersion} vs ${latestVersion}` + ); + process.exit(2); + } + + if (order >= 0) { + console.log(`Archgate is already up-to-date (${currentVersion}).`); + process.exit(0); + } + + console.log(`Upgrading ${currentVersion} -> ${latestVersion}...`); const method = await detectInstallMethod(); if (method.type === "binary") { - await upgradeBinaryInstall(currentVersion); + await upgradeBinary(tag); + } else if (method.type === "proto") { + await runExternalUpgrade( + [method.protoCmd, "install", "archgate", "latest", "--pin"], + "proto install archgate latest --pin" + ); } else { - await upgradePackageManager(currentVersion, method); + await runExternalUpgrade( + [method.cmd, ...method.args], + method.manualHint + ); } + + console.log(`Archgate upgraded to ${latestVersion} successfully.`); }); } @@ -276,5 +294,7 @@ export function registerUpgradeCommand(program: Command) { export { isBinaryInstall as _isBinaryInstall, + isProtoInstall as _isProtoInstall, + isLocalInstall as _isLocalInstall, detectInstallMethod as _detectInstallMethod, }; diff --git a/src/helpers/update-check.ts b/src/helpers/update-check.ts index e64b406a..8fe1cdb8 100644 --- a/src/helpers/update-check.ts +++ b/src/helpers/update-check.ts @@ -1,14 +1,14 @@ import { semver } from "bun"; +import { fetchLatestGitHubVersion } from "./binary-upgrade"; import { logDebug } from "./log"; import { internalPath } from "./paths"; const CACHE_FILE = "last-update-check"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours -const NPM_REGISTRY = "https://registry.npmjs.org/archgate/latest"; /** - * Checks npm for a newer Archgate release (at most once per 24h). + * Checks GitHub Releases for a newer Archgate release (at most once per 24h). * Returns a human-readable notice string if an update is available, or null otherwise. * All errors are swallowed — this is non-fatal and runs in the background. */ @@ -33,25 +33,13 @@ export async function checkForUpdatesIfNeeded( logDebug("Checking for updates..."); - const response = await fetch(NPM_REGISTRY, { - headers: { "User-Agent": "archgate-cli" }, - signal: AbortSignal.timeout(5000), - }); - - if (!response.ok) { - logDebug("Update check failed — npm registry returned", response.status); + const tag = await fetchLatestGitHubVersion(); + if (!tag) { + logDebug("Update check failed — could not fetch latest GitHub release"); return null; } - const data = (await response.json()) as { version: string }; - const latestVersion = data.version; - - if (!latestVersion) { - logDebug( - "Update check failed — could not parse version from npm registry" - ); - return null; - } + const latestVersion = tag.replace(/^v/, ""); // Write new cache timestamp regardless of result await Bun.write(cacheFile, String(Date.now())); diff --git a/tests/helpers/update-check.test.ts b/tests/helpers/update-check.test.ts index 60899bd3..d011d690 100644 --- a/tests/helpers/update-check.test.ts +++ b/tests/helpers/update-check.test.ts @@ -40,7 +40,7 @@ describe("checkForUpdatesIfNeeded", () => { const mockFetch = mock(() => Promise.resolve({ ok: true, - json: () => Promise.resolve({ version: "0.1.0" }), + json: () => Promise.resolve({ tag_name: "v0.1.0" }), }) ); globalThis.fetch = mockFetch as unknown as typeof fetch; @@ -57,7 +57,7 @@ describe("checkForUpdatesIfNeeded", () => { const mockFetch = mock(() => Promise.resolve({ ok: true, - json: () => Promise.resolve({ version: "0.2.0" }), + json: () => Promise.resolve({ tag_name: "v0.2.0" }), }) ); globalThis.fetch = mockFetch as unknown as typeof fetch; @@ -73,7 +73,7 @@ describe("checkForUpdatesIfNeeded", () => { expect(result).toContain("archgate upgrade"); }); - test("returns null when npm registry returns non-ok response", async () => { + test("returns null when GitHub API returns non-ok response", async () => { const mockFetch = mock(() => Promise.resolve({ ok: false, @@ -91,7 +91,7 @@ describe("checkForUpdatesIfNeeded", () => { expect(result).toBeNull(); }); - test("returns null when version is missing from response", async () => { + test("returns null when tag_name is missing from response", async () => { const mockFetch = mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }) ); From b48d53ba622ef89c9018584518950dab2295847f Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Fri, 20 Mar 2026 22:23:41 +0100 Subject: [PATCH 2/2] test: add smoke tests for all upgrade install method detection paths Add CI smoke tests (Windows + Linux) for binary, proto, and node_modules install paths. Add unit tests for install method detection helpers. --- .github/workflows/smoke-test.yml | 98 +++++++++++- tests/commands/upgrade.test.ts | 252 ++++++++++++++++++++++++++++++- 2 files changed, 348 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 5c2708fb..92ae796f 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -62,6 +62,54 @@ jobs: exit 1 } + - name: Smoke test binary from install dir + shell: pwsh + run: | + # Simulate binary install at %APPDATA%\archgate\ + $installDir = Join-Path $env:APPDATA "archgate" + New-Item -Path $installDir -ItemType Directory -Force | Out-Null + Copy-Item "dist/archgate-smoke-test.exe" (Join-Path $installDir "archgate.exe") + $output = & (Join-Path $installDir "archgate.exe") --version 2>&1 + Write-Host "archgate version from binary install dir: $output" + if ($LASTEXITCODE -ne 0) { + Write-Error "Binary exited with code $LASTEXITCODE" + exit 1 + } + Remove-Item -Path $installDir -Recurse -Force -ErrorAction SilentlyContinue + + - name: Smoke test binary from proto dir + shell: pwsh + run: | + # Simulate proto install at ~/.proto/tools/archgate// + $protoDir = Join-Path $env:USERPROFILE ".proto" "tools" "archgate" "0.0.0" + New-Item -Path $protoDir -ItemType Directory -Force | Out-Null + Copy-Item "dist/archgate-smoke-test.exe" (Join-Path $protoDir "archgate.exe") + $output = & (Join-Path $protoDir "archgate.exe") --version 2>&1 + Write-Host "archgate version from proto dir: $output" + if ($LASTEXITCODE -ne 0) { + Write-Error "Binary exited with code $LASTEXITCODE" + exit 1 + } + Remove-Item -Path (Join-Path $env:USERPROFILE ".proto") -Recurse -Force -ErrorAction SilentlyContinue + + - name: Smoke test binary from node_modules + shell: pwsh + run: | + # Simulate local dev dependency install + $projectDir = Join-Path $env:TEMP "archgate-local-smoke" + $binDir = Join-Path $projectDir "node_modules" ".bin" + New-Item -Path $binDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $projectDir "package.json") -Value "{}" + Set-Content -Path (Join-Path $projectDir "bun.lock") -Value "" + Copy-Item "dist/archgate-smoke-test.exe" (Join-Path $binDir "archgate.exe") + $output = & (Join-Path $binDir "archgate.exe") --version 2>&1 + Write-Host "archgate version from node_modules: $output" + if ($LASTEXITCODE -ne 0) { + Write-Error "Binary exited with code $LASTEXITCODE" + exit 1 + } + Remove-Item -Path $projectDir -Recurse -Force -ErrorAction SilentlyContinue + - name: Smoke test install.ps1 shell: pwsh env: @@ -107,13 +155,61 @@ jobs: Remove-Item -Path $installDir -Recurse -Force -ErrorAction SilentlyContinue linux: - name: Linux Install Script Smoke Test + name: Linux Smoke Test runs-on: ubuntu-latest timeout-minutes: 10 if: github.event.pull_request.draft == false steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: moonrepo/setup-toolchain@v0 + with: + auto-install: true + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build Linux binary + run: bun build src/cli.ts --compile --bytecode --outfile dist/archgate-smoke-test + + - name: Smoke test binary + run: | + chmod +x dist/archgate-smoke-test + output=$(dist/archgate-smoke-test --version 2>&1) + echo "archgate version: $output" + + - name: Smoke test binary from ~/.archgate/bin/ + run: | + mkdir -p ~/.archgate/bin + cp dist/archgate-smoke-test ~/.archgate/bin/archgate + chmod +x ~/.archgate/bin/archgate + output=$(~/.archgate/bin/archgate --version 2>&1) + echo "archgate version from binary install dir: $output" + rm -rf ~/.archgate/bin + + - name: Smoke test binary from proto dir + run: | + mkdir -p ~/.proto/tools/archgate/0.0.0 + cp dist/archgate-smoke-test ~/.proto/tools/archgate/0.0.0/archgate + chmod +x ~/.proto/tools/archgate/0.0.0/archgate + output=$(~/.proto/tools/archgate/0.0.0/archgate --version 2>&1) + echo "archgate version from proto dir: $output" + rm -rf ~/.proto/tools/archgate + + - name: Smoke test binary from node_modules + run: | + project_dir=$(mktemp -d) + mkdir -p "$project_dir/node_modules/.bin" + echo '{}' > "$project_dir/package.json" + touch "$project_dir/bun.lock" + cp dist/archgate-smoke-test "$project_dir/node_modules/.bin/archgate" + chmod +x "$project_dir/node_modules/.bin/archgate" + output=$("$project_dir/node_modules/.bin/archgate" --version 2>&1) + echo "archgate version from node_modules: $output" + rm -rf "$project_dir" - name: Smoke test install.sh env: diff --git a/tests/commands/upgrade.test.ts b/tests/commands/upgrade.test.ts index 0a94ac93..033d87b9 100644 --- a/tests/commands/upgrade.test.ts +++ b/tests/commands/upgrade.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Command } from "@commander-js/extra-typings"; @@ -19,3 +22,250 @@ describe("registerUpgradeCommand", () => { expect(sub.description()).toBeTruthy(); }); }); + +// --------------------------------------------------------------------------- +// Install method detection +// --------------------------------------------------------------------------- + +describe("install method detection", () => { + let tempDir: string; + let originalExecPath: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + let originalProtoHome: string | undefined; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-upgrade-test-")); + originalExecPath = process.execPath; + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; + originalProtoHome = process.env.PROTO_HOME; + process.env.HOME = tempDir; + process.env.USERPROFILE = tempDir; + delete process.env.PROTO_HOME; + }); + + afterEach(() => { + Object.defineProperty(process, "execPath", { + value: originalExecPath, + writable: true, + configurable: true, + }); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + if (originalProtoHome === undefined) delete process.env.PROTO_HOME; + else process.env.PROTO_HOME = originalProtoHome; + rmSync(tempDir, { recursive: true, force: true }); + }); + + function setExecPath(path: string) { + Object.defineProperty(process, "execPath", { + value: path, + writable: true, + configurable: true, + }); + } + + describe("_isBinaryInstall", () => { + test("returns true when execPath is under ~/.archgate/bin/", async () => { + const fakeBinary = join(tempDir, ".archgate", "bin", "archgate"); + setExecPath(fakeBinary); + + const { _isBinaryInstall } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + expect(_isBinaryInstall()).toBe(true); + }); + + test("returns false when execPath is elsewhere", async () => { + setExecPath(join(tempDir, "usr", "local", "bin", "archgate")); + + const { _isBinaryInstall } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + expect(_isBinaryInstall()).toBe(false); + }); + }); + + describe("_isProtoInstall", () => { + test("returns true when execPath is under ~/.proto/tools/archgate/", async () => { + const fakeBinary = join( + tempDir, + ".proto", + "tools", + "archgate", + "0.13.0", + "archgate" + ); + setExecPath(fakeBinary); + + const { _isProtoInstall } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + expect(_isProtoInstall()).toBe(true); + }); + + test("respects PROTO_HOME env var", async () => { + const customProto = join(tempDir, "custom-proto"); + process.env.PROTO_HOME = customProto; + const fakeBinary = join( + customProto, + "tools", + "archgate", + "0.13.0", + "archgate" + ); + setExecPath(fakeBinary); + + const { _isProtoInstall } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + expect(_isProtoInstall()).toBe(true); + }); + + test("returns false when execPath is elsewhere", async () => { + setExecPath(join(tempDir, "usr", "local", "bin", "archgate")); + + const { _isProtoInstall } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + expect(_isProtoInstall()).toBe(false); + }); + }); + + describe("_isLocalInstall", () => { + test("returns true when execPath contains node_modules", async () => { + setExecPath(join(tempDir, "project", "node_modules", ".bin", "archgate")); + + const { _isLocalInstall } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + expect(_isLocalInstall()).toBe(true); + }); + + test("returns false when execPath has no node_modules", async () => { + setExecPath(join(tempDir, "usr", "local", "bin", "archgate")); + + const { _isLocalInstall } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + expect(_isLocalInstall()).toBe(false); + }); + }); + + describe("_detectInstallMethod", () => { + test("detects binary install", async () => { + const fakeBinary = join(tempDir, ".archgate", "bin", "archgate"); + setExecPath(fakeBinary); + + const { _detectInstallMethod } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + const method = await _detectInstallMethod(); + expect(method.type).toBe("binary"); + expect(method).toHaveProperty("binaryPath", fakeBinary); + }); + + test("detects proto install", async () => { + const fakeBinary = join( + tempDir, + ".proto", + "tools", + "archgate", + "0.13.0", + "archgate" + ); + setExecPath(fakeBinary); + + const { _detectInstallMethod } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + const method = await _detectInstallMethod(); + expect(method.type).toBe("proto"); + expect(method).toHaveProperty("protoCmd"); + }); + + test("detects local install with bun.lock", async () => { + const projectDir = join(tempDir, "project-bun"); + mkdirSync(join(projectDir, "node_modules", ".bin"), { recursive: true }); + writeFileSync(join(projectDir, "package.json"), "{}"); + writeFileSync(join(projectDir, "bun.lock"), ""); + setExecPath(join(projectDir, "node_modules", ".bin", "archgate")); + + const { _detectInstallMethod } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + const method = await _detectInstallMethod(); + expect(method.type).toBe("local"); + expect(method.manualHint).toContain("bun"); + }); + + test("detects local install with pnpm-lock.yaml", async () => { + const projectDir = join(tempDir, "project-pnpm"); + mkdirSync(join(projectDir, "node_modules", ".bin"), { recursive: true }); + writeFileSync(join(projectDir, "package.json"), "{}"); + writeFileSync(join(projectDir, "pnpm-lock.yaml"), ""); + setExecPath(join(projectDir, "node_modules", ".bin", "archgate")); + + const { _detectInstallMethod } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + const method = await _detectInstallMethod(); + expect(method.type).toBe("local"); + expect(method.manualHint).toContain("pnpm"); + }); + + test("detects local install with yarn.lock", async () => { + const projectDir = join(tempDir, "project-yarn"); + mkdirSync(join(projectDir, "node_modules", ".bin"), { recursive: true }); + writeFileSync(join(projectDir, "package.json"), "{}"); + writeFileSync(join(projectDir, "yarn.lock"), ""); + setExecPath(join(projectDir, "node_modules", ".bin", "archgate")); + + const { _detectInstallMethod } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + const method = await _detectInstallMethod(); + expect(method.type).toBe("local"); + expect(method.manualHint).toContain("yarn"); + }); + + test("detects local install with package-lock.json", async () => { + const projectDir = join(tempDir, "project-npm"); + mkdirSync(join(projectDir, "node_modules", ".bin"), { recursive: true }); + writeFileSync(join(projectDir, "package.json"), "{}"); + writeFileSync(join(projectDir, "package-lock.json"), "{}"); + setExecPath(join(projectDir, "node_modules", ".bin", "archgate")); + + const { _detectInstallMethod } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + const method = await _detectInstallMethod(); + expect(method.type).toBe("local"); + expect(method.manualHint).toContain("npm"); + }); + + test("falls back to package-manager for unknown location", async () => { + setExecPath(join(tempDir, "some", "random", "path", "archgate")); + + const { _detectInstallMethod } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + const method = await _detectInstallMethod(); + expect(method.type).toBe("package-manager"); + }); + + test("binary detection takes priority over other methods", async () => { + const fakeBinary = join(tempDir, ".archgate", "bin", "archgate"); + setExecPath(fakeBinary); + + const { _detectInstallMethod } = await import( + `../../src/commands/upgrade?t=${Date.now()}` + ); + const method = await _detectInstallMethod(); + expect(method.type).toBe("binary"); + }); + }); +});