Skip to content

Commit 8970eb7

Browse files
authored
feat: add secret crud to rli (#71)
### Secret Commands (alias: `s`) ```bash rli secret create <name> # Create a new secret. Value can be piped in or entered via stdin (securely) rli secret list # List all secrets rli secret get <name> # Get secret metadata by name rli secret update <name> # Update a secret value Value can be piped in or entered via stdin (securely) rli secret delete <name> # Delete a secret ```
1 parent 047dbe4 commit 8970eb7

10 files changed

Lines changed: 897 additions & 0 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,16 @@ rli network-policy create # Create a new network policy
140140
rli network-policy delete <id> # Delete a network policy
141141
```
142142

143+
### Secret Commands (alias: `s`)
144+
145+
```bash
146+
rli secret create <name> # Create a new secret. Value can be pip...
147+
rli secret list # List all secrets
148+
rli secret get <name> # Get secret metadata by name
149+
rli secret update <name> # Update a secret value (value from std...
150+
rli secret delete <name> # Delete a secret
151+
```
152+
143153
### Mcp Commands
144154

145155
```bash

src/commands/secret/create.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Create secret command
3+
*/
4+
5+
import { getClient } from "../../utils/client.js";
6+
import { output, outputError } from "../../utils/output.js";
7+
import { getSecretValue } from "../../utils/stdin.js";
8+
9+
interface CreateOptions {
10+
output?: string;
11+
}
12+
13+
export async function createSecret(name: string, options: CreateOptions = {}) {
14+
try {
15+
// Get secret value from stdin (piped) or interactive prompt
16+
const value = await getSecretValue();
17+
18+
if (!value) {
19+
outputError("Secret value cannot be empty", new Error("Empty value"));
20+
}
21+
22+
const client = getClient();
23+
const secret = await client.secrets.create({ name, value });
24+
25+
// Default: just output the ID for easy scripting
26+
if (!options.output || options.output === "text") {
27+
console.log(secret.id);
28+
} else {
29+
output(secret, { format: options.output, defaultFormat: "json" });
30+
}
31+
} catch (error) {
32+
outputError("Failed to create secret", error);
33+
}
34+
}

src/commands/secret/delete.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Delete secret command
3+
*/
4+
5+
import * as readline from "readline";
6+
import { getClient } from "../../utils/client.js";
7+
import { output } from "../../utils/output.js";
8+
9+
interface DeleteOptions {
10+
yes?: boolean;
11+
output?: string;
12+
}
13+
14+
/**
15+
* Prompt for confirmation
16+
*/
17+
async function confirm(message: string): Promise<boolean> {
18+
return new Promise((resolve) => {
19+
const rl = readline.createInterface({
20+
input: process.stdin,
21+
output: process.stdout,
22+
});
23+
24+
rl.question(`${message} [y/N] `, (answer) => {
25+
rl.close();
26+
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
27+
});
28+
});
29+
}
30+
31+
export async function deleteSecret(name: string, options: DeleteOptions = {}) {
32+
try {
33+
const client = getClient();
34+
35+
// Confirm deletion unless --yes flag is passed
36+
if (!options.yes) {
37+
const confirmed = await confirm(
38+
`Are you sure you want to delete secret "${name}"?`,
39+
);
40+
if (!confirmed) {
41+
console.log("Aborted.");
42+
return;
43+
}
44+
}
45+
46+
// Delete by name
47+
const secret = await client.secrets.delete(name);
48+
49+
// Default: show confirmation message
50+
if (!options.output || options.output === "text") {
51+
console.log(`Deleted secret "${name}" (${secret.id})`);
52+
} else {
53+
output(
54+
{ id: secret.id, name, status: "deleted" },
55+
{ format: options.output, defaultFormat: "json" },
56+
);
57+
}
58+
} catch (error) {
59+
const errorMessage = error instanceof Error ? error.message : String(error);
60+
if (errorMessage.includes("404") || errorMessage.includes("not found")) {
61+
console.error(`Error: Secret "${name}" not found`);
62+
} else {
63+
console.error(`Error: Failed to delete secret`);
64+
console.error(` ${errorMessage}`);
65+
}
66+
process.exit(1);
67+
}
68+
}

src/commands/secret/get.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Get secret metadata command
3+
*
4+
* Note: The API doesn't have a direct "get by name" endpoint,
5+
* so we list all secrets and filter by name.
6+
*/
7+
8+
import { getClient } from "../../utils/client.js";
9+
import { output, outputError } from "../../utils/output.js";
10+
11+
interface GetOptions {
12+
output?: string;
13+
}
14+
15+
export async function getSecret(name: string, options: GetOptions = {}) {
16+
try {
17+
const client = getClient();
18+
19+
// List all secrets and find by name
20+
const result = await client.secrets.list({ limit: 5000 });
21+
const secret = result.secrets?.find((s) => s.name === name);
22+
23+
if (!secret) {
24+
outputError(`Secret "${name}" not found`, new Error("Secret not found"));
25+
}
26+
27+
output(secret, { format: options.output, defaultFormat: "json" });
28+
} catch (error) {
29+
outputError("Failed to get secret", error);
30+
}
31+
}

src/commands/secret/list.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* List secrets command
3+
*/
4+
5+
import { getClient } from "../../utils/client.js";
6+
import { output, outputError } from "../../utils/output.js";
7+
8+
interface ListOptions {
9+
limit?: string;
10+
output?: string;
11+
}
12+
13+
const DEFAULT_PAGE_SIZE = 20;
14+
15+
export async function listSecrets(options: ListOptions = {}) {
16+
try {
17+
const client = getClient();
18+
19+
const limit = options.limit
20+
? parseInt(options.limit, 10)
21+
: DEFAULT_PAGE_SIZE;
22+
23+
// Fetch secrets
24+
const result = await client.secrets.list({ limit });
25+
26+
// Extract secrets array
27+
const secrets = result.secrets || [];
28+
29+
// Default: output JSON for lists
30+
output(secrets, { format: options.output, defaultFormat: "json" });
31+
} catch (error) {
32+
outputError("Failed to list secrets", error);
33+
}
34+
}

src/commands/secret/update.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Update secret command
3+
*/
4+
5+
import { getClient } from "../../utils/client.js";
6+
import { output, outputError } from "../../utils/output.js";
7+
import { getSecretValue } from "../../utils/stdin.js";
8+
9+
interface UpdateOptions {
10+
output?: string;
11+
}
12+
13+
export async function updateSecret(name: string, options: UpdateOptions = {}) {
14+
try {
15+
// Get new secret value from stdin (piped) or interactive prompt
16+
const value = await getSecretValue();
17+
18+
if (!value) {
19+
outputError("Secret value cannot be empty", new Error("Empty value"));
20+
}
21+
22+
const client = getClient();
23+
const secret = await client.secrets.update(name, { value });
24+
25+
// Default: just output the ID for easy scripting
26+
if (!options.output || options.output === "text") {
27+
console.log(secret.id);
28+
} else {
29+
output(secret, { format: options.output, defaultFormat: "json" });
30+
}
31+
} catch (error) {
32+
outputError("Failed to update secret", error);
33+
}
34+
}

src/utils/commands.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,76 @@ export function createProgram(): Command {
684684
await deleteNetworkPolicy(id, options);
685685
});
686686

