diff --git a/.github/installer.json b/.github/installer.json new file mode 100644 index 0000000..19f2ed6 --- /dev/null +++ b/.github/installer.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "repo": "pablozaiden/link", + "installDir": "$HOME/.local/bin", + "binaries": [ + { + "name": "link-cli", + "assetPrefix": "link-cli", + "postInstallMessage": "Run 'link-cli web' to start Link." + } + ], + "checksums": { + "required": true, + "extension": ".sha256" + }, + "platforms": { + "linux": ["x64", "arm64"], + "darwin": ["x64", "arm64"] + } +} diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml index 1f69ba2..e6b4d09 100644 --- a/.github/workflows/binary-release.yml +++ b/.github/workflows/binary-release.yml @@ -6,61 +6,18 @@ on: - published jobs: - build-binaries: - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - target: linux-x64 - - os: ubuntu-latest - target: linux-arm64 - - os: macos-latest - target: darwin-x64 - - os: macos-latest - target: darwin-arm64 - - runs-on: ${{ matrix.os }} + binaries: + uses: pablozaiden/installer/.github/workflows/reusable-binary-release.yml@f7315acbadbf0c7fc9f869b4f1e88daa925a4756 permissions: contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v5 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Update package.json version - run: | - VERSION="${GITHUB_REF_NAME#v}" - jq --arg v "$VERSION" '.version = $v' package.json > package.json.tmp && mv package.json.tmp package.json - - - name: Build web assets - run: bun run build - - - name: Build standalone binary - run: bun run build-binary.ts --target=bun-${{ matrix.target }} --outfile=dist/link-cli-${{ github.ref_name }}-${{ matrix.target }} - - - name: Generate checksum - working-directory: dist - run: shasum -a 256 link-cli-${{ github.ref_name }}-${{ matrix.target }} > link-cli-${{ github.ref_name }}-${{ matrix.target }}.sha256 - - - name: Upload workflow artifact - uses: actions/upload-artifact@v4 - with: - name: link-cli-${{ github.ref_name }}-${{ matrix.target }} - path: | - dist/link-cli-${{ github.ref_name }}-${{ matrix.target }} - dist/link-cli-${{ github.ref_name }}-${{ matrix.target }}.sha256 - if-no-files-found: error - - - name: Upload binary to release - env: - GH_TOKEN: ${{ github.token }} - run: gh release upload ${{ github.ref_name }} dist/link-cli-${{ github.ref_name }}-${{ matrix.target }} dist/link-cli-${{ github.ref_name }}-${{ matrix.target }}.sha256 --clobber + with: + prebuild_command: bun run build + binaries: | + [ + { + "name": "link-cli", + "asset_prefix": "link-cli", + "build_command": "bun run build-binary.ts --target=$BUN_TARGET --outfile=dist/$ASSET_NAME", + "output_path": "dist/$ASSET_NAME" + } + ] diff --git a/README.md b/README.md index b2fcd9b..0b84dd0 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@ Link is a tool to track relationships between entities in an agent-friendly way. Install the latest Linux or macOS binary release: ```bash -curl -fsSL https://raw.githubusercontent.com/pablozaiden/link/main/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/pablozaiden/installer/v0.0.2/install.sh | sh -s -- pablozaiden/link ``` -The installer downloads the latest release for your platform and installs it as `link-cli` in `$HOME/.local/bin`. If that directory is not on your `PATH`, the installer prints the shell profile line to add. +The shared installer reads Link's installer manifest, downloads the latest release for your platform, verifies its checksum, and installs it as `link-cli` in `$HOME/.local/bin`. If that directory is not on your `PATH`, the installer prints the shell profile line to add. Installed binaries can check for or install release updates: diff --git a/bun.lock b/bun.lock index 441a37a..ec0b070 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "bun-react-template", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@pablozaiden/installer": "github:pablozaiden/installer#v0.0.2", "bun-plugin-tailwind": "^0.1.2", "react": "^19", "react-dom": "^19", @@ -48,6 +49,8 @@ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-6gy4hhQSjq/T/S9hC9m3NxY0RY+9Ww+XNlB+8koIMTsMSYEjk7Ho+hFHQz1Bn4W61Ub7Vykufg+jgDgPfa2GFA=="], + "@pablozaiden/installer": ["@pablozaiden/installer@github:pablozaiden/installer#f7315ac", { "peerDependencies": { "typescript": "^5" } }, "PabloZaiden-installer-f7315ac", "sha512-sL6zFUwyV+bp3h2QEhojrr77+c/mX6HVB3oYMUfb9c/isuEipNw5haLUg4PewBGIv+nPAbYoAW0V9fqXse2vvQ=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], @@ -238,6 +241,8 @@ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], diff --git a/install.sh b/install.sh deleted file mode 100755 index df744e7..0000000 --- a/install.sh +++ /dev/null @@ -1,137 +0,0 @@ -#!/bin/sh -set -e - -# Link installer script -# Usage: curl -fsSL https://raw.githubusercontent.com/pablozaiden/link/main/install.sh | sh - -REPO="pablozaiden/link" -INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" -BINARY_NAME="link-cli" - -make_temp_file() { - case "$OS" in - darwin) mktemp -t link-cli.XXXXXX ;; - *) mktemp "${TMPDIR:-/tmp}/link-cli.XXXXXX" ;; - esac -} - -verify_checksum() { - checksum_file=$1 - binary_file=$2 - - if command -v sha256sum >/dev/null 2>&1; then - (cd "$(dirname "$binary_file")" && sha256sum -c "$checksum_file") - elif command -v shasum >/dev/null 2>&1; then - (cd "$(dirname "$binary_file")" && shasum -a 256 -c "$checksum_file") - else - echo "Error: sha256sum or shasum is required to verify the downloaded binary." - exit 1 - fi -} - -detect_os() { - case "$(uname -s)" in - Linux*) echo "linux" ;; - Darwin*) echo "darwin" ;; - *) echo "unsupported" ;; - esac -} - -detect_arch() { - case "$(uname -m)" in - x86_64) echo "x64" ;; - amd64) echo "x64" ;; - aarch64) echo "arm64" ;; - arm64) echo "arm64" ;; - *) echo "unsupported" ;; - esac -} - -if ! command -v curl >/dev/null 2>&1; then - echo "Error: curl is required to install Link." - exit 1 -fi - -OS=$(detect_os) -ARCH=$(detect_arch) - -if [ "$OS" = "unsupported" ]; then - echo "Error: Unsupported operating system: $(uname -s)" - echo "Link supports Linux and macOS only." - exit 1 -fi - -if [ "$ARCH" = "unsupported" ]; then - echo "Error: Unsupported architecture: $(uname -m)" - echo "Link supports x64 and arm64 architectures only." - exit 1 -fi - -echo "Detected platform: $OS-$ARCH" - -echo "Fetching latest release..." -LATEST_TAG=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') - -if [ -z "$LATEST_TAG" ]; then - echo "Error: Could not determine latest release version." - exit 1 -fi - -echo "Latest version: $LATEST_TAG" - -mkdir -p "$INSTALL_DIR" - -ASSET_NAME="$BINARY_NAME-$LATEST_TAG-$OS-$ARCH" -DOWNLOAD_URL="https://github.com/$REPO/releases/download/$LATEST_TAG/$ASSET_NAME" -CHECKSUM_NAME="$ASSET_NAME.sha256" -CHECKSUM_URL="$DOWNLOAD_URL.sha256" -TEMP_FILE=$(make_temp_file) -CHECKSUM_FILE=$(make_temp_file) - -cleanup() { - rm -f "$TEMP_FILE" "$CHECKSUM_FILE" -} - -trap cleanup EXIT - -echo "Downloading $ASSET_NAME..." -if ! curl -fsSL "$DOWNLOAD_URL" -o "$TEMP_FILE"; then - echo "Error: Failed to download from $DOWNLOAD_URL" - exit 1 -fi - -echo "Downloading $CHECKSUM_NAME..." -if ! curl -fsSL "$CHECKSUM_URL" -o "$CHECKSUM_FILE"; then - echo "Error: Failed to download checksum from $CHECKSUM_URL" - exit 1 -fi - -echo "Verifying checksum..." -VERIFY_DIR=$(dirname "$TEMP_FILE") -VERIFY_FILE="$VERIFY_DIR/$ASSET_NAME" -mv "$TEMP_FILE" "$VERIFY_FILE" -verify_checksum "$CHECKSUM_FILE" "$VERIFY_FILE" -TEMP_FILE="$VERIFY_FILE" - -mv "$TEMP_FILE" "$INSTALL_DIR/$BINARY_NAME" -TEMP_FILE="" -chmod +x "$INSTALL_DIR/$BINARY_NAME" -echo "Installed $BINARY_NAME to $INSTALL_DIR/$BINARY_NAME" - -case ":$PATH:" in - *":$INSTALL_DIR:"*) - echo "" - echo "Installation complete!" - echo "Run '$BINARY_NAME web' to start Link." - ;; - *) - echo "" - echo "Warning: $INSTALL_DIR is not in your PATH." - echo "" - echo "Add it to your shell profile:" - echo " export PATH=\"$INSTALL_DIR:\$PATH\"" - echo "" - echo "Or run directly with:" - echo " $INSTALL_DIR/$BINARY_NAME" - ;; -esac diff --git a/package.json b/package.json index 56c961a..9fcc3f1 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@pablozaiden/installer": "github:pablozaiden/installer#v0.0.2", "bun-plugin-tailwind": "^0.1.2", "react": "^19", "react-dom": "^19", diff --git a/src/server/commands.ts b/src/server/commands.ts index f7e9a31..f02cd7d 100644 --- a/src/server/commands.ts +++ b/src/server/commands.ts @@ -1,7 +1,6 @@ import { GraphError } from "../domain/errors"; import { callGraphTool } from "../graph/tools"; import { JsonGraphRepository, validateGraphPath } from "../storage/json"; -import { LINK_VERSION } from "../version"; import { loadConfig } from "./config"; import { runUpdateCommand } from "./update"; import { @@ -33,7 +32,7 @@ export async function runCliCommand(command: CliCommand): Promise; writes: Array<{ path: string; content: string }>; @@ -41,10 +36,11 @@ function checksumResponse(assetName: string, content = "binary"): Response { function createDependencies( responses: Response[], - overrides: Partial = {}, -): { dependencies: CliUpdateDependencies; state: MockUpdateState } { + overrides: Partial = {}, +): { dependencies: Partial; state: MockUpdateState } { const state: MockUpdateState = { outputs: [], + errors: [], fetchedUrls: [], fetchInits: [], writes: [], @@ -53,7 +49,7 @@ function createDependencies( removes: [], }; const queuedResponses = [...responses]; - const dependencies: CliUpdateDependencies = { + const dependencies: Partial = { fetchFn: (async (input: RequestInfo | URL, init?: RequestInit) => { state.fetchedUrls.push(String(input)); state.fetchInits.push(init); @@ -61,15 +57,17 @@ function createDependencies( if (!response) throw new Error(`Unexpected fetch: ${String(input)}`); return response; }) as typeof fetch, - out: (message: string) => { + out: message => { state.outputs.push(message); }, - currentVersion: "0.1.0", + err: message => { + state.errors.push(message); + }, getPlatform: () => ({ platform: "linux", arch: "x64" }), getExecutablePath: () => "/usr/local/bin/link-cli", resolveRealPath: async () => "/real/link-cli", fileExists: async () => true, - createTempDirectory: async () => "/real/.link-update-test", + createTempDirectory: async (_targetDirectory, prefix) => `/real/${prefix}test`, writeBinary: async (path, content) => { const text = typeof content === "string" ? content : new TextDecoder().decode(content); state.writes.push({ path, content: text }); @@ -80,7 +78,7 @@ function createDependencies( renameFile: async (from, to) => { state.renames.push({ from, to }); }, - removeFile: async (path) => { + removeFile: async path => { state.removes.push(path); }, statFile: async () => ({ mode: 0o100755 }), @@ -90,31 +88,18 @@ function createDependencies( return { dependencies, state }; } -describe("release version helpers", () => { - test("normalizes release versions and tags", () => { - expect(normalizeReleaseVersion(" v1.2.3 ")).toBe("1.2.3"); - expect(normalizeReleaseTag("1.2.3")).toBe("v1.2.3"); - expect(() => normalizeReleaseVersion(" ")).toThrow("Missing release version"); - }); - - test("compares semantic versions and prereleases", () => { - expect(compareReleaseVersions("1.0.0", "1.0.0")).toBe(0); - expect(compareReleaseVersions("1.0.0", "1.0.1")).toBeLessThan(0); - expect(compareReleaseVersions("1.1.0", "1.0.9")).toBeGreaterThan(0); - expect(compareReleaseVersions("1.0.0-beta.1", "1.0.0")).toBeLessThan(0); - expect(compareReleaseVersions("1.0.0-beta.2", "1.0.0-beta.1")).toBeGreaterThan(0); - }); - - test("resolves supported platforms and asset names", () => { - expect(resolveReleasePlatform("linux", "x64")).toEqual({ os: "linux", arch: "x64" }); - expect(resolveReleasePlatform("darwin", "arm64")).toEqual({ os: "darwin", arch: "arm64" }); - expect(buildReleaseAssetName("v1.2.3", { os: "linux", arch: "x64" })).toBe("link-cli-v1.2.3-linux-x64"); - expect(() => resolveReleasePlatform("win32", "x64")).toThrow("Unsupported platform"); +describe("Link updater adapter", () => { + test("uses the shared installer updater with Link config", () => { + expect(LINK_UPDATER_CONFIG).toEqual({ + repository: "pablozaiden/link", + binaryName: "link-cli", + currentVersion: LINK_VERSION, + productName: "Link", + checksum: { required: true }, + }); }); -}); -describe("runUpdateCommand", () => { - test("checks for updates without replacing the binary", async () => { + test("checks for updates using Link release assets", async () => { const { dependencies, state } = createDependencies([ releaseResponse("v0.2.0", ["link-cli-v0.2.0-linux-x64"]), ]); @@ -122,32 +107,18 @@ describe("runUpdateCommand", () => { const exitCode = await runUpdateCommand({ checkOnly: true }, dependencies); expect(exitCode).toBe(0); - expect(state.outputs).toContain("Update available: 0.1.0 -> 0.2.0"); + expect(state.fetchedUrls).toEqual(["https://api.github.com/repos/pablozaiden/link/releases/latest"]); expect(state.fetchInits[0]?.headers).toEqual({ accept: "application/vnd.github+json", "user-agent": "link-cli-updater", "x-github-api-version": "2022-11-28", }); + expect(state.outputs).toContain(`Update available: ${LINK_VERSION} -> 0.2.0`); expect(state.writes).toHaveLength(0); expect(state.renames).toHaveLength(0); }); - test("does not replace when latest release is already installed", async () => { - const { dependencies, state } = createDependencies( - [releaseResponse("v0.1.0", ["link-cli-v0.1.0-linux-x64"])], - { - getExecutablePath: () => "/usr/bin/bun", - }, - ); - - const exitCode = await runUpdateCommand({ checkOnly: false }, dependencies); - - expect(exitCode).toBe(0); - expect(state.outputs).toContain("link-cli 0.1.0 is up to date."); - expect(state.renames).toHaveLength(0); - }); - - test("installs an explicit release version", async () => { + test("installs an explicit Link release after checksum verification", async () => { const assetName = "link-cli-v1.2.3-linux-x64"; const { dependencies, state } = createDependencies([ releaseResponse("v1.2.3", [assetName, `${assetName}.sha256`]), @@ -164,20 +135,18 @@ describe("runUpdateCommand", () => { "https://downloads.example/link-cli-v1.2.3-linux-x64.sha256", ]); expect(state.writes).toEqual([ - { path: "/real/.link-update-test/link-cli-v1.2.3-linux-x64", content: "new-binary" }, + { path: "/real/.link-cli-update-test/link-cli-v1.2.3-linux-x64", content: "new-binary" }, ]); - expect(state.chmods).toEqual([{ path: "/real/.link-update-test/link-cli-v1.2.3-linux-x64", mode: 0o755 }]); + expect(state.chmods).toEqual([{ path: "/real/.link-cli-update-test/link-cli-v1.2.3-linux-x64", mode: 0o755 }]); expect(state.renames).toEqual([ - { from: "/real/.link-update-test/link-cli-v1.2.3-linux-x64", to: "/real/link-cli" }, - ]); - expect(state.removes).toEqual([ - "/real/.link-update-test/link-cli-v1.2.3-linux-x64", - "/real/.link-update-test", + { from: "/real/link-cli", to: "/real/.link-cli-update-test/link-cli.backup" }, + { from: "/real/.link-cli-update-test/link-cli-v1.2.3-linux-x64", to: "/real/link-cli" }, ]); + expect(state.removes).toEqual(["/real/.link-cli-update-test"]); expect(state.outputs).toContain("Installed link-cli 1.2.3 at /real/link-cli."); }); - test("rejects source-mode execution for installs", async () => { + test("surfaces source-mode execution guidance from the shared updater", async () => { const { dependencies } = createDependencies( [releaseResponse("v0.2.0", ["link-cli-v0.2.0-linux-x64"])], { @@ -186,100 +155,7 @@ describe("runUpdateCommand", () => { ); await expect(runUpdateCommand({ checkOnly: false }, dependencies)).rejects.toThrow( - "link-cli update only works from an installed Link CLI binary", - ); - }); - - test("fails clearly when a release asset is missing", async () => { - const { dependencies } = createDependencies([releaseResponse("v0.2.0", [])]); - - await expect(runUpdateCommand({ checkOnly: true }, dependencies)).rejects.toThrow( - "Release v0.2.0 does not include asset link-cli-v0.2.0-linux-x64", - ); - }); - - test("fails clearly on metadata and download errors", async () => { - const metadataFailure = createDependencies([new Response("missing", { status: 404 })]); - await expect(runUpdateCommand({ checkOnly: false, version: "9.9.9" }, metadataFailure.dependencies)).rejects.toThrow( - "Release not found: v9.9.9", - ); - - const downloadFailure = createDependencies([ - releaseResponse("v0.2.0", ["link-cli-v0.2.0-linux-x64"]), - new Response("nope", { status: 500 }), - ]); - await expect(runUpdateCommand({ checkOnly: false }, downloadFailure.dependencies)).rejects.toThrow( - "Failed to update /real/link-cli", - ); - }); - - test("requires and validates checksum assets before replacing the binary", async () => { - const assetName = "link-cli-v0.2.0-linux-x64"; - const missingChecksum = createDependencies([ - releaseResponse("v0.2.0", [assetName]), - binaryResponse("new-binary"), - ]); - await expect(runUpdateCommand({ checkOnly: false }, missingChecksum.dependencies)).rejects.toThrow( - "Release asset link-cli-v0.2.0-linux-x64.sha256 is required", - ); - expect(missingChecksum.state.writes).toHaveLength(0); - expect(missingChecksum.state.renames).toHaveLength(0); - - const checksumMismatch = createDependencies([ - releaseResponse("v0.2.0", [assetName, `${assetName}.sha256`]), - binaryResponse("new-binary"), - checksumResponse(assetName, "different-binary"), - ]); - await expect(runUpdateCommand({ checkOnly: false }, checksumMismatch.dependencies)).rejects.toThrow( - "Checksum verification failed", - ); - expect(checksumMismatch.state.writes).toHaveLength(0); - expect(checksumMismatch.state.renames).toHaveLength(0); - - const checksumWrongFile = createDependencies([ - releaseResponse("v0.2.0", [assetName, `${assetName}.sha256`]), - binaryResponse("new-binary"), - checksumResponse("other-binary", "new-binary"), - ]); - await expect(runUpdateCommand({ checkOnly: false }, checksumWrongFile.dependencies)).rejects.toThrow( - "did not contain a valid SHA-256 entry", - ); - expect(checksumWrongFile.state.writes).toHaveLength(0); - expect(checksumWrongFile.state.renames).toHaveLength(0); - }); - - test("validates the resolved executable path before replacing the binary", async () => { - const assetName = "link-cli-v0.2.0-linux-x64"; - const { dependencies } = createDependencies( - [ - releaseResponse("v0.2.0", [assetName, `${assetName}.sha256`]), - binaryResponse(), - checksumResponse(assetName), - ], - { - fileExists: async () => false, - }, - ); - - await expect(runUpdateCommand({ checkOnly: false }, dependencies)).rejects.toThrow( - "Installed binary does not exist: /real/link-cli", + "link-cli update only works from an installed Link binary. Use the installer script when running from source.", ); }); - - test("surfaces permission errors with an actionable message", async () => { - const { dependencies } = createDependencies( - [ - releaseResponse("v0.2.0", ["link-cli-v0.2.0-linux-x64", "link-cli-v0.2.0-linux-x64.sha256"]), - binaryResponse(), - checksumResponse("link-cli-v0.2.0-linux-x64"), - ], - { - renameFile: async () => { - throw Object.assign(new Error("denied"), { code: "EACCES" }); - }, - }, - ); - - await expect(runUpdateCommand({ checkOnly: false }, dependencies)).rejects.toThrow("permission denied"); - }); }); diff --git a/src/server/update.ts b/src/server/update.ts index 3cb4f8e..483535a 100644 --- a/src/server/update.ts +++ b/src/server/update.ts @@ -1,365 +1,24 @@ -import { createHash } from "crypto"; -import { chmod, mkdtemp, realpath, rename, rm, stat } from "fs/promises"; -import { basename, dirname, join } from "path"; -import { z } from "zod"; - -const GITHUB_REPOSITORY = "pablozaiden/link"; -const GITHUB_API_BASE_URL = `https://api.github.com/repos/${GITHUB_REPOSITORY}`; -const CLI_BINARY_NAME = "link-cli"; -const GITHUB_API_VERSION = "2022-11-28"; -const GITHUB_USER_AGENT = "link-cli-updater"; - -const ReleaseAssetSchema = z.object({ - name: z.string().min(1), - browser_download_url: z.string().url(), -}); - -const ReleaseSchema = z.object({ - tag_name: z.string().min(1), - assets: z.array(ReleaseAssetSchema), -}); - -export interface UpdateCommandOptions { - checkOnly: boolean; - version?: string; -} - -export type ReleasePlatform = { - os: "linux" | "darwin"; - arch: "x64" | "arm64"; -}; - -type WritableBinaryContent = Uint8Array | string; -type GitHubRelease = z.infer; - -type ReleaseAsset = { - version: string; - assetName: string; - downloadUrl: string; - checksumAssetName: string; - checksumDownloadUrl?: string; -}; - -export interface CliUpdateDependencies { - fetchFn: typeof fetch; - out: (message: string) => void; - currentVersion: string; - getPlatform: () => { - platform: NodeJS.Platform; - arch: string; - }; - getExecutablePath: () => string; - resolveRealPath: (path: string) => Promise; - fileExists: (path: string) => Promise; - createTempDirectory: (targetDirectory: string) => Promise; - writeBinary: (path: string, content: WritableBinaryContent) => Promise; - chmodFile: (path: string, mode: number) => Promise; - renameFile: (from: string, to: string) => Promise; - removeFile: (path: string) => Promise; - statFile: (path: string) => Promise<{ mode: number }>; -} - -function createDefaultUpdateDependencies(): CliUpdateDependencies { - return { - fetchFn: fetch, - out: console.log, - currentVersion: "0.0.0-development", - getPlatform: () => ({ - platform: process.platform, - arch: process.arch, - }), - getExecutablePath: () => process.execPath, - resolveRealPath: async (path: string) => await realpath(path), - fileExists: async (path: string) => await Bun.file(path).exists(), - createTempDirectory: async (targetDirectory: string) => await mkdtemp(join(targetDirectory, ".link-update-")), - writeBinary: async (path: string, content: WritableBinaryContent) => { - await Bun.write(path, content); - }, - chmodFile: async (path: string, mode: number) => { - await chmod(path, mode); - }, - renameFile: async (from: string, to: string) => { - await rename(from, to); - }, - removeFile: async (path: string) => { - await rm(path, { force: true, recursive: true }); - }, - statFile: async (path: string) => { - const currentStat = await stat(path); - return { mode: currentStat.mode }; - }, - }; -} - -export function normalizeReleaseVersion(rawValue: string): string { - const trimmed = rawValue.trim(); - if (!trimmed) throw new Error("Missing release version."); - return trimmed.startsWith("v") ? trimmed.slice(1) : trimmed; -} - -export function normalizeReleaseTag(rawValue: string): string { - return `v${normalizeReleaseVersion(rawValue)}`; -} - -function parseVersion(value: string): { - major: number; - minor: number; - patch: number; - prerelease: string[]; -} | null { - const parsed = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(normalizeReleaseVersion(value)); - if (!parsed) return null; - - return { - major: Number(parsed[1]), - minor: Number(parsed[2]), - patch: Number(parsed[3]), - prerelease: parsed[4]?.split(".") ?? [], - }; -} - -function comparePrereleaseIdentifiers(left: string[], right: string[]): number { - const limit = Math.max(left.length, right.length); - for (let index = 0; index < limit; index += 1) { - const leftPart = left[index]; - const rightPart = right[index]; - if (leftPart === undefined) return -1; - if (rightPart === undefined) return 1; - if (leftPart === rightPart) continue; - - const leftNumber = /^\d+$/.test(leftPart) ? Number(leftPart) : null; - const rightNumber = /^\d+$/.test(rightPart) ? Number(rightPart) : null; - if (leftNumber !== null && rightNumber !== null) { - return leftNumber === rightNumber ? 0 : leftNumber < rightNumber ? -1 : 1; - } - if (leftNumber !== null) return -1; - if (rightNumber !== null) return 1; - return leftPart.localeCompare(rightPart); - } - - return 0; -} - -export function compareReleaseVersions(left: string, right: string): number { - const leftVersion = parseVersion(left); - const rightVersion = parseVersion(right); - if (!leftVersion || !rightVersion) { - return normalizeReleaseVersion(left).localeCompare(normalizeReleaseVersion(right)); - } - - if (leftVersion.major !== rightVersion.major) return leftVersion.major < rightVersion.major ? -1 : 1; - if (leftVersion.minor !== rightVersion.minor) return leftVersion.minor < rightVersion.minor ? -1 : 1; - if (leftVersion.patch !== rightVersion.patch) return leftVersion.patch < rightVersion.patch ? -1 : 1; - - const leftHasPrerelease = leftVersion.prerelease.length > 0; - const rightHasPrerelease = rightVersion.prerelease.length > 0; - if (!leftHasPrerelease && !rightHasPrerelease) return 0; - if (!leftHasPrerelease) return 1; - if (!rightHasPrerelease) return -1; - return comparePrereleaseIdentifiers(leftVersion.prerelease, rightVersion.prerelease); -} - -export function resolveReleasePlatform(platform: NodeJS.Platform, arch: string): ReleasePlatform { - if ((platform === "linux" || platform === "darwin") && (arch === "x64" || arch === "arm64")) { - return { os: platform, arch }; - } - - throw new Error(`Unsupported platform: ${platform}-${arch}. Link releases support Linux and macOS on x64 and arm64.`); -} - -export function buildReleaseAssetName(tag: string, target: ReleasePlatform): string { - return `${CLI_BINARY_NAME}-${tag}-${target.os}-${target.arch}`; -} - -async function fetchRelease(version: string | undefined, dependencies: CliUpdateDependencies): Promise { - const tag = version ? normalizeReleaseTag(version) : undefined; - const releaseUrl = tag - ? `${GITHUB_API_BASE_URL}/releases/tags/${tag}` - : `${GITHUB_API_BASE_URL}/releases/latest`; - dependencies.out(tag ? `Fetching release metadata for ${tag}...` : "Fetching release metadata..."); - const response = await dependencies.fetchFn(releaseUrl, { - headers: { - accept: "application/vnd.github+json", - "user-agent": GITHUB_USER_AGENT, - "x-github-api-version": GITHUB_API_VERSION, - }, - }); - - if (response.status === 404 && tag) throw new Error(`Release not found: ${tag}`); - if (!response.ok) throw new Error(`Failed to load release metadata: GitHub returned ${String(response.status)}.`); - - let rawBody: unknown; - try { - rawBody = await response.json(); - } catch (error) { - throw new Error(`Failed to parse release metadata: ${String(error)}`); - } - - return ReleaseSchema.parse(rawBody); -} - -function resolveReleaseAsset(release: GitHubRelease, target: ReleasePlatform): ReleaseAsset { - const assetName = buildReleaseAssetName(release.tag_name, target); - const checksumAssetName = `${assetName}.sha256`; - const asset = release.assets.find(entry => entry.name === assetName); - if (!asset) throw new Error(`Release ${release.tag_name} does not include asset ${assetName}.`); - const checksumAsset = release.assets.find(entry => entry.name === checksumAssetName); - - return { - version: normalizeReleaseVersion(release.tag_name), - assetName, - downloadUrl: asset.browser_download_url, - checksumAssetName, - checksumDownloadUrl: checksumAsset?.browser_download_url, - }; -} - -async function resolveInstalledBinaryPath(dependencies: CliUpdateDependencies): Promise { - const executablePath = dependencies.getExecutablePath(); - const executableName = basename(executablePath); - if (executableName === "bun" || executableName.startsWith("bun-")) { - throw new Error("link-cli update only works from an installed Link CLI binary. Use install.sh when running from source."); - } - const resolvedPath = await dependencies.resolveRealPath(executablePath); - if (!(await dependencies.fileExists(resolvedPath))) { - throw new Error(`Installed binary does not exist: ${resolvedPath}`); - } - return resolvedPath; -} - -function formatCheckMessage(currentVersion: string, targetVersion: string): string { - const comparison = compareReleaseVersions(currentVersion, targetVersion); - if (comparison === 0) return `${CLI_BINARY_NAME} ${currentVersion} is up to date.`; - if (comparison > 0) { - return `${CLI_BINARY_NAME} ${currentVersion} is newer than the latest published release ${targetVersion}.`; - } - return `Update available: ${currentVersion} -> ${targetVersion}`; -} - -function toPermissionMessage(path: string, error: unknown): Error { - const code = typeof error === "object" && error && "code" in error ? String(error.code) : undefined; - if (code === "EACCES" || code === "EPERM") { - return new Error( - `Cannot update ${path}: permission denied. Re-run with permission to modify the installed binary or use the installer script.`, - ); - } - if (code === "EBUSY" || code === "ETXTBSY") { - return new Error(`Cannot update ${path}: the binary is currently in use. Stop any running Link process and try again.`); - } - return new Error(`Failed to update ${path}: ${String(error)}`); -} - -function parseExpectedSha256(checksumText: string, assetName: string): string { - const plainHashes: string[] = []; - - for (const rawLine of checksumText.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line) continue; - const [hash, ...fileParts] = line.split(/\s+/); - if (!hash || !/^[0-9a-fA-F]{64}$/.test(hash)) continue; - - const normalizedHash = hash.toLowerCase(); - const fileName = fileParts.join(" ").replace(/^\*/, ""); - if (fileName) { - if (basename(fileName) === assetName) return normalizedHash; - continue; - } - plainHashes.push(normalizedHash); - } - - if (plainHashes.length === 1) { - const onlyHash = plainHashes[0]; - if (onlyHash) return onlyHash; - } - throw new Error(`Checksum for ${assetName} did not contain a valid SHA-256 entry.`); -} - -async function verifyReleaseAssetChecksum( - asset: ReleaseAsset, - payload: Uint8Array, - dependencies: CliUpdateDependencies, -): Promise { - if (!asset.checksumDownloadUrl) throw new Error(`Release asset ${asset.checksumAssetName} is required to verify ${asset.assetName}.`); - - dependencies.out(`Downloading ${asset.checksumAssetName}...`); - const response = await dependencies.fetchFn(asset.checksumDownloadUrl); - if (!response.ok) throw new Error(`Failed to download ${asset.checksumAssetName}: GitHub returned ${String(response.status)}.`); - - const expected = parseExpectedSha256(await response.text(), asset.assetName); - const actual = createHash("sha256").update(payload).digest("hex"); - if (actual !== expected) { - throw new Error(`Checksum verification failed for ${asset.assetName}: expected ${expected}, got ${actual}.`); - } - dependencies.out(`Verified checksum for ${asset.assetName}.`); -} - -async function replaceInstalledBinary(asset: ReleaseAsset, dependencies: CliUpdateDependencies): Promise { - const targetPath = await resolveInstalledBinaryPath(dependencies); - let tempDirectory: string | undefined; - let tempPath: string | undefined; - let tempCreated = false; - - try { - tempDirectory = await dependencies.createTempDirectory(dirname(targetPath)); - tempPath = join(tempDirectory, asset.assetName); - dependencies.out(`Downloading ${asset.assetName}...`); - const response = await dependencies.fetchFn(asset.downloadUrl); - if (!response.ok) throw new Error(`Failed to download ${asset.assetName}: GitHub returned ${String(response.status)}.`); - - const payload = new Uint8Array(await response.arrayBuffer()); - await verifyReleaseAssetChecksum(asset, payload, dependencies); - await dependencies.writeBinary(tempPath, payload); - tempCreated = true; - - const installedBinaryStat = await dependencies.statFile(targetPath); - const executableMode = installedBinaryStat.mode & 0o777; - await dependencies.chmodFile(tempPath, executableMode || 0o755); - dependencies.out(`Replacing ${targetPath}...`); - await dependencies.renameFile(tempPath, targetPath); - return targetPath; - } catch (error) { - throw toPermissionMessage(targetPath, error); - } finally { - if (tempCreated && tempPath) await dependencies.removeFile(tempPath); - if (tempDirectory) await dependencies.removeFile(tempDirectory); - } -} +import { + runUpdateCommand as runInstallerUpdateCommand, + type UpdateCommandOptions, + type UpdaterConfig, + type UpdaterDependencies, +} from "@pablozaiden/installer"; +import { LINK_VERSION } from "../version"; + +export type { UpdateCommandOptions }; + +export const LINK_UPDATER_CONFIG = { + repository: "pablozaiden/link", + binaryName: "link-cli", + currentVersion: LINK_VERSION, + productName: "Link", + checksum: { required: true }, +} satisfies UpdaterConfig; export async function runUpdateCommand( command: UpdateCommandOptions, - dependencyOverrides: Partial = {}, + dependencyOverrides: Partial = {}, ): Promise { - const dependencies = { - ...createDefaultUpdateDependencies(), - ...dependencyOverrides, - }; - const currentVersion = normalizeReleaseVersion(dependencies.currentVersion); - const release = await fetchRelease(command.version, dependencies); - const runtimePlatform = dependencies.getPlatform(); - const releasePlatform = resolveReleasePlatform(runtimePlatform.platform, runtimePlatform.arch); - const releaseAsset = resolveReleaseAsset(release, releasePlatform); - - if (command.checkOnly) { - dependencies.out(formatCheckMessage(currentVersion, releaseAsset.version)); - return 0; - } - - if (!command.version && compareReleaseVersions(currentVersion, releaseAsset.version) >= 0) { - dependencies.out(formatCheckMessage(currentVersion, releaseAsset.version)); - return 0; - } - - if (command.version && compareReleaseVersions(currentVersion, releaseAsset.version) === 0) { - dependencies.out(`${CLI_BINARY_NAME} ${currentVersion} is already installed.`); - return 0; - } - - const installedPath = await replaceInstalledBinary(releaseAsset, dependencies); - if (command.version) { - dependencies.out(`Installed ${CLI_BINARY_NAME} ${releaseAsset.version} at ${installedPath}.`); - } else { - dependencies.out(`Updated ${CLI_BINARY_NAME} ${currentVersion} -> ${releaseAsset.version} at ${installedPath}.`); - } - - return 0; + return await runInstallerUpdateCommand(command, LINK_UPDATER_CONFIG, dependencyOverrides); }