mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +00:00
feat(credentials): link directories to team secrets (#129)
This commit is contained in:
parent
e4723f31b1
commit
12a1d7f479
7 changed files with 265 additions and 27 deletions
|
|
@ -16,15 +16,15 @@ export const CLI_DEFAULT_API = "https://app.logicsrc.com";
|
||||||
* @param {string} origin - the origin this request arrived on
|
* @param {string} origin - the origin this request arrived on
|
||||||
* @returns {string} the card's HTML
|
* @returns {string} the card's HTML
|
||||||
*/
|
*/
|
||||||
// Vaults are addressed as <team> <project> <env> -- three positionals. Anything
|
// The short workflow is deliberately directory-linked: up/down must never
|
||||||
// shorter exits with "missing required argument", so a hint that omits one is
|
// guess a remote target. The explicit push/pull commands remain available,
|
||||||
// not merely stale, it fails on paste. `--env <path>` is the local .env file
|
// but the dashboard teaches the safer link-once flow people use every day.
|
||||||
// and already defaults to .env; spelling it out here only invites confusion
|
|
||||||
// with the <env> positional next to it.
|
|
||||||
export const CLI_HINT = (origin) => `<div class="card" style="margin-bottom:22px"><div class="card-head"><span class="h">Connect the CLI</span><span class="pill on">end-to-end encrypted</span></div>
|
export const CLI_HINT = (origin) => `<div class="card" style="margin-bottom:22px"><div class="card-head"><span class="h">Connect the CLI</span><span class="pill on">end-to-end encrypted</span></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="dim" style="margin-top:0;font-size:.9rem">Secrets are encrypted on your machine — decrypt them with the <code>logicsrc</code> CLI, never here.</p>
|
<p class="dim" style="margin-top:0;font-size:.9rem">Secrets are encrypted on your machine — decrypt them with the <code>logicsrc</code> CLI, never here.</p>
|
||||||
<pre class="mono" style="background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;font-size:.8rem;margin:0">${origin === CLI_DEFAULT_API ? "" : `LOGICSRC_API=${esc(origin)} `}logicsrc login
|
<pre class="mono" style="background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto;font-size:.8rem;margin:0">${origin === CLI_DEFAULT_API ? "" : `LOGICSRC_API=${esc(origin)} `}logicsrc login
|
||||||
logicsrc teams push <team> <project> <env> # share
|
cd /path/to/your/project
|
||||||
logicsrc teams pull <team> <project> <env> # receive</pre>
|
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</pre>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
// The dashboard's "Connect the CLI" card kept printing commands that no longer
|
// The dashboard's "Connect the CLI" card is the copy/paste entry point for the
|
||||||
// ran. It survived two releases of drift: `logicsrc teams push <team> prod` is
|
// directory-linked workflow. Pin the actual commands so the hosted app cannot
|
||||||
// two positionals, and since vaults became <team> <project> <env> the CLI exits
|
// drift back to verbose targets or imply that up/down work without a link.
|
||||||
// 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.
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
|
|
@ -22,16 +16,14 @@ const commands = (origin) =>
|
||||||
|
|
||||||
const HOSTED = "https://app.logicsrc.com";
|
const HOSTED = "https://app.logicsrc.com";
|
||||||
|
|
||||||
test("push and pull carry all three vault positionals", () => {
|
test("the dashboard teaches link before up and down", () => {
|
||||||
for (const verb of ["push", "pull"]) {
|
const lines = commands(HOSTED);
|
||||||
const line = commands(HOSTED).find((l) => l.includes(`teams ${verb}`));
|
const link = lines.findIndex((line) => line.includes("secrets teams link"));
|
||||||
assert.ok(line, `no teams ${verb} line`);
|
const up = lines.findIndex((line) => line.includes("secrets up"));
|
||||||
assert.match(line, /teams (push|pull) <team> <project> <env>/);
|
const down = lines.findIndex((line) => line.includes("secrets down [env]"));
|
||||||
// Guards the specific regression: two positionals used to be enough.
|
assert.ok(link >= 0, "no secrets teams link line");
|
||||||
// Drop "logicsrc teams <verb>" and count only what follows.
|
assert.ok(up > link, "secrets up must appear after link");
|
||||||
const args = line.split("#")[0].trim().split(/\s+/).slice(3);
|
assert.ok(down > link, "secrets down must appear after link");
|
||||||
assert.equal(args.length, 3, `teams ${verb} needs 3 args, got ${args.join(" ")}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the local .env path is left at its default", () => {
|
test("the local .env path is left at its default", () => {
|
||||||
|
|
|
||||||
|
|
@ -218,12 +218,26 @@ logicsrc teams accept <token-from-email>
|
||||||
# …an existing member runs: logicsrc teams grant acme web prod teammate@example.com
|
# …an existing member runs: logicsrc teams grant acme web prod teammate@example.com
|
||||||
logicsrc teams pull acme web prod --env .env # download + decrypt
|
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
|
# Inspect / manage
|
||||||
logicsrc teams list
|
logicsrc teams list
|
||||||
logicsrc teams members acme
|
logicsrc teams members acme
|
||||||
logicsrc teams vaults 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
|
### Rotating a vault key
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,10 @@ import {
|
||||||
teamsVaultsAction,
|
teamsVaultsAction,
|
||||||
teamsGrantAction,
|
teamsGrantAction,
|
||||||
teamsPushAction,
|
teamsPushAction,
|
||||||
teamsPullAction
|
teamsPullAction,
|
||||||
|
secretsTeamsLinkAction,
|
||||||
|
secretsUpAction,
|
||||||
|
secretsDownAction
|
||||||
} from "./teams.js";
|
} from "./teams.js";
|
||||||
import { credentialsRotateAction } from "./rotate.js";
|
import { credentialsRotateAction } from "./rotate.js";
|
||||||
import { boards, tasks } from "./fixtures.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 <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 <path>", "Source .env file", ".env")
|
||||||
|
.option("--format <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 <path>", "Destination .env file", ".env")
|
||||||
|
.option("--format <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(
|
withEndpointOptions(
|
||||||
credentials.command("inspect").requiredOption("--provider <provider>", "Provider id"),
|
credentials.command("inspect").requiredOption("--provider <provider>", "Provider id"),
|
||||||
"",
|
"",
|
||||||
|
|
|
||||||
46
packages/cli/src/secrets-link.test.ts
Normal file
46
packages/cli/src/secrets-link.test.ts
Normal file
|
|
@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
packages/cli/src/secrets-link.ts
Normal file
75
packages/cli/src/secrets-link.ts
Normal file
|
|
@ -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<string, SecretsLink>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SecretsLinkStore>;
|
||||||
|
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<SecretsLink, "team" | "project" | "env">,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { createServer } from "node:http";
|
||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
import { hostname } from "node:os";
|
import { hostname } from "node:os";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
import { createInterface } from "node:readline/promises";
|
||||||
import {
|
import {
|
||||||
TeamClient,
|
TeamClient,
|
||||||
TeamApiError,
|
TeamApiError,
|
||||||
|
|
@ -18,6 +19,7 @@ import {
|
||||||
type CredentialEndpoint
|
type CredentialEndpoint
|
||||||
} from "@logicsrc/plugin-credential-sharing";
|
} from "@logicsrc/plugin-credential-sharing";
|
||||||
import { print, type OutputFormat } from "./format.js";
|
import { print, type OutputFormat } from "./format.js";
|
||||||
|
import { linkedDirectory, requireSecretsLink, writeSecretsLink } from "./secrets-link.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `logicsrc login` + `logicsrc teams …` — the team credential-sharing surface.
|
* `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}.`);
|
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);
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const link = requireSecretsLink(options.cwd);
|
||||||
|
await teamsPullAction(link.team, link.project, envName ?? link.env, { env: options.env, format: options.format });
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue