Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,42 @@ ln -sf ~/scripts/bin/gh-prs-merge ~/.local/bin/gh-prs-merge # and so on
has to carry it in an environment again:

```sh
cli-tools config set openai # prompts; the value is never echoed
cli-tools config pull # import them from the logicsrc team vault
cli-tools config set openai # or set one by hand; the value is never echoed
cli-tools config # what is set, and which source is winning
cli-tools config unset openai
```

### From the team vault

`cli-tools config pull` decrypts the shared logicsrc vault and imports the keys
these commands use — the fastest way to set a new machine up, and the way a
rotated key reaches it:

```sh
cli-tools config pull
# config: imported OPENAI_API_KEY (sk-pr…ZyAA (164 chars))
# config: imported ANTHROPIC_API_KEY (sk-an…uAAA (108 chars))
# 11 other key(s) in the vault were left there
```

It defaults to `profullstack/profullstack-sharable-keys--prod`, overridable with
`CLI_TOOLS_VAULT_TEAM`, `CLI_TOOLS_VAULT_PROJECT` and `CLI_TOOLS_VAULT_ENV`.
Needs the `logicsrc` CLI and a login (`moshcode install secrets`, then
`logicsrc login`); if it is missing, the error says so rather than failing
obscurely.

**It imports only the keys these commands read, and leaves the rest in the
vault.** Copying a whole vault down would make the local file a second copy of
every team secret that nobody remembers to invalidate — which is the thing the
vault exists to avoid. The vault stays the authority; this is a cache of the two
or three keys `generate-names` actually needs.

`logicsrc teams pull` can only write a decrypted `.env` to a path, so the
plaintext exists for the length of one read: it goes to a `0700` temporary
directory and is removed in a `finally`, including when the pull or the parse
fails.

