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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Jump to the release that interests you:

| Release | Highlights |
|---------|-----------|
| [0.5.1](#051---2026-09-23) | One macOS Keychain prompt instead of one per account |
| [0.5.0](#050---2026-08-18) | DeepSeek & GitHub Copilot providers, platform 2025.3, progress bar fix, build hardening |
| [0.4.0](#040---2026-07-23) | Redesigned tooltip, session auto-refresh, multi-account support |
| [0.3.1](#031---2026-07-08) | ClinePass usage limits, improved CLI detection |
Expand All @@ -28,6 +29,21 @@ Jump to the release that interests you:

### Removed

## [0.5.1] - 2026-09-23

> **Highlights:** macOS Keychain now asks once for all TokenPulse credentials instead of once per account

### Fixed
- **No more Keychain prompt storm on macOS** — every account's secret used to live in its own
Keychain entry, and macOS ties an "Always Allow" grant to the IDE's code signature. Each IDE
update (EAP builds especially) therefore revoked the grant and re-prompted once *per account* —
ten accounts meant ten password dialogs. All secrets now live in a single PasswordSafe entry
(`TokenPulse — vault`), read once per IDE session and cached in memory, so an IDE update costs
at most one prompt.
- **Automatic migration** — existing per-account entries are moved into the vault on first use
and then deleted. The vault is written before the old entry is removed, so a secret is never
lost mid-migration. macOS may ask for each old entry one last time during this migration.

## [0.5.0] - 2026-08-18

> **Highlights:** New DeepSeek & GitHub Copilot providers • Platform raised to 2025.3 • Build hardening (non-public API & Detekt gates) • Progress bar & UI fixes
Expand Down
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ token-pulse/

All versioning and platform compatibility info is centralized in **`gradle.properties`**:
```properties
pluginVersion = 0.5.0
pluginVersion = 0.5.1
pluginSinceBuild = 253
platformVersion = 2025.3.6
```
Expand Down Expand Up @@ -135,6 +135,6 @@ Code quality is enforced via **Detekt**. The build will fail if any issues are f

1. Update version in `gradle.properties`.
2. Add a new entry to `CHANGELOG.md`.
3. Create a git tag: `git tag v0.5.0` and push it.
3. Create a git tag: `git tag v0.5.1` and push it.
4. CI will automatically create a GitHub Release and attach the ZIP artifact.
5. **Manually publish** the signed artifact to the JetBrains Marketplace (see `MARKETPLACE.md`).
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# TokenPulse

[![JetBrains Plugin](https://img.shields.io/badge/JetBrains-Plugin-orange.svg)](https://plugins.jetbrains.com/plugin/30615-tokenpulse)
[![Version](https://img.shields.io/badge/version-0.5.0-blue.svg)](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/releases)
[![Version](https://img.shields.io/badge/version-0.5.1-blue.svg)](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/releases)
[![CI](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

> ⚠️ **Beta Release** — This is an early release (v0.5.0). Features may change and some functionality may be incomplete. Please [report issues](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/issues) on GitHub.
> ⚠️ **Beta Release** — This is an early release (v0.5.1). Features may change and some functionality may be incomplete. Please [report issues](https://github.com/DimazzzZ/tokenpulse-intellij-plugin/issues) on GitHub.

**TokenPulse** is an IntelliJ IDEA plugin that aggregates token balances and credit usage across multiple AI providers directly in your IDE status bar.

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ kotlin.compiler.execution.strategy=in-process
# Plugin metadata
pluginGroup = org.zhavoronkov.tokenpulse
pluginName = TokenPulse
pluginVersion = 0.5.0
pluginVersion = 0.5.1
pluginRepositoryUrl = https://github.com/DimazzzZ/tokenpulse-intellij-plugin

# Compatibility
Expand Down
Original file line number Diff line number Diff line change
@@ -1,43 +1,110 @@
package org.zhavoronkov.tokenpulse.settings

import com.intellij.credentialStore.CredentialAttributes
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.intellij.credentialStore.generateServiceName
import com.intellij.ide.passwordSafe.PasswordSafe
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import org.zhavoronkov.tokenpulse.utils.TokenPulseLogger

/**
* Raw key/value secret storage. One [get] of a key = one OS keychain access,
* which on macOS may show an access prompt.
*/
internal interface SecretBackend {
fun get(key: String): String?
fun set(key: String, value: String?)
}

/** [SecretBackend] on top of IntelliJ's PasswordSafe (macOS Keychain, KeePass, ...). */
private class PasswordSafeBackend : SecretBackend {
override fun get(key: String): String? = PasswordSafe.instance.getPassword(attributes(key))

override fun set(key: String, value: String?) = PasswordSafe.instance.setPassword(attributes(key), value)

// Constructed via the Java shim so the emitted bytecode binds to the plain
// CredentialAttributes(String, String) JVM constructor. A Kotlin-side call would
// route through the default-args synthetic ctor that 2026.1 (build 261) marks
// @Deprecated(ERROR); see CredentialAttributesFactory for details.
private fun attributes(key: String) =
CredentialAttributesFactory.create(generateServiceName("TokenPulse", key), key)
}

/**
* Secure credential storage for API keys and OAuth tokens.
*
* Uses IntelliJ's PasswordSafe for secure storage.
* PasswordSafe handles threading internally.
* All secrets are kept in ONE PasswordSafe entry (the vault, a JSON map
* accountId -> secret) that is read once per IDE session and cached in memory.
* macOS ties a Keychain "Always Allow" grant to the IDE's code signature, so
* every IDE update re-prompts once per entry; a single entry means a single prompt.
*
* Older versions stored one entry per account. Such a legacy entry is migrated
* into the vault on first access and then deleted.
*/
@Service(Service.Level.APP)
class CredentialsStore {
class CredentialsStore internal constructor(private val backend: SecretBackend) {
constructor() : this(PasswordSafeBackend())

companion object {
internal const val VAULT_KEY = "vault"

fun getInstance(): CredentialsStore = service()
}

fun saveApiKey(accountId: String, apiKey: String) {
val attributes = createAttributes(accountId)
PasswordSafe.instance.setPassword(attributes, apiKey)
private val gson = Gson()
private val lock = Any()
private var vault: MutableMap<String, String>? = null

/** Account ids whose legacy per-account entry was already checked/removed this session. */
private val legacyChecked = mutableSetOf<String>()

fun saveApiKey(accountId: String, apiKey: String) = synchronized(lock) {
loadedVault()[accountId] = apiKey
persist()
dropLegacy(accountId)
}

fun getApiKey(accountId: String): String? = synchronized(lock) {
loadedVault()[accountId] ?: migrateLegacy(accountId)
}

fun removeApiKey(accountId: String) = synchronized(lock) {
if (loadedVault().remove(accountId) != null) persist()
dropLegacy(accountId)
}

private fun loadedVault(): MutableMap<String, String> =
vault ?: parseVault(backend.get(VAULT_KEY)).also { vault = it }

private fun parseVault(raw: String?): MutableMap<String, String> {
if (raw.isNullOrEmpty()) return mutableMapOf()
return try {
gson.fromJson(raw, JsonObject::class.java).entrySet()
.filter { it.value.isJsonPrimitive }
.associateTo(mutableMapOf()) { it.key to it.value.asString }
} catch (e: Exception) {
TokenPulseLogger.Settings.warn("Credential vault is unreadable, starting empty", e)
mutableMapOf()
}
}

fun getApiKey(accountId: String): String? {
val attributes = createAttributes(accountId)
return PasswordSafe.instance.getPassword(attributes)
private fun persist() {
backend.set(VAULT_KEY, gson.toJson(loadedVault()))
}

fun removeApiKey(accountId: String) {
val attributes = createAttributes(accountId)
PasswordSafe.instance.setPassword(attributes, null)
private fun migrateLegacy(accountId: String): String? {
if (!legacyChecked.add(accountId)) return null
val legacy = backend.get(accountId) ?: return null
// Write the vault first so a failure between the two steps never loses the secret.
loadedVault()[accountId] = legacy
persist()
backend.set(accountId, null)
TokenPulseLogger.Settings.info("Migrated credential for account $accountId into the vault")
return legacy
}

private fun createAttributes(accountId: String): CredentialAttributes {
// Constructed via the Java shim so the emitted bytecode binds to the plain
// CredentialAttributes(String, String) JVM constructor. A Kotlin-side call would
// route through the default-args synthetic ctor that 2026.1 (build 261) marks
// @Deprecated(ERROR); see CredentialAttributesFactory for details.
return CredentialAttributesFactory.create(generateServiceName("TokenPulse", accountId), accountId)
private fun dropLegacy(accountId: String) {
if (legacyChecked.add(accountId)) backend.set(accountId, null)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ class WhatsNewNotificationActivity : ProjectActivity {
"TokenPulse Updated to v$version",
"""
<b>Thank you for using TokenPulse!</b><br/><br/>
<b>What&rsquo;s new in v$version:</b><br/>
<b>Fixed in v$version:</b><br/>
• <b>One Keychain prompt instead of one per account</b> &mdash; on macOS, all TokenPulse
credentials now share a single Keychain entry, so IDE updates no longer trigger a
password dialog for every account (existing keys are migrated automatically)<br/><br/>
<b>Recently added in v0.5.0:</b><br/>
• <b>DeepSeek provider</b> &mdash; track your DeepSeek API usage and quota<br/>
• <b>GitHub Copilot provider</b> &mdash; personal and organization budget tracking<br/>
• <b>Platform raised to IntelliJ 2025.3</b> &mdash; required for forward compatibility<br/>
Expand Down
14 changes: 14 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@
]]></description>

<change-notes><![CDATA[
<b>0.5.1</b> — One macOS Keychain prompt instead of one per account (2026-09-23)<br>
<ul>
<li><b>Keychain fix:</b> all credentials now share a single Keychain entry, so IDE updates no longer re-prompt once for every account</li>
<li><b>Automatic migration:</b> existing per-account entries are moved into the shared entry on first use</li>
</ul>
<br>
<b>0.5.0</b> — New providers and platform 2025.3 (2026-08-18)<br>
<ul>
<li><b>DeepSeek provider:</b> track your DeepSeek API usage and quota</li>
<li><b>GitHub Copilot provider:</b> personal and organization budget tracking</li>
<li><b>Minimum platform raised to IntelliJ 2025.3</b> (build 253)</li>
<li><b>UI fix:</b> Add/Edit Account dialog hint no longer floods the log or gets stuck tall</li>
</ul>
<br>
<b>0.4.0</b> — Session robustness, unified Xiaomi, and a richer tooltip (2026-07-23)<br>
<ul>
<li><b>Nebius session auto-refresh:</b> silently re-mints the CSRF token when it rotates, so a still-valid session keeps working without reconnecting</li>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package org.zhavoronkov.tokenpulse.settings

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

/**
* Tests for [CredentialsStore]: all secrets live in ONE backend entry (the vault),
* so macOS Keychain asks for access once per IDE build instead of once per account.
*/
class CredentialsStoreTest {

/** In-memory backend that counts reads per key (each read = potential Keychain prompt). */
private class FakeBackend(initial: Map<String, String> = emptyMap()) : SecretBackend {
val entries = initial.toMutableMap()
val reads = mutableMapOf<String, Int>()

override fun get(key: String): String? {
reads.merge(key, 1, Int::plus)
return entries[key]
}

override fun set(key: String, value: String?) {
if (value == null) entries.remove(key) else entries[key] = value
}
}

@Test
fun `saved keys are stored in a single vault entry`() {
val backend = FakeBackend()
val store = CredentialsStore(backend)

store.saveApiKey("a1", "key-1")
store.saveApiKey("a2", "key-2")

assertEquals(setOf(CredentialsStore.VAULT_KEY), backend.entries.keys)
}

@Test
fun `keys round-trip through a fresh store instance`() {
val backend = FakeBackend()
CredentialsStore(backend).apply {
saveApiKey("a1", "key-1")
saveApiKey("a2", "{\"pat\":\"x\",\"org\":\"y\"}")
}

val reopened = CredentialsStore(backend)

assertEquals("key-1", reopened.getApiKey("a1"))
assertEquals("{\"pat\":\"x\",\"org\":\"y\"}", reopened.getApiKey("a2"))
}

@Test
fun `vault is read from the backend only once for many accounts`() {
val backend = FakeBackend()
CredentialsStore(backend).apply { (1..10).forEach { saveApiKey("a$it", "key-$it") } }
backend.reads.clear()

val store = CredentialsStore(backend)
repeat(3) { (1..10).forEach { i -> assertEquals("key-$i", store.getApiKey("a$i")) } }

assertEquals(1, backend.reads[CredentialsStore.VAULT_KEY])
assertEquals(setOf(CredentialsStore.VAULT_KEY), backend.reads.keys)
}

@Test
fun `remove deletes the key from the vault`() {
val backend = FakeBackend()
val store = CredentialsStore(backend)
store.saveApiKey("a1", "key-1")
store.saveApiKey("a2", "key-2")

store.removeApiKey("a1")

assertNull(store.getApiKey("a1"))
assertNull(CredentialsStore(backend).getApiKey("a1"))
assertEquals("key-2", CredentialsStore(backend).getApiKey("a2"))
}

@Test
fun `legacy per-account entry is migrated into the vault and deleted`() {
val backend = FakeBackend(mapOf("a1" to "legacy-key"))
val store = CredentialsStore(backend)

assertEquals("legacy-key", store.getApiKey("a1"))

assertFalse(backend.entries.containsKey("a1"))
assertEquals("legacy-key", CredentialsStore(backend).getApiKey("a1"))
}

@Test
fun `missing legacy entry is looked up only once per session`() {
val backend = FakeBackend()
val store = CredentialsStore(backend)

repeat(5) { assertNull(store.getApiKey("ghost")) }

assertEquals(1, backend.reads["ghost"])
}

@Test
fun `migrated account is not looked up in legacy storage again`() {
val backend = FakeBackend(mapOf("a1" to "legacy-key"))
CredentialsStore(backend).getApiKey("a1")
backend.reads.clear()

CredentialsStore(backend).getApiKey("a1")

assertNull(backend.reads["a1"])
}

@Test
fun `saving over a legacy entry removes the legacy entry`() {
val backend = FakeBackend(mapOf("a1" to "old"))
val store = CredentialsStore(backend)

store.saveApiKey("a1", "new")

assertFalse(backend.entries.containsKey("a1"))
assertEquals("new", CredentialsStore(backend).getApiKey("a1"))
}

@Test
fun `removing an account also removes its legacy entry`() {
val backend = FakeBackend(mapOf("a1" to "old"))

CredentialsStore(backend).removeApiKey("a1")

assertTrue(backend.entries.isEmpty() || backend.entries.keys == setOf(CredentialsStore.VAULT_KEY))
assertNull(CredentialsStore(backend).getApiKey("a1"))
}

@Test
fun `corrupt vault is treated as empty and legacy entries still resolve`() {
val backend = FakeBackend(mapOf(CredentialsStore.VAULT_KEY to "not json", "a1" to "legacy-key"))

assertEquals("legacy-key", CredentialsStore(backend).getApiKey("a1"))
}
}
Loading