Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion graphcode/Sources/Clients/NodCredentials.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ enum NodCredential: String, CaseIterable, Sendable {
case anthropicAPIKey = "anthropic-api-key"
/// A Claude subscription token. Only stored when `NodClaudeSignIn.subscriptionLoginAllowed`.
case claudeSubscription = "claude-subscription"
case githubCopilot = "github-copilot"
/// The account NodRuntime reads (`KeychainAccount.githubToken` in credentials.ts).
case githubCopilot = "github-token"

var engine: NodEngine {
switch self {
Expand Down Expand Up @@ -101,6 +102,16 @@ struct NodCredentialStore: Sendable {
}
}

/// The Copilot CLI's own GitHub login, which NodRuntime's Copilot engine falls back to
/// when Nod holds no token. Attributes only, so it raises no Keychain prompt. Never in
/// the test host, whose sign-in assertions must not depend on this Mac's own login.
static func copilotCLISignInFound() -> Bool {
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else {
return false
}
return NodClaudeSignIn.genericPasswordExists(service: "copilot-cli")
}

func signOut(_ engine: NodEngine) throws {
for credential in NodCredential.allCases where credential.engine == engine {
try delete(credential)
Expand Down
12 changes: 11 additions & 1 deletion graphcode/Sources/Features/Settings/Nod/NodSetupModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,27 @@ final class NodSetupModel {
var copilotPhase: CopilotPhase = .idle
private(set) var signedIn: [NodEngine: Bool] = [:]
private(set) var claudeCodeSignInFound = false
/// Signed in only through the Copilot CLI's login, which Nod uses but cannot sign out of.
private(set) var usesCopilotCLISignIn = false

@ObservationIgnored private let credentials: NodCredentialStore
@ObservationIgnored private let deviceFlow: CopilotDeviceFlow
@ObservationIgnored private let readSettings: () -> NodSettings
@ObservationIgnored private let writeSettings: (NodSettings) -> Void
@ObservationIgnored private let openURL: (URL) -> Void
@ObservationIgnored private var copilotTask: Task<Void, Never>?
@ObservationIgnored private let copilotCLISignInFound: () -> Bool

init(
credentials: NodCredentialStore = .live,
deviceFlow: CopilotDeviceFlow = .live,
readSettings: @escaping () -> NodSettings = { SettingsModel.shared.settings.nod },
writeSettings: @escaping (NodSettings) -> Void = { SettingsModel.shared.settings.nod = $0 },
openURL: @escaping (URL) -> Void = { NSWorkspace.shared.open($0) },
claudeCodeSignInFound: () -> Bool = { NodClaudeSignIn.claudeCodeSignInFound() }
claudeCodeSignInFound: () -> Bool = { NodClaudeSignIn.claudeCodeSignInFound() },
copilotCLISignInFound: @escaping () -> Bool = { NodCredentialStore.copilotCLISignInFound() }
) {
self.copilotCLISignInFound = copilotCLISignInFound
self.credentials = credentials
self.deviceFlow = deviceFlow
self.readSettings = readSettings
Expand All @@ -59,6 +64,9 @@ final class NodSetupModel {

func isSignedIn(_ engine: NodEngine) -> Bool { signedIn[engine] ?? false }

/// Whether this build can run GitHub's device flow — it needs GraphCode's OAuth app id.
var canStartDeviceFlow: Bool { deviceFlow.clientID != nil }

func selectEngine(_ engine: NodEngine) {
settings.switchEngine(to: engine)
}
Expand All @@ -67,6 +75,8 @@ final class NodSetupModel {
for engine in NodEngine.allCases {
signedIn[engine] = credentials.isSignedIn(engine)
}
usesCopilotCLISignIn = !isSignedIn(.copilotSDK) && copilotCLISignInFound()
if usesCopilotCLISignIn { signedIn[.copilotSDK] = true }
if isSignedIn(.copilotSDK), copilotPhase == .idle {
copilotPhase = .signedIn(nil)
}
Expand Down
19 changes: 16 additions & 3 deletions graphcode/Sources/Features/Settings/Nod/NodSetupView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,15 @@ struct NodCopilotSignInCard: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
switch model.copilotPhase {
case .idle:
case .idle where model.canStartDeviceFlow:
Button("Sign in to GitHub") { model.startCopilotSignIn() }
.buttonStyle(.borderedProminent)
case .idle:
Text("Sign in with the Copilot CLI: run copilot login in a terminal, then check again.")
.font(.system(size: 12))
.foregroundStyle(.white.opacity(0.75))
.textSelection(.enabled)
Button("Check again") { model.refreshSignIn() }
case .requesting:
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Expand Down Expand Up @@ -242,7 +248,12 @@ struct NodCopilotSignInCard: View {
.foregroundStyle(NodSetupInk.ok)
VStack(alignment: .leading, spacing: 3) {
Text(
[account.map { "Signed in as \($0.login)" } ?? "Signed in to GitHub", account?.planName]
model.usesCopilotCLISignIn
? "Using your Copilot CLI sign-in"
: [
account.map { "Signed in as \($0.login)" } ?? "Signed in to GitHub",
account?.planName,
]
.compactMap { $0 }.joined(separator: " · ")
)
.font(.system(size: 12.5, weight: .medium))
Expand All @@ -253,7 +264,9 @@ struct NodCopilotSignInCard: View {
}
}
Spacer(minLength: 4)
Button("Sign out") { model.signOut(.copilotSDK) }
if !model.usesCopilotCLISignIn {
Button("Sign out") { model.signOut(.copilotSDK) }
}
}
.padding(10)
.background(.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 8))
Expand Down
35 changes: 33 additions & 2 deletions graphcode/Tests/NodSetupTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -468,12 +468,14 @@ import Testing

static func model(
_ box: Box, credentials: NodCredentialStore = .inMemory(),
flow: CopilotDeviceFlow = CopilotDeviceFlow(clientID: nil, transport: { _ in (Data(), 500) })
flow: CopilotDeviceFlow = CopilotDeviceFlow(clientID: nil, transport: { _ in (Data(), 500) }),
copilotCLISignInFound: @escaping () -> Bool = { false }
) -> NodSetupModel {
NodSetupModel(
credentials: credentials, deviceFlow: flow,
readSettings: { box.settings }, writeSettings: { box.settings = $0 },
openURL: { box.opened.append($0) }, claudeCodeSignInFound: { true })
openURL: { box.opened.append($0) }, claudeCodeSignInFound: { true },
copilotCLISignInFound: copilotCLISignInFound)
}

@Test func savingAValidKeySignsClaudeIn() throws {
Expand Down Expand Up @@ -528,6 +530,35 @@ import Testing
#expect(!model.isSignedIn(.copilotSDK))
}

/// NodRuntime's Copilot engine falls back to the Copilot CLI's own login, so a Mac
/// signed in there is signed in for Nod, with nothing of Nod's to sign out of.
@Test func theCopilotCLILoginSignsCopilotIn() {
var found = false
let model = Self.model(Box(), copilotCLISignInFound: { found })
#expect(!model.isSignedIn(.copilotSDK))
#expect(!model.canStartDeviceFlow)

found = true
model.refreshSignIn()

#expect(model.isSignedIn(.copilotSDK))
#expect(model.usesCopilotCLISignIn)
#expect(model.copilotPhase == .signedIn(nil))
}

@Test func nodsOwnTokenWinsOverTheCopilotCLILogin() {
let model = Self.model(
Box(), credentials: .inMemory([.githubCopilot: "gho_x"]), copilotCLISignInFound: { true })
#expect(model.isSignedIn(.copilotSDK))
#expect(!model.usesCopilotCLISignIn)
}

/// The runtime reads `github-token` (NodRuntime/src/credentials.ts); a token written under
/// any other account never reaches the Copilot engine.
@Test func theCopilotTokenIsStoredWhereTheRuntimeReadsIt() {
#expect(NodCredential.githubCopilot.rawValue == "github-token")
}

@Test func anUnconfiguredBuildSaysSo() async {
let model = Self.model(Box())
model.startCopilotSignIn()
Expand Down
Loading