Skip to content
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,57 @@ tuist --help
swiftlint --help
```

### Authenticating with private repositories & GitHub Enterprise Server

If a tool's `url` points at a private repository — on `github.com` or a GitHub Enterprise
Server instance — set an environment variable holding a personal access token before running
`luca install`:

- `github.com` reads `LUCA_GITHUB_TOKEN`.
- Any other host reads `LUCA_GITHUB_TOKEN_<HOST>`, where `<HOST>` is the hostname uppercased
with every non-alphanumeric character replaced by `_`. For example, a Lucafile entry
pointing at `https://ghe.my-company.com/iOS/ModuleCreator/releases/download/2.5.0/ModuleCreator-macOS.zip`
is authenticated by setting `LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM`.

```bash
export LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM=ghp_xxxxxxxxxxxx
luca install
```

The token is only ever sent to the host it was configured for. Note that `github.com` asset
downloads never carry this header, even if `LUCA_GITHUB_TOKEN` is set — GitHub redirects those
downloads to a separate signed-URL storage host, so the token is unused there today; it is
still read and applied to the `api.github.com` release-metadata lookup used by
`luca install org/repo@version`.

#### GitHub Enterprise Server behind an SSO gateway (e.g. Okta)

If your GHE instance sits behind a browser-SSO gateway, the plain release-download URL
(`.../releases/download/{version}/{asset}`) may be intercepted before it reaches GHE and served
back as an HTML login page — even with `LUCA_GITHUB_TOKEN_<HOST>` set — because that URL is
treated as web traffic requiring an interactive session, not a token. The `/api/v3/...` paths are
typically exempt from that gate, since they're designed for token-based access. Point `url` at the
release's **API asset URL** instead of the browser download URL:

```bash
curl -H "Authorization: Bearer $LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM" \
https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/tags/2.5.0
```

Find the matching asset's `url` field in the response (not `browser_download_url`) — it looks like
`https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/assets/12345` — and use that in
your Lucafile:

```yaml
tools:
- name: ModuleCreator
version: 2.5.0
url: https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/assets/12345
```

Luca always sends `Accept: application/octet-stream` on the download request, which the API asset
endpoint requires to return the raw binary instead of JSON metadata.

### Uninstalling tools

Uninstall a specific tool version:
Expand Down
51 changes: 51 additions & 0 deletions Sources/LucaCLI/LucaCLI.docc/Lucafile.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,57 @@ Use checksums for critical tools to verify integrity and protect against tamperi

Add `.luca/tools/` to your `.gitignore` file. Luca can manage this automatically with the `--install-git-hook` flag.

## Authenticating with Private Repositories & GitHub Enterprise Server

If a tool's `url` points at a private repository — on `github.com` or a GitHub Enterprise
Server instance — set an environment variable holding a personal access token before running
`luca install`:

- `github.com` reads `LUCA_GITHUB_TOKEN`.
- Any other host reads `LUCA_GITHUB_TOKEN_<HOST>`, where `<HOST>` is the hostname uppercased
with every non-alphanumeric character replaced by `_`. For example, a Lucafile entry
pointing at `https://ghe.my-company.com/iOS/ModuleCreator/releases/download/2.5.0/ModuleCreator-macOS.zip`
is authenticated by setting `LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM`.

```bash
export LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM=ghp_xxxxxxxxxxxx
luca install
```

The token is only ever sent to the host it was configured for. Note that `github.com` asset
downloads never carry this header, even if `LUCA_GITHUB_TOKEN` is set — GitHub redirects those
downloads to a separate signed-URL storage host, so the token is unused there today; it is
still read and applied to the `api.github.com` release-metadata lookup used by
`luca install org/repo@version`.

### GitHub Enterprise Server Behind an SSO Gateway (e.g. Okta)

If your GHE instance sits behind a browser-SSO gateway, the plain release-download URL
(`.../releases/download/{version}/{asset}`) may be intercepted before it reaches GHE and served
back as an HTML login page — even with `LUCA_GITHUB_TOKEN_<HOST>` set — because that URL is
treated as web traffic requiring an interactive session, not a token. The `/api/v3/...` paths are
typically exempt from that gate, since they're designed for token-based access. Point `url` at the
release's **API asset URL** instead of the browser download URL:

