mirror of
https://github.com/schpet/linear-cli.git
synced 2026-09-14 14:26:50 +08:00
b8a7e54854
Move API key storage from plaintext TOML to OS-native keyrings (macOS
Keychain, Linux `libsecret`, Windows Credential Manager). The
credentials file retains only workspace metadata. Keys are loaded into
an in-memory cache at startup so all downstream reads remain synchronous
— no changes needed to any command files.
## Changes
### Keyring (`src/keyring/`)
- Platform-detecting wrapper with `getPassword`, `setPassword`,
`deletePassword` exports
- macOS: `/usr/bin/security` (exit 44 = not found)
- Linux: `secret-tool` via stdin for writes (exit 1 = not found)
- Windows: `Deno.dlopen("advapi32.dll")` FFI calling
`CredReadW`/`CredWriteW`/`CredDeleteW` directly
- `_setBackend()` test seam for injecting an in-memory `Map` backend
### Windows Credential Manager via FFI
The Windows backend calls `advapi32.dll` directly via Deno's FFI
(`Deno.dlopen`) rather than shelling out to PowerShell. This matches the
standard approach taken by every comparable credential tool:
-
[`danieljoos/wincred`](https://github.com/danieljoos/wincred/blob/623325312d3224d48d131159187b93e906216563/sys.go)
— Go library calling `advapi32.dll` via `windows.NewLazySystemDLL`, used
by:
-
[`docker-credential-helpers`](https://github.com/docker/docker-credential-helpers/blob/2b4e08bca3dbdb8e6c6e28790042742d0c0fc48f/wincred/wincred.go)
- [`gh`
CLI](https://github.com/cli/cli/blob/2c54a0d36a2f3c9c1f1b869a64120837c3a1e6f5/internal/keyring/keyring.go)
(via
[`zalando/go-keyring`](https://github.com/zalando/go-keyring/blob/5c6f7e0ba54d20daa8ea4e03f7ce0a27c075bfb6/keyring_windows.go))
-
[`aws-vault`](https://github.com/99designs/aws-vault/blob/70522e8f0b8f9c5b4e2e4e1e1e1cc4e3e5c3f04c/go.mod)
(via `99designs/keyring`)
-
[`node-keytar`](https://github.com/atom/node-keytar/blob/deae59a488789f2cd4a8dba6c7e58665795804fe/src/keytar_win.cc)
— C++ N-API addon, `#include <wincred.h>`
-
[`jaraco/keyring`](https://github.com/jaraco/keyring/blob/38c040133559682902f25fe96496756ee6849820/keyring/backends/Windows.py)
— Python, `win32cred` (pywin32-ctypes wrapping advapi32 via ctypes)
The implementation packs the 80-byte `CREDENTIALW` struct manually via
`DataView`, encodes strings as UTF-16LE for the `W`-suffix APIs, and
uses `GetLastError` from `kernel32.dll` to distinguish "not found"
(`ERROR_NOT_FOUND` = 1168) from real failures. DLLs are lazy-loaded so
the module import doesn't fail on macOS/Linux.
### Credentials (`src/credentials.ts`)
- `Credentials` interface changed from index signature to `{ default?:
string; workspaces: string[] }`
- `apiKeyCache` `Map` populated at startup, keeping
`getCredentialApiKey()` sync
- `addCredential`/`removeCredential` write to keyring first, only mutate
local state on success
- `parseInlineCredentials` / `parseKeyringCredentials` /
`populateKeyringCache` extracted from `loadCredentials`
- Parallel keyring lookups via `Promise.all`
- Malformed TOML parse errors caught with recovery guidance
- Warnings for: missing keyring entries, dangling default workspace,
inline format detected
### Backward Compatibility
- Inline-format TOML files (keys stored as `workspace = "lin_api_..."`)
are detected by `hasInlineKeys` and served from the file directly
- `addCredential` on an inline-format installation rewrites the file to
keyring format
### Auth List (`src/commands/auth/auth-list.ts`)
- Replaces removed `getAllCredentials()` with `getApiKeyForWorkspace()`
- Distinguishes auth errors (401/403) from network/other failures
instead of labeling everything "invalid credentials"
### CI
- Added `keyring-integration` job on `macos-latest` and `windows-latest`
for real credential round-trip testing
## Testing
- Subprocess isolation via `deno eval` for credential tests (required by
top-level `await loadCredentials()`)
- Mock keyring backend injected via `_setBackend` — covers happy paths,
error propagation, and cache consistency
- Integration test (`test/keyring.integration.test.ts`) exercises the
real macOS Keychain and Windows Credential Manager lifecycle
- Edge cases covered: keyring write/delete failures leave state
unchanged, null keyring returns warn but don't crash, dangling default
dropped on load, inline→keyring format transition on `addCredential`
## References
Closes #130
---------
Co-authored-by: Peter Schilling <code@schpet.com>
111 lines
3.5 KiB
TypeScript
111 lines
3.5 KiB
TypeScript
import { assertEquals } from "@std/assert"
|
|
import { fromFileUrl } from "@std/path"
|
|
|
|
const keyringUrl = new URL("../src/keyring/index.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
|
|
|
|
const MOCK_BACKEND = `
|
|
const _store = new Map();
|
|
_setBackend({
|
|
get(account) { return Promise.resolve(_store.get(account) ?? null) },
|
|
set(account, password) { _store.set(account, password); return Promise.resolve() },
|
|
delete(account) { _store.delete(account); return Promise.resolve() },
|
|
});
|
|
`.trim()
|
|
|
|
function mockAndImport(imports: string): string {
|
|
return `import { ${imports}, _setBackend } from "${keyringUrl}";\n${MOCK_BACKEND}`
|
|
}
|
|
|
|
async function runWithKeyring(code: string): Promise<string> {
|
|
const command = new Deno.Command("deno", {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
code,
|
|
],
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
|
|
const { stdout, stderr } = await command.output()
|
|
const output = new TextDecoder().decode(stdout).trim()
|
|
const errorOutput = new TextDecoder().decode(stderr)
|
|
|
|
if (errorOutput && !errorOutput.startsWith("Check file:")) {
|
|
console.error("Subprocess stderr:", errorOutput)
|
|
}
|
|
|
|
return output
|
|
}
|
|
|
|
Deno.test("keyring - getPassword returns null when not set", async () => {
|
|
const code = `
|
|
${mockAndImport("getPassword")}
|
|
const result = await getPassword("missing");
|
|
console.log(result === null ? "null" : result);
|
|
`
|
|
const output = await runWithKeyring(code)
|
|
assertEquals(output, "null")
|
|
})
|
|
|
|
Deno.test("keyring - setPassword and getPassword round-trip", async () => {
|
|
const code = `
|
|
${mockAndImport("getPassword, setPassword")}
|
|
await setPassword("my-account", "secret123");
|
|
const result = await getPassword("my-account");
|
|
console.log(result);
|
|
`
|
|
const output = await runWithKeyring(code)
|
|
assertEquals(output, "secret123")
|
|
})
|
|
|
|
Deno.test("keyring - deletePassword removes stored password", async () => {
|
|
const code = `
|
|
${mockAndImport("getPassword, setPassword, deletePassword")}
|
|
await setPassword("my-account", "secret123");
|
|
await deletePassword("my-account");
|
|
const result = await getPassword("my-account");
|
|
console.log(result === null ? "null" : result);
|
|
`
|
|
const output = await runWithKeyring(code)
|
|
assertEquals(output, "null")
|
|
})
|
|
|
|
Deno.test("keyring - setPassword overwrites existing value", async () => {
|
|
const code = `
|
|
${mockAndImport("getPassword, setPassword")}
|
|
await setPassword("my-account", "first");
|
|
await setPassword("my-account", "second");
|
|
const result = await getPassword("my-account");
|
|
console.log(result);
|
|
`
|
|
const output = await runWithKeyring(code)
|
|
assertEquals(output, "second")
|
|
})
|
|
|
|
Deno.test("keyring - multiple accounts are independent", async () => {
|
|
const code = `
|
|
${mockAndImport("getPassword, setPassword")}
|
|
await setPassword("account-a", "password-a");
|
|
await setPassword("account-b", "password-b");
|
|
const a = await getPassword("account-a");
|
|
const b = await getPassword("account-b");
|
|
console.log(JSON.stringify({ a, b }));
|
|
`
|
|
const output = await runWithKeyring(code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.a, "password-a")
|
|
assertEquals(result.b, "password-b")
|
|
})
|
|
|
|
Deno.test("keyring - deletePassword on missing account is a no-op", async () => {
|
|
const code = `
|
|
${mockAndImport("deletePassword")}
|
|
await deletePassword("nonexistent");
|
|
console.log("ok");
|
|
`
|
|
const output = await runWithKeyring(code)
|
|
assertEquals(output, "ok")
|
|
})
|