Keys live in `~/.config/cli-tools/credentials.json`, written `0600` in a `0700`
directory (`$CLI_TOOLS_CREDENTIALS` overrides the path). Nothing prints a whole
key back — `config` shows a masked preview and a length, which is enough to tell
Expand Down
75 changes: 73 additions & 2 deletions bin/cli-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
mask,
saveStored,
} from '../src/credentials.ts';
import { pullVault, vaultTarget } from '../src/vault.ts';
import { isMain } from '../src/is-main.ts';
import {
aliasesPath,
Expand All @@ -45,7 +46,7 @@ const USAGE = `Usage:
cli-tools link [--force]
cli-tools unlink
cli-tools aliases [--install]
cli-tools config [set <key> [value] | unset <key>]
cli-tools config [pull | set <key> [value] | unset <key>]
cli-tools <command> [args…]

Commands:
Expand All @@ -55,6 +56,7 @@ Commands:
unlink Remove the symlinks we own
aliases Print the moshcode pit aliases, or write them with --install
config API keys: what is set, where it came from, and how to change it
"config pull" imports them from the logicsrc team vault
where Print the checkout this command is running from

Keys (config set <key>):
Expand Down Expand Up @@ -233,8 +235,77 @@ async function configCommand(rest: readonly string[], json: boolean): Promise<nu
return 0;
}

if (verb === 'pull') {
const target = vaultTarget();
const label = `${target.team}/${target.project}--${target.env}`;
process.stderr.write(`config: pulling ${label}…\n`);

let vault: Record<string, string>;
try {
vault = pullVault(target);
} catch (error) {
process.stderr.write(`config: ${(error as Error).message}\n`);
return 1;
}

// Only the keys these commands use. Copying the whole vault down would make
// this file a second, drifting copy of every team secret — which is the
// thing the vault exists to avoid.
const stored = loadStored();
const imported: string[] = [];
const unchanged: string[] = [];
for (const variable of Object.values(KNOWN_KEYS)) {
const value = vault[variable];
if (!value) continue;
if (stored[variable] === value) {
unchanged.push(variable);
continue;
}
stored[variable] = value;
imported.push(variable);
}

if (imported.length === 0 && unchanged.length === 0) {
process.stderr.write(
`config: ${label} has ${Object.keys(vault).length} keys, none of them ones these ` +
`commands use (${Object.values(KNOWN_KEYS).join(', ')}).\n`,
);
return 1;
}

if (imported.length > 0) {
const path = saveStored(stored);
for (const variable of imported) {
process.stdout.write(`config: imported ${variable} (${mask(stored[variable]!)})\n`);
}
process.stdout.write(`config: written to ${path}\n`);
}
for (const variable of unchanged) {
process.stdout.write(`config: ${variable} already matches the vault\n`);
}

const ignored = Object.keys(vault).filter(
(key) => !Object.values(KNOWN_KEYS).includes(key),
).length;
if (ignored > 0) {
process.stdout.write(
`\n${ignored} other key(s) in the vault were left there — this stores only what\n` +
'these commands read. The vault stays the authority.\n',
);
}

const shadowed = imported.filter((variable) => process.env[variable]);
if (shadowed.length > 0) {
process.stdout.write(
`\nNote: ${shadowed.join(', ')} ${shadowed.length === 1 ? 'is' : 'are'} also set in your\n` +
'environment, which wins over what was just stored.\n',
);
}
return 0;
}

if (verb !== 'set' && verb !== 'unset') {
process.stderr.write(`config: unknown verb "${verb}" (expected set or unset)\n`);
process.stderr.write(`config: unknown verb "${verb}" (expected set, unset or pull)\n`);
return 1;
}

Expand Down
29 changes: 27 additions & 2 deletions plugins/tools/commands/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,37 @@ Set up, inspect or clear the API keys `cli-tools` commands use.

```bash
cli-tools config # what is set, and where each key came from
cli-tools config set openai # prompts; the value is never echoed
cli-tools config set anthropic
cli-tools config pull # import from the logicsrc team vault
cli-tools config set openai # or by hand; prompts, never echoed
cli-tools config unset openai
cli-tools config --json # machine-readable, still masked
```

## From the team vault

`cli-tools config pull` is the normal way to set a machine up, and the way a
rotated key reaches one:

```bash
cli-tools config pull
```

It decrypts `profullstack/profullstack-sharable-keys--prod` and imports the keys
these commands use. Override the target with `CLI_TOOLS_VAULT_TEAM`,
`CLI_TOOLS_VAULT_PROJECT`, `CLI_TOOLS_VAULT_ENV`.

Needs the `logicsrc` CLI and a login — `moshcode install secrets`, then
`logicsrc login`. A missing CLI is reported as such rather than as a generic
failure.

**Only the keys these commands read are imported; the rest stay in the vault.**
Pulling a whole vault down would leave a second copy of every team secret on the
machine, drifting from the vault that is supposed to be the authority. This is a
cache of two or three keys, not a mirror.

The decrypted `.env` logicsrc writes lives in a `0700` temp directory for the
length of one read and is removed in a `finally`, including on failure.

Keys live in `~/.config/cli-tools/credentials.json`, written `0600` inside a
`0700` directory. `$CLI_TOOLS_CREDENTIALS` overrides the path.

Expand Down
123 changes: 123 additions & 0 deletions src/vault.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

/**
* Read API keys out of a logicsrc team vault.
*
* The vault is the authority; {@link ../credentials.ts} is a cache of the few
* keys these commands actually use. That direction matters — copying the whole
* vault down would make the local file a second, silently drifting copy of
* every team secret, which is the thing the vault exists to avoid.
*
* `logicsrc teams pull` can only write a decrypted `.env` to a path, so the
* plaintext exists on disk for the length of one read. It goes to a 0700
* temporary directory and is removed in a `finally`, including when the parse
* throws.
*/

export interface VaultTarget {
team: string;
project: string;
env: string;
}

/** The team vault holding account-level keys shared across the org. */
export const DEFAULT_TARGET: VaultTarget = {
team: 'profullstack',
project: 'profullstack-sharable-keys',
env: 'prod',
};

/** Resolve the target, letting the environment point at a different vault. */
export function vaultTarget(env: NodeJS.ProcessEnv = process.env): VaultTarget {
return {
team: env.CLI_TOOLS_VAULT_TEAM || DEFAULT_TARGET.team,
project: env.CLI_TOOLS_VAULT_PROJECT || DEFAULT_TARGET.project,
env: env.CLI_TOOLS_VAULT_ENV || DEFAULT_TARGET.env,
};
}

/** Parse a dotenv file. Only what a vault actually contains: KEY=value lines. */
export function parseEnvFile(text: string): Record<string, string> {
const parsed: Record<string, string> = {};
for (const raw of text.split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const at = line.indexOf('=');
if (at <= 0) continue;

const key = line.slice(0, at).trim();
let value = line.slice(at + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
(value.startsWith("'") && value.endsWith("'") && value.length > 1)
) {
value = value.slice(1, -1);
}
if (key && value) parsed[key] = value;
}
return parsed;
}

export type Runner = (args: readonly string[], envPath: string) => { status: number; stderr: string };

/** Shell out to the real logicsrc CLI. */
export const logicsrcRunner: Runner = (args, envPath) => {
const result = spawnSync('logicsrc', [...args, '--env', envPath], { encoding: 'utf8' });
if (result.error) {
const code = (result.error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {
status: 127,
stderr:
'logicsrc is not installed. Install it with `moshcode install secrets`, ' +
'or see https://logicsrc.com',
};
}
return { status: 1, stderr: result.error.message };
}
return { status: result.status ?? 1, stderr: result.stderr ?? '' };
};

/**
* Pull a vault and return its keys.
*
* The decrypted file never leaves this function, and the caller receives only
* the parsed record — so nothing downstream has a path it could accidentally
* leave lying around.
*/
export function pullVault(
target: VaultTarget = vaultTarget(),
run: Runner = logicsrcRunner,
): Record<string, string> {
const dir = mkdtempSync(join(tmpdir(), 'cli-tools-vault-'));
const envPath = join(dir, 'vault.env');

try {
const { status, stderr } = run(
['teams', 'pull', target.team, target.project, target.env],
envPath,
);
if (status !== 0) {
const detail = stderr.trim().split('\n').slice(-3).join('\n');
throw new Error(
`logicsrc teams pull ${target.team} ${target.project} ${target.env} failed` +
(detail ? `:\n${detail}` : '.'),
);
}

let text: string;
try {
text = readFileSync(envPath, 'utf8');
} catch {
throw new Error('logicsrc reported success but wrote no file — nothing imported.');
}
return parseEnvFile(text);
} finally {
// Recursive so the temp directory goes with it, and force so a failure
// before the file existed is not itself an error.
rmSync(dir, { recursive: true, force: true });
}
}
Loading
Loading