feat(cli): address team vaults as <project> <env> (#109)

`teams push|pull|grant` took a single `<vault>` name, so a team holding
more than one project had to encode both halves by hand and hope
everyone spelled it the same way. They now take `<project> <env>` and
join them into the `project/env` vault name.

The split lives entirely in the CLI — vaultName()/splitVaultName() are
the only things that know about it, and the server still stores one
opaque vault name — so there's no migration. Both halves reject a "/"
so the join stays unambiguous and the split is a true inverse.

`teams vaults` now breaks the name back into project/env columns,
falling back to the raw name for vaults created before the convention.
Those legacy vaults are no longer addressable (their names don't
contain a slash), so resolveVaultId() lists what the team actually has
instead of just saying "not found" — better than silently retargeting a
push, which in a secrets tool would write to the wrong vault.

Note push/pull carry two different "env"s: the `<env>` positional is
the environment half of the address, `--env` is the local .env path.
Verified commander keeps them separate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-30 11:39:22 -07:00 committed by GitHub
parent 7c9796ae51
commit ca182bc057
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 130 additions and 23 deletions

View file

@ -183,15 +183,16 @@ re-wraps (seals) it to the new member's public key. The private key lives only i
logicsrc login logicsrc login
# Owner: create a team, push a local .env into an encrypted vault, invite people. # Owner: create a team, push a local .env into an encrypted vault, invite people.
# A vault is addressed as <project> <env>, stored as the vault name project/env.
logicsrc teams create acme --name "Acme Inc" logicsrc teams create acme --name "Acme Inc"
logicsrc teams push acme prod --env .env # encrypt + upload logicsrc teams push acme web prod --env .env # encrypt + upload
logicsrc teams invite acme teammate@example.com # emails an accept link logicsrc teams invite acme teammate@example.com # emails an accept link
# Teammate: accept, then get granted, then pull + decrypt locally. # Teammate: accept, then get granted, then pull + decrypt locally.
logicsrc login logicsrc login
logicsrc teams accept <token-from-email> logicsrc teams accept <token-from-email>
# …an existing member runs: logicsrc teams grant acme prod teammate@example.com # …an existing member runs: logicsrc teams grant acme web prod teammate@example.com
logicsrc teams pull acme prod --env .env # download + decrypt logicsrc teams pull acme web prod --env .env # download + decrypt
# Inspect / manage # Inspect / manage
logicsrc teams list logicsrc teams list

View file

@ -601,29 +601,32 @@ teams
teams teams
.command("grant") .command("grant")
.argument("<slug>", "Team slug") .argument("<slug>", "Team slug")
.argument("<vault>", "Vault name") .argument("<project>", "Project name")
.argument("<env>", "Environment name (prod, staging, …)")
.argument("<email>", "Teammate email to grant vault access") .argument("<email>", "Teammate email to grant vault access")
.option("--format <format>", "table, json, or markdown", "table") .option("--format <format>", "table, json, or markdown", "table")
.description("Grant a member decryption access to a vault (re-wraps the vault key to their key).") .description("Grant a member decryption access to a vault (re-wraps the vault key to their key).")
.action((slug, vault, email, options) => teamsGrantAction(slug, vault, email, options.format as OutputFormat)); .action((slug, project, env, email, options) => teamsGrantAction(slug, project, env, email, options.format as OutputFormat));
teams teams
.command("push") .command("push")
.argument("<slug>", "Team slug") .argument("<slug>", "Team slug")
.argument("<vault>", "Vault name") .argument("<project>", "Project name")
.argument("<env>", "Environment name (prod, staging, …)")
.option("--env <path>", "Source .env file", ".env") .option("--env <path>", "Source .env file", ".env")
.option("--format <format>", "table, json, or markdown", "table") .option("--format <format>", "table, json, or markdown", "table")
.description("Encrypt and push a local .env into a team vault.") .description("Encrypt and push a local .env into a team vault (<project>/<env>).")
.action((slug, vault, options) => teamsPushAction(slug, vault, { env: options.env, format: options.format as OutputFormat })); .action((slug, project, env, options) => teamsPushAction(slug, project, env, { env: options.env, format: options.format as OutputFormat }));
teams teams
.command("pull") .command("pull")
.argument("<slug>", "Team slug") .argument("<slug>", "Team slug")
.argument("<vault>", "Vault name") .argument("<project>", "Project name")
.argument("<env>", "Environment name (prod, staging, …)")
.option("--env <path>", "Destination .env file", ".env") .option("--env <path>", "Destination .env file", ".env")
.option("--format <format>", "table, json, or markdown", "table") .option("--format <format>", "table, json, or markdown", "table")
.description("Pull a team vault and decrypt it into a local .env.") .description("Pull a team vault (<project>/<env>) and decrypt it into a local .env.")
.action((slug, vault, options) => teamsPullAction(slug, vault, { env: options.env, format: options.format as OutputFormat })); .action((slug, project, env, options) => teamsPullAction(slug, project, env, { env: options.env, format: options.format as OutputFormat }));
const accounts = program.command("accounts").description("Manage connected social and email accounts."); const accounts = program.command("accounts").description("Manage connected social and email accounts.");

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { splitVaultName, vaultName } from "./teams.js";
// A vault is addressed as <project> <env> on the command line and stored as a
// single `project/env` name server-side. The join is the only thing keeping
// those two halves apart, so it has to reject anything that would make the
// name ambiguous — a wrong split would point a push at the wrong vault.
describe("vaultName", () => {
it("joins project and env with a slash", () => {
expect(vaultName("web", "prod")).toBe("web/prod");
});
it("keeps distinct envs of one project apart", () => {
expect(vaultName("web", "staging")).not.toBe(vaultName("web", "prod"));
});
it("keeps distinct projects in one env apart", () => {
expect(vaultName("api", "prod")).not.toBe(vaultName("web", "prod"));
});
it("rejects a slash in either half", () => {
expect(() => vaultName("web/api", "prod")).toThrow(/cannot contain/);
expect(() => vaultName("web", "prod/eu")).toThrow(/cannot contain/);
});
it("rejects empty or blank halves", () => {
expect(() => vaultName("", "prod")).toThrow(/Missing project/);
expect(() => vaultName("web", "")).toThrow(/Missing env/);
expect(() => vaultName(" ", "prod")).toThrow(/Missing project/);
});
});
describe("splitVaultName", () => {
it("round-trips a name built by vaultName", () => {
expect(splitVaultName(vaultName("web", "prod"))).toEqual({ project: "web", env: "prod" });
});
it("returns null for legacy single-word names", () => {
// Vaults created before the split are still listable; they just don't
// decompose, so `teams vaults` shows the raw name instead of guessing.
expect(splitVaultName("prod")).toBeNull();
});
it("returns null rather than guessing at an ambiguous name", () => {
expect(splitVaultName("a/b/c")).toBeNull();
expect(splitVaultName("/prod")).toBeNull();
expect(splitVaultName("web/")).toBeNull();
});
});

View file

@ -176,11 +176,48 @@ class DeviceFlowUnsupported extends Error {
constructor() { super("device flow not supported by this server"); } constructor() { super("device flow not supported by this server"); }
} }
// A vault is addressed as <project>/<env>, so one team can hold web/prod,
// web/staging and api/prod side by side. The split lives entirely in the CLI —
// the server still stores a single opaque vault name — so this join and
// splitVaultName() below are the only places that know about the convention.
// Neither half may contain a slash, which keeps the join unambiguous and makes
// splitVaultName a true inverse.
export function vaultName(project: string, env: string): string {
const parts: ReadonlyArray<readonly [string, string]> = [
["project", project],
["env", env]
];
for (const [label, value] of parts) {
if (!value || !value.trim()) {
throw new Error(`Missing ${label}. Usage: logicsrc teams push <team> <project> <env>`);
}
if (value.includes("/")) {
throw new Error(`The ${label} "${value}" cannot contain "/" — it separates project from env in a vault name.`);
}
}
return `${project}/${env}`;
}
/** Inverse of vaultName; null for names that predate the convention. */
export function splitVaultName(name: string): { project: string; env: string } | null {
const slash = name.indexOf("/");
if (slash <= 0 || slash === name.length - 1) return null;
const env = name.slice(slash + 1);
if (env.includes("/")) return null;
return { project: name.slice(0, slash), env };
}
async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise<string> { async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise<string> {
const { vaults } = await client.listVaults(slug); const { vaults } = await client.listVaults(slug);
const found = vaults.find((v) => v.name === vault); const found = vaults.find((v) => v.name === vault);
if (!found) throw new Error(`Vault "${vault}" not found in team "${slug}". Create it by pushing to it.`); if (found) return found.id;
return found.id; // Vault names were a single word before they became <project>/<env>, so a
// team can still hold legacy rows. Name them instead of silently retargeting
// — picking a different vault than the one asked for would mean pushing
// secrets somewhere the caller didn't say.
const known = vaults.map((v) => v.name);
const hint = known.length ? ` Existing vaults: ${known.join(", ")}.` : "";
throw new Error(`Vault "${vault}" not found in team "${slug}". Create it by pushing to it.${hint}`);
} }
export async function loginAction(options: { apiUrl?: string; token?: string; device?: boolean; web?: boolean }): Promise<void> { export async function loginAction(options: { apiUrl?: string; token?: string; device?: boolean; web?: boolean }): Promise<void> {
@ -288,13 +325,25 @@ export async function teamsVaultsAction(slug: string, format: OutputFormat): Pro
const { client } = authedClient(); const { client } = authedClient();
const { vaults } = await client.listVaults(slug); const { vaults } = await client.listVaults(slug);
print( print(
vaults.length ? vaults.map((v) => ({ vault: v.name, secrets: v.secretCount, youHaveAccess: v.hasAccess })) : [{ note: "No vaults yet. Push to create one: logicsrc teams push <team> <vault>" }], vaults.length
? vaults.map((v) => {
const parts = splitVaultName(v.name);
return {
vault: v.name,
project: parts?.project ?? v.name,
env: parts?.env ?? "—",
secrets: v.secretCount,
youHaveAccess: v.hasAccess
};
})
: [{ note: "No vaults yet. Push to create one: logicsrc teams push <team> <project> <env>" }],
format format
); );
} }
export async function teamsGrantAction(slug: string, vault: string, email: string, format: OutputFormat): Promise<void> { export async function teamsGrantAction(slug: string, project: string, env: string, email: string, format: OutputFormat): Promise<void> {
const { client, identity } = authedClient(); const { client, identity } = authedClient();
const vault = vaultName(project, env);
const vaultId = await resolveVaultId(client, slug, vault); const vaultId = await resolveVaultId(client, slug, vault);
// Unwrap the vault DEK with our own key, then re-wrap it to the target member. // Unwrap the vault DEK with our own key, then re-wrap it to the target member.
@ -314,44 +363,48 @@ export async function teamsGrantAction(slug: string, vault: string, email: strin
if (!target.publicKey) throw new Error(`${email} has not registered a key yet. Ask them to run: logicsrc login --email ${email}`); if (!target.publicKey) throw new Error(`${email} has not registered a key yet. Ask them to run: logicsrc login --email ${email}`);
await client.putGrant(vaultId, email, await wrapVaultKey(dek, target.publicKey)); await client.putGrant(vaultId, email, await wrapVaultKey(dek, target.publicKey));
console.error(`Granted ${email} access to ${slug}/${vault}. They can now: logicsrc teams pull ${slug} ${vault}`); console.error(`Granted ${email} access to ${slug}/${vault}. They can now: logicsrc teams pull ${slug} ${project} ${env}`);
print({ granted: email, team: slug, vault }, format); print({ granted: email, team: slug, project, env, vault }, format);
} }
function teamEndpoint(slug: string, vault: string): CredentialEndpoint { function teamEndpoint(slug: string, vault: string): CredentialEndpoint {
return { provider: "team", project: slug, config: vault }; return { provider: "team", project: slug, config: vault };
} }
export async function teamsPushAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise<void> { // Note the two different "env"s: `envName` is the environment half of the vault
// address (prod, staging), while `options.env` is the local .env file path.
export async function teamsPushAction(slug: string, project: string, envName: string, options: { env: string; format: OutputFormat }): Promise<void> {
requireAuth(); requireAuth();
const vault = vaultName(project, envName);
const engine = createCredentialEngine(); const engine = createCredentialEngine();
const from: CredentialEndpoint = { provider: "env", path: options.env }; const from: CredentialEndpoint = { provider: "env", path: options.env };
const plan = await engine.createCredentialSyncPlan({ from, to: teamEndpoint(slug, vault) }); const plan = await engine.createCredentialSyncPlan({ from, to: teamEndpoint(slug, vault) });
if (plan.changes.length === 0) { if (plan.changes.length === 0) {
console.error(`${slug}/${vault} is already up to date with ${options.env}.`); console.error(`${slug}/${vault} is already up to date with ${options.env}.`);
print({ team: slug, vault, changes: 0 }, options.format); print({ team: slug, project, env: envName, vault, changes: 0 }, options.format);
return; return;
} }
const approval = engine.approveCredentialSync(plan.id); const approval = engine.approveCredentialSync(plan.id);
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval }); const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
const applied = run.results.filter((r) => r.applied).length; const applied = run.results.filter((r) => r.applied).length;
console.error(`Pushed ${applied} secret(s) from ${options.env} to ${slug}/${vault} (end-to-end encrypted).`); console.error(`Pushed ${applied} secret(s) from ${options.env} to ${slug}/${vault} (end-to-end encrypted).`);
print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format); print({ team: slug, project, env: envName, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
} }
export async function teamsPullAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise<void> { export async function teamsPullAction(slug: string, project: string, envName: string, options: { env: string; format: OutputFormat }): Promise<void> {
requireAuth(); requireAuth();
const vault = vaultName(project, envName);
const engine = createCredentialEngine(); const engine = createCredentialEngine();
const to: CredentialEndpoint = { provider: "env", path: options.env }; const to: CredentialEndpoint = { provider: "env", path: options.env };
const plan = await engine.createCredentialSyncPlan({ from: teamEndpoint(slug, vault), to }); const plan = await engine.createCredentialSyncPlan({ from: teamEndpoint(slug, vault), to });
if (plan.changes.length === 0) { if (plan.changes.length === 0) {
console.error(`${options.env} is already up to date with ${slug}/${vault}.`); console.error(`${options.env} is already up to date with ${slug}/${vault}.`);
print({ team: slug, vault, changes: 0 }, options.format); print({ team: slug, project, env: envName, vault, changes: 0 }, options.format);
return; return;
} }
const approval = engine.approveCredentialSync(plan.id); const approval = engine.approveCredentialSync(plan.id);
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval }); const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
const applied = run.results.filter((r) => r.applied).length; const applied = run.results.filter((r) => r.applied).length;
console.error(`Pulled ${applied} secret(s) from ${slug}/${vault} into ${options.env}.`); console.error(`Pulled ${applied} secret(s) from ${slug}/${vault} into ${options.env}.`);
print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format); print({ team: slug, project, env: envName, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
} }