Skip to content

Commit fbe4073

Browse files
ralyodioclaude
andcommitted
cli-tools config pull: take API keys from the logicsrc team vault
Setting a machine up meant knowing which of ~180 vaults happened to carry the OpenAI key and pulling it by hand — and a rotated key reached a machine only when somebody remembered to repeat that. Meanwhile the same key was already sitting in 27 prod vaults, which is duplication nobody chose. So there is now a shared vault, profullstack-sharable-keys--prod, holding the account-level keys that are one account across many projects, and: cli-tools config pull decrypts it and imports what these commands read. It imports ONLY those keys and says how many it left behind. Copying the whole vault down would put a second copy of every team secret on the machine, drifting from the thing that is supposed to be authoritative — the failure the vault exists to prevent. This is a cache of two or three keys, not a mirror, and the direction of authority is the point. `logicsrc teams pull` can only write a decrypted .env to a path, so plaintext exists for the length of one read: a 0700 temp directory, removed in a finally so a failed pull or an unparseable file cannot leave it behind. Tests assert the directory is gone on both paths, because that is the whole risk of the feature. A missing logicsrc is reported as a missing logicsrc, with the command that installs it, rather than as an exec failure three layers down. The runner is injectable, so none of the 13 new tests talk to a real vault. 180 tests pass (was 167), typecheck clean. Verified against the real vault: imports 2 of 13 keys, second run reports both already matching, credentials.json stays 0600, and no temp directory survives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fb38671 commit fbe4073

5 files changed

Lines changed: 365 additions & 5 deletions

File tree

README.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,42 @@ ln -sf ~/scripts/bin/gh-prs-merge ~/.local/bin/gh-prs-merge # and so on
9797
has to carry it in an environment again:
9898

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

106+
### From the team vault
107+
108+
`cli-tools config pull` decrypts the shared logicsrc vault and imports the keys
109+
these commands use — the fastest way to set a new machine up, and the way a
110+
rotated key reaches it:
111+
112+
```sh
113+
cli-tools config pull
114+
# config: imported OPENAI_API_KEY (sk-pr…ZyAA (164 chars))
115+
# config: imported ANTHROPIC_API_KEY (sk-an…uAAA (108 chars))
116+
# 11 other key(s) in the vault were left there
117+
```
118+
119+
It defaults to `profullstack/profullstack-sharable-keys--prod`, overridable with
120+
`CLI_TOOLS_VAULT_TEAM`, `CLI_TOOLS_VAULT_PROJECT` and `CLI_TOOLS_VAULT_ENV`.
121+
Needs the `logicsrc` CLI and a login (`moshcode install secrets`, then
122+
`logicsrc login`); if it is missing, the error says so rather than failing
123+
obscurely.
124+
125+
**It imports only the keys these commands read, and leaves the rest in the
126+
vault.** Copying a whole vault down would make the local file a second copy of
127+
every team secret that nobody remembers to invalidate — which is the thing the
128+
vault exists to avoid. The vault stays the authority; this is a cache of the two
129+
or three keys `generate-names` actually needs.
130+
131+
`logicsrc teams pull` can only write a decrypted `.env` to a path, so the
132+
plaintext exists for the length of one read: it goes to a `0700` temporary
133+
directory and is removed in a `finally`, including when the pull or the parse
134+
fails.
135+
105136
Keys live in `~/.config/cli-tools/credentials.json`, written `0600` in a `0700`
106137
directory (`$CLI_TOOLS_CREDENTIALS` overrides the path). Nothing prints a whole
107138
key back — `config` shows a masked preview and a length, which is enough to tell

bin/cli-tools.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
mask,
3030
saveStored,
3131
} from '../src/credentials.ts';
32+
import { pullVault, vaultTarget } from '../src/vault.ts';
3233
import { isMain } from '../src/is-main.ts';
3334
import {
3435
aliasesPath,
@@ -45,7 +46,7 @@ const USAGE = `Usage:
4546
cli-tools link [--force]
4647
cli-tools unlink
4748
cli-tools aliases [--install]
48-
cli-tools config [set <key> [value] | unset <key>]
49+
cli-tools config [pull | set <key> [value] | unset <key>]
4950
cli-tools <command> [args…]
5051
5152
Commands:
@@ -55,6 +56,7 @@ Commands:
5556
unlink Remove the symlinks we own
5657
aliases Print the moshcode pit aliases, or write them with --install
5758
config API keys: what is set, where it came from, and how to change it
59+
"config pull" imports them from the logicsrc team vault
5860
where Print the checkout this command is running from
5961
6062
Keys (config set <key>):
@@ -233,8 +235,77 @@ async function configCommand(rest: readonly string[], json: boolean): Promise<nu
233235
return 0;
234236
}
235237

238+
if (verb === 'pull') {
239+
const target = vaultTarget();
240+
const label = `${target.team}/${target.project}--${target.env}`;
241+
process.stderr.write(`config: pulling ${label}…\n`);
242+
243+
let vault: Record<string, string>;
244+
try {
245+
vault = pullVault(target);
246+
} catch (error) {
247+
process.stderr.write(`config: ${(error as Error).message}\n`);
248+
return 1;
249+
}
250+
251+
// Only the keys these commands use. Copying the whole vault down would make
252+
// this file a second, drifting copy of every team secret — which is the
253+
// thing the vault exists to avoid.
254+
const stored = loadStored();
255+
const imported: string[] = [];
256+
const unchanged: string[] = [];
257+
for (const variable of Object.values(KNOWN_KEYS)) {
258+
const value = vault[variable];
259+
if (!value) continue;
260+
if (stored[variable] === value) {
261+
unchanged.push(variable);
262+
continue;
263+
}
264+
stored[variable] = value;
265+
imported.push(variable);
266+
}
267+
268+
if (imported.length === 0 && unchanged.length === 0) {
269+
process.stderr.write(
270+
`config: ${label} has ${Object.keys(vault).length} keys, none of them ones these ` +
271+
`commands use (${Object.values(KNOWN_KEYS).join(', ')}).\n`,
272+
);
273+
return 1;
274+
}
275+
276+
if (imported.length > 0) {
277+
const path = saveStored(stored);
278+
for (const variable of imported) {
279+
process.stdout.write(`config: imported ${variable} (${mask(stored[variable]!)})\n`);
280+
}
281+
process.stdout.write(`config: written to ${path}\n`);
282+
}
283+
for (const variable of unchanged) {
284+
process.stdout.write(`config: ${variable} already matches the vault\n`);
285+
}
286+
287+
const ignored = Object.keys(vault).filter(
288+
(key) => !Object.values(KNOWN_KEYS).includes(key),
289+
).length;
290+
if (ignored > 0) {
291+
process.stdout.write(
292+
`\n${ignored} other key(s) in the vault were left there — this stores only what\n` +
293+
'these commands read. The vault stays the authority.\n',
294+
);
295+
}
296+
297+
const shadowed = imported.filter((variable) => process.env[variable]);
298+
if (shadowed.length > 0) {
299+
process.stdout.write(
300+
`\nNote: ${shadowed.join(', ')} ${shadowed.length === 1 ? 'is' : 'are'} also set in your\n` +
301+
'environment, which wins over what was just stored.\n',
302+
);
303+
}
304+
return 0;
305+
}
306+
236307
if (verb !== 'set' && verb !== 'unset') {
237-
process.stderr.write(`config: unknown verb "${verb}" (expected set or unset)\n`);
308+
process.stderr.write(`config: unknown verb "${verb}" (expected set, unset or pull)\n`);
238309
return 1;
239310
}
240311

plugins/tools/commands/config.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,37 @@ Set up, inspect or clear the API keys `cli-tools` commands use.
99

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

18+
## From the team vault
19+
20+
`cli-tools config pull` is the normal way to set a machine up, and the way a
21+
rotated key reaches one:
22+
23+
```bash
24+
cli-tools config pull
25+
```
26+
27+
It decrypts `profullstack/profullstack-sharable-keys--prod` and imports the keys
28+
these commands use. Override the target with `CLI_TOOLS_VAULT_TEAM`,
29+
`CLI_TOOLS_VAULT_PROJECT`, `CLI_TOOLS_VAULT_ENV`.
30+
31+
Needs the `logicsrc` CLI and a login — `moshcode install secrets`, then
32+
`logicsrc login`. A missing CLI is reported as such rather than as a generic
33+
failure.
34+
35+
**Only the keys these commands read are imported; the rest stay in the vault.**
36+
Pulling a whole vault down would leave a second copy of every team secret on the
37+
machine, drifting from the vault that is supposed to be the authority. This is a
38+
cache of two or three keys, not a mirror.
39+
40+
The decrypted `.env` logicsrc writes lives in a `0700` temp directory for the
41+
length of one read and is removed in a `finally`, including on failure.
42+
1843
Keys live in `~/.config/cli-tools/credentials.json`, written `0600` inside a
1944
`0700` directory. `$CLI_TOOLS_CREDENTIALS` overrides the path.
2045

src/vault.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { spawnSync } from 'node:child_process';
2+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
6+
/**
7+
* Read API keys out of a logicsrc team vault.
8+
*
9+
* The vault is the authority; {@link ../credentials.ts} is a cache of the few
10+
* keys these commands actually use. That direction matters — copying the whole
11+
* vault down would make the local file a second, silently drifting copy of
12+
* every team secret, which is the thing the vault exists to avoid.
13+
*
14+
* `logicsrc teams pull` can only write a decrypted `.env` to a path, so the
15+
* plaintext exists on disk for the length of one read. It goes to a 0700
16+
* temporary directory and is removed in a `finally`, including when the parse
17+
* throws.
18+
*/
19+
20+
export interface VaultTarget {
21+
team: string;
22+
project: string;
23+
env: string;
24+
}
25+
26+
/** The team vault holding account-level keys shared across the org. */
27+
export const DEFAULT_TARGET: VaultTarget = {
28+
team: 'profullstack',
29+
project: 'profullstack-sharable-keys',
30+
env: 'prod',
31+
};
32+
33+
/** Resolve the target, letting the environment point at a different vault. */
34+
export function vaultTarget(env: NodeJS.ProcessEnv = process.env): VaultTarget {
35+
return {
36+
team: env.CLI_TOOLS_VAULT_TEAM || DEFAULT_TARGET.team,
37+
project: env.CLI_TOOLS_VAULT_PROJECT || DEFAULT_TARGET.project,
38+
env: env.CLI_TOOLS_VAULT_ENV || DEFAULT_TARGET.env,
39+
};
40+
}
41+
42+
/** Parse a dotenv file. Only what a vault actually contains: KEY=value lines. */
43+
export function parseEnvFile(text: string): Record<string, string> {
44+
const parsed: Record<string, string> = {};
45+
for (const raw of text.split('\n')) {
46+
const line = raw.trim();
47+
if (!line || line.startsWith('#')) continue;
48+
const at = line.indexOf('=');
49+
if (at <= 0) continue;
50+
51+
const key = line.slice(0, at).trim();
52+
let value = line.slice(at + 1).trim();
53+
if (
54+
(value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
55+
(value.startsWith("'") && value.endsWith("'") && value.length > 1)
56+
) {
57+
value = value.slice(1, -1);
58+
}
59+
if (key && value) parsed[key] = value;
60+
}
61+
return parsed;
62+
}
63+
64+
export type Runner = (args: readonly string[], envPath: string) => { status: number; stderr: string };
65+
66+
/** Shell out to the real logicsrc CLI. */
67+
export const logicsrcRunner: Runner = (args, envPath) => {
68+
const result = spawnSync('logicsrc', [...args, '--env', envPath], { encoding: 'utf8' });
69+
if (result.error) {
70+
const code = (result.error as NodeJS.ErrnoException).code;
71+
if (code === 'ENOENT') {
72+
return {
73+
status: 127,
74+
stderr:
75+
'logicsrc is not installed. Install it with `moshcode install secrets`, ' +
76+
'or see https://logicsrc.com',
77+
};
78+
}
79+
return { status: 1, stderr: result.error.message };
80+
}
81+
return { status: result.status ?? 1, stderr: result.stderr ?? '' };
82+
};
83+
84+
/**
85+
* Pull a vault and return its keys.
86+
*
87+
* The decrypted file never leaves this function, and the caller receives only
88+
* the parsed record — so nothing downstream has a path it could accidentally
89+
* leave lying around.
90+
*/
91+
export function pullVault(
92+
target: VaultTarget = vaultTarget(),
93+
run: Runner = logicsrcRunner,
94+
): Record<string, string> {
95+
const dir = mkdtempSync(join(tmpdir(), 'cli-tools-vault-'));
96+
const envPath = join(dir, 'vault.env');
97+
98+
try {
99+
const { status, stderr } = run(
100+
['teams', 'pull', target.team, target.project, target.env],
101+
envPath,
102+
);
103+
if (status !== 0) {
104+
const detail = stderr.trim().split('\n').slice(-3).join('\n');
105+
throw new Error(
106+
`logicsrc teams pull ${target.team} ${target.project} ${target.env} failed` +
107+
(detail ? `:\n${detail}` : '.'),
108+
);
109+
}
110+
111+
let text: string;
112+
try {
113+
text = readFileSync(envPath, 'utf8');
114+
} catch {
115+
throw new Error('logicsrc reported success but wrote no file — nothing imported.');
116+
}
117+
return parseEnvFile(text);
118+
} finally {
119+
// Recursive so the temp directory goes with it, and force so a failure
120+
// before the file existed is not itself an error.
121+
rmSync(dir, { recursive: true, force: true });
122+
}
123+
}

0 commit comments

Comments
 (0)