@motive_sx/envm 1.0.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 +151 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +61 -0
- package/dist/commands/edit.d.ts +2 -0
- package/dist/commands/edit.js +57 -0
- package/dist/commands/envs.d.ts +2 -0
- package/dist/commands/envs.js +46 -0
- package/dist/commands/get.d.ts +2 -0
- package/dist/commands/get.js +45 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +17 -0
- package/dist/commands/list.d.ts +2 -0
- package/dist/commands/list.js +50 -0
- package/dist/commands/pull.d.ts +2 -0
- package/dist/commands/pull.js +37 -0
- package/dist/commands/push.d.ts +2 -0
- package/dist/commands/push.js +44 -0
- package/dist/commands/server.d.ts +2 -0
- package/dist/commands/server.js +20 -0
- package/dist/commands/set.d.ts +2 -0
- package/dist/commands/set.js +49 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +31 -0
- package/dist/lib/client.d.ts +6 -0
- package/dist/lib/client.js +57 -0
- package/dist/lib/config.d.ts +5 -0
- package/dist/lib/config.js +32 -0
- package/dist/lib/crypto.d.ts +8 -0
- package/dist/lib/crypto.js +61 -0
- package/dist/lib/env-parser.d.ts +20 -0
- package/dist/lib/env-parser.js +63 -0
- package/dist/lib/server.d.ts +3 -0
- package/dist/lib/server.js +146 -0
- package/dist/lib/storage.d.ts +11 -0
- package/dist/lib/storage.js +119 -0
- package/dist/types/index.d.ts +30 -0
- package/dist/types/index.js +1 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# envm
|
|
2
|
+
|
|
3
|
+
Secure environment variable manager - store and sync .env files across machines.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Encrypted storage**: All env files are encrypted with AES-256-GCM
|
|
8
|
+
- **Multi-environment support**: Manage dev, staging, prod, and custom environments
|
|
9
|
+
- **Remote sync**: Pull and push env files between server and local machine
|
|
10
|
+
- **Simple CLI**: Easy-to-use commands for managing environment variables
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g envm
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or run directly with npx:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx envm <command>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
### On your server
|
|
27
|
+
|
|
28
|
+
1. Start the envm server:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
envm server start --port 3737
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
2. Create a project and add variables:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
envm init myapp
|
|
38
|
+
envm set myapp dev DB_HOST=localhost DB_PORT=5432
|
|
39
|
+
envm set myapp prod DB_HOST=prod.example.com DB_PORT=5432
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### On your local machine
|
|
43
|
+
|
|
44
|
+
1. Open an SSH tunnel to your server:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
ssh -L 3737:localhost:3737 your-server
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
2. Configure the CLI:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
envm config set server http://localhost:3737
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
3. Pull env files:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
cd your-project
|
|
60
|
+
envm pull myapp dev # Saves to .env
|
|
61
|
+
envm pull myapp prod -o .env.production
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
4. Push local changes:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
envm push myapp dev # Pushes .env
|
|
68
|
+
envm push myapp staging -f .env.staging
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Commands
|
|
72
|
+
|
|
73
|
+
### Server Commands
|
|
74
|
+
|
|
75
|
+
| Command | Description |
|
|
76
|
+
|---------|-------------|
|
|
77
|
+
| `envm server start [--port 3737]` | Start the envm server |
|
|
78
|
+
| `envm init <project>` | Create a new project |
|
|
79
|
+
| `envm set <project> [env] KEY=value...` | Set environment variables |
|
|
80
|
+
| `envm get <project> [env] [key]` | Get environment variables |
|
|
81
|
+
| `envm edit <project> [env]` | Edit env in your default editor |
|
|
82
|
+
| `envm list` | List all projects |
|
|
83
|
+
| `envm envs <project>` | List environments for a project |
|
|
84
|
+
|
|
85
|
+
### Client Commands
|
|
86
|
+
|
|
87
|
+
| Command | Description |
|
|
88
|
+
|---------|-------------|
|
|
89
|
+
| `envm config set server <url>` | Configure remote server URL |
|
|
90
|
+
| `envm config get [key]` | View configuration |
|
|
91
|
+
| `envm pull <project> [env]` | Pull env from remote to local .env |
|
|
92
|
+
| `envm push <project> [env]` | Push local .env to remote |
|
|
93
|
+
| `envm list -r` | List projects from remote |
|
|
94
|
+
| `envm envs <project> -r` | List environments from remote |
|
|
95
|
+
|
|
96
|
+
### Options
|
|
97
|
+
|
|
98
|
+
- `[env]` defaults to `dev` if not specified
|
|
99
|
+
- `--output, -o <file>` - Specify output file for pull (default: `.env`)
|
|
100
|
+
- `--file, -f <file>` - Specify input file for push (default: `.env`)
|
|
101
|
+
- `--force` - Overwrite existing files without prompting
|
|
102
|
+
- `--create` - Create project on push if it doesn't exist
|
|
103
|
+
- `--remote, -r` - Force remote server operation
|
|
104
|
+
|
|
105
|
+
## Data Storage
|
|
106
|
+
|
|
107
|
+
### Server
|
|
108
|
+
|
|
109
|
+
All data is stored in `~/.envm/`:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
~/.envm/
|
|
113
|
+
├── master.key # Auto-generated encryption key
|
|
114
|
+
├── projects.json # Project metadata
|
|
115
|
+
└── projects/
|
|
116
|
+
└── myapp/
|
|
117
|
+
├── dev.enc # Encrypted env files
|
|
118
|
+
├── staging.enc
|
|
119
|
+
└── prod.enc
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Client
|
|
123
|
+
|
|
124
|
+
Client configuration is stored in `~/.envm/config.json`.
|
|
125
|
+
|
|
126
|
+
## Security
|
|
127
|
+
|
|
128
|
+
- **Encryption**: All env files are encrypted with AES-256-GCM
|
|
129
|
+
- **Master key**: Auto-generated on first run, stored in `~/.envm/master.key`
|
|
130
|
+
- **Localhost only**: Server binds to 127.0.0.1 by default (use SSH tunnel for remote access)
|
|
131
|
+
- **No auth required**: Security is provided by SSH tunnel
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# Install dependencies
|
|
137
|
+
npm install
|
|
138
|
+
|
|
139
|
+
# Run in development
|
|
140
|
+
npm run dev -- <command>
|
|
141
|
+
|
|
142
|
+
# Build
|
|
143
|
+
npm run build
|
|
144
|
+
|
|
145
|
+
# Run built version
|
|
146
|
+
npm start -- <command>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { loadConfig, setServerUrl } from "../lib/config.js";
|
|
4
|
+
export const configCommand = new Command("config")
|
|
5
|
+
.description("Manage CLI configuration");
|
|
6
|
+
configCommand
|
|
7
|
+
.command("set")
|
|
8
|
+
.description("Set a configuration value")
|
|
9
|
+
.argument("<key>", "Configuration key (server)")
|
|
10
|
+
.argument("<value>", "Configuration value")
|
|
11
|
+
.action((key, value) => {
|
|
12
|
+
try {
|
|
13
|
+
if (key === "server") {
|
|
14
|
+
// Validate URL
|
|
15
|
+
try {
|
|
16
|
+
new URL(value);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
console.error(chalk.red("Invalid URL format"));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
setServerUrl(value);
|
|
23
|
+
console.log(chalk.green(`✓ Server URL set to ${value}`));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
console.error(chalk.red(`Unknown config key: ${key}`));
|
|
27
|
+
console.log(chalk.dim("Available keys: server"));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
configCommand
|
|
37
|
+
.command("get")
|
|
38
|
+
.description("Get a configuration value")
|
|
39
|
+
.argument("[key]", "Configuration key (optional, shows all if omitted)")
|
|
40
|
+
.action((key) => {
|
|
41
|
+
try {
|
|
42
|
+
const config = loadConfig();
|
|
43
|
+
if (key) {
|
|
44
|
+
if (key === "server") {
|
|
45
|
+
console.log(config.server || chalk.dim("(not set)"));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
console.error(chalk.red(`Unknown config key: ${key}`));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
console.log(chalk.bold("Configuration:"));
|
|
54
|
+
console.log(` server: ${config.server || chalk.dim("(not set)")}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import * as storage from "../lib/storage.js";
|
|
8
|
+
export const editCommand = new Command("edit")
|
|
9
|
+
.description("Edit environment in your default editor")
|
|
10
|
+
.argument("<project>", "Project name")
|
|
11
|
+
.argument("[env]", "Environment name", "dev")
|
|
12
|
+
.action(async (project, env) => {
|
|
13
|
+
try {
|
|
14
|
+
// Get existing content or start fresh
|
|
15
|
+
let content = "";
|
|
16
|
+
try {
|
|
17
|
+
content = storage.getEnvContent(project, env);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Environment doesn't exist yet, start with empty
|
|
21
|
+
console.log(chalk.dim(`Creating new environment: ${project}/${env}`));
|
|
22
|
+
}
|
|
23
|
+
// Create temp file
|
|
24
|
+
const tmpDir = os.tmpdir();
|
|
25
|
+
const tmpFile = path.join(tmpDir, `envm-${project}-${env}-${Date.now()}.env`);
|
|
26
|
+
fs.writeFileSync(tmpFile, content);
|
|
27
|
+
// Get editor
|
|
28
|
+
const editor = process.env.EDITOR || process.env.VISUAL || (process.platform === "win32" ? "notepad" : "vi");
|
|
29
|
+
// Open editor
|
|
30
|
+
const child = spawn(editor, [tmpFile], {
|
|
31
|
+
stdio: "inherit",
|
|
32
|
+
shell: true,
|
|
33
|
+
});
|
|
34
|
+
child.on("exit", (code) => {
|
|
35
|
+
if (code === 0) {
|
|
36
|
+
// Read modified content
|
|
37
|
+
const newContent = fs.readFileSync(tmpFile, "utf-8");
|
|
38
|
+
storage.setEnvContent(project, env, newContent);
|
|
39
|
+
console.log(chalk.green(`✓ Updated ${project}/${env}`));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.log(chalk.yellow("Editor exited with non-zero code, changes not saved"));
|
|
43
|
+
}
|
|
44
|
+
// Clean up temp file
|
|
45
|
+
try {
|
|
46
|
+
fs.unlinkSync(tmpFile);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Ignore cleanup errors
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import * as storage from "../lib/storage.js";
|
|
4
|
+
import * as client from "../lib/client.js";
|
|
5
|
+
import { getServerUrl } from "../lib/config.js";
|
|
6
|
+
export const envsCommand = new Command("envs")
|
|
7
|
+
.description("List environments for a project")
|
|
8
|
+
.argument("<project>", "Project name")
|
|
9
|
+
.option("-r, --remote", "List environments from remote server")
|
|
10
|
+
.action(async (project, options) => {
|
|
11
|
+
try {
|
|
12
|
+
if (options.remote || getServerUrl()) {
|
|
13
|
+
try {
|
|
14
|
+
const environments = await client.fetchEnvironments(project);
|
|
15
|
+
if (environments.length === 0) {
|
|
16
|
+
console.log(chalk.dim(`No environments found for project "${project}"`));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
console.log(chalk.bold(`Environments for ${chalk.cyan(project)} (remote):`));
|
|
20
|
+
for (const env of environments) {
|
|
21
|
+
console.log(` ${chalk.green(env)}`);
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (options.remote) {
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const environments = storage.listEnvironments(project);
|
|
32
|
+
if (environments.length === 0) {
|
|
33
|
+
console.log(chalk.dim(`No environments found for project "${project}"`));
|
|
34
|
+
console.log(chalk.dim(`Add one with: envm set ${project} dev KEY=value`));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
console.log(chalk.bold(`Environments for ${chalk.cyan(project)}:`));
|
|
38
|
+
for (const env of environments) {
|
|
39
|
+
console.log(` ${chalk.green(env)}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import * as storage from "../lib/storage.js";
|
|
4
|
+
import { parseEnv } from "../lib/env-parser.js";
|
|
5
|
+
export const getCommand = new Command("get")
|
|
6
|
+
.description("Get environment variable value")
|
|
7
|
+
.argument("<project>", "Project name")
|
|
8
|
+
.argument("[env]", "Environment name", "dev")
|
|
9
|
+
.argument("[key]", "Variable key (optional, shows all if omitted)")
|
|
10
|
+
.action((project, env, key) => {
|
|
11
|
+
try {
|
|
12
|
+
// Check if env is actually a key (no env specified)
|
|
13
|
+
if (env && !["dev", "staging", "prod", "production", "test", "local"].includes(env) && !key) {
|
|
14
|
+
key = env;
|
|
15
|
+
env = "dev";
|
|
16
|
+
}
|
|
17
|
+
const content = storage.getEnvContent(project, env);
|
|
18
|
+
const data = parseEnv(content);
|
|
19
|
+
if (key) {
|
|
20
|
+
if (key in data) {
|
|
21
|
+
console.log(data[key]);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
console.error(chalk.red(`Variable "${key}" not found in ${project}/${env}`));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
// Show all variables
|
|
30
|
+
const keys = Object.keys(data);
|
|
31
|
+
if (keys.length === 0) {
|
|
32
|
+
console.log(chalk.dim("No variables set"));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
console.log(chalk.bold(`Variables in ${chalk.cyan(project)}/${chalk.green(env)}:`));
|
|
36
|
+
for (const [k, v] of Object.entries(data)) {
|
|
37
|
+
console.log(` ${chalk.yellow(k)}=${v}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import * as storage from "../lib/storage.js";
|
|
4
|
+
export const initCommand = new Command("init")
|
|
5
|
+
.description("Initialize a new project")
|
|
6
|
+
.argument("<project>", "Project name")
|
|
7
|
+
.action((project) => {
|
|
8
|
+
try {
|
|
9
|
+
storage.createProject(project);
|
|
10
|
+
console.log(chalk.green(`✓ Project "${project}" created successfully`));
|
|
11
|
+
console.log(chalk.dim(` Add environment variables with: envm set ${project} KEY=value`));
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import * as storage from "../lib/storage.js";
|
|
4
|
+
import * as client from "../lib/client.js";
|
|
5
|
+
import { getServerUrl } from "../lib/config.js";
|
|
6
|
+
export const listCommand = new Command("list")
|
|
7
|
+
.description("List all projects")
|
|
8
|
+
.option("-r, --remote", "List projects from remote server")
|
|
9
|
+
.action(async (options) => {
|
|
10
|
+
try {
|
|
11
|
+
if (options.remote || getServerUrl()) {
|
|
12
|
+
// Try remote first if configured
|
|
13
|
+
try {
|
|
14
|
+
const projects = await client.fetchProjects();
|
|
15
|
+
if (projects.length === 0) {
|
|
16
|
+
console.log(chalk.dim("No projects found on remote server"));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
console.log(chalk.bold("Projects (remote):"));
|
|
20
|
+
for (const project of projects) {
|
|
21
|
+
const envCount = project.environments.length;
|
|
22
|
+
console.log(` ${chalk.cyan(project.name)} ${chalk.dim(`(${envCount} env${envCount !== 1 ? "s" : ""})`)}`);
|
|
23
|
+
}
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (options.remote) {
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
// Fall through to local if remote fails and not explicitly requested
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Local projects
|
|
34
|
+
const projects = storage.listProjects();
|
|
35
|
+
if (projects.length === 0) {
|
|
36
|
+
console.log(chalk.dim("No projects found"));
|
|
37
|
+
console.log(chalk.dim("Create one with: envm init <project-name>"));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
console.log(chalk.bold("Projects:"));
|
|
41
|
+
for (const project of projects) {
|
|
42
|
+
const envCount = project.environments.length;
|
|
43
|
+
console.log(` ${chalk.cyan(project.name)} ${chalk.dim(`(${envCount} env${envCount !== 1 ? "s" : ""})`)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import * as client from "../lib/client.js";
|
|
6
|
+
export const pullCommand = new Command("pull")
|
|
7
|
+
.description("Pull environment file from remote server")
|
|
8
|
+
.argument("<project>", "Project name")
|
|
9
|
+
.argument("[env]", "Environment name", "dev")
|
|
10
|
+
.option("-o, --output <file>", "Output file path", ".env")
|
|
11
|
+
.option("-f, --force", "Overwrite existing file without prompting")
|
|
12
|
+
.action(async (project, env, options) => {
|
|
13
|
+
try {
|
|
14
|
+
const outputPath = path.resolve(options.output);
|
|
15
|
+
// Check if file exists
|
|
16
|
+
if (fs.existsSync(outputPath) && !options.force) {
|
|
17
|
+
console.log(chalk.yellow(`File ${options.output} already exists.`));
|
|
18
|
+
console.log(chalk.dim("Use --force to overwrite"));
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
console.log(chalk.dim(`Pulling ${project}/${env} from remote...`));
|
|
22
|
+
const content = await client.fetchEnvContent(project, env);
|
|
23
|
+
// Ensure directory exists
|
|
24
|
+
const dir = path.dirname(outputPath);
|
|
25
|
+
if (!fs.existsSync(dir)) {
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
fs.writeFileSync(outputPath, content);
|
|
29
|
+
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")).length;
|
|
30
|
+
console.log(chalk.green(`✓ Pulled ${project}/${env} to ${options.output}`));
|
|
31
|
+
console.log(chalk.dim(` ${lines} variable${lines !== 1 ? "s" : ""}`));
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import * as client from "../lib/client.js";
|
|
6
|
+
export const pushCommand = new Command("push")
|
|
7
|
+
.description("Push environment file to remote server")
|
|
8
|
+
.argument("<project>", "Project name")
|
|
9
|
+
.argument("[env]", "Environment name", "dev")
|
|
10
|
+
.option("-f, --file <file>", "Input file path", ".env")
|
|
11
|
+
.option("--create", "Create project if it doesn't exist")
|
|
12
|
+
.action(async (project, env, options) => {
|
|
13
|
+
try {
|
|
14
|
+
const inputPath = path.resolve(options.file);
|
|
15
|
+
// Check if file exists
|
|
16
|
+
if (!fs.existsSync(inputPath)) {
|
|
17
|
+
console.error(chalk.red(`File not found: ${options.file}`));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
const content = fs.readFileSync(inputPath, "utf-8");
|
|
21
|
+
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")).length;
|
|
22
|
+
console.log(chalk.dim(`Pushing ${options.file} to ${project}/${env}...`));
|
|
23
|
+
try {
|
|
24
|
+
await client.pushEnvContent(project, env, content);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
const message = error.message;
|
|
28
|
+
if (message.includes("not found") && options.create) {
|
|
29
|
+
console.log(chalk.dim(`Creating project "${project}"...`));
|
|
30
|
+
await client.createRemoteProject(project);
|
|
31
|
+
await client.pushEnvContent(project, env, content);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
console.log(chalk.green(`✓ Pushed ${options.file} to ${project}/${env}`));
|
|
38
|
+
console.log(chalk.dim(` ${lines} variable${lines !== 1 ? "s" : ""}`));
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { startServer } from "../lib/server.js";
|
|
3
|
+
export const serverCommand = new Command("server")
|
|
4
|
+
.description("Server management commands");
|
|
5
|
+
serverCommand
|
|
6
|
+
.command("start")
|
|
7
|
+
.description("Start the envm server")
|
|
8
|
+
.option("-p, --port <port>", "Port to listen on", "3737")
|
|
9
|
+
.option("-d, --data-dir <dir>", "Data directory for storing encrypted envs")
|
|
10
|
+
.action((options) => {
|
|
11
|
+
if (options.dataDir) {
|
|
12
|
+
process.env.ENVM_DATA_DIR = options.dataDir;
|
|
13
|
+
}
|
|
14
|
+
const port = parseInt(options.port, 10);
|
|
15
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
16
|
+
console.error("Invalid port number");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
startServer(port);
|
|
20
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import * as storage from "../lib/storage.js";
|
|
4
|
+
import { parseEnv, serializeEnv, mergeEnv, parseKeyValue } from "../lib/env-parser.js";
|
|
5
|
+
export const setCommand = new Command("set")
|
|
6
|
+
.description("Set environment variable(s)")
|
|
7
|
+
.argument("<project>", "Project name")
|
|
8
|
+
.argument("[env]", "Environment name", "dev")
|
|
9
|
+
.argument("<keyvalues...>", "KEY=value pairs to set")
|
|
10
|
+
.action((project, env, keyvalues) => {
|
|
11
|
+
try {
|
|
12
|
+
// Check if env is actually a KEY=value (no env specified)
|
|
13
|
+
if (env.includes("=")) {
|
|
14
|
+
keyvalues = [env, ...keyvalues];
|
|
15
|
+
env = "dev";
|
|
16
|
+
}
|
|
17
|
+
// Parse all KEY=value pairs
|
|
18
|
+
const updates = {};
|
|
19
|
+
for (const kv of keyvalues) {
|
|
20
|
+
const parsed = parseKeyValue(kv);
|
|
21
|
+
if (!parsed) {
|
|
22
|
+
console.error(chalk.red(`Invalid format: "${kv}". Use KEY=value`));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
updates[parsed.key] = parsed.value;
|
|
26
|
+
}
|
|
27
|
+
// Get existing content or start fresh
|
|
28
|
+
let existingContent = "";
|
|
29
|
+
try {
|
|
30
|
+
existingContent = storage.getEnvContent(project, env);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Environment doesn't exist yet
|
|
34
|
+
}
|
|
35
|
+
const existingData = parseEnv(existingContent);
|
|
36
|
+
const mergedData = mergeEnv(existingData, updates);
|
|
37
|
+
const newContent = serializeEnv(mergedData);
|
|
38
|
+
storage.setEnvContent(project, env, newContent);
|
|
39
|
+
const keys = Object.keys(updates);
|
|
40
|
+
console.log(chalk.green(`✓ Set ${keys.length} variable${keys.length !== 1 ? "s" : ""} in ${project}/${env}`));
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
console.log(chalk.dim(` ${key}=***`));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { serverCommand } from "./commands/server.js";
|
|
4
|
+
import { initCommand } from "./commands/init.js";
|
|
5
|
+
import { listCommand } from "./commands/list.js";
|
|
6
|
+
import { envsCommand } from "./commands/envs.js";
|
|
7
|
+
import { setCommand } from "./commands/set.js";
|
|
8
|
+
import { getCommand } from "./commands/get.js";
|
|
9
|
+
import { editCommand } from "./commands/edit.js";
|
|
10
|
+
import { configCommand } from "./commands/config.js";
|
|
11
|
+
import { pullCommand } from "./commands/pull.js";
|
|
12
|
+
import { pushCommand } from "./commands/push.js";
|
|
13
|
+
const program = new Command();
|
|
14
|
+
program
|
|
15
|
+
.name("envm")
|
|
16
|
+
.description("Secure environment variable manager - store and sync .env files across machines")
|
|
17
|
+
.version("1.0.0");
|
|
18
|
+
// Server commands
|
|
19
|
+
program.addCommand(serverCommand);
|
|
20
|
+
// Local/Server commands
|
|
21
|
+
program.addCommand(initCommand);
|
|
22
|
+
program.addCommand(listCommand);
|
|
23
|
+
program.addCommand(envsCommand);
|
|
24
|
+
program.addCommand(setCommand);
|
|
25
|
+
program.addCommand(getCommand);
|
|
26
|
+
program.addCommand(editCommand);
|
|
27
|
+
// Client commands
|
|
28
|
+
program.addCommand(configCommand);
|
|
29
|
+
program.addCommand(pullCommand);
|
|
30
|
+
program.addCommand(pushCommand);
|
|
31
|
+
program.parse();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Project } from "../types/index.js";
|
|
2
|
+
export declare function fetchProjects(): Promise<Project[]>;
|
|
3
|
+
export declare function fetchEnvironments(projectName: string): Promise<string[]>;
|
|
4
|
+
export declare function fetchEnvContent(projectName: string, envName: string): Promise<string>;
|
|
5
|
+
export declare function pushEnvContent(projectName: string, envName: string, content: string): Promise<void>;
|
|
6
|
+
export declare function createRemoteProject(projectName: string): Promise<Project>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { getServerUrl } from "./config.js";
|
|
2
|
+
class ApiError extends Error {
|
|
3
|
+
statusCode;
|
|
4
|
+
constructor(message, statusCode) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.statusCode = statusCode;
|
|
7
|
+
this.name = "ApiError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
async function request(method, endpoint, body) {
|
|
11
|
+
const serverUrl = getServerUrl();
|
|
12
|
+
if (!serverUrl) {
|
|
13
|
+
throw new ApiError("Server not configured. Run: envm config set server <url>");
|
|
14
|
+
}
|
|
15
|
+
const url = `${serverUrl}${endpoint}`;
|
|
16
|
+
const options = {
|
|
17
|
+
method,
|
|
18
|
+
headers: {
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
if (body) {
|
|
23
|
+
options.body = JSON.stringify(body);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(url, options);
|
|
27
|
+
const data = (await response.json());
|
|
28
|
+
if (!response.ok || !data.success) {
|
|
29
|
+
throw new ApiError(data.error || `Request failed with status ${response.status}`, response.status);
|
|
30
|
+
}
|
|
31
|
+
return data.data;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof ApiError) {
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
throw new ApiError(`Failed to connect to server: ${error.message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function fetchProjects() {
|
|
41
|
+
const data = await request("GET", "/projects");
|
|
42
|
+
return data.projects;
|
|
43
|
+
}
|
|
44
|
+
export async function fetchEnvironments(projectName) {
|
|
45
|
+
const data = await request("GET", `/projects/${encodeURIComponent(projectName)}/envs`);
|
|
46
|
+
return data.environments;
|
|
47
|
+
}
|
|
48
|
+
export async function fetchEnvContent(projectName, envName) {
|
|
49
|
+
const data = await request("GET", `/projects/${encodeURIComponent(projectName)}/envs/${encodeURIComponent(envName)}`);
|
|
50
|
+
return data.content;
|
|
51
|
+
}
|
|
52
|
+
export async function pushEnvContent(projectName, envName, content) {
|
|
53
|
+
await request("PUT", `/projects/${encodeURIComponent(projectName)}/envs/${encodeURIComponent(envName)}`, { content });
|
|
54
|
+
}
|
|
55
|
+
export async function createRemoteProject(projectName) {
|
|
56
|
+
return await request("POST", `/projects/${encodeURIComponent(projectName)}`);
|
|
57
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ClientConfig } from "../types/index.js";
|
|
2
|
+
export declare function loadConfig(): ClientConfig;
|
|
3
|
+
export declare function saveConfig(config: ClientConfig): void;
|
|
4
|
+
export declare function getServerUrl(): string | undefined;
|
|
5
|
+
export declare function setServerUrl(url: string): void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function getConfigDir() {
|
|
4
|
+
return path.join(process.env.HOME || process.env.USERPROFILE || "~", ".envm");
|
|
5
|
+
}
|
|
6
|
+
function getConfigPath() {
|
|
7
|
+
return path.join(getConfigDir(), "config.json");
|
|
8
|
+
}
|
|
9
|
+
export function loadConfig() {
|
|
10
|
+
const configPath = getConfigPath();
|
|
11
|
+
if (!fs.existsSync(configPath)) {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
15
|
+
return JSON.parse(content);
|
|
16
|
+
}
|
|
17
|
+
export function saveConfig(config) {
|
|
18
|
+
const configDir = getConfigDir();
|
|
19
|
+
if (!fs.existsSync(configDir)) {
|
|
20
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
const configPath = getConfigPath();
|
|
23
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
24
|
+
}
|
|
25
|
+
export function getServerUrl() {
|
|
26
|
+
return loadConfig().server;
|
|
27
|
+
}
|
|
28
|
+
export function setServerUrl(url) {
|
|
29
|
+
const config = loadConfig();
|
|
30
|
+
config.server = url;
|
|
31
|
+
saveConfig(config);
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function getDataDir(): string;
|
|
2
|
+
export declare function getMasterKeyPath(): string;
|
|
3
|
+
export declare function ensureDataDir(): void;
|
|
4
|
+
export declare function generateMasterKey(): Buffer;
|
|
5
|
+
export declare function saveMasterKey(key: Buffer): void;
|
|
6
|
+
export declare function loadMasterKey(): Buffer;
|
|
7
|
+
export declare function encrypt(plaintext: string, key: Buffer): string;
|
|
8
|
+
export declare function decrypt(encryptedData: string, key: Buffer): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const ALGORITHM = "aes-256-gcm";
|
|
5
|
+
const KEY_LENGTH = 32; // 256 bits
|
|
6
|
+
const IV_LENGTH = 16;
|
|
7
|
+
const AUTH_TAG_LENGTH = 16;
|
|
8
|
+
export function getDataDir() {
|
|
9
|
+
return process.env.ENVM_DATA_DIR || path.join(process.env.HOME || process.env.USERPROFILE || "~", ".envm");
|
|
10
|
+
}
|
|
11
|
+
export function getMasterKeyPath() {
|
|
12
|
+
return path.join(getDataDir(), "master.key");
|
|
13
|
+
}
|
|
14
|
+
export function ensureDataDir() {
|
|
15
|
+
const dataDir = getDataDir();
|
|
16
|
+
if (!fs.existsSync(dataDir)) {
|
|
17
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function generateMasterKey() {
|
|
21
|
+
return crypto.randomBytes(KEY_LENGTH);
|
|
22
|
+
}
|
|
23
|
+
export function saveMasterKey(key) {
|
|
24
|
+
ensureDataDir();
|
|
25
|
+
const keyPath = getMasterKeyPath();
|
|
26
|
+
fs.writeFileSync(keyPath, key.toString("hex"), { mode: 0o600 });
|
|
27
|
+
}
|
|
28
|
+
export function loadMasterKey() {
|
|
29
|
+
const keyPath = getMasterKeyPath();
|
|
30
|
+
if (!fs.existsSync(keyPath)) {
|
|
31
|
+
// Auto-generate master key on first use
|
|
32
|
+
const key = generateMasterKey();
|
|
33
|
+
saveMasterKey(key);
|
|
34
|
+
return key;
|
|
35
|
+
}
|
|
36
|
+
const hexKey = fs.readFileSync(keyPath, "utf-8").trim();
|
|
37
|
+
return Buffer.from(hexKey, "hex");
|
|
38
|
+
}
|
|
39
|
+
export function encrypt(plaintext, key) {
|
|
40
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
41
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
42
|
+
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
|
43
|
+
encrypted += cipher.final("hex");
|
|
44
|
+
const authTag = cipher.getAuthTag();
|
|
45
|
+
// Format: iv:authTag:encryptedData (all hex)
|
|
46
|
+
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
|
|
47
|
+
}
|
|
48
|
+
export function decrypt(encryptedData, key) {
|
|
49
|
+
const parts = encryptedData.split(":");
|
|
50
|
+
if (parts.length !== 3) {
|
|
51
|
+
throw new Error("Invalid encrypted data format");
|
|
52
|
+
}
|
|
53
|
+
const [ivHex, authTagHex, encrypted] = parts;
|
|
54
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
55
|
+
const authTag = Buffer.from(authTagHex, "hex");
|
|
56
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
57
|
+
decipher.setAuthTag(authTag);
|
|
58
|
+
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
|
59
|
+
decrypted += decipher.final("utf8");
|
|
60
|
+
return decrypted;
|
|
61
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { EnvData } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse .env file content into key-value object
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseEnv(content: string): EnvData;
|
|
6
|
+
/**
|
|
7
|
+
* Serialize key-value object to .env file content
|
|
8
|
+
*/
|
|
9
|
+
export declare function serializeEnv(data: EnvData): string;
|
|
10
|
+
/**
|
|
11
|
+
* Merge new values into existing env data
|
|
12
|
+
*/
|
|
13
|
+
export declare function mergeEnv(existing: EnvData, updates: EnvData): EnvData;
|
|
14
|
+
/**
|
|
15
|
+
* Parse KEY=value string
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseKeyValue(input: string): {
|
|
18
|
+
key: string;
|
|
19
|
+
value: string;
|
|
20
|
+
} | null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse .env file content into key-value object
|
|
3
|
+
*/
|
|
4
|
+
export function parseEnv(content) {
|
|
5
|
+
const result = {};
|
|
6
|
+
const lines = content.split("\n");
|
|
7
|
+
for (const line of lines) {
|
|
8
|
+
// Skip empty lines and comments
|
|
9
|
+
const trimmed = line.trim();
|
|
10
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
// Find first = sign
|
|
14
|
+
const eqIndex = trimmed.indexOf("=");
|
|
15
|
+
if (eqIndex === -1) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const key = trimmed.substring(0, eqIndex).trim();
|
|
19
|
+
let value = trimmed.substring(eqIndex + 1).trim();
|
|
20
|
+
// Remove quotes if present
|
|
21
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
22
|
+
value = value.slice(1, -1);
|
|
23
|
+
}
|
|
24
|
+
if (key) {
|
|
25
|
+
result[key] = value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Serialize key-value object to .env file content
|
|
32
|
+
*/
|
|
33
|
+
export function serializeEnv(data) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
for (const [key, value] of Object.entries(data)) {
|
|
36
|
+
// Quote values that contain spaces, newlines, or special characters
|
|
37
|
+
const needsQuotes = /[\s#"'\\]/.test(value) || value.includes("=");
|
|
38
|
+
const formattedValue = needsQuotes ? `"${value.replace(/"/g, '\\"')}"` : value;
|
|
39
|
+
lines.push(`${key}=${formattedValue}`);
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Merge new values into existing env data
|
|
45
|
+
*/
|
|
46
|
+
export function mergeEnv(existing, updates) {
|
|
47
|
+
return { ...existing, ...updates };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parse KEY=value string
|
|
51
|
+
*/
|
|
52
|
+
export function parseKeyValue(input) {
|
|
53
|
+
const eqIndex = input.indexOf("=");
|
|
54
|
+
if (eqIndex === -1) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const key = input.substring(0, eqIndex).trim();
|
|
58
|
+
const value = input.substring(eqIndex + 1);
|
|
59
|
+
if (!key) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return { key, value };
|
|
63
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import * as storage from "./storage.js";
|
|
4
|
+
import { parseEnv, serializeEnv, mergeEnv } from "./env-parser.js";
|
|
5
|
+
import { loadMasterKey } from "./crypto.js";
|
|
6
|
+
export function createServer() {
|
|
7
|
+
const app = new Hono();
|
|
8
|
+
// Helper to send success response
|
|
9
|
+
const success = (data) => ({ success: true, data });
|
|
10
|
+
// Helper to send error response
|
|
11
|
+
const error = (message) => ({ success: false, error: message });
|
|
12
|
+
// List all projects
|
|
13
|
+
app.get("/projects", (c) => {
|
|
14
|
+
try {
|
|
15
|
+
const projects = storage.listProjects();
|
|
16
|
+
return c.json(success({ projects }));
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
return c.json(error(err.message), 500);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
// Create project
|
|
23
|
+
app.post("/projects/:name", (c) => {
|
|
24
|
+
try {
|
|
25
|
+
const { name } = c.req.param();
|
|
26
|
+
const project = storage.createProject(name);
|
|
27
|
+
return c.json(success(project), 201);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
const message = err.message;
|
|
31
|
+
const status = message.includes("already exists") ? 409 : 500;
|
|
32
|
+
return c.json(error(message), status);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
// Delete project
|
|
36
|
+
app.delete("/projects/:name", (c) => {
|
|
37
|
+
try {
|
|
38
|
+
const { name } = c.req.param();
|
|
39
|
+
storage.deleteProject(name);
|
|
40
|
+
return c.json(success({ deleted: true }));
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const message = err.message;
|
|
44
|
+
const status = message.includes("not found") ? 404 : 500;
|
|
45
|
+
return c.json(error(message), status);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
// List environments for a project
|
|
49
|
+
app.get("/projects/:name/envs", (c) => {
|
|
50
|
+
try {
|
|
51
|
+
const { name } = c.req.param();
|
|
52
|
+
const environments = storage.listEnvironments(name);
|
|
53
|
+
return c.json(success({ environments }));
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const message = err.message;
|
|
57
|
+
const status = message.includes("not found") ? 404 : 500;
|
|
58
|
+
return c.json(error(message), status);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
// Get env content
|
|
62
|
+
app.get("/projects/:name/envs/:env", (c) => {
|
|
63
|
+
try {
|
|
64
|
+
const { name, env } = c.req.param();
|
|
65
|
+
const content = storage.getEnvContent(name, env);
|
|
66
|
+
return c.json(success({
|
|
67
|
+
content,
|
|
68
|
+
env,
|
|
69
|
+
project: name,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const message = err.message;
|
|
74
|
+
const status = message.includes("not found") ? 404 : 500;
|
|
75
|
+
return c.json(error(message), status);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
// Set entire env content (PUT)
|
|
79
|
+
app.put("/projects/:name/envs/:env", async (c) => {
|
|
80
|
+
try {
|
|
81
|
+
const { name, env } = c.req.param();
|
|
82
|
+
const body = await c.req.json();
|
|
83
|
+
storage.setEnvContent(name, env, body.content);
|
|
84
|
+
return c.json(success({ updated: true }));
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
const message = err.message;
|
|
88
|
+
const status = message.includes("not found") ? 404 : 500;
|
|
89
|
+
return c.json(error(message), status);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
// Update single key (PATCH)
|
|
93
|
+
app.patch("/projects/:name/envs/:env", async (c) => {
|
|
94
|
+
try {
|
|
95
|
+
const { name, env } = c.req.param();
|
|
96
|
+
const body = await c.req.json();
|
|
97
|
+
let existingContent = "";
|
|
98
|
+
try {
|
|
99
|
+
existingContent = storage.getEnvContent(name, env);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Env doesn't exist yet, start fresh
|
|
103
|
+
}
|
|
104
|
+
const existingData = parseEnv(existingContent);
|
|
105
|
+
const updatedData = mergeEnv(existingData, { [body.key]: body.value });
|
|
106
|
+
const newContent = serializeEnv(updatedData);
|
|
107
|
+
storage.setEnvContent(name, env, newContent);
|
|
108
|
+
return c.json(success({ updated: true }));
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
const message = err.message;
|
|
112
|
+
const status = message.includes("not found") ? 404 : 500;
|
|
113
|
+
return c.json(error(message), status);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
// Delete env
|
|
117
|
+
app.delete("/projects/:name/envs/:env", (c) => {
|
|
118
|
+
try {
|
|
119
|
+
const { name, env } = c.req.param();
|
|
120
|
+
storage.deleteEnv(name, env);
|
|
121
|
+
return c.json(success({ deleted: true }));
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
const message = err.message;
|
|
125
|
+
const status = message.includes("not found") ? 404 : 500;
|
|
126
|
+
return c.json(error(message), status);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
// Health check
|
|
130
|
+
app.get("/health", (c) => {
|
|
131
|
+
return c.json(success({ status: "ok" }));
|
|
132
|
+
});
|
|
133
|
+
return app;
|
|
134
|
+
}
|
|
135
|
+
export function startServer(port = 3737) {
|
|
136
|
+
// Ensure master key exists
|
|
137
|
+
loadMasterKey();
|
|
138
|
+
const app = createServer();
|
|
139
|
+
console.log(`Starting envm server on http://localhost:${port}`);
|
|
140
|
+
console.log(`Data directory: ${process.env.ENVM_DATA_DIR || "~/.envm"}`);
|
|
141
|
+
serve({
|
|
142
|
+
fetch: app.fetch,
|
|
143
|
+
port,
|
|
144
|
+
hostname: "127.0.0.1", // Bind to localhost only for security
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Project, ProjectsData } from "../types/index.js";
|
|
2
|
+
export declare function loadProjectsData(): ProjectsData;
|
|
3
|
+
export declare function saveProjectsData(data: ProjectsData): void;
|
|
4
|
+
export declare function listProjects(): Project[];
|
|
5
|
+
export declare function getProject(name: string): Project | undefined;
|
|
6
|
+
export declare function createProject(name: string): Project;
|
|
7
|
+
export declare function deleteProject(name: string): void;
|
|
8
|
+
export declare function listEnvironments(projectName: string): string[];
|
|
9
|
+
export declare function getEnvContent(projectName: string, envName: string): string;
|
|
10
|
+
export declare function setEnvContent(projectName: string, envName: string, content: string): void;
|
|
11
|
+
export declare function deleteEnv(projectName: string, envName: string): void;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { encrypt, decrypt, loadMasterKey, getDataDir, ensureDataDir } from "./crypto.js";
|
|
4
|
+
function getProjectsFilePath() {
|
|
5
|
+
return path.join(getDataDir(), "projects.json");
|
|
6
|
+
}
|
|
7
|
+
function getProjectsDir() {
|
|
8
|
+
return path.join(getDataDir(), "projects");
|
|
9
|
+
}
|
|
10
|
+
function getProjectDir(projectName) {
|
|
11
|
+
return path.join(getProjectsDir(), projectName);
|
|
12
|
+
}
|
|
13
|
+
function getEnvFilePath(projectName, envName) {
|
|
14
|
+
return path.join(getProjectDir(projectName), `${envName}.enc`);
|
|
15
|
+
}
|
|
16
|
+
export function loadProjectsData() {
|
|
17
|
+
const filePath = getProjectsFilePath();
|
|
18
|
+
if (!fs.existsSync(filePath)) {
|
|
19
|
+
return { projects: [] };
|
|
20
|
+
}
|
|
21
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
22
|
+
return JSON.parse(content);
|
|
23
|
+
}
|
|
24
|
+
export function saveProjectsData(data) {
|
|
25
|
+
ensureDataDir();
|
|
26
|
+
const filePath = getProjectsFilePath();
|
|
27
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
28
|
+
}
|
|
29
|
+
export function listProjects() {
|
|
30
|
+
return loadProjectsData().projects;
|
|
31
|
+
}
|
|
32
|
+
export function getProject(name) {
|
|
33
|
+
return loadProjectsData().projects.find((p) => p.name === name);
|
|
34
|
+
}
|
|
35
|
+
export function createProject(name) {
|
|
36
|
+
const data = loadProjectsData();
|
|
37
|
+
if (data.projects.some((p) => p.name === name)) {
|
|
38
|
+
throw new Error(`Project "${name}" already exists`);
|
|
39
|
+
}
|
|
40
|
+
const project = {
|
|
41
|
+
name,
|
|
42
|
+
createdAt: new Date().toISOString(),
|
|
43
|
+
environments: [],
|
|
44
|
+
};
|
|
45
|
+
data.projects.push(project);
|
|
46
|
+
saveProjectsData(data);
|
|
47
|
+
// Create project directory
|
|
48
|
+
const projectDir = getProjectDir(name);
|
|
49
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
50
|
+
return project;
|
|
51
|
+
}
|
|
52
|
+
export function deleteProject(name) {
|
|
53
|
+
const data = loadProjectsData();
|
|
54
|
+
const index = data.projects.findIndex((p) => p.name === name);
|
|
55
|
+
if (index === -1) {
|
|
56
|
+
throw new Error(`Project "${name}" not found`);
|
|
57
|
+
}
|
|
58
|
+
data.projects.splice(index, 1);
|
|
59
|
+
saveProjectsData(data);
|
|
60
|
+
// Delete project directory
|
|
61
|
+
const projectDir = getProjectDir(name);
|
|
62
|
+
if (fs.existsSync(projectDir)) {
|
|
63
|
+
fs.rmSync(projectDir, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export function listEnvironments(projectName) {
|
|
67
|
+
const project = getProject(projectName);
|
|
68
|
+
if (!project) {
|
|
69
|
+
throw new Error(`Project "${projectName}" not found`);
|
|
70
|
+
}
|
|
71
|
+
return project.environments;
|
|
72
|
+
}
|
|
73
|
+
export function getEnvContent(projectName, envName) {
|
|
74
|
+
const project = getProject(projectName);
|
|
75
|
+
if (!project) {
|
|
76
|
+
throw new Error(`Project "${projectName}" not found`);
|
|
77
|
+
}
|
|
78
|
+
const envPath = getEnvFilePath(projectName, envName);
|
|
79
|
+
if (!fs.existsSync(envPath)) {
|
|
80
|
+
throw new Error(`Environment "${envName}" not found in project "${projectName}"`);
|
|
81
|
+
}
|
|
82
|
+
const encrypted = fs.readFileSync(envPath, "utf-8");
|
|
83
|
+
const key = loadMasterKey();
|
|
84
|
+
return decrypt(encrypted, key);
|
|
85
|
+
}
|
|
86
|
+
export function setEnvContent(projectName, envName, content) {
|
|
87
|
+
const data = loadProjectsData();
|
|
88
|
+
const project = data.projects.find((p) => p.name === projectName);
|
|
89
|
+
if (!project) {
|
|
90
|
+
throw new Error(`Project "${projectName}" not found`);
|
|
91
|
+
}
|
|
92
|
+
const key = loadMasterKey();
|
|
93
|
+
const encrypted = encrypt(content, key);
|
|
94
|
+
const envPath = getEnvFilePath(projectName, envName);
|
|
95
|
+
fs.mkdirSync(path.dirname(envPath), { recursive: true });
|
|
96
|
+
fs.writeFileSync(envPath, encrypted);
|
|
97
|
+
// Update environments list
|
|
98
|
+
if (!project.environments.includes(envName)) {
|
|
99
|
+
project.environments.push(envName);
|
|
100
|
+
saveProjectsData(data);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function deleteEnv(projectName, envName) {
|
|
104
|
+
const data = loadProjectsData();
|
|
105
|
+
const project = data.projects.find((p) => p.name === projectName);
|
|
106
|
+
if (!project) {
|
|
107
|
+
throw new Error(`Project "${projectName}" not found`);
|
|
108
|
+
}
|
|
109
|
+
const envPath = getEnvFilePath(projectName, envName);
|
|
110
|
+
if (fs.existsSync(envPath)) {
|
|
111
|
+
fs.unlinkSync(envPath);
|
|
112
|
+
}
|
|
113
|
+
// Update environments list
|
|
114
|
+
const envIndex = project.environments.indexOf(envName);
|
|
115
|
+
if (envIndex !== -1) {
|
|
116
|
+
project.environments.splice(envIndex, 1);
|
|
117
|
+
saveProjectsData(data);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface Project {
|
|
2
|
+
name: string;
|
|
3
|
+
createdAt: string;
|
|
4
|
+
environments: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface ProjectsData {
|
|
7
|
+
projects: Project[];
|
|
8
|
+
}
|
|
9
|
+
export interface EnvData {
|
|
10
|
+
[key: string]: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ClientConfig {
|
|
13
|
+
server?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ApiResponse<T = unknown> {
|
|
16
|
+
success: boolean;
|
|
17
|
+
data?: T;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ProjectListResponse {
|
|
21
|
+
projects: Project[];
|
|
22
|
+
}
|
|
23
|
+
export interface EnvListResponse {
|
|
24
|
+
environments: string[];
|
|
25
|
+
}
|
|
26
|
+
export interface EnvContentResponse {
|
|
27
|
+
content: string;
|
|
28
|
+
env: string;
|
|
29
|
+
project: string;
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@motive_sx/envm",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Secure environment variable manager - store and sync .env files across machines",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"envm": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "tsx src/index.ts",
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"start": "node dist/index.js",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"env",
|
|
18
|
+
"environment",
|
|
19
|
+
"dotenv",
|
|
20
|
+
"secrets",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/anthropics/envm.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/anthropics/envm/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/anthropics/envm#readme",
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@hono/node-server": "^1.13.8",
|
|
42
|
+
"chalk": "^5.4.1",
|
|
43
|
+
"commander": "^13.1.0",
|
|
44
|
+
"dotenv": "^16.4.7",
|
|
45
|
+
"hono": "^4.7.4"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.13.4",
|
|
49
|
+
"tsx": "^4.19.2",
|
|
50
|
+
"typescript": "^5.7.3"
|
|
51
|
+
}
|
|
52
|
+
}
|