feat(credentials): link directories to team secrets (#129)

This commit is contained in:
Anthony Ettinger 2026-08-04 17:17:04 -07:00 committed by GitHub
parent e4723f31b1
commit 12a1d7f479
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 265 additions and 27 deletions

View file

@ -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";
@ -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"),
"",

View file

@ -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/);
});
});

View file

@ -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;
}

View file

@ -2,6 +2,7 @@ import { createServer } from "node:http";
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,
@ -18,6 +19,7 @@ import {
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.
@ -425,3 +427,79 @@ export async function teamsPullAction(slug: string, project: string, envName: st
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.`);
}
console.error(`\nSelect ${label.toLowerCase()}:`);
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 });
}