@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 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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const configCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const editCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const envsCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const getCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const initCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const listCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const pullCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const pushCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const serverCommand: Command;
@@ -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,2 @@
1
+ import { Command } from "commander";
2
+ export declare const setCommand: Command;
@@ -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
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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,3 @@
1
+ import { Hono } from "hono";
2
+ export declare function createServer(): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
3
+ export declare function startServer(port?: number): void;
@@ -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
+ }