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
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 6 additions & 5 deletions docs/credential-sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ logicsrc credentials inspect --provider env --path .env
logicsrc credentials diff --from env --from-path .env --to railway \
--to-project <projectId> --to-config <environmentId>

# 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 <project> --to-config <config>
logicsrc credentials sync --plan <planId> # dry-run (no writes)
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/config.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

Expand All @@ -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() {
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
defaultApiUrl,
resolveApiUrl,
createCredentialEngine,
identityPath,
unwrapVaultKey,
wrapVaultKey,
type CredentialEndpoint
Expand Down Expand Up @@ -280,7 +281,7 @@ export async function loginAction(options: { apiUrl?: string; token?: string; de

export async function logoutAction(): Promise<void> {
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<void> {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/credential-sharing/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
54 changes: 52 additions & 2 deletions plugins/credential-sharing/src/identity.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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";

/**
* 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.
*/
Expand All @@ -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)
Expand Down
122 changes: 122 additions & 0 deletions plugins/credential-sharing/src/paths.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;
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"));
});
});
4 changes: 2 additions & 2 deletions plugins/credential-sharing/src/providers/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<CredentialSnapshot> {
Expand Down
Loading
Loading