687+
// Secret commands
688+
const secret = program
689+
.command("secret")
690+
.description("Manage secrets")
691+
.alias("s");
692+
693+
secret
694+
.command("create <name>")
695+
.description(
696+
"Create a new secret. Value can be piped via stdin (e.g., echo 'val' | rli secret create name) or entered interactively with masked input for security.",
697+
)
698+
.option(
699+
"-o, --output [format]",
700+
"Output format: text|json|yaml (default: text)",
701+
)
702+
.action(async (name, options) => {
703+
const { createSecret } = await import("../commands/secret/create.js");
704+
await createSecret(name, options);
705+
});
706+
707+
secret
708+
.command("list")
709+
.description("List all secrets")
710+
.option("--limit <n>", "Max results", "20")
711+
.option(
712+
"-o, --output [format]",
713+
"Output format: text|json|yaml (default: json)",
714+
)
715+
.action(async (options) => {
716+
const { listSecrets } = await import("../commands/secret/list.js");
717+
await listSecrets(options);
718+
});
719+
720+
secret
721+
.command("get <name>")
722+
.description("Get secret metadata by name")
723+
.option(
724+
"-o, --output [format]",
725+
"Output format: text|json|yaml (default: json)",
726+
)
727+
.action(async (name, options) => {
728+
const { getSecret } = await import("../commands/secret/get.js");
729+
await getSecret(name, options);
730+
});
731+
732+
secret
733+
.command("update <name>")
734+
.description("Update a secret value (value from stdin or secure prompt)")
735+
.option(
736+
"-o, --output [format]",
737+
"Output format: text|json|yaml (default: text)",
738+
)
739+
.action(async (name, options) => {
740+
const { updateSecret } = await import("../commands/secret/update.js");
741+
await updateSecret(name, options);
742+
});
743+
744+
secret
745+
.command("delete <name>")
746+
.description("Delete a secret")
747+
.option("-y, --yes", "Skip confirmation prompt")
748+
.option(
749+
"-o, --output [format]",
750+
"Output format: text|json|yaml (default: text)",
751+
)
752+
.action(async (name, options) => {
753+
const { deleteSecret } = await import("../commands/secret/delete.js");
754+
await deleteSecret(name, options);
755+
});
756+
687757
// MCP server commands
688758
const mcp = program
689759
.command("mcp")

