diff --git a/docs/config.md b/docs/config.md index 2062c29..b7ce0a3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -3,7 +3,7 @@ Logicsrc stores user config at: ```text -$HOME/.logicsrc/config.json +$HOME/.config/logicsrc/config.json ``` Read and write values with dot paths: diff --git a/docs/credential-sharing.md b/docs/credential-sharing.md index 4217e33..bc916c1 100644 --- a/docs/credential-sharing.md +++ b/docs/credential-sharing.md @@ -21,7 +21,7 @@ logicsrc credentials inspect --provider env --path .env logicsrc credentials diff --from env --from-path .env --to railway \ --to-project --to-config -# Build a plan (stored under .logicsrc/credentials), then dry-run, then apply +# Build a plan (stored under ~/.config/logicsrc/credentials), then dry-run, then apply logicsrc credentials plan --from env --from-path .env --to doppler \ --to-project --to-config logicsrc credentials sync --plan # dry-run (no writes) @@ -40,8 +40,9 @@ Implementation notes: - `github-secrets` is write-only for values (GitHub never returns secret values), so it cannot be a sync source or a value-restoring rollback target. Secret writes are libsodium sealed-box encrypted against the repo/org/environment public key. -- Rollback captures the target's prior values into a 0600 vault under `.logicsrc/` - (gitignored) — the only place raw values touch disk. Plans, runs, and audit records +- Rollback captures the target's prior values into a 0600 vault under + `~/.config/logicsrc/` — outside any project, so there is nothing to gitignore + and nothing lands in a repo. The only place raw values touch disk. Plans, runs, and audit records contain fingerprints only. Credential Sharing is a LogicSRC OpenSpec for portable, auditable secret synchronization across local files and infrastructure providers. It is intended to replace closed, proprietary credential-sharing workflows with a provider-neutral contract. @@ -195,7 +196,7 @@ relay for secret values**. It stores only: Plaintext secret values and the raw DEK never leave a member's machine. Granting a teammate access = an existing member unwraps the DEK with their private key and re-wraps (seals) it to the new member's public key. The private key lives only in -`~/.logicsrc/identity.json` (mode 0600) and is never uploaded. +`~/.config/logicsrc/identity.json` (mode 0600) and is never uploaded. ### CLI @@ -275,7 +276,7 @@ Safety properties, all enforced rather than documented: It talks to the hosted credentials app by default. Point it elsewhere (local dev, self-hosted) with `LOGICSRC_API=http://localhost:8080 logicsrc login` or `logicsrc login --api-url …`; the chosen origin is remembered in -`~/.logicsrc/identity.json` once login succeeds. +`~/.config/logicsrc/identity.json` once login succeeds. Because `team` is a normal provider, the generic sync surface works too — e.g. `logicsrc credentials plan --from env --from-path .env --to team --to-project acme diff --git a/packages/cli/package.json b/packages/cli/package.json index c9e5d96..9afd75d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@logicsrc/cli", - "version": "0.1.0", + "version": "0.1.1", "description": "LogicSRC OpenSpec CLI.", "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 34a9f8c..dafb282 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import { homedir } from "node:os"; +import { logicsrcHome } from "@logicsrc/plugin-credential-sharing"; export type JsonObject = Record; @@ -21,8 +21,14 @@ export const defaultConfig: JsonObject = { } }; +/** + * The same one directory the identity and the vault use. + * + * Shared rather than re-derived: three copies of "where does logicsrc keep + * things" is how the vault ended up somewhere the config never was. + */ export function configPath() { - return join(homedir(), ".logicsrc", "config.json"); + return join(logicsrcHome(), "config.json"); } export function readConfig() { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index dd5adc6..e9c97b1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { evaluateAccountPolicy, scoreAccountActionRisk } from "@logicsrc/account-core"; import { Command } from "commander"; -import { createCredentialEngine, listCredentialProviders, type CredentialEndpoint } from "@logicsrc/plugin-credential-sharing"; +import { createCredentialEngine, listCredentialProviders, logicsrcHome, type CredentialEndpoint } from "@logicsrc/plugin-credential-sharing"; import { listEmailAccountProviders } from "@logicsrc/plugin-email-accounts"; import { discoverFeeds, listFeedProviders, probeSite, renderDiscoveryOutput, validateFeed, type FeedKind, type FeedOutputFormat } from "@logicsrc/plugin-feed-discovery"; import { listSocialAccountProviders } from "@logicsrc/plugin-social-accounts"; @@ -822,12 +822,14 @@ program process.exitCode = 1; return; } - console.log(`Updated. Install root: ${installHome()} — config preserved at ~/.logicsrc`); + console.log(`Updated. Install root: ${installHome()} — config preserved at ${logicsrcHome()}`); }); program.command("remove").alias("uninstall").option("--purge", "Remove config and auth tokens").description("Remove local LogicSRC CLI.").action((options) => { console.log("Removed LogicSRC CLI."); - console.log(options.purge ? "Removed config and auth tokens from $HOME/.logicsrc." : "Preserved config at $HOME/.logicsrc. Run with --purge to remove config and auth tokens."); + console.log(options.purge + ? `Removed config and auth tokens from ${logicsrcHome()}.` + : `Preserved config at ${logicsrcHome()}. Run with --purge to remove config and auth tokens.`); }); function validateFile(kindArg: string, file: string) { diff --git a/packages/cli/src/teams.ts b/packages/cli/src/teams.ts index f49f2e3..50e917f 100644 --- a/packages/cli/src/teams.ts +++ b/packages/cli/src/teams.ts @@ -12,6 +12,7 @@ import { defaultApiUrl, resolveApiUrl, createCredentialEngine, + identityPath, unwrapVaultKey, wrapVaultKey, type CredentialEndpoint @@ -280,7 +281,7 @@ export async function loginAction(options: { apiUrl?: string; token?: string; de export async function logoutAction(): Promise { await updateIdentity({ apiToken: undefined, email: undefined, userId: undefined }); - console.error("Logged out (local token cleared; revoke the key at /settings). Identity key retained — delete ~/.logicsrc/identity.json to remove it."); + console.error(`Logged out (local token cleared; revoke the key at /settings). Identity key retained — delete ${identityPath()} to remove it.`); } export async function whoamiAction(format: OutputFormat): Promise { diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index ae956f3..7193a35 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -26,7 +26,7 @@ export type UpdateStatus = { latestCommit: string | null; }; -/** Install root the installer uses (not the config dir, which is ~/.logicsrc). */ +/** Install root the installer uses (not the config dir, which is ~/.config/logicsrc). */ export function installHome(env: NodeJS.ProcessEnv = process.env): string { return env.LOGICSRC_HOME || join(env.HOME || homedir(), ".logicsrc-cli"); } diff --git a/plugins/credential-sharing/package.json b/plugins/credential-sharing/package.json index f7db1e3..8124452 100644 --- a/plugins/credential-sharing/package.json +++ b/plugins/credential-sharing/package.json @@ -1,6 +1,6 @@ { "name": "@logicsrc/plugin-credential-sharing", - "version": "0.1.0", + "version": "0.1.1", "description": "LogicSRC Credential Sharing OpenSpec plugin: portable, auditable secret sync across .env, Doppler, Railway, and GitHub Secrets.", "type": "module", "main": "./dist/index.js", diff --git a/plugins/credential-sharing/src/identity.ts b/plugins/credential-sharing/src/identity.ts index dc824a6..e258034 100644 --- a/plugins/credential-sharing/src/identity.ts +++ b/plugins/credential-sharing/src/identity.ts @@ -1,4 +1,4 @@ -import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync, renameSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { generateIdentityKeyPair, publicKeyForSecret, type IdentityKeyPair } from "./crypto.js"; @@ -6,7 +6,7 @@ import { generateIdentityKeyPair, publicKeyForSecret, type IdentityKeyPair } fro /** * Local, machine-bound member identity for team credential sharing. * - * Stored at `$LOGICSRC_HOME/identity.json` (default `~/.logicsrc/identity.json`), + * Stored at `$LOGICSRC_HOME/identity.json` (default `~/.config/logicsrc/identity.json`), * mode 0600 — it holds the member's X25519 SECRET key and the server API token. * The secret key never leaves this file; only the public key is uploaded. */ @@ -25,13 +25,63 @@ export interface LocalIdentity { updatedAt: string; } +/** + * The one logicsrc directory for this user, on this machine. + * + * `$LOGICSRC_HOME`, else `$XDG_CONFIG_HOME/logicsrc`, else + * `~/.config/logicsrc`. Never anything derived from the working directory: + * there is a single identity and a single vault per user, and a path that + * moves when you `cd` gives you one of each per directory you happened to be + * standing in — which is how a machine ends up with a `.logicsrc/` inside + * unrelated git repos, holding a directory called `credentials/vault`. + * + * A previous install kept this at `~/.logicsrc`. That directory holds the + * X25519 secret key, so it is moved rather than abandoned — losing it means + * losing access to every team vault the member was ever given. + */ export function logicsrcHome(): string { if (process.env.LOGICSRC_HOME) { return resolve(process.env.LOGICSRC_HOME); } + const configHome = process.env.XDG_CONFIG_HOME + ? resolve(process.env.XDG_CONFIG_HOME) + : join(homedir(), ".config"); + const home = join(configHome, "logicsrc"); + migrateLegacyHome(home); + return home; +} + +/** Where this lived before the move, kept only to be migrated away from. */ +export function legacyLogicsrcHome(): string { return join(homedir(), ".logicsrc"); } +/** + * Move `~/.logicsrc` to the config dir, once, if the new one is not there yet. + * + * Deliberately a move and not a copy: two directories both claiming to be the + * identity is the state where a login writes to one and a read finds the + * other. If it cannot be moved the failure is named on stderr rather than + * swallowed, because the alternative is a member silently logged out with a + * secret key still sitting somewhere they were not told about. + */ +function migrateLegacyHome(target: string): void { + const legacy = legacyLogicsrcHome(); + if (legacy === target || existsSync(target) || !existsSync(legacy)) { + return; + } + try { + mkdirSync(dirname(target), { recursive: true }); + renameSync(legacy, target); + } catch (error) { + const why = error instanceof Error ? error.message : String(error); + process.emitWarning( + `logicsrc: could not move ${legacy} to ${target} (${why}). ` + + `Move it by hand — it holds your identity key.` + ); + } +} + export function identityPath(): string { return process.env.LOGICSRC_IDENTITY_FILE ? resolve(process.env.LOGICSRC_IDENTITY_FILE) diff --git a/plugins/credential-sharing/src/paths.test.ts b/plugins/credential-sharing/src/paths.test.ts new file mode 100644 index 0000000..65ffaf8 --- /dev/null +++ b/plugins/credential-sharing/src/paths.test.ts @@ -0,0 +1,122 @@ +// Where the identity and the vault live. +// +// These used to be three different answers. The identity was under +// `~/.logicsrc`, the CLI config beside it, and the credential store resolved +// against `process.cwd()` — so the vault was wherever you were standing when +// you ran the command. Running the CLI inside a git checkout wrote a directory +// literally named `credentials/vault` into that repo's working tree: untracked, +// unignored, one `git add -A` from being published. +// +// There is one vault per user, per machine. That is what these pin. +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { logicsrcHome, identityPath, legacyLogicsrcHome } from "./identity.js"; +import { defaultCredentialHome } from "./store.js"; + +const ENV_KEYS = ["LOGICSRC_HOME", "XDG_CONFIG_HOME", "HOME", "LOGICSRC_CREDENTIAL_HOME", "LOGICSRC_IDENTITY_FILE"] as const; + +let saved: Record; +let sandbox: string; + +beforeEach(() => { + saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + sandbox = mkdtempSync(join(tmpdir(), "logicsrc-paths-")); + for (const k of ENV_KEYS) delete process.env[k]; + process.env.HOME = sandbox; +}); + +afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + rmSync(sandbox, { recursive: true, force: true }); +}); + +describe("logicsrc home", () => { + it("defaults to ~/.config/logicsrc", () => { + expect(logicsrcHome()).toBe(join(sandbox, ".config", "logicsrc")); + }); + + it("honours XDG_CONFIG_HOME", () => { + process.env.XDG_CONFIG_HOME = join(sandbox, "xdg"); + expect(logicsrcHome()).toBe(join(sandbox, "xdg", "logicsrc")); + }); + + it("lets LOGICSRC_HOME override everything", () => { + process.env.LOGICSRC_HOME = join(sandbox, "explicit"); + expect(logicsrcHome()).toBe(join(sandbox, "explicit")); + }); +}); + +describe("the credential store", () => { + it("never resolves against the working directory", () => { + // The regression this exists for. Whatever the cwd is, the vault is not + // under it — a `.logicsrc/` appearing inside a project is the bug. + const home = defaultCredentialHome(); + expect(home).toBe(join(sandbox, ".config", "logicsrc", "credentials")); + expect(home.startsWith(process.cwd())).toBe(false); + }); + + it("is the same store no matter where the CLI is run from", () => { + const before = defaultCredentialHome(); + const elsewhere = mkdtempSync(join(tmpdir(), "logicsrc-cwd-")); + const original = process.cwd(); + try { + process.chdir(elsewhere); + expect(defaultCredentialHome()).toBe(before); + } finally { + process.chdir(original); + rmSync(elsewhere, { recursive: true, force: true }); + } + }); + + it("still takes an explicit LOGICSRC_CREDENTIAL_HOME", () => { + process.env.LOGICSRC_CREDENTIAL_HOME = join(sandbox, "vol", "creds"); + expect(defaultCredentialHome()).toBe(join(sandbox, "vol", "creds")); + }); + + it("sits beside the identity, under one home", () => { + expect(defaultCredentialHome()).toBe(join(logicsrcHome(), "credentials")); + expect(identityPath()).toBe(join(logicsrcHome(), "identity.json")); + }); +}); + +describe("migrating off ~/.logicsrc", () => { + it("moves the old directory, keeping the identity key", () => { + // The secret key is the whole account: losing it loses every team vault + // the member was ever given. So this is a move, not a fresh start. + const legacy = legacyLogicsrcHome(); + mkdirSync(legacy, { recursive: true }); + writeFileSync(join(legacy, "identity.json"), '{"keys":{"secretKey":"kept"}}'); + + const home = logicsrcHome(); + expect(existsSync(legacy)).toBe(false); + expect(JSON.parse(readFileSync(join(home, "identity.json"), "utf8")).keys.secretKey).toBe("kept"); + }); + + it("leaves the old directory alone once the new one exists", () => { + // Two directories both claiming to be the identity is the state where a + // login writes one and a read finds the other. Whatever is already at the + // new path wins; the legacy one is not merged over it. + const legacy = legacyLogicsrcHome(); + mkdirSync(legacy, { recursive: true }); + writeFileSync(join(legacy, "identity.json"), '{"keys":{"secretKey":"old"}}'); + const home = join(sandbox, ".config", "logicsrc"); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, "identity.json"), '{"keys":{"secretKey":"current"}}'); + + logicsrcHome(); + expect(JSON.parse(readFileSync(join(home, "identity.json"), "utf8")).keys.secretKey).toBe("current"); + expect(existsSync(legacy)).toBe(true); + }); + + it("does nothing when there is no legacy directory", () => { + const home = logicsrcHome(); + expect(existsSync(legacyLogicsrcHome())).toBe(false); + expect(home).toBe(join(sandbox, ".config", "logicsrc")); + }); +}); diff --git a/plugins/credential-sharing/src/providers/team.ts b/plugins/credential-sharing/src/providers/team.ts index e036710..1acf428 100644 --- a/plugins/credential-sharing/src/providers/team.ts +++ b/plugins/credential-sharing/src/providers/team.ts @@ -17,7 +17,7 @@ import type { * vault). Secret values are encrypted/decrypted on THIS machine with the vault * DEK; the server only ever sees ciphertext and the DEK sealed to member keys. * - * Auth + identity come from the local `~/.logicsrc/identity.json` (via + * Auth + identity come from the local `~/.config/logicsrc/identity.json` (via * `logicsrc login`), mirroring how `env` reads files and `github-secrets` reads * GITHUB_TOKEN — the provider is pure I/O over ambient credentials. */ @@ -77,7 +77,7 @@ export const teamProvider: CredentialProvider = { name: "LogicSRC Team Vault", description: "End-to-end-encrypted team credential vault. Share secrets with teammates by email — the server never sees plaintext.", status: "available", - authRequirements: ["logicsrc login (identity at ~/.logicsrc/identity.json)"], + authRequirements: ["logicsrc login (identity at ~/.config/logicsrc/identity.json)"], capabilities: { readValues: true, readNames: true, write: true, delete: true, rollback: true, audit: true }, async inspect(endpoint: CredentialEndpoint): Promise { diff --git a/plugins/credential-sharing/src/store.ts b/plugins/credential-sharing/src/store.ts index 6b73cc5..c14b38c 100644 --- a/plugins/credential-sharing/src/store.ts +++ b/plugins/credential-sharing/src/store.ts @@ -1,21 +1,23 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs"; -import { homedir } from "node:os"; import { join, resolve } from "node:path"; +import { logicsrcHome } from "./identity.js"; import type { CredentialSyncPlan, CredentialSyncRun, CredentialAuditEvent, CredentialValueBag } from "./types.js"; /** * File-backed store so the CLI can reference plans/runs by id across invocations. * * Layout under the base dir (default `$LOGICSRC_CREDENTIAL_HOME` or - * `/.logicsrc/credentials`): + * `~/.config/logicsrc/credentials`): * plans/.json redacted sync plans (fingerprints only) * runs/.json run records (fingerprints only) * audit/.json audit events (fingerprints only) * vault/.json rollback pre-image — RAW prior target values, mode 0600 * * The vault is the only place raw values touch disk, and only to make rollback - * possible. It is written 0600 and lives under a `.logicsrc` dir that callers - * should gitignore. Audit and plan records never contain raw values. + * possible. It is written 0600 and lives in the user's config dir, outside any + * project — so there is nothing for a caller to gitignore, and nothing that + * lands in a repo because the CLI was run from inside one. Audit and plan + * records never contain raw values. */ export interface CredentialStore { baseDir: string; @@ -29,14 +31,26 @@ export interface CredentialStore { getVault(runId: string): CredentialValueBag | undefined; } +/** + * The one credential store for this user, on this machine. + * + * This used to fall back to `/.logicsrc/credentials`, which meant the + * vault was wherever you happened to be standing: run the CLI in a git + * checkout and it wrote a directory named `credentials/vault` into that + * repo's working tree — untracked, unignored, one `git add -A` away from + * being published. Worse, the store is meant to be the record of what was + * rotated, and a per-directory store is a record with as many disagreeing + * copies as you have project folders. + * + * There is one vault per user. `$LOGICSRC_CREDENTIAL_HOME` still points it + * somewhere explicit, for tests and for anyone keeping it on a mounted + * volume; nothing derives it from the working directory any more. + */ export function defaultCredentialHome(): string { if (process.env.LOGICSRC_CREDENTIAL_HOME) { return resolve(process.env.LOGICSRC_CREDENTIAL_HOME); } - if (process.env.LOGICSRC_HOME) { - return resolve(process.env.LOGICSRC_HOME, "credentials"); - } - return resolve(process.cwd(), ".logicsrc", "credentials"); + return join(logicsrcHome(), "credentials"); } function readJson(file: string): T | undefined {