Skip to content

Commit ca182bc

Browse files
ralyodioclaude
andauthored
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>
1 parent 7c9796a commit ca182bc

4 files changed

Lines changed: 130 additions & 23 deletions

File tree

docs/credential-sharing.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,15 +183,16 @@ re-wraps (seals) it to the new member's public key. The private key lives only i
183183
logicsrc login
184184

185185
# Owner: create a team, push a local .env into an encrypted vault, invite people.
186+
# A vault is addressed as <project> <env>, stored as the vault name project/env.
186187
logicsrc teams create acme --name "Acme Inc"
187-
logicsrc teams push acme prod --env .env # encrypt + upload
188+
logicsrc teams push acme web prod --env .env # encrypt + upload
188189
logicsrc teams invite acme teammate@example.com # emails an accept link
189190

190191
# Teammate: accept, then get granted, then pull + decrypt locally.
191192
logicsrc login
192193
logicsrc teams accept <token-from-email>
193-
# …an existing member runs: logicsrc teams grant acme prod teammate@example.com
194-
logicsrc teams pull acme prod --env .env # download + decrypt
194+
# …an existing member runs: logicsrc teams grant acme web prod teammate@example.com
195+
logicsrc teams pull acme web prod --env .env # download + decrypt
195196

196197
# Inspect / manage
197198
logicsrc teams list

packages/cli/src/index.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -601,29 +601,32 @@ teams
601601
teams
602602
.command("grant")
603603
.argument("<slug>", "Team slug")
604-
.argument("<vault>", "Vault name")
604+
.argument("<project>", "Project name")
605+
.argument("<env>", "Environment name (prod, staging, …)")
605606
.argument("<email>", "Teammate email to grant vault access")
606607
.option("--format <format>", "table, json, or markdown", "table")
607608
.description("Grant a member decryption access to a vault (re-wraps the vault key to their key).")
608-
.action((slug, vault, email, options) => teamsGrantAction(slug, vault, email, options.format as OutputFormat));
609+
.action((slug, project, env, email, options) => teamsGrantAction(slug, project, env, email, options.format as OutputFormat));
609610

610611
teams
611612
.command("push")
612613
.argument("<slug>", "Team slug")
613-
.argument("<vault>", "Vault name")
614+
.argument("<project>", "Project name")
615+
.argument("<env>", "Environment name (prod, staging, …)")
614616
.option("--env <path>", "Source .env file", ".env")
615617
.option("--format <format>", "table, json, or markdown", "table")
616-
.description("Encrypt and push a local .env into a team vault.")
617-
.action((slug, vault, options) => teamsPushAction(slug, vault, { env: options.env, format: options.format as OutputFormat }));
618+
.description("Encrypt and push a local .env into a team vault (<project>/<env>).")
619+
.action((slug, project, env, options) => teamsPushAction(slug, project, env, { env: options.env, format: options.format as OutputFormat }));
618620

619621
teams
620622
.command("pull")
621623
.argument("<slug>", "Team slug")
622-
.argument("<vault>", "Vault name")
624+
.argument("<project>", "Project name")
625+
.argument("<env>", "Environment name (prod, staging, …)")
623626
.option("--env <path>", "Destination .env file", ".env")
624627
.option("--format <format>", "table, json, or markdown", "table")
625-
.description("Pull a team vault and decrypt it into a local .env.")
626-
.action((slug, vault, options) => teamsPullAction(slug, vault, { env: options.env, format: options.format as OutputFormat }));
628+
.description("Pull a team vault (<project>/<env>) and decrypt it into a local .env.")
629+
.action((slug, project, env, options) => teamsPullAction(slug, project, env, { env: options.env, format: options.format as OutputFormat }));
627630

628631
const accounts = program.command("accounts").description("Manage connected social and email accounts.");
629632

packages/cli/src/teams.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
import { splitVaultName, vaultName } from "./teams.js";
3+
4+
// A vault is addressed as <project> <env> on the command line and stored as a
5+
// single `project/env` name server-side. The join is the only thing keeping
6+
// those two halves apart, so it has to reject anything that would make the
7+
// name ambiguous — a wrong split would point a push at the wrong vault.
8+
9+
describe("vaultName", () => {
10+
it("joins project and env with a slash", () => {
11+
expect(vaultName("web", "prod")).toBe("web/prod");
12+
});
13+
14+
it("keeps distinct envs of one project apart", () => {
15+
expect(vaultName("web", "staging")).not.toBe(vaultName("web", "prod"));
16+
});
17+
18+
it("keeps distinct projects in one env apart", () => {
19+
expect(vaultName("api", "prod")).not.toBe(vaultName("web", "prod"));
20+
});
21+
22+
it("rejects a slash in either half", () => {
23+
expect(() => vaultName("web/api", "prod")).toThrow(/cannot contain/);
24+
expect(() => vaultName("web", "prod/eu")).toThrow(/cannot contain/);
25+
});
26+
27+
it("rejects empty or blank halves", () => {
28+
expect(() => vaultName("", "prod")).toThrow(/Missing project/);
29+
expect(() => vaultName("web", "")).toThrow(/Missing env/);
30+
expect(() => vaultName(" ", "prod")).toThrow(/Missing project/);
31+
});
32+
});
33+
34+
describe("splitVaultName", () => {
35+
it("round-trips a name built by vaultName", () => {
36+
expect(splitVaultName(vaultName("web", "prod"))).toEqual({ project: "web", env: "prod" });
37+
});
38+
39+
it("returns null for legacy single-word names", () => {
40+
// Vaults created before the split are still listable; they just don't
41+
// decompose, so `teams vaults` shows the raw name instead of guessing.
42+
expect(splitVaultName("prod")).toBeNull();
43+
});
44+
45+
it("returns null rather than guessing at an ambiguous name", () => {
46+
expect(splitVaultName("a/b/c")).toBeNull();
47+
expect(splitVaultName("/prod")).toBeNull();
48+
expect(splitVaultName("web/")).toBeNull();
49+
});
50+
});

packages/cli/src/teams.ts

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,48 @@ class DeviceFlowUnsupported extends Error {
176176
constructor() { super("device flow not supported by this server"); }
177177
}
178178

179+
// A vault is addressed as <project>/<env>, so one team can hold web/prod,
180+
// web/staging and api/prod side by side. The split lives entirely in the CLI —
181+
// the server still stores a single opaque vault name — so this join and
182+
// splitVaultName() below are the only places that know about the convention.
183+
// Neither half may contain a slash, which keeps the join unambiguous and makes
184+
// splitVaultName a true inverse.
185+
export function vaultName(project: string, env: string): string {
186+
const parts: ReadonlyArray<readonly [string, string]> = [
187+
["project", project],
188+
["env", env]
189+
];
190+
for (const [label, value] of parts) {
191+
if (!value || !value.trim()) {
192+
throw new Error(`Missing ${label}. Usage: logicsrc teams push <team> <project> <env>`);
193+
}
194+
if (value.includes("/")) {
195+
throw new Error(`The ${label} "${value}" cannot contain "/" — it separates project from env in a vault name.`);
196+
}
197+
}
198+
return `${project}/${env}`;
199+
}
200+
201+
/** Inverse of vaultName; null for names that predate the convention. */
202+
export function splitVaultName(name: string): { project: string; env: string } | null {
203+
const slash = name.indexOf("/");
204+
if (slash <= 0 || slash === name.length - 1) return null;
205+
const env = name.slice(slash + 1);
206+
if (env.includes("/")) return null;
207+
return { project: name.slice(0, slash), env };
208+
}
209+
179210
async function resolveVaultId(client: TeamClient, slug: string, vault: string): Promise<string> {
180211
const { vaults } = await client.listVaults(slug);
181212
const found = vaults.find((v) => v.name === vault);
182-
if (!found) throw new Error(`Vault "${vault}" not found in team "${slug}". Create it by pushing to it.`);
183-
return found.id;
213+
if (found) return found.id;
214+
// Vault names were a single word before they became <project>/<env>, so a
215+
// team can still hold legacy rows. Name them instead of silently retargeting
216+
// — picking a different vault than the one asked for would mean pushing
217+
// secrets somewhere the caller didn't say.
218+
const known = vaults.map((v) => v.name);
219+
const hint = known.length ? ` Existing vaults: ${known.join(", ")}.` : "";
220+
throw new Error(`Vault "${vault}" not found in team "${slug}". Create it by pushing to it.${hint}`);
184221
}
185222

