mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
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:
parent
7c9796ae51
commit
ca182bc057
4 changed files with 130 additions and 23 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
|
||||||
|
|
|
||||||
50
packages/cli/src/teams.test.ts
Normal file
50
packages/cli/src/teams.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue