diff --git a/README.md b/README.md index aa7b50b..9f17b72 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,14 @@ Set up or update your Kernel API key and Cronometer credentials. If credentials crono login ``` +**Headless / container deployments:** all credentials can be provided via environment variables instead of `crono login`. Env vars take precedence over stored credentials. + +| Variable | Credential | +| --------------------------- | ------------------- | +| `KERNEL_API_KEY` | Kernel.sh API key | +| `CRONO_CRONOMETER_USERNAME` | Cronometer email | +| `CRONO_CRONOMETER_PASSWORD` | Cronometer password | + ### `crono quick-add` Add a quick macro entry to your Cronometer diary. diff --git a/src/credentials.ts b/src/credentials.ts index 2cf0fe2..cfcad64 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -23,6 +23,17 @@ export type CredentialKey = const SERVICE_NAME = "crono"; +/** + * Environment variables that override stored credentials. + * Useful for headless/container deployments where `crono login` + * cannot be run interactively. + */ +const ENV_VARS: Record = { + "kernel-api-key": "KERNEL_API_KEY", + "cronometer-username": "CRONO_CRONOMETER_USERNAME", + "cronometer-password": "CRONO_CRONOMETER_PASSWORD", +}; + let configDirOverride: string | null = null; function getConfigDir(): string { @@ -174,6 +185,8 @@ function fileDelete(key: string): void { // -- Public API -- export function getCredential(key: CredentialKey): string | null { + const envValue = process.env[ENV_VARS[key]]; + if (envValue) return envValue; return getBackend() === "keyring" ? keyringGet(key) : fileGet(key); } diff --git a/src/cronometer/export.ts b/src/cronometer/export.ts index 55de890..f156eb8 100644 --- a/src/cronometer/export.ts +++ b/src/cronometer/export.ts @@ -109,7 +109,10 @@ export async function exportData( const password = getCredential("cronometer-password"); if (!username || !password) { - throw new Error("No Cronometer credentials found. Run: crono login"); + throw new Error( + "No Cronometer credentials found. Run `crono login`, or set:\n" + + " CRONO_CRONOMETER_USERNAME and CRONO_CRONOMETER_PASSWORD" + ); } // Resolve GWT overrides: env vars > config > defaults diff --git a/src/kernel/client.ts b/src/kernel/client.ts index 739960c..ab50890 100644 --- a/src/kernel/client.ts +++ b/src/kernel/client.ts @@ -52,8 +52,7 @@ export type { * Each operation creates a fresh browser and logs in. */ export async function getKernelClient(): Promise { - const apiKey = - process.env["KERNEL_API_KEY"] ?? getCredential("kernel-api-key"); + const apiKey = getCredential("kernel-api-key"); if (!apiKey) { throw new Error( "Kernel API key not found.\n" + diff --git a/tests/credentials.test.ts b/tests/credentials.test.ts index f87fbc5..ca7e685 100644 --- a/tests/credentials.test.ts +++ b/tests/credentials.test.ts @@ -107,3 +107,54 @@ describe("credentials (encrypted file backend)", () => { }); }); }); + +describe("credentials (environment variable overrides)", () => { + const ENV_KEYS = [ + "KERNEL_API_KEY", + "CRONO_CRONOMETER_USERNAME", + "CRONO_CRONOMETER_PASSWORD", + ]; + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + _resetBackend(); + _setBackend("file"); + _setConfigDir(testDir); + for (const key of ENV_KEYS) delete process.env[key]; + }); + + afterEach(() => { + for (const key of ENV_KEYS) delete process.env[key]; + _setConfigDir(null); + _resetBackend(); + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // cleanup best-effort + } + }); + + it("should resolve credentials from env vars without stored values", () => { + process.env.KERNEL_API_KEY = "env-key"; + process.env.CRONO_CRONOMETER_USERNAME = "env@example.com"; + process.env.CRONO_CRONOMETER_PASSWORD = "env-password"; + + expect(getCredential("kernel-api-key")).toBe("env-key"); + expect(getCredential("cronometer-username")).toBe("env@example.com"); + expect(getCredential("cronometer-password")).toBe("env-password"); + expect(hasCredentials()).toBe(true); + }); + + it("should prefer env vars over stored credentials", () => { + setCredential("cronometer-username", "stored@example.com"); + process.env.CRONO_CRONOMETER_USERNAME = "env@example.com"; + + expect(getCredential("cronometer-username")).toBe("env@example.com"); + }); + + it("should fall back to stored credentials when env var is unset", () => { + setCredential("cronometer-username", "stored@example.com"); + + expect(getCredential("cronometer-username")).toBe("stored@example.com"); + }); +});