diff --git a/apps/pwa/src/lib/cli-hint.mjs b/apps/pwa/src/lib/cli-hint.mjs index ac3ccb3..b6f07dd 100644 --- a/apps/pwa/src/lib/cli-hint.mjs +++ b/apps/pwa/src/lib/cli-hint.mjs @@ -16,15 +16,15 @@ export const CLI_DEFAULT_API = "https://app.logicsrc.com"; * @param {string} origin - the origin this request arrived on * @returns {string} the card's HTML */ -// Vaults are addressed as -- three positionals. Anything -// shorter exits with "missing required argument", so a hint that omits one is -// not merely stale, it fails on paste. `--env ` is the local .env file -// and already defaults to .env; spelling it out here only invites confusion -// with the positional next to it. +// The short workflow is deliberately directory-linked: up/down must never +// guess a remote target. The explicit push/pull commands remain available, +// but the dashboard teaches the safer link-once flow people use every day. export const CLI_HINT = (origin) => `
Connect the CLIend-to-end encrypted

Secrets are encrypted on your machine — decrypt them with the logicsrc CLI, never here.

${origin === CLI_DEFAULT_API ? "" : `LOGICSRC_API=${esc(origin)} `}logicsrc login
-logicsrc teams push <team> <project> <env>   # share
-logicsrc teams pull <team> <project> <env>   # receive
+cd /path/to/your/project +logicsrc secrets teams link # select team → project → env +logicsrc secrets up # share this project's .env +logicsrc secrets down [env] # receive default or named env
`; diff --git a/apps/pwa/test/cli-hint.test.mjs b/apps/pwa/test/cli-hint.test.mjs index d429d64..24e7d4a 100644 --- a/apps/pwa/test/cli-hint.test.mjs +++ b/apps/pwa/test/cli-hint.test.mjs @@ -1,12 +1,6 @@ -// The dashboard's "Connect the CLI" card kept printing commands that no longer -// ran. It survived two releases of drift: `logicsrc teams push prod` is -// two positionals, and since vaults became the CLI exits -// with a usage error on paste. It also told everyone to set LOGICSRC_API to the -// value the CLI already defaults to, which reads like a required step. -// -// A card that hands out commands is only useful if the commands run, so these -// pin the shape rather than the prose -- restyling the card is free, quietly -// dropping an argument is not. +// The dashboard's "Connect the CLI" card is the copy/paste entry point for the +// directory-linked workflow. Pin the actual commands so the hosted app cannot +// drift back to verbose targets or imply that up/down work without a link. import assert from "node:assert/strict"; import test from "node:test"; @@ -22,16 +16,14 @@ const commands = (origin) => const HOSTED = "https://app.logicsrc.com"; -test("push and pull carry all three vault positionals", () => { - for (const verb of ["push", "pull"]) { - const line = commands(HOSTED).find((l) => l.includes(`teams ${verb}`)); - assert.ok(line, `no teams ${verb} line`); - assert.match(line, /teams (push|pull) /); - // Guards the specific regression: two positionals used to be enough. - // Drop "logicsrc teams " and count only what follows. - const args = line.split("#")[0].trim().split(/\s+/).slice(3); - assert.equal(args.length, 3, `teams ${verb} needs 3 args, got ${args.join(" ")}`); - } +test("the dashboard teaches link before up and down", () => { + const lines = commands(HOSTED); + const link = lines.findIndex((line) => line.includes("secrets teams link")); + const up = lines.findIndex((line) => line.includes("secrets up")); + const down = lines.findIndex((line) => line.includes("secrets down [env]")); + assert.ok(link >= 0, "no secrets teams link line"); + assert.ok(up > link, "secrets up must appear after link"); + assert.ok(down > link, "secrets down must appear after link"); }); test("the local .env path is left at its default", () => { diff --git a/docs/credential-sharing.md b/docs/credential-sharing.md index bc916c1..b463f9f 100644 --- a/docs/credential-sharing.md +++ b/docs/credential-sharing.md @@ -218,12 +218,26 @@ logicsrc teams accept # …an existing member runs: logicsrc teams grant acme web prod teammate@example.com logicsrc teams pull acme web prod --env .env # download + decrypt +# Link a checkout once, then use the short workflow from that directory. +# With no arguments, link interactively selects team → project → environment. +logicsrc secrets teams link +logicsrc secrets up # push .env to the linked default environment +logicsrc secrets down # pull the linked default environment +logicsrc secrets down staging # pull another env in the linked project + # Inspect / manage logicsrc teams list logicsrc teams members acme logicsrc teams vaults acme ``` +`secrets up` and `secrets down` require an explicit directory link and fail +before doing any network or `.env` operation when one is missing. For +automation, write the link explicitly with +`logicsrc secrets teams link acme web prod`. Links contain only the resolved +directory path and team/project/environment names; they live in the user's +LogicSRC config directory, never in the project and never contain secret values. + ### Rotating a vault key ```bash diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e9c97b1..f49b14b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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 ", "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 ", "Source .env file", ".env") + .option("--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 ", "Destination .env file", ".env") + .option("--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 id"), "", diff --git a/packages/cli/src/secrets-link.test.ts b/packages/cli/src/secrets-link.test.ts new file mode 100644 index 0000000..a184098 --- /dev/null +++ b/packages/cli/src/secrets-link.test.ts @@ -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/); + }); +}); diff --git a/packages/cli/src/secrets-link.ts b/packages/cli/src/secrets-link.ts new file mode 100644 index 0000000..39c7738 --- /dev/null +++ b/packages/cli/src/secrets-link.ts @@ -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; +} + +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; + 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, + 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; +} diff --git a/packages/cli/src/teams.ts b/packages/cli/src/teams.ts index 50e917f..9fc6b38 100644 --- a/packages/cli/src/teams.ts +++ b/packages/cli/src/teams.ts @@ -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 { + 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 { + 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 { + 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 { + const link = requireSecretsLink(options.cwd); + await teamsPullAction(link.team, link.project, envName ?? link.env, { env: options.env, format: options.format }); +}