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>
1043 lines
32 KiB
TypeScript
1043 lines
32 KiB
TypeScript
import { assertEquals } from "@std/assert"
|
|
import { fromFileUrl } from "@std/path"
|
|
|
|
// Testing the credentials module requires running subprocesses because
|
|
// credentials are loaded at module initialization via top-level await.
|
|
|
|
const credentialsUrl = new URL("../src/credentials.ts", import.meta.url)
|
|
const keyringUrl = new URL("../src/keyring/index.ts", import.meta.url)
|
|
const denoJsonPath = fromFileUrl(new URL("../deno.json", import.meta.url))
|
|
// Pass DENO_DIR so subprocesses reuse the cached dependency graph
|
|
// instead of re-downloading and compiling on every test run.
|
|
const denoDir = Deno.env.get("DENO_DIR") ??
|
|
(Deno.build.os === "darwin"
|
|
? `${Deno.env.get("HOME")}/Library/Caches/deno`
|
|
: `${Deno.env.get("HOME")}/.cache/deno`)
|
|
|
|
function mockBackendAndImport(imports: string): string {
|
|
return `
|
|
import { _setBackend } from "${keyringUrl}";
|
|
const _store = new Map<string, string>();
|
|
_setBackend({
|
|
async get(account: string) { return _store.get(account) ?? null },
|
|
async set(account: string, password: string) { _store.set(account, password) },
|
|
async delete(account: string) { _store.delete(account) },
|
|
async isAvailable() { return true },
|
|
});
|
|
const { ${imports} } = await import("${credentialsUrl}");
|
|
`
|
|
}
|
|
|
|
async function runWithCredentials(
|
|
tempDir: string,
|
|
code: string,
|
|
): Promise<string> {
|
|
const isWindows = Deno.build.os === "windows"
|
|
// On Unix, set XDG_CONFIG_HOME to tempDir so credentials go to tempDir/linear/.
|
|
// This overrides HOME-based path and ensures isolation in CI.
|
|
const env: Record<string, string> = isWindows
|
|
? { APPDATA: tempDir, SystemRoot: Deno.env.get("SystemRoot") ?? "" }
|
|
: {
|
|
HOME: tempDir,
|
|
XDG_CONFIG_HOME: tempDir,
|
|
DENO_DIR: denoDir,
|
|
PATH: Deno.env.get("PATH") ?? "",
|
|
}
|
|
|
|
const command = new Deno.Command("deno", {
|
|
args: [
|
|
"eval",
|
|
`--config=${denoJsonPath}`,
|
|
code,
|
|
],
|
|
cwd: tempDir,
|
|
env,
|
|
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("credentials - getCredentialsPath returns correct path", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const isWindows = Deno.build.os === "windows"
|
|
// With XDG_CONFIG_HOME set to tempDir, path is tempDir/linear/credentials.toml
|
|
const expectedPath = isWindows
|
|
? `${tempDir}\\linear\\credentials.toml`
|
|
: `${tempDir}/linear/credentials.toml`
|
|
|
|
const code = `
|
|
${mockBackendAndImport("getCredentialsPath")}
|
|
console.log(getCredentialsPath());
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
assertEquals(output, expectedPath)
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - loadCredentials returns empty when no file", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${mockBackendAndImport("loadCredentials")}
|
|
const creds = await loadCredentials();
|
|
console.log(JSON.stringify(creds));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.workspaces, [])
|
|
assertEquals(result.default, undefined)
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - addCredential creates file and sets default", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"addCredential, getCredentialApiKey, getDefaultWorkspace",
|
|
)
|
|
}
|
|
await addCredential("test-workspace", "lin_api_test123");
|
|
console.log(JSON.stringify({
|
|
apiKey: getCredentialApiKey("test-workspace"),
|
|
default: getDefaultWorkspace()
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
|
|
assertEquals(result.default, "test-workspace")
|
|
assertEquals(result.apiKey, "lin_api_test123")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - addCredential preserves existing default", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${mockBackendAndImport("addCredential, getDefaultWorkspace")}
|
|
await addCredential("first-workspace", "lin_api_first");
|
|
await addCredential("second-workspace", "lin_api_second");
|
|
console.log(getDefaultWorkspace());
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
assertEquals(output, "first-workspace")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - TOML file does not contain API keys after addCredential", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${mockBackendAndImport("addCredential, getCredentialsPath")}
|
|
await addCredential("my-workspace", "lin_api_secret");
|
|
const toml = await Deno.readTextFile(getCredentialsPath()!);
|
|
console.log(toml);
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
assertEquals(output.includes("lin_api_secret"), false)
|
|
assertEquals(output.includes("my-workspace"), true)
|
|
assertEquals(output.includes("workspaces"), true)
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - removeCredential deletes workspace", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${mockBackendAndImport("addCredential, removeCredential, getWorkspaces")}
|
|
await addCredential("workspace-a", "lin_api_a");
|
|
await addCredential("workspace-b", "lin_api_b");
|
|
await removeCredential("workspace-a");
|
|
console.log(JSON.stringify(getWorkspaces()));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const workspaces = JSON.parse(output)
|
|
|
|
assertEquals(workspaces, ["workspace-b"])
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - removeCredential reassigns default", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"addCredential, removeCredential, getDefaultWorkspace",
|
|
)
|
|
}
|
|
await addCredential("workspace-a", "lin_api_a");
|
|
await addCredential("workspace-b", "lin_api_b");
|
|
await removeCredential("workspace-a");
|
|
console.log(getDefaultWorkspace());
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
assertEquals(output, "workspace-b")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - removeCredential cleans up cache", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"addCredential, removeCredential, getCredentialApiKey",
|
|
)
|
|
}
|
|
await addCredential("workspace-a", "lin_api_a");
|
|
await removeCredential("workspace-a");
|
|
console.log(getCredentialApiKey("workspace-a") ?? "undefined");
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
assertEquals(output, "undefined")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - setDefaultWorkspace changes default", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"addCredential, setDefaultWorkspace, getDefaultWorkspace",
|
|
)
|
|
}
|
|
await addCredential("workspace-a", "lin_api_a");
|
|
await addCredential("workspace-b", "lin_api_b");
|
|
await setDefaultWorkspace("workspace-b");
|
|
console.log(getDefaultWorkspace());
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
assertEquals(output, "workspace-b")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - getCredentialApiKey returns key for workspace", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${mockBackendAndImport("addCredential, getCredentialApiKey")}
|
|
await addCredential("my-workspace", "lin_api_mykey");
|
|
console.log(getCredentialApiKey("my-workspace"));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
assertEquals(output, "lin_api_mykey")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - getCredentialApiKey returns default when no workspace specified", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${mockBackendAndImport("addCredential, getCredentialApiKey")}
|
|
await addCredential("default-workspace", "lin_api_default");
|
|
console.log(getCredentialApiKey());
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
assertEquals(output, "lin_api_default")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - getCredentialApiKey returns undefined for unknown workspace", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${mockBackendAndImport("addCredential, getCredentialApiKey")}
|
|
await addCredential("known-workspace", "lin_api_known");
|
|
console.log(getCredentialApiKey("unknown-workspace") ?? "undefined");
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
assertEquals(output, "undefined")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - getCredentialApiKey reads from cache", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${mockBackendAndImport("addCredential, getCredentialApiKey")}
|
|
await addCredential("ws", "lin_api_cached");
|
|
console.log(getCredentialApiKey("ws"));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
assertEquals(output, "lin_api_cached")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - hasWorkspace returns correct boolean", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${mockBackendAndImport("addCredential, hasWorkspace")}
|
|
await addCredential("exists", "lin_api_exists");
|
|
console.log(JSON.stringify({
|
|
exists: hasWorkspace("exists"),
|
|
notExists: hasWorkspace("not-exists")
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
|
|
assertEquals(result.exists, true)
|
|
assertEquals(result.notExists, false)
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - old format TOML backward compatibility", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "preexisting"\npreexisting = "lin_api_preexisting"\n`,
|
|
)
|
|
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"getDefaultWorkspace, getWorkspaces, getCredentialApiKey",
|
|
)
|
|
}
|
|
console.log(JSON.stringify({
|
|
default: getDefaultWorkspace(),
|
|
workspaces: getWorkspaces(),
|
|
apiKey: getCredentialApiKey("preexisting"),
|
|
credApiKey: getCredentialApiKey(),
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
|
|
assertEquals(result.default, "preexisting")
|
|
assertEquals(result.workspaces, ["preexisting"])
|
|
assertEquals(result.apiKey, "lin_api_preexisting")
|
|
assertEquals(result.credApiKey, "lin_api_preexisting")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - old format with multiple workspaces", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "ws-a"\nws-a = "lin_api_a"\nws-b = "lin_api_b"\n`,
|
|
)
|
|
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"getDefaultWorkspace, getWorkspaces, getCredentialApiKey",
|
|
)
|
|
}
|
|
console.log(JSON.stringify({
|
|
default: getDefaultWorkspace(),
|
|
workspaces: getWorkspaces().sort(),
|
|
apiKeyA: getCredentialApiKey("ws-a"),
|
|
apiKeyB: getCredentialApiKey("ws-b"),
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
|
|
assertEquals(result.default, "ws-a")
|
|
assertEquals(result.workspaces, ["ws-a", "ws-b"])
|
|
assertEquals(result.apiKeyA, "lin_api_a")
|
|
assertEquals(result.apiKeyB, "lin_api_b")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - setDefaultWorkspace throws for unknown workspace", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${mockBackendAndImport("addCredential, setDefaultWorkspace")}
|
|
await addCredential("workspace-a", "lin_api_a");
|
|
try {
|
|
await setDefaultWorkspace("nonexistent");
|
|
console.log("no-error");
|
|
} catch (e) {
|
|
console.log("error:" + e.message);
|
|
}
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
assertEquals(output.startsWith("error:"), true)
|
|
assertEquals(output.includes("nonexistent"), true)
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - addCredential throws when keyring write fails", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
import { _setBackend } from "${keyringUrl}";
|
|
_setBackend({
|
|
async get(_account: string) { return null },
|
|
async set(_account: string, _password: string) { throw new Error("keyring locked") },
|
|
async delete(_account: string) {},
|
|
async isAvailable() { return true },
|
|
});
|
|
const { addCredential, getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}");
|
|
try {
|
|
await addCredential("ws", "lin_api_key");
|
|
console.log("no-error");
|
|
} catch (e) {
|
|
console.log(JSON.stringify({
|
|
error: e.message,
|
|
workspaces: getWorkspaces(),
|
|
cached: getCredentialApiKey("ws") ?? "undefined",
|
|
}));
|
|
}
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.error.includes("keyring locked"), true)
|
|
assertEquals(result.workspaces, [])
|
|
assertEquals(result.cached, "undefined")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - loadCredentials warns but continues when keyring fails for one workspace", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "ws-ok"\nworkspaces = ["ws-ok", "ws-fail"]\n`,
|
|
)
|
|
|
|
const code = `
|
|
import { _setBackend } from "${keyringUrl}";
|
|
_setBackend({
|
|
async get(account: string) {
|
|
if (account === "ws-fail") throw new Error("keyring error");
|
|
return "lin_api_ok";
|
|
},
|
|
async set(_a: string, _p: string) {},
|
|
async delete(_a: string) {},
|
|
async isAvailable() { return true },
|
|
});
|
|
const { getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}");
|
|
console.log(JSON.stringify({
|
|
workspaces: getWorkspaces(),
|
|
okKey: getCredentialApiKey("ws-ok"),
|
|
failKey: getCredentialApiKey("ws-fail") ?? "undefined",
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.workspaces, ["ws-ok", "ws-fail"])
|
|
assertEquals(result.okKey, "lin_api_ok")
|
|
assertEquals(result.failKey, "undefined")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - removeCredential throws when keyring delete fails", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
import { _setBackend } from "${keyringUrl}";
|
|
const _store = new Map<string, string>();
|
|
_setBackend({
|
|
async get(account: string) { return _store.get(account) ?? null },
|
|
async set(account: string, password: string) { _store.set(account, password) },
|
|
async delete(_account: string) { throw new Error("keyring locked") },
|
|
async isAvailable() { return true },
|
|
});
|
|
const { addCredential, removeCredential, getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}");
|
|
await addCredential("ws", "lin_api_key");
|
|
try {
|
|
await removeCredential("ws");
|
|
console.log("no-error");
|
|
} catch (e) {
|
|
console.log(JSON.stringify({
|
|
error: e.message,
|
|
workspaces: getWorkspaces(),
|
|
cached: getCredentialApiKey("ws") ?? "undefined",
|
|
}));
|
|
}
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.error.includes("keyring locked"), true)
|
|
assertEquals(result.workspaces, ["ws"])
|
|
assertEquals(result.cached, "lin_api_key")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - loadCredentials warns when keyring returns null for workspace", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "ws-a"\nworkspaces = ["ws-a", "ws-missing"]\n`,
|
|
)
|
|
|
|
const code = `
|
|
import { _setBackend } from "${keyringUrl}";
|
|
_setBackend({
|
|
async get(account: string) {
|
|
if (account === "ws-missing") return null;
|
|
return "lin_api_a";
|
|
},
|
|
async set(_a: string, _p: string) {},
|
|
async delete(_a: string) {},
|
|
async isAvailable() { return true },
|
|
});
|
|
const { getWorkspaces, getCredentialApiKey } = await import("${credentialsUrl}");
|
|
console.log(JSON.stringify({
|
|
workspaces: getWorkspaces(),
|
|
aKey: getCredentialApiKey("ws-a"),
|
|
missingKey: getCredentialApiKey("ws-missing") ?? "undefined",
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.workspaces, ["ws-a", "ws-missing"])
|
|
assertEquals(result.aKey, "lin_api_a")
|
|
assertEquals(result.missingKey, "undefined")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - addCredential on inline-format file preserves inline format", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "old-ws"\nold-ws = "lin_api_old"\n`,
|
|
)
|
|
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"addCredential, getCredentialsPath, getWorkspaces, getCredentialApiKey",
|
|
)
|
|
}
|
|
await addCredential("new-ws", "lin_api_new");
|
|
const toml = await Deno.readTextFile(getCredentialsPath()!);
|
|
console.log(JSON.stringify({
|
|
workspaces: getWorkspaces(),
|
|
hasWorkspacesKey: toml.includes("workspaces"),
|
|
hasInlineKey: toml.includes("lin_api"),
|
|
oldKeyPreserved: toml.includes("lin_api_old"),
|
|
newKeyPresent: toml.includes("lin_api_new"),
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.hasWorkspacesKey, false)
|
|
assertEquals(result.hasInlineKey, true)
|
|
assertEquals(result.oldKeyPreserved, true)
|
|
assertEquals(result.newKeyPresent, true)
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - dangling default is dropped on load", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "ghost"\nworkspaces = ["real"]\n`,
|
|
)
|
|
|
|
const code = `
|
|
import { _setBackend } from "${keyringUrl}";
|
|
_setBackend({
|
|
async get(_account: string) { return "lin_api_real" },
|
|
async set(_a: string, _p: string) {},
|
|
async delete(_a: string) {},
|
|
async isAvailable() { return true },
|
|
});
|
|
const { getDefaultWorkspace, getWorkspaces } = await import("${credentialsUrl}");
|
|
console.log(JSON.stringify({
|
|
default: getDefaultWorkspace() ?? "undefined",
|
|
workspaces: getWorkspaces(),
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.default, "undefined")
|
|
assertEquals(result.workspaces, ["real"])
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - loading inline credentials does not print warning to stderr", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "my-ws"\nmy-ws = "lin_api_key"\n`,
|
|
)
|
|
|
|
const isWindows = Deno.build.os === "windows"
|
|
const env: Record<string, string> = isWindows
|
|
? { APPDATA: tempDir, SystemRoot: Deno.env.get("SystemRoot") ?? "" }
|
|
: {
|
|
HOME: tempDir,
|
|
XDG_CONFIG_HOME: tempDir,
|
|
DENO_DIR: denoDir,
|
|
PATH: Deno.env.get("PATH") ?? "",
|
|
}
|
|
|
|
const code = `
|
|
import { _setBackend } from "${keyringUrl}";
|
|
_setBackend({
|
|
async get(_account: string) { return null },
|
|
async set(_account: string, _password: string) {},
|
|
async delete(_account: string) {},
|
|
async isAvailable() { return true },
|
|
});
|
|
const { getCredentialApiKey } = await import("${credentialsUrl}");
|
|
console.log(getCredentialApiKey("my-ws"));
|
|
`
|
|
|
|
const command = new Deno.Command("deno", {
|
|
args: ["eval", `--config=${denoJsonPath}`, code],
|
|
cwd: tempDir,
|
|
env,
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
})
|
|
|
|
const { stdout, stderr } = await command.output()
|
|
const output = new TextDecoder().decode(stdout).trim()
|
|
const errorOutput = new TextDecoder().decode(stderr)
|
|
|
|
assertEquals(output, "lin_api_key")
|
|
assertEquals(errorOutput.includes("Warning"), false)
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - addCredential with plaintext writes key to TOML file", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"addCredential, getCredentialsPath, getCredentialApiKey",
|
|
)
|
|
}
|
|
await addCredential("my-ws", "lin_api_plain", { plaintext: true });
|
|
const toml = await Deno.readTextFile(getCredentialsPath()!);
|
|
console.log(JSON.stringify({
|
|
apiKey: getCredentialApiKey("my-ws"),
|
|
hasInlineKey: toml.includes("lin_api_plain"),
|
|
hasWorkspacesArray: toml.includes("workspaces"),
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
|
|
assertEquals(result.apiKey, "lin_api_plain")
|
|
assertEquals(result.hasInlineKey, true)
|
|
assertEquals(result.hasWorkspacesArray, false)
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - addCredential without plaintext uses keyring", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${mockBackendAndImport("addCredential, getCredentialsPath")}
|
|
await addCredential("my-ws", "lin_api_secret");
|
|
const toml = await Deno.readTextFile(getCredentialsPath()!);
|
|
console.log(JSON.stringify({
|
|
hasInlineKey: toml.includes("lin_api_secret"),
|
|
hasWorkspacesArray: toml.includes("workspaces"),
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
|
|
assertEquals(result.hasInlineKey, false)
|
|
assertEquals(result.hasWorkspacesArray, true)
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - migrateToKeyring moves inline keys to keyring", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "ws-a"\nws-a = "lin_api_a"\nws-b = "lin_api_b"\n`,
|
|
)
|
|
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"migrateToKeyring, isUsingInlineFormat, getCredentialsPath, getCredentialApiKey",
|
|
)
|
|
}
|
|
const wasinline = isUsingInlineFormat();
|
|
const migrated = await migrateToKeyring();
|
|
const toml = await Deno.readTextFile(getCredentialsPath()!);
|
|
console.log(JSON.stringify({
|
|
wasinline,
|
|
isInlineAfter: isUsingInlineFormat(),
|
|
migrated: migrated.sort(),
|
|
hasWorkspacesArray: toml.includes("workspaces"),
|
|
hasInlineKey: toml.includes("lin_api"),
|
|
keyA: getCredentialApiKey("ws-a"),
|
|
keyB: getCredentialApiKey("ws-b"),
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
|
|
assertEquals(result.wasinline, true)
|
|
assertEquals(result.isInlineAfter, false)
|
|
assertEquals(result.migrated, ["ws-a", "ws-b"])
|
|
assertEquals(result.hasWorkspacesArray, true)
|
|
assertEquals(result.hasInlineKey, false)
|
|
assertEquals(result.keyA, "lin_api_a")
|
|
assertEquals(result.keyB, "lin_api_b")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - removeCredential on inline-format file preserves inline format", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "ws-a"\nws-a = "lin_api_a"\nws-b = "lin_api_b"\n`,
|
|
)
|
|
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"removeCredential, getCredentialsPath, getWorkspaces, getCredentialApiKey, isUsingInlineFormat",
|
|
)
|
|
}
|
|
await removeCredential("ws-a");
|
|
const toml = await Deno.readTextFile(getCredentialsPath()!);
|
|
console.log(JSON.stringify({
|
|
workspaces: getWorkspaces(),
|
|
isInline: isUsingInlineFormat(),
|
|
hasWorkspacesArray: toml.includes("workspaces"),
|
|
hasInlineKeyB: toml.includes("lin_api_b"),
|
|
hasInlineKeyA: toml.includes("lin_api_a"),
|
|
keyB: getCredentialApiKey("ws-b"),
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.workspaces, ["ws-b"])
|
|
assertEquals(result.isInline, true)
|
|
assertEquals(result.hasWorkspacesArray, false)
|
|
assertEquals(result.hasInlineKeyB, true)
|
|
assertEquals(result.hasInlineKeyA, false)
|
|
assertEquals(result.keyB, "lin_api_b")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - setDefaultWorkspace on inline-format file preserves inline format", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "ws-a"\nws-a = "lin_api_a"\nws-b = "lin_api_b"\n`,
|
|
)
|
|
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"setDefaultWorkspace, getCredentialsPath, getDefaultWorkspace, getCredentialApiKey, isUsingInlineFormat",
|
|
)
|
|
}
|
|
await setDefaultWorkspace("ws-b");
|
|
const toml = await Deno.readTextFile(getCredentialsPath()!);
|
|
console.log(JSON.stringify({
|
|
default: getDefaultWorkspace(),
|
|
isInline: isUsingInlineFormat(),
|
|
hasWorkspacesArray: toml.includes("workspaces"),
|
|
hasInlineKeyA: toml.includes("lin_api_a"),
|
|
hasInlineKeyB: toml.includes("lin_api_b"),
|
|
keyA: getCredentialApiKey("ws-a"),
|
|
keyB: getCredentialApiKey("ws-b"),
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.default, "ws-b")
|
|
assertEquals(result.isInline, true)
|
|
assertEquals(result.hasWorkspacesArray, false)
|
|
assertEquals(result.hasInlineKeyA, true)
|
|
assertEquals(result.hasInlineKeyB, true)
|
|
assertEquals(result.keyA, "lin_api_a")
|
|
assertEquals(result.keyB, "lin_api_b")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - addCredential with plaintext false on inline file migrates all keys to keyring", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "ws-a"\nws-a = "lin_api_a"\n`,
|
|
)
|
|
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"addCredential, getCredentialsPath, getCredentialApiKey, isUsingInlineFormat",
|
|
)
|
|
}
|
|
await addCredential("ws-b", "lin_api_b", { plaintext: false });
|
|
const toml = await Deno.readTextFile(getCredentialsPath()!);
|
|
console.log(JSON.stringify({
|
|
isInline: isUsingInlineFormat(),
|
|
hasWorkspacesArray: toml.includes("workspaces"),
|
|
hasInlineKeyA: toml.includes("lin_api_a"),
|
|
hasInlineKeyB: toml.includes("lin_api_b"),
|
|
keyA: getCredentialApiKey("ws-a"),
|
|
keyB: getCredentialApiKey("ws-b"),
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.isInline, false)
|
|
assertEquals(result.hasWorkspacesArray, true)
|
|
assertEquals(result.hasInlineKeyA, false)
|
|
assertEquals(result.hasInlineKeyB, false)
|
|
assertEquals(result.keyA, "lin_api_a")
|
|
assertEquals(result.keyB, "lin_api_b")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - migrateToKeyring rolls back on partial failure", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const configDir = `${tempDir}/linear`
|
|
await Deno.mkdir(configDir, { recursive: true })
|
|
await Deno.writeTextFile(
|
|
`${configDir}/credentials.toml`,
|
|
`default = "ws-a"\nws-a = "lin_api_a"\nws-b = "lin_api_b"\n`,
|
|
)
|
|
|
|
const code = `
|
|
import { _setBackend } from "${keyringUrl}";
|
|
const _store = new Map<string, string>();
|
|
_setBackend({
|
|
async get(account: string) { return _store.get(account) ?? null },
|
|
async set(account: string, password: string) {
|
|
if (account === "ws-b") throw new Error("keyring locked");
|
|
_store.set(account, password);
|
|
},
|
|
async delete(account: string) { _store.delete(account) },
|
|
async isAvailable() { return true },
|
|
});
|
|
const { migrateToKeyring, isUsingInlineFormat, getCredentialsPath, getCredentialApiKey } = await import("${credentialsUrl}");
|
|
let error = "";
|
|
try {
|
|
await migrateToKeyring();
|
|
} catch (e) {
|
|
error = e.message;
|
|
}
|
|
const toml = await Deno.readTextFile(getCredentialsPath()!);
|
|
console.log(JSON.stringify({
|
|
error,
|
|
isInline: isUsingInlineFormat(),
|
|
hasInlineKeyA: toml.includes("lin_api_a"),
|
|
hasInlineKeyB: toml.includes("lin_api_b"),
|
|
hasWorkspacesArray: toml.includes("workspaces"),
|
|
keyA: getCredentialApiKey("ws-a"),
|
|
keyB: getCredentialApiKey("ws-b"),
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
assertEquals(result.error.includes("keyring locked"), true)
|
|
assertEquals(result.error.includes("Rolled back"), true)
|
|
assertEquals(result.isInline, true)
|
|
assertEquals(result.hasInlineKeyA, true)
|
|
assertEquals(result.hasInlineKeyB, true)
|
|
assertEquals(result.hasWorkspacesArray, false)
|
|
assertEquals(result.keyA, "lin_api_a")
|
|
assertEquals(result.keyB, "lin_api_b")
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|
|
|
|
Deno.test("credentials - migrateToKeyring is no-op when already using keyring", async () => {
|
|
const tempDir = await Deno.makeTempDir()
|
|
|
|
try {
|
|
const code = `
|
|
${
|
|
mockBackendAndImport(
|
|
"addCredential, migrateToKeyring, isUsingInlineFormat",
|
|
)
|
|
}
|
|
await addCredential("my-ws", "lin_api_key");
|
|
const migrated = await migrateToKeyring();
|
|
console.log(JSON.stringify({
|
|
isInline: isUsingInlineFormat(),
|
|
migrated,
|
|
}));
|
|
`
|
|
|
|
const output = await runWithCredentials(tempDir, code)
|
|
const result = JSON.parse(output)
|
|
|
|
assertEquals(result.isInline, false)
|
|
assertEquals(result.migrated, [])
|
|
} finally {
|
|
await Deno.remove(tempDir, { recursive: true })
|
|
}
|
|
})
|