src/utils/stdin.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Utilities for reading secure input from stdin
3+
*/
4+
5+
/**
6+
* Prompt for a secret value with masked input (shows * for each character)
7+
* Only works when stdin is a TTY (interactive terminal)
8+
*/
9+
export async function promptSecretValue(
10+
prompt = "Enter secret value: ",
11+
): Promise<string> {
12+
return new Promise((resolve) => {
13+
process.stdout.write(prompt);
14+
15+
let value = "";
16+
process.stdin.setRawMode(true);
17+
process.stdin.resume();
18+
process.stdin.setEncoding("utf8");
19+
20+
const onData = (char: string) => {
21+
if (char === "\n" || char === "\r") {
22+
process.stdin.setRawMode(false);
23+
process.stdin.removeListener("data", onData);
24+
process.stdin.pause();
25+
process.stdout.write("\n");
26+
resolve(value);
27+
} else if (char === "\u0003") {
28+
// Ctrl+C
29+
process.stdin.setRawMode(false);
30+
process.stdout.write("\n");
31+
process.exit(0);
32+
} else if (char === "\u007F" || char === "\b") {
33+
// Backspace (0x7F) or Ctrl+H (0x08)
34+
if (value.length > 0) {
35+
value = value.slice(0, -1);
36+
process.stdout.write("\b \b");
37+
}
38+
} else if (char >= " ") {
39+
// Only add printable characters
40+
value += char;
41+
process.stdout.write("*");
42+
}
43+
};
44+
45+
process.stdin.on("data", onData);
46+
});
47+
}
48+
49+
/**
50+
* Read all data from stdin (for piped input)
51+
*/
52+
export async function readStdin(): Promise<string> {
53+
const chunks: Buffer[] = [];
54+
for await (const chunk of process.stdin) {
55+
chunks.push(Buffer.from(chunk));
56+
}
57+
return Buffer.concat(chunks).toString().trim();
58+
}
59+
60+
/**
61+
* Get a secret value from either piped stdin or interactive prompt
62+
* Automatically detects whether input is piped or interactive
63+
*/
64+
export async function getSecretValue(
65+
prompt = "Enter secret value: ",
66+
): Promise<string> {
67+
if (process.stdin.isTTY) {
68+
return promptSecretValue(prompt);
69+
} else {
70+
return readStdin();
71+
}
72+
}

0 commit comments

Comments
 (0)