```bash
curl -H "Authorization: Bearer $LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM" \
https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/tags/2.5.0
```

Find the matching asset's `url` field in the response (not `browser_download_url`) — it looks like
`https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/assets/12345` — and use that in
your Lucafile:

```yaml
tools:
- name: ModuleCreator
version: 2.5.0
url: https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/assets/12345
```

Luca always sends `Accept: application/octet-stream` on the download request, which the API asset
endpoint requires to return the raw binary instead of JSON metadata.

## Skills

The optional `skills:` key installs agentic skills from Git repositories.
Expand Down
22 changes: 20 additions & 2 deletions Sources/ManagerCore/Core/Downloader/Downloader.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
// Downloader.swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

/// Downloads release archives and executables from remote URLs.
///
/// The `Downloader` handles fetching tool releases from remote servers,
/// supporting archive formats (zip, tar.gz) and standalone executables.
/// When the target host is a GitHub Enterprise Server instance (any host other than
/// `github.com`) and a token is configured for it, the request carries an
/// `Authorization: Bearer` header so private-repository release assets can be downloaded.
/// `github.com` never receives this header: its release-asset downloads redirect to a
/// separate presigned-URL storage host, and forwarding a token there would leak it.
///
/// ## Topics
///
Expand All @@ -14,17 +22,27 @@ import Foundation
struct Downloader: Downloading {

private var fileDownloader: FileDownloading
private var tokenResolver: GitHubTokenResolving

init(fileDownloader: FileDownloading) {
init(fileDownloader: FileDownloading, tokenResolver: GitHubTokenResolving = GitHubTokenResolver()) {
self.fileDownloader = fileDownloader
self.tokenResolver = tokenResolver
}

/// Downloads a release from the specified URL.
///
/// - Parameter url: The URL to download from.
/// - Returns: A URL to the downloaded file in a temporary location.
func downloadRelease(at url: URL) async throws -> URL {
let (tempDownloadURL, _) = try await fileDownloader.download(from: url)
var request = URLRequest(url: url)
// Requesting the raw asset bytes explicitly is required by GitHub's release-asset API
// endpoint (".../releases/assets/{id}"), which otherwise returns JSON metadata. Harmless
// for plain download URLs, which ignore Accept and return the file regardless.
request.setValue("application/octet-stream", forHTTPHeaderField: "Accept")
if let host = url.host, host != "github.com", let token = tokenResolver.token(forHost: host) {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (tempDownloadURL, _) = try await fileDownloader.download(for: request)
return tempDownloadURL
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// GitHubTokenResolver.swift

import Foundation

/// Reads a per-host GitHub personal access token from environment variables.
///
/// `github.com` reads `LUCA_GITHUB_TOKEN`. Any other host — a GitHub Enterprise Server
/// instance — reads `LUCA_GITHUB_TOKEN_<HOST>`, where `<HOST>` is the hostname uppercased
/// with every non-alphanumeric character replaced by `_` (e.g. `ghe.my-company.com` becomes
/// `LUCA_GITHUB_TOKEN_GHE_MY_COMPANY_COM`).
struct GitHubTokenResolver: GitHubTokenResolving {

private let environment: [String: String]

init(environment: [String: String] = ProcessInfo.processInfo.environment) {
self.environment = environment
}

// MARK: - GitHubTokenResolving

func token(forHost host: String) -> String? {
let variableName = host == "github.com"
? "LUCA_GITHUB_TOKEN"
: "LUCA_GITHUB_TOKEN_\(Self.sanitize(host))"
let value = environment[variableName]
return (value?.isEmpty ?? true) ? nil : value
}

// MARK: - Private

private static func sanitize(_ host: String) -> String {
String(host.uppercased().map { $0.isLetter || $0.isNumber ? $0 : "_" })
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// GitHubTokenResolving.swift

import Foundation

/// Resolves a GitHub authentication token for a given host.
protocol GitHubTokenResolving {
/// Returns the token configured for `host`, or `nil` if none is set.
/// - Parameter host: The hostname of the GitHub instance (e.g. `"github.com"` or a
/// GitHub Enterprise Server hostname such as `"ghe.my-company.com"`).
func token(forHost host: String) -> String?
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,28 +36,35 @@ struct ReleaseInfoProvider: ReleaseInfoProviding {
}

private var dataDownloader: DataDownloading

init(dataDownloader: DataDownloading) {
private var tokenResolver: GitHubTokenResolving

init(dataDownloader: DataDownloading, tokenResolver: GitHubTokenResolving = GitHubTokenResolver()) {
self.dataDownloader = dataDownloader
self.tokenResolver = tokenResolver
}

// MARK: - Internal

/// Returns the release asset whose name best matches the current platform keywords.
func platformAsset(for release: Release) async throws -> ReleaseAsset {
let releaseInfo = try await fetchReleaseInfo(release: release)
return try findPlatformAsset(in: releaseInfo.assets)
}

// MARK: - Private

private func fetchReleaseInfo(release: Release) async throws -> ReleaseInfo {
let releaseUrl = try GitHubReleaseURLFactory().makeApiReleaseURL(release: release)

var request = URLRequest(url: releaseUrl)
request.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept")
request.setValue("luca.tools.cli", forHTTPHeaderField: "User-Agent")

// GitHubReleaseURLFactory only ever builds github.com API URLs today, so the token
// to attach is always the one configured for "github.com".
if let token = tokenResolver.token(forHost: "github.com") {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}

let (data, response) = try await dataDownloader.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
Expand Down
2 changes: 1 addition & 1 deletion Sources/ManagerCore/Core/SelfUpdater/SelfUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ public struct SelfUpdater: SelfUpdating {

printer.printFormatted("\(.raw("🔄 Updating Luca \(currentVersion) → \(targetVersion)..."))")

let (tempZipURL, _) = try await fileDownloader.download(from: downloadURL(for: targetVersion))
let (tempZipURL, _) = try await fileDownloader.download(for: URLRequest(url: downloadURL(for: targetVersion)))

let tempExtractDir = FileManager.default.temporaryDirectory
.appendingPathComponent("luca-update-\(UUID().uuidString)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct FileDownloader: FileDownloading {
self.session = session
}

func download(from url: URL) async throws -> (URL, URLResponse) {
try await session.download(from: url)
func download(for request: URLRequest) async throws -> (URL, URLResponse) {
try await session.download(for: request)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import Foundation
import FoundationNetworking
#endif

/// Downloads a file from a URL and writes it to a temporary location on disk.
/// Downloads a file and writes it to a temporary location on disk.
protocol FileDownloading {
/// Downloads the resource at `url` and saves it to a temporary file.
/// - Parameter url: The remote URL of the file to download.
/// Performs `request` and saves the response body to a temporary file.
/// - Parameter request: The request to perform, with any headers (e.g. `Authorization`) already set.
/// - Returns: A tuple of the temporary file URL and the URL response.
func download(from url: URL) async throws -> (URL, URLResponse)
func download(for request: URLRequest) async throws -> (URL, URLResponse)
}
55 changes: 55 additions & 0 deletions Tests/Core/DownloaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,59 @@ struct DownloaderTests {
_ = try await sut.downloadRelease(at: url)
}
}

@Test
func test_downloadRelease_attachesTokenForEnterpriseHost() async throws {
let tempURL = FileManager.default.temporaryDirectory.appending(component: "tool.zip")
let fileDownloader = FileDownloadingMock(result: .success(tempURL))
let tokenResolver = GitHubTokenResolvingMock(tokensByHost: ["ghe.my-company.com": "ghe-token"])
let sut = Downloader(fileDownloader: fileDownloader, tokenResolver: tokenResolver)

let url = try #require(URL(string: "https://ghe.my-company.com/iOS/ModuleCreator/releases/download/2.5.0/ModuleCreator-macOS.zip"))
_ = try await sut.downloadRelease(at: url)

#expect(fileDownloader.lastRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer ghe-token")
}

@Test
func test_downloadRelease_omitsAuthorizationForGitHubDotCom() async throws {
let tempURL = FileManager.default.temporaryDirectory.appending(component: "tool.zip")
let fileDownloader = FileDownloadingMock(result: .success(tempURL))
// Even if a token happens to be configured for github.com, it must never be attached
// to the asset-download request: github.com redirects release-asset downloads to
// objects.githubusercontent.com with a presigned URL, and forwarding our token there
// would leak it to a third-party host.
let tokenResolver = GitHubTokenResolvingMock(tokensByHost: ["github.com": "dotcom-token"])
let sut = Downloader(fileDownloader: fileDownloader, tokenResolver: tokenResolver)

let url = try #require(URL(string: "https://github.com/realm/SwiftLint/releases/download/0.61.0/SwiftLintBinary.artifactbundle.zip"))
_ = try await sut.downloadRelease(at: url)

#expect(fileDownloader.lastRequest?.value(forHTTPHeaderField: "Authorization") == nil)
}

@Test
func test_downloadRelease_omitsAuthorizationWhenNoTokenConfigured() async throws {
let tempURL = FileManager.default.temporaryDirectory.appending(component: "tool.zip")
let fileDownloader = FileDownloadingMock(result: .success(tempURL))
let tokenResolver = GitHubTokenResolvingMock(tokensByHost: [:])
let sut = Downloader(fileDownloader: fileDownloader, tokenResolver: tokenResolver)

let url = try #require(URL(string: "https://ghe.my-company.com/iOS/ModuleCreator/releases/download/2.5.0/ModuleCreator-macOS.zip"))
_ = try await sut.downloadRelease(at: url)

#expect(fileDownloader.lastRequest?.value(forHTTPHeaderField: "Authorization") == nil)
}

@Test
func test_downloadRelease_setsOctetStreamAcceptHeader() async throws {
let tempURL = FileManager.default.temporaryDirectory.appending(component: "tool.zip")
let fileDownloader = FileDownloadingMock(result: .success(tempURL))
let sut = Downloader(fileDownloader: fileDownloader, tokenResolver: GitHubTokenResolvingMock(tokensByHost: [:]))

let url = try #require(URL(string: "https://ghe.my-company.com/api/v3/repos/iOS/ModuleCreator/releases/assets/12345"))
_ = try await sut.downloadRelease(at: url)

#expect(fileDownloader.lastRequest?.value(forHTTPHeaderField: "Accept") == "application/octet-stream")
}
}
24 changes: 22 additions & 2 deletions Tests/Core/FileDownloaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ struct FileDownloaderTests {
let sut = FileDownloader(session: URLSession(configuration: config))

let url = try #require(URL(string: "https://example.com/tool.zip"))
let (downloadedURL, response) = try await sut.download(from: url)
let (downloadedURL, response) = try await sut.download(for: URLRequest(url: url))

#expect(downloadedURL.isFileURL)
let httpResponse = try #require(response as? HTTPURLResponse)
Expand All @@ -42,9 +42,29 @@ struct FileDownloaderTests {

let url = try #require(URL(string: "https://example.com/tool.zip"))
await #expect(throws: (any Error).self) {
_ = try await sut.download(from: url)
_ = try await sut.download(for: URLRequest(url: url))
}
}

@Test
func test_download_forwardsRequestHeaders() async throws {
var capturedRequest: URLRequest?
MockFileURLProtocol.requestHandler = { request in
capturedRequest = request
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, Data("file contents".utf8))
}
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockFileURLProtocol.self]
let sut = FileDownloader(session: URLSession(configuration: config))

let url = try #require(URL(string: "https://example.com/tool.zip"))
var request = URLRequest(url: url)
request.setValue("Bearer test-token-value", forHTTPHeaderField: "Authorization")
_ = try await sut.download(for: request)

#expect(capturedRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer test-token-value")
}
}

// MARK: - Private Mocks
Expand Down
Loading