186223
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
288325
const { client } = authedClient();
289326
const { vaults } = await client.listVaults(slug);
290327
print(
291-
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>" }],
328+
vaults.length
329+
? vaults.map((v) => {
330+
const parts = splitVaultName(v.name);
331+
return {
332+
vault: v.name,
333+
project: parts?.project ?? v.name,
334+
env: parts?.env ?? "—",
335+
secrets: v.secretCount,
336+
youHaveAccess: v.hasAccess
337+
};
338+
})
339+
: [{ note: "No vaults yet. Push to create one: logicsrc teams push <team> <project> <env>" }],
292340
format
293341
);
294342
}
295343

296-
export async function teamsGrantAction(slug: string, vault: string, email: string, format: OutputFormat): Promise<void> {
344+
export async function teamsGrantAction(slug: string, project: string, env: string, email: string, format: OutputFormat): Promise<void> {
297345
const { client, identity } = authedClient();
346+
const vault = vaultName(project, env);
298347
const vaultId = await resolveVaultId(client, slug, vault);
299348

300349
// 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
314363
if (!target.publicKey) throw new Error(`${email} has not registered a key yet. Ask them to run: logicsrc login --email ${email}`);
315364

316365
await client.putGrant(vaultId, email, await wrapVaultKey(dek, target.publicKey));
317-
console.error(`Granted ${email} access to ${slug}/${vault}. They can now: logicsrc teams pull ${slug} ${vault}`);
318-
print({ granted: email, team: slug, vault }, format);
366+
console.error(`Granted ${email} access to ${slug}/${vault}. They can now: logicsrc teams pull ${slug} ${project} ${env}`);
367+
print({ granted: email, team: slug, project, env, vault }, format);
319368
}
320369

321370
function teamEndpoint(slug: string, vault: string): CredentialEndpoint {
322371
return { provider: "team", project: slug, config: vault };
323372
}
324373

325-
export async function teamsPushAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise<void> {
374+
// Note the two different "env"s: `envName` is the environment half of the vault
375+
// address (prod, staging), while `options.env` is the local .env file path.
376+
export async function teamsPushAction(slug: string, project: string, envName: string, options: { env: string; format: OutputFormat }): Promise<void> {
326377
requireAuth();
378+
const vault = vaultName(project, envName);
327379
const engine = createCredentialEngine();
328380
const from: CredentialEndpoint = { provider: "env", path: options.env };
329381
const plan = await engine.createCredentialSyncPlan({ from, to: teamEndpoint(slug, vault) });
330382
if (plan.changes.length === 0) {
331383
console.error(`${slug}/${vault} is already up to date with ${options.env}.`);
332-
print({ team: slug, vault, changes: 0 }, options.format);
384+
print({ team: slug, project, env: envName, vault, changes: 0 }, options.format);
333385
return;
334386
}
335387
const approval = engine.approveCredentialSync(plan.id);
336388
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
337389
const applied = run.results.filter((r) => r.applied).length;
338390
console.error(`Pushed ${applied} secret(s) from ${options.env} to ${slug}/${vault} (end-to-end encrypted).`);
339-
print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
391+
print({ team: slug, project, env: envName, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
340392
}
341393

342-
export async function teamsPullAction(slug: string, vault: string, options: { env: string; format: OutputFormat }): Promise<void> {
394+
export async function teamsPullAction(slug: string, project: string, envName: string, options: { env: string; format: OutputFormat }): Promise<void> {
343395
requireAuth();
396+
const vault = vaultName(project, envName);
344397
const engine = createCredentialEngine();
345398
const to: CredentialEndpoint = { provider: "env", path: options.env };
346399
const plan = await engine.createCredentialSyncPlan({ from: teamEndpoint(slug, vault), to });
347400
if (plan.changes.length === 0) {
348401
console.error(`${options.env} is already up to date with ${slug}/${vault}.`);
349-
print({ team: slug, vault, changes: 0 }, options.format);
402+
print({ team: slug, project, env: envName, vault, changes: 0 }, options.format);
350403
return;
351404
}
352405
const approval = engine.approveCredentialSync(plan.id);
353406
const run = await engine.runCredentialSync(plan.id, { dryRun: false, approval });
354407
const applied = run.results.filter((r) => r.applied).length;
355408
console.error(`Pulled ${applied} secret(s) from ${slug}/${vault} into ${options.env}.`);
356-
print({ team: slug, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
409+
print({ team: slug, project, env: envName, vault, applied, keys: run.results.map((r) => ({ key: r.key, op: r.op, applied: r.applied })) }, options.format);
357410
}

0 commit comments

Comments
 (0)