relic 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # Relic CLI
2
+
3
+ Zero-knowledge secret layer CLI. Fetches encrypted secrets from the server, decrypts them locally, and injects them into the process environment.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun install
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ | Command | Description |
14
+ |---------|-------------|
15
+ | `relic` | Launch the TUI (default) |
16
+ | `relic login` | Authenticate via device code flow |
17
+ | `relic logout` | Clear session, cached keys, and password |
18
+ | `relic whoami` | Show current user (name, email, plan) |
19
+ | `relic projects` | List projects with environments and folders |
20
+ | `relic init` | Create `relic.toml` and `.relic/` directory |
21
+ | `relic run` | Run a command with secrets injected |
22
+ | `relic telemetry status` | Show telemetry status |
23
+ | `relic telemetry enable` | Enable telemetry |
24
+ | `relic telemetry disable` | Disable telemetry |
25
+
26
+ ### `relic run`
27
+
28
+ ```bash
29
+ relic run -e <environment> [options] -- <command>
30
+ ```
31
+
32
+ | Flag | Description |
33
+ |------|-------------|
34
+ | `-e, --environment` | Environment name (required) |
35
+ | `-f, --folder` | Folder name |
36
+ | `-s, --scope` | `client`, `server`, or `shared` |
37
+ | `-p, --project` | Project ID (overrides `relic.toml`) |
38
+
39
+ ```bash
40
+ relic run -e production -- npm run deploy
41
+ relic run -e staging -f database -- ./migrate.sh
42
+ relic run -e production -s client -- npm run build
43
+ ```
44
+
45
+ ## Configuration
46
+
47
+ `relic.toml` in project root:
48
+
49
+ ```toml
50
+ project_id = "<uuid>"
51
+ ```
52
+
53
+ Created by `relic init`. The CLI walks up from the current directory to find it.
54
+
55
+ ## Runner (FFI)
56
+
57
+ Secret injection uses a Rust binary (`packages/runner`) loaded via Bun FFI (`dlopen`). The runner:
58
+
59
+ - Spawns the child process with a clean environment (`env_clear()`)
60
+ - Injects only the decrypted secrets
61
+ - Forwards signals (SIGTERM, SIGINT)
62
+ - Uses `Zeroizing` for secret memory and disables core dumps
63
+
64
+ Prebuilt binaries in `prebuilds/` for: `darwin-arm64`, `darwin-x64`, `linux-x64`, `win32-x64`.
65
+
66
+ ## Caching
67
+
68
+ Local SQLite cache at `.relic/cache.db` (relative to `relic.toml` location). Used in session mode only; API key mode always fetches fresh data.
69
+
70
+ **Cached data:** environment/folder ID mappings, encrypted secrets, encrypted project key.
71
+
72
+ **Invalidation:** on each `relic run`, the CLI compares local `lastCachedAt` against the backend `updatedAt`. Stale cache triggers a fresh fetch. Key rotation invalidates all caches.
73
+
74
+ Scope filtering (`--scope`) is applied locally against cached data.
75
+
76
+ ## CI/CD
77
+
78
+ Use API keys instead of interactive login:
79
+
80
+ | Variable | Description |
81
+ |----------|-------------|
82
+ | `RELIC_API_KEY` | API key for authentication |
83
+ | `RELIC_PASSWORD` | Master password for decryption |
84
+ | `RELIC_PROJECT_ID` | Project ID (optional if `relic.toml` exists) |
85
+ | `CONVEX_SITE_URL` | Convex HTTP actions URL |
86
+
87
+ ### GitHub Actions
88
+
89
+ ```yaml
90
+ - name: Deploy with secrets
91
+ env:
92
+ RELIC_API_KEY: ${{ secrets.RELIC_API_KEY }}
93
+ RELIC_PASSWORD: ${{ secrets.RELIC_PASSWORD }}
94
+ RELIC_PROJECT_ID: ${{ secrets.RELIC_PROJECT_ID }}
95
+ CONVEX_URL: ${{ secrets.CONVEX_URL }}
96
+ CONVEX_SITE_URL: ${{ secrets.CONVEX_SITE_URL }}
97
+ run: bunx relic run -e production -- npm run deploy
98
+ ```
99
+
100
+ ## Structure
101
+
102
+ ```
103
+ ├── index.ts # Entry point (commander setup)
104
+ ├── commands/
105
+ │ ├── init.ts # relic init
106
+ │ ├── login.ts # relic login
107
+ │ ├── logout.ts # relic logout
108
+ │ ├── whoami.ts # relic whoami
109
+ │ ├── projects.ts # relic projects
110
+ │ ├── run.ts # relic run
111
+ │ └── telemetry.ts # relic telemetry
112
+ ├── lib/
113
+ │ ├── api.ts # Convex API client, secret export
114
+ │ ├── config.ts # relic.toml loading/saving
115
+ │ ├── crypto.ts # Secret decryption helpers
116
+ │ └── types.ts # SecretScope type
117
+ ├── ffi/
118
+ │ ├── bridge.ts # RunnerBridge FFI wrapper
119
+ │ ├── helper.ts # Library loading (dlopen)
120
+ │ └── constants.ts # URLs
121
+ └── helpers/
122
+ └── cache.ts # SQLite cache
123
+ ```
124
+
125
+ ## Development
126
+
127
+ ```bash
128
+ bun run index.ts
129
+ ```
@@ -0,0 +1,105 @@
1
+ import * as p from "@clack/prompts";
2
+ import { AuthenticationError, validateSession } from "@repo/auth";
3
+ import { createLogger, trackEvent } from "@repo/logger";
4
+ import pc from "picocolors";
5
+
6
+ const log = createLogger("cli");
7
+
8
+ import { getApi, type ProjectListItem } from "../lib/api";
9
+ import {
10
+ configExists,
11
+ createConfig,
12
+ createRelicDir,
13
+ getConfigFilePath,
14
+ saveConfig,
15
+ } from "../lib/config";
16
+
17
+ interface ProjectOption extends ProjectListItem {
18
+ isShared: boolean;
19
+ }
20
+
21
+ export default async function init() {
22
+ p.intro(pc.bgCyan(pc.black(" relic init ")));
23
+
24
+ if (await configExists()) {
25
+ p.log.warn(pc.yellow("Already initialized"));
26
+ p.outro(pc.dim("Delete relic.toml to re-initialize"));
27
+ return;
28
+ }
29
+
30
+ const spinner = p.spinner();
31
+ spinner.start("Checking authentication...");
32
+
33
+ const sessionValidation = await validateSession();
34
+ if (!sessionValidation.isValid || sessionValidation.isExpired) {
35
+ spinner.stop("Not logged in");
36
+ p.log.error(pc.yellow("Not logged in"));
37
+ p.outro(pc.dim("Run `relic login` to authenticate"));
38
+ process.exit(1);
39
+ }
40
+
41
+ spinner.message("Loading projects...");
42
+
43
+ try {
44
+ const api = getApi();
45
+ const [ownedProjects, sharedProjects] = await Promise.all([
46
+ api.listProjects(),
47
+ api.listSharedProjects(),
48
+ ]);
49
+
50
+ const allProjects: ProjectOption[] = [
51
+ ...ownedProjects.filter((p) => !p.isArchived).map((p) => ({ ...p, isShared: false })),
52
+ ...sharedProjects.filter((p) => !p.isArchived).map((p) => ({ ...p, isShared: true })),
53
+ ];
54
+
55
+ spinner.stop("Projects loaded");
56
+
57
+ if (allProjects.length === 0) {
58
+ p.log.warn(pc.yellow("No projects found. Run `relic` to create a new project"));
59
+ process.exit(1);
60
+ }
61
+
62
+ const selectedProjectId = await p.select({
63
+ message: "Select a project",
64
+ options: allProjects.map((project) => ({
65
+ value: project.id,
66
+ label: project.isShared ? `${project.name} ${pc.dim("(shared)")}` : project.name,
67
+ })),
68
+ });
69
+
70
+ if (p.isCancel(selectedProjectId)) {
71
+ p.cancel("Operation cancelled");
72
+ process.exit(0);
73
+ }
74
+
75
+ const selectedProject = allProjects.find((p) => p.id === selectedProjectId);
76
+ if (!selectedProject) {
77
+ p.log.error(pc.red("Failed to find selected project"));
78
+ process.exit(1);
79
+ }
80
+
81
+ spinner.start("Saving configuration...");
82
+ const config = createConfig(selectedProject.id);
83
+ await saveConfig(config);
84
+ await createRelicDir();
85
+ spinner.stop("Configuration saved");
86
+
87
+ trackEvent("cli_project_initialized", { success: true });
88
+ p.log.success(pc.green(`Initialized Relic for ${selectedProject.name}`));
89
+ p.outro(pc.dim(`Config saved to ${getConfigFilePath()}`));
90
+ } catch (err) {
91
+ spinner.stop("Failed");
92
+
93
+ if (err instanceof AuthenticationError) {
94
+ p.log.error(pc.yellow("Not logged in"));
95
+ p.outro(pc.dim("Run `relic login` to authenticate"));
96
+ process.exit(1);
97
+ }
98
+
99
+ log.error("Init failed", err);
100
+ trackEvent("cli_project_initialized", { success: false });
101
+ const message = err instanceof Error ? err.message : "Failed to initialize";
102
+ p.log.error(pc.red(`Error: ${message}`));
103
+ process.exit(1);
104
+ }
105
+ }
@@ -0,0 +1,85 @@
1
+ import {
2
+ type DeviceAuthStatus,
3
+ type DeviceCodeResponse,
4
+ deviceAuth,
5
+ validateSession,
6
+ } from "@repo/auth";
7
+ import { createLogger, trackEvent } from "@repo/logger";
8
+ import ora from "ora";
9
+ import pc from "picocolors";
10
+
11
+ const log = createLogger("cli");
12
+
13
+ function formatCode(code: string): string {
14
+ if (code.includes("-")) return code;
15
+ if (code.length === 8) return `${code.slice(0, 4)}-${code.slice(4)}`;
16
+ return code;
17
+ }
18
+
19
+ export default async function login() {
20
+ const spinner = ora("Connecting to server...").start();
21
+
22
+ try {
23
+ // Check if already logged in
24
+ const sessionValidation = await validateSession();
25
+ if (sessionValidation.isValid && !sessionValidation.isExpired) {
26
+ spinner.succeed(pc.green("Already logged in"));
27
+ return;
28
+ }
29
+
30
+ trackEvent("cli_login_started");
31
+
32
+ let userCode: string | null = null;
33
+ let verificationUri: string | null = null;
34
+ let currentStatus: DeviceAuthStatus | "starting" | "approved" = "starting";
35
+
36
+ const result = await deviceAuth.startAuth({
37
+ onCodeReceived: (code: DeviceCodeResponse) => {
38
+ userCode = code.user_code;
39
+ verificationUri = code.verification_uri_complete;
40
+
41
+ spinner.stop();
42
+ console.log();
43
+ console.log("Your verification code:");
44
+ console.log();
45
+ console.log(` ${pc.bold(pc.green(formatCode(userCode)))}`);
46
+ console.log();
47
+ if (verificationUri) {
48
+ console.log(pc.dim(`Opening browser to: ${verificationUri}`));
49
+ }
50
+ console.log();
51
+ spinner.start("Waiting for authorization...");
52
+ },
53
+ onStatusChange: (newStatus: DeviceAuthStatus) => {
54
+ currentStatus = newStatus;
55
+ },
56
+ onSuccess: () => {
57
+ currentStatus = "approved";
58
+ },
59
+ onError: (error: Error) => {
60
+ spinner.fail(pc.red(`Error: ${error.message}`));
61
+ process.exit(1);
62
+ },
63
+ });
64
+
65
+ if (result.success) {
66
+ trackEvent("cli_login_completed", { success: true });
67
+ spinner.succeed(pc.green("Login successful!"));
68
+ } else if (result.error) {
69
+ trackEvent("cli_login_completed", { success: false, reason: currentStatus });
70
+ if ((currentStatus as string) === "denied") {
71
+ spinner.fail(pc.red("Authorization denied"));
72
+ } else if ((currentStatus as string) === "expired") {
73
+ spinner.fail(pc.red("Code expired. Please try again."));
74
+ } else {
75
+ spinner.fail(pc.red(`Error: ${result.error.message}`));
76
+ }
77
+ process.exit(1);
78
+ }
79
+ } catch (err) {
80
+ log.error("Login failed", err);
81
+ trackEvent("cli_login_completed", { success: false });
82
+ spinner.fail(pc.red(err instanceof Error ? err.message : String(err)));
83
+ process.exit(1);
84
+ }
85
+ }
@@ -0,0 +1,39 @@
1
+ import {
2
+ clearCachedUserKeys,
3
+ clearPassword,
4
+ clearSession,
5
+ getUserKeyCacheDb,
6
+ validateSession,
7
+ } from "@repo/auth";
8
+ import { createLogger, trackEvent } from "@repo/logger";
9
+ import ora from "ora";
10
+ import pc from "picocolors";
11
+
12
+ const log = createLogger("cli");
13
+
14
+ export default async function logout() {
15
+ const spinner = ora("Checking session...").start();
16
+
17
+ try {
18
+ const session = await validateSession();
19
+
20
+ if (!session.isValid) {
21
+ spinner.warn(pc.yellow("Not logged in"));
22
+ return;
23
+ }
24
+
25
+ spinner.text = "Logging out...";
26
+ const userKeyDb = await getUserKeyCacheDb();
27
+ clearCachedUserKeys(userKeyDb);
28
+ await clearSession();
29
+ await clearPassword();
30
+ trackEvent("cli_logout", { success: true });
31
+ spinner.succeed(pc.green("Logged out"));
32
+ } catch (err) {
33
+ log.error("Logout failed", err);
34
+ trackEvent("cli_logout", { success: false });
35
+ const message = err instanceof Error ? err.message : "Failed to logout";
36
+ spinner.fail(pc.red(`Error: ${message}`));
37
+ process.exit(1);
38
+ }
39
+ }
@@ -0,0 +1,154 @@
1
+ import { validateSession } from "@repo/auth";
2
+ import { trackEvent } from "@repo/logger";
3
+ import ora from "ora";
4
+ import pc from "picocolors";
5
+ import { type Environment, type Folder, getApi, type ProjectListItem } from "../lib/api";
6
+
7
+ interface ProjectWithDetails extends ProjectListItem {
8
+ isShared: boolean;
9
+ environments: Array<Environment & { folders: Folder[] }>;
10
+ }
11
+
12
+ const TREE = {
13
+ BRANCH: "├── ",
14
+ LAST_BRANCH: "└── ",
15
+ VERTICAL: "│ ",
16
+ EMPTY: " ",
17
+ } as const;
18
+
19
+ function renderProjectTree(projects: ProjectWithDetails[]): void {
20
+ if (projects.length === 0) {
21
+ console.log(pc.dim("No projects found"));
22
+ console.log(pc.dim("Create one at app.relic.so"));
23
+ return;
24
+ }
25
+
26
+ console.log(pc.bold("Your Projects"));
27
+ console.log();
28
+
29
+ for (let projectIndex = 0; projectIndex < projects.length; projectIndex++) {
30
+ const project = projects[projectIndex]!;
31
+ const isLastProject = projectIndex === projects.length - 1;
32
+ const projectPrefix = isLastProject ? TREE.LAST_BRANCH : TREE.BRANCH;
33
+ const childPrefix = isLastProject ? TREE.EMPTY : TREE.VERTICAL;
34
+
35
+ const badges: string[] = [];
36
+ if (project.isShared) badges.push("shared");
37
+ if (project.isArchived) badges.push("archived");
38
+ const badgeText = badges.length > 0 ? pc.dim(` (${badges.join(", ")})`) : "";
39
+
40
+ const projectName = project.isArchived ? pc.dim(project.name) : pc.bold(project.name);
41
+
42
+ console.log(`${pc.dim(projectPrefix)}${projectName}${badgeText}`);
43
+
44
+ for (let envIndex = 0; envIndex < project.environments.length; envIndex++) {
45
+ const env = project.environments[envIndex]!;
46
+ const isLastEnv = envIndex === project.environments.length - 1;
47
+ const envPrefix = isLastEnv ? TREE.LAST_BRANCH : TREE.BRANCH;
48
+ const envChildPrefix = isLastEnv ? TREE.EMPTY : TREE.VERTICAL;
49
+
50
+ const envColor = env.color || "white";
51
+ const colorFn =
52
+ envColor in pc
53
+ ? (pc as unknown as Record<string, (s: string) => string>)[envColor]!
54
+ : (s: string) => s;
55
+ console.log(`${pc.dim(childPrefix)}${pc.dim(envPrefix)}${colorFn(env.name)}`);
56
+
57
+ for (let folderIndex = 0; folderIndex < env.folders.length; folderIndex++) {
58
+ const folder = env.folders[folderIndex]!;
59
+ const isLastFolder = folderIndex === env.folders.length - 1;
60
+ const folderPrefix = isLastFolder ? TREE.LAST_BRANCH : TREE.BRANCH;
61
+
62
+ console.log(
63
+ `${pc.dim(childPrefix)}${pc.dim(envChildPrefix)}${pc.dim(folderPrefix)}${pc.dim(`${folder.name}/`)}`,
64
+ );
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ export default async function projects() {
71
+ const spinner = ora("Connecting...").start();
72
+
73
+ try {
74
+ const sessionValidation = await validateSession();
75
+ if (!sessionValidation.isValid || sessionValidation.isExpired) {
76
+ spinner.stop();
77
+ console.log(pc.yellow("Not logged in"));
78
+ console.log(pc.dim("Run `relic login` to authenticate"));
79
+ return;
80
+ }
81
+
82
+ const api = getApi();
83
+
84
+ spinner.text = "Fetching projects...";
85
+ const [ownedProjects, sharedProjects] = await Promise.all([
86
+ api.listProjects(),
87
+ api.listSharedProjects(),
88
+ ]);
89
+
90
+ const allProjects: ProjectWithDetails[] = [
91
+ ...ownedProjects.map((p) => ({
92
+ ...p,
93
+ isShared: false,
94
+ environments: [] as Array<Environment & { folders: Folder[] }>,
95
+ })),
96
+ ...sharedProjects.map((p) => ({
97
+ ...p,
98
+ isShared: true,
99
+ environments: [] as Array<Environment & { folders: Folder[] }>,
100
+ })),
101
+ ];
102
+
103
+ spinner.text = "Fetching environments...";
104
+ const projectsWithEnvs = await Promise.all(
105
+ allProjects.map(async (project) => {
106
+ try {
107
+ const environments = await api.getProjectEnvironments(project.id);
108
+ return {
109
+ ...project,
110
+ environments: environments.map((e) => ({ ...e, folders: [] as Folder[] })),
111
+ };
112
+ } catch {
113
+ return { ...project, environments: [] };
114
+ }
115
+ }),
116
+ );
117
+
118
+ spinner.text = "Fetching folders...";
119
+ const projectsWithFolders = await Promise.all(
120
+ projectsWithEnvs.map(async (project) => {
121
+ const environmentsWithFolders = await Promise.all(
122
+ project.environments.map(async (env) => {
123
+ try {
124
+ const data = await api.getEnvironmentData(env.id);
125
+ return { ...env, folders: data.folders };
126
+ } catch {
127
+ return { ...env, folders: [] };
128
+ }
129
+ }),
130
+ );
131
+ return { ...project, environments: environmentsWithFolders };
132
+ }),
133
+ );
134
+
135
+ trackEvent("cli_command_executed", { command: "projects", count: projectsWithFolders.length });
136
+ spinner.stop();
137
+ renderProjectTree(projectsWithFolders);
138
+ } catch (err) {
139
+ const message = err instanceof Error ? err.message : "Failed to fetch projects";
140
+ // Handle auth-related errors more gracefully
141
+ if (
142
+ message.includes("Not authenticated") ||
143
+ message.includes("JWT") ||
144
+ message.includes("token")
145
+ ) {
146
+ spinner.stop();
147
+ console.log(pc.yellow("Not logged in"));
148
+ console.log(pc.dim("Run `relic login` to authenticate"));
149
+ return;
150
+ }
151
+ spinner.fail(pc.red(`Error: ${message}`));
152
+ process.exit(1);
153
+ }
154
+ }