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
14 changes: 7 additions & 7 deletions apps/pwa/src/lib/cli-hint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ export const CLI_DEFAULT_API = "https://app.logicsrc.com";
* @param {string} origin - the origin this request arrived on
* @returns {string} the card's HTML
*/
// Vaults are addressed as <team> <project> <env> -- three positionals. Anything
// shorter exits with "missing required argument", so a hint that omits one is
// not merely stale, it fails on paste. `--env <path>` is the local .env file
// and already defaults to .env; spelling it out here only invites confusion
// with the <env> positional next to it.
// The short workflow is deliberately directory-linked: up/down must never
// guess a remote target. The explicit push/pull commands remain available,
// but the dashboard teaches the safer link-once flow people use every day.
export const CLI_HINT = (origin) => `<div class="card" style="margin-bottom:22px"><div class="card-head"><span class="h">Connect the CLI</span><span class="pill on">end-to-end encrypted</span></div>
<div class="card-body">
<p class="dim" style="margin-top:0;font-size:.9rem">Secrets are encrypted on your machine — decrypt them with the <code>logicsrc</code> CLI, never here.</p>
<pre class="mono" style="background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;font-size:.8rem;margin:0">${origin === CLI_DEFAULT_API ? "" : `LOGICSRC_API=${esc(origin)} `}logicsrc login
logicsrc teams push &lt;team&gt; &lt;project&gt; &lt;env&gt; # share
logicsrc teams pull &lt;team&gt; &lt;project&gt; &lt;env&gt; # receive</pre>
cd /path/to/your/project
logicsrc secrets teams link # select team → project → env
logicsrc secrets up # share this project's .env
logicsrc secrets down [env] # receive default or named env</pre>
</div></div>`;
30 changes: 11 additions & 19 deletions apps/pwa/test/cli-hint.test.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
// The dashboard's "Connect the CLI" card kept printing commands that no longer
// ran. It survived two releases of drift: `logicsrc teams push <team> prod` is
// two positionals, and since vaults became <team> <project> <env> the CLI exits
// with a usage error on paste. It also told everyone to set LOGICSRC_API to the
// value the CLI already defaults to, which reads like a required step.
//
// A card that hands out commands is only useful if the commands run, so these
// pin the shape rather than the prose -- restyling the card is free, quietly
// dropping an argument is not.
// The dashboard's "Connect the CLI" card is the copy/paste entry point for the
// directory-linked workflow. Pin the actual commands so the hosted app cannot
// drift back to verbose targets or imply that up/down work without a link.
import assert from "node:assert/strict";
import test from "node:test";

Expand All @@ -22,16 +16,14 @@ const commands = (origin) =>

const HOSTED = "https://app.logicsrc.com";

