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

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