test("push and pull carry all three vault positionals", () => {
for (const verb of ["push", "pull"]) {
const line = commands(HOSTED).find((l) => l.includes(`teams ${verb}`));
assert.ok(line, `no teams ${verb} line`);
assert.match(line, /teams (push|pull) <team> <project> <env>/);
// Guards the specific regression: two positionals used to be enough.
// Drop "logicsrc teams <verb>" and count only what follows.
const args = line.split("#")[0].trim().split(/\s+/).slice(3);
assert.equal(args.length, 3, `teams ${verb} needs 3 args, got ${args.join(" ")}`);
}
test("the dashboard teaches link before up and down", () => {
const lines = commands(HOSTED);
const link = lines.findIndex((line) => line.includes("secrets teams link"));
const up = lines.findIndex((line) => line.includes("secrets up"));
const down = lines.findIndex((line) => line.includes("secrets down [env]"));
assert.ok(link >= 0, "no secrets teams link line");
assert.ok(up > link, "secrets up must appear after link");
assert.ok(down > link, "secrets down must appear after link");
});

test("the local .env path is left at its default", () => {
Expand Down
14 changes: 14 additions & 0 deletions docs/credential-sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,26 @@ logicsrc teams accept <token-from-email>
# …an existing member runs: logicsrc teams grant acme web prod teammate@example.com
logicsrc teams pull acme web prod --env .env # download + decrypt

# Link a checkout once, then use the short workflow from that directory.
# With no arguments, link interactively selects team → project → environment.
logicsrc secrets teams link
logicsrc secrets up # push .env to the linked default environment
logicsrc secrets down # pull the linked default environment
logicsrc secrets down staging # pull another env in the linked project

# Inspect / manage
logicsrc teams list
logicsrc teams members acme
logicsrc teams vaults acme
```

`secrets up` and `secrets down` require an explicit directory link and fail
before doing any network or `.env` operation when one is missing. For
automation, write the link explicitly with
`logicsrc secrets teams link acme web prod`. Links contain only the resolved
directory path and team/project/environment names; they live in the user's
LogicSRC config directory, never in the project and never contain secret values.

### Rotating a vault key

```bash
Expand Down
35 changes: 34 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ import {
teamsVaultsAction,
teamsGrantAction,
teamsPushAction,
teamsPullAction
teamsPullAction,
secretsTeamsLinkAction,
secretsUpAction,
secretsDownAction
} from "./teams.js";
import { credentialsRotateAction } from "./rotate.js";
import { boards, tasks } from "./fixtures.js";
Expand Down Expand Up @@ -457,6 +460,36 @@ credentials
);
});

const secretsTeams = credentials
.command("teams")
.description("Link this directory to an end-to-end-encrypted team project/environment.");

secretsTeams
.command("link")
.argument("[team]", "Team slug (selected interactively when omitted)")
.argument("[project]", "Project name (selected interactively when omitted)")
.argument("[env]", "Default environment (selected interactively when omitted)")
.option("--format <format>", "table, json, or markdown", "table")
.description("Link the current directory to a team/project/environment.")
.action((team, project, env, options) =>
secretsTeamsLinkAction(team, project, env, { format: options.format as OutputFormat })
);

credentials
.command("up")
.option("--env <path>", "Source .env file", ".env")
.option("--format <format>", "table, json, or markdown", "table")
.description("Push .env to this directory's linked team environment.")
.action((options) => secretsUpAction({ env: options.env, format: options.format as OutputFormat }));

credentials
.command("down")
.argument("[env]", "Environment override within the linked project")
.option("--env <path>", "Destination .env file", ".env")
.option("--format <format>", "table, json, or markdown", "table")
.description("Pull the linked team environment into .env.")
.action((env, options) => secretsDownAction(env, { env: options.env, format: options.format as OutputFormat }));

withEndpointOptions(
credentials.command("inspect").requiredOption("--provider <provider>", "Provider id"),
"",
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/secrets-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { mkdtempSync, mkdirSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { linkedDirectory, readSecretsLink, requireSecretsLink, writeSecretsLink } from "./secrets-link.js";

describe("directory secrets links", () => {
it("stores link metadata outside the linked project", () => {
const sandbox = mkdtempSync(join(tmpdir(), "logicsrc-link-"));
const project = join(sandbox, "project");
const file = join(sandbox, "config", "secrets-links.json");
mkdirSync(project);

const link = writeSecretsLink({ team: "acme", project: "web", env: "prod" }, project, file);

expect(readSecretsLink(project, file)).toEqual(link);
expect(link.directory).toBe(project);
expect(file.startsWith(project)).toBe(false);
});

it("keys links by the real directory so symlinked paths share one link", () => {
const sandbox = mkdtempSync(join(tmpdir(), "logicsrc-link-"));
const project = join(sandbox, "project");
const alias = join(sandbox, "alias");
const file = join(sandbox, "secrets-links.json");
mkdirSync(project);
symlinkSync(project, alias, "dir");

writeSecretsLink({ team: "acme", project: "api", env: "staging" }, alias, file);

expect(linkedDirectory(alias)).toBe(project);
expect(readSecretsLink(project, file)?.env).toBe("staging");
});

it("requires an explicit link for each directory", () => {
const sandbox = mkdtempSync(join(tmpdir(), "logicsrc-link-"));
const linked = join(sandbox, "linked");
const unlinked = join(sandbox, "unlinked");
const file = join(sandbox, "secrets-links.json");
mkdirSync(linked);
mkdirSync(unlinked);
writeSecretsLink({ team: "acme", project: "web", env: "prod" }, linked, file);

expect(() => requireSecretsLink(unlinked, file)).toThrow(/logicsrc secrets teams link/);
});
});
75 changes: 75 additions & 0 deletions packages/cli/src/secrets-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { logicsrcHome } from "@logicsrc/plugin-credential-sharing";

export interface SecretsLink {
directory: string;
team: string;
project: string;
env: string;
linkedAt: string;
}

interface SecretsLinkStore {
version: 1;
links: Record<string, SecretsLink>;
}

const emptyStore = (): SecretsLinkStore => ({ version: 1, links: {} });

export function secretsLinksPath(): string {
return join(logicsrcHome(), "secrets-links.json");
}

/** Resolve aliases/symlinks so the same directory cannot acquire two links. */
export function linkedDirectory(directory = process.cwd()): string {
const absolute = resolve(directory);
return existsSync(absolute) ? realpathSync(absolute) : absolute;
}

function readStore(file: string): SecretsLinkStore {
if (!existsSync(file)) return emptyStore();
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<SecretsLinkStore>;
if (parsed.version !== 1 || !parsed.links || typeof parsed.links !== "object" || Array.isArray(parsed.links)) {
throw new Error(`Invalid secrets link file: ${file}`);
}
return { version: 1, links: parsed.links } as SecretsLinkStore;
}

export function readSecretsLink(directory = process.cwd(), file = secretsLinksPath()): SecretsLink | undefined {
return readStore(file).links[linkedDirectory(directory)];
}

export function requireSecretsLink(directory = process.cwd(), file = secretsLinksPath()): SecretsLink {
const resolved = linkedDirectory(directory);
const link = readStore(file).links[resolved];
if (!link) {
throw new Error(`No team secrets are linked to ${resolved}. Run: logicsrc secrets teams link`);
}
return link;
}

export function writeSecretsLink(
target: Pick<SecretsLink, "team" | "project" | "env">,
directory = process.cwd(),
file = secretsLinksPath()
): SecretsLink {
const resolved = linkedDirectory(directory);
const store = readStore(file);
const link: SecretsLink = {
directory: resolved,
team: target.team,
project: target.project,
env: target.env,
linkedAt: new Date().toISOString()
};
store.links[resolved] = link;

mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
const temporary = `${file}.${process.pid}.tmp`;
writeFileSync(temporary, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
chmodSync(temporary, 0o600);
renameSync(temporary, file);
chmodSync(file, 0o600);
return link;
}
78 changes: 78 additions & 0 deletions packages/cli/src/teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { createHash, randomBytes } from "node:crypto";
import { hostname } from "node:os";
import { spawn } from "node:child_process";
import { createInterface } from "node:readline/promises";
import {
TeamClient,
TeamApiError,
Expand All @@ -18,6 +19,7 @@
type CredentialEndpoint
} from "@logicsrc/plugin-credential-sharing";
import { print, type OutputFormat } from "./format.js";
import { linkedDirectory, requireSecretsLink, writeSecretsLink } from "./secrets-link.js";

/**
* `logicsrc login` + `logicsrc teams …` — the team credential-sharing surface.
Expand Down Expand Up @@ -425,3 +427,79 @@
console.error(`Pulled ${applied} secret(s) from ${slug}/${vault} into ${options.env}.`);
print({ team: slug, project, env: envName, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
}

async function selectOne(label: string, values: string[]): Promise<string> {
const choices = [...new Set(values)].sort();
if (choices.length === 0) throw new Error(`No ${label.toLowerCase()} options are available.`);
if (choices.length === 1) {
console.error(`Using ${label.toLowerCase()}: ${choices[0]}`);
return choices[0]!;
}
if (!process.stdin.isTTY || !process.stderr.isTTY) {
throw new Error(`Cannot select a ${label.toLowerCase()} without an interactive terminal. Pass team, project, and env explicitly.`);

Check warning

Code scanning / threatcrush

SQL built from a template literal or f-string Medium

SQL built from a template literal or f-string (CWE-89)
}

console.error(`\nSelect ${label.toLowerCase()}:`);

Check warning

Code scanning / threatcrush

SQL built from a template literal or f-string Medium

SQL built from a template literal or f-string (CWE-89)
choices.forEach((choice, index) => console.error(` ${index + 1}) ${choice}`));
const prompt = createInterface({ input: process.stdin, output: process.stderr });
try {
while (true) {
const answer = (await prompt.question("> ")).trim();
const index = Number(answer) - 1;
if (Number.isInteger(index) && index >= 0 && index < choices.length) return choices[index]!;
console.error(`Enter a number from 1 to ${choices.length}.`);
}
} finally {
prompt.close();
}
}

/** Link this working directory to one team project/environment vault. */
export async function secretsTeamsLinkAction(
requestedTeam: string | undefined,
requestedProject: string | undefined,
requestedEnv: string | undefined,
options: { cwd?: string; format: OutputFormat }
): Promise<void> {
const { client } = authedClient();
const { teams } = await client.listTeams();
const teamSlugs = teams.map((candidate) => candidate.slug);
const team = requestedTeam ?? await selectOne("Team", teamSlugs);
if (!teamSlugs.includes(team)) throw new Error(`You are not an active member of team "${team}".`);

const { vaults } = await client.listVaults(team);
const targets = vaults.flatMap((vault) => {
const parts = splitVaultName(vault.name);
return parts ? [{ ...parts, hasAccess: vault.hasAccess }] : [];
});
const accessibleTargets = targets.filter((target) => target.hasAccess);
const project = requestedProject ?? await selectOne("Project", accessibleTargets.map((target) => target.project));
const projectTargets = targets.filter((target) => target.project === project);
if (requestedProject && projectTargets.length === 0 && !requestedEnv) {
throw new Error(`Project "${project}" has no environments to select. Pass an env explicitly to link a new target.`);
}
const env = requestedEnv ?? await selectOne("Environment", projectTargets.filter((target) => target.hasAccess).map((target) => target.env));
vaultName(project, env); // use the same target validation as teams push/pull

const existing = projectTargets.find((target) => target.env === env);
if (existing && !existing.hasAccess) {
throw new Error(`You do not have access to ${team}/${project}/${env}, so it cannot be linked.`);
}

const cwd = linkedDirectory(options.cwd);
const link = writeSecretsLink({ team, project, env }, cwd);
console.error(`Linked ${cwd} to ${team}/${project}/${env}.`);
print(link, options.format);
}

/** Push requires a directory link; there is deliberately no target override. */
export async function secretsUpAction(options: { cwd?: string; env: string; format: OutputFormat }): Promise<void> {
const link = requireSecretsLink(options.cwd);
await teamsPushAction(link.team, link.project, link.env, { env: options.env, format: options.format });
}

/** Pull the linked default environment, or another env in the linked project. */
export async function secretsDownAction(envName: string | undefined, options: { cwd?: string; env: string; format: OutputFormat }): Promise<void> {
const link = requireSecretsLink(options.cwd);
await teamsPullAction(link.team, link.project, envName ?? link.env, { env: options.env, format: options.format });
}
Loading