enylock_cli 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 +238 -0
- package/bin/envlock.js +4 -0
- package/package.json +42 -0
- package/src/commands/login.js +120 -0
- package/src/commands/logout.js +24 -0
- package/src/commands/pull.js +82 -0
- package/src/commands/run.js +65 -0
- package/src/commands/whoami.js +32 -0
- package/src/index.js +66 -0
- package/src/lib/api.js +81 -0
- package/src/lib/credentials.js +63 -0
- package/src/lib/gitignore.js +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# EnvLock
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/envlock)
|
|
4
|
+
[](https://www.npmjs.com/package/envlock)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
EnvLock is a secrets management system for development teams. It consists of a web dashboard, where projects and secrets are organized and controlled, and a command-line tool, which developers use to bring those secrets onto their own machines. It replaces the common practice of sharing `.env` files over chat or email with a controlled, auditable alternative.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Table of Contents
|
|
12
|
+
|
|
13
|
+
- [How It Works](#how-it-works)
|
|
14
|
+
- [Features](#features)
|
|
15
|
+
- [Requirements](#requirements)
|
|
16
|
+
- [Installation](#installation--usage)
|
|
17
|
+
- [Getting Started](#getting-started)
|
|
18
|
+
- [CLI Command Reference](#cli-command-reference)
|
|
19
|
+
- [Security](#security)
|
|
20
|
+
- [Contributing](#contributing)
|
|
21
|
+
- [Support & Links](#support--links)
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## How It Works
|
|
26
|
+
|
|
27
|
+
EnvLock is made up of two parts that work together: a dashboard and a CLI.
|
|
28
|
+
|
|
29
|
+
### The Dashboard
|
|
30
|
+
|
|
31
|
+
The dashboard, at envlock.app, is where a project actually lives. From there, a project owner can:
|
|
32
|
+
|
|
33
|
+
- Create a project and invite team members, assigning each person a role (owner, admin, member, or viewer).
|
|
34
|
+
- Define environments within that project, typically development, staging, and production, each holding its own independent set of secrets.
|
|
35
|
+
- Add, edit, and version secrets under any environment.
|
|
36
|
+
- Issue and revoke access tokens, each one scoped to a specific project and to specific environments within it.
|
|
37
|
+
|
|
38
|
+
Every change made on the dashboard is recorded in an audit log, so a team can see who added or modified a secret, and when.
|
|
39
|
+
|
|
40
|
+
### The CLI
|
|
41
|
+
|
|
42
|
+
The CLI is what a developer uses day to day, once a project has already been set up on the dashboard. Running `envlock login` opens a browser window where the developer approves access to a specific project and, depending on the role granted, specific environments within it. That approval only needs to happen once per project folder. After that, secrets can be pulled into a local `.env` file, or injected directly into a running process, without opening the dashboard again.
|
|
43
|
+
|
|
44
|
+
In short: the dashboard is where secrets are organized and controlled, and the CLI is how they reach a developer's machine.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- Authentication through a browser-based device authorization flow, rather than pasted API keys.
|
|
51
|
+
- Access tokens scoped to a single project and specific environments, so a compromised token cannot expose secrets outside its intended scope.
|
|
52
|
+
- Session credentials stored per project folder, so working on multiple EnvLock projects on the same machine does not cause conflicts.
|
|
53
|
+
- An option to inject secrets directly into a running process's memory, so plaintext values never need to be written to disk.
|
|
54
|
+
- Automatic `.gitignore` configuration on login, to prevent secrets from being committed by mistake.
|
|
55
|
+
- A full audit log on the dashboard, recording who changed which secret and when.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
- Node.js 18 or later
|
|
62
|
+
- An EnvLock account, created at envlock.app
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Installation & Usage
|
|
67
|
+
|
|
68
|
+
EnvLock can be used in either of two ways.
|
|
69
|
+
|
|
70
|
+
### Option A: Zero Install
|
|
71
|
+
|
|
72
|
+
Run commands directly without a global install. This is the recommended path for CI/CD pipelines, such as GitHub Actions:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npx envlock login
|
|
76
|
+
npx envlock pull
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Option B: Global Installation
|
|
80
|
+
|
|
81
|
+
Install once, then run it from anywhere:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm install -g envlock
|
|
85
|
+
|
|
86
|
+
envlock login
|
|
87
|
+
envlock pull
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Getting Started
|
|
93
|
+
|
|
94
|
+
### 1. Authenticate
|
|
95
|
+
|
|
96
|
+
Run the login command inside your project directory:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
envlock login
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
This will print a user code in your terminal, open your browser to complete approval, and prompt you to choose which project this directory should have access to. Once approved, your local `.gitignore` is updated automatically.
|
|
103
|
+
|
|
104
|
+
### 2. Pull Secrets
|
|
105
|
+
|
|
106
|
+
Write your project's secrets to a local `.env` file:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
envlock pull
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Pull from a specific environment:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
envlock pull --env production
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 3. Run Commands with Secrets Injected
|
|
119
|
+
|
|
120
|
+
Run your application with secrets passed directly into the process, without writing them to disk:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
envlock run -- node app.js
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Use a different environment:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
envlock run --env staging -- npm run dev
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## CLI Command Reference
|
|
135
|
+
|
|
136
|
+
Every command supports `--help` for details on its arguments and options.
|
|
137
|
+
|
|
138
|
+
### Global Flags
|
|
139
|
+
|
|
140
|
+
| Flag | Description |
|
|
141
|
+
| --------------- | ---------------------------------------------- |
|
|
142
|
+
| `-h, --help` | Show help for the CLI or a specific subcommand |
|
|
143
|
+
| `-V, --version` | Output the current version number |
|
|
144
|
+
|
|
145
|
+
### `envlock login`
|
|
146
|
+
|
|
147
|
+
Authenticates the current project folder through the device authorization flow: displays a user code, opens the browser, and waits for approval.
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
envlock login
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### `envlock pull`
|
|
154
|
+
|
|
155
|
+
Downloads decrypted secrets and writes them locally as key-value pairs.
|
|
156
|
+
|
|
157
|
+
| Option | Description | Default |
|
|
158
|
+
| --------------------- | --------------------------------------------------- | ------------- |
|
|
159
|
+
| `-e, --env <name>` | Environment to pull from | `development` |
|
|
160
|
+
| `-o, --output <file>` | Target filename to write | `.env` |
|
|
161
|
+
| `--overwrite` | Allow overwriting a file that already contains keys | `false` |
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
envlock pull --output .env.local
|
|
165
|
+
envlock pull --env production --overwrite
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### `envlock run`
|
|
169
|
+
|
|
170
|
+
Fetches decrypted secrets in memory and runs a command with them injected. Plaintext values are never written to disk.
|
|
171
|
+
|
|
172
|
+
| Option | Description | Default |
|
|
173
|
+
| ------------------ | -------------------------------------- | ------------- |
|
|
174
|
+
| `-e, --env <name>` | Environment to retrieve variables from | `development` |
|
|
175
|
+
|
|
176
|
+
Place `--` before your target command so its own arguments pass through correctly.
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
envlock run -- node index.js
|
|
180
|
+
envlock run --env production -- npm start
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `envlock whoami`
|
|
184
|
+
|
|
185
|
+
Shows which project and environment this directory is currently authenticated to.
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
envlock whoami
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### `envlock logout`
|
|
192
|
+
|
|
193
|
+
Removes the local session by deleting the credentials stored in `.envlock/`.
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
envlock logout
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### `envlock help`
|
|
200
|
+
|
|
201
|
+
Lists all available commands with a short description of each.
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
envlock help
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Security
|
|
210
|
+
|
|
211
|
+
- Session tokens stored locally in `.envlock/credentials.json` are saved with owner-only read/write permissions (`0o600`).
|
|
212
|
+
- During login, the server holds an approved token in a pending state and deletes it the moment the CLI retrieves it, so it is never persisted longer than necessary.
|
|
213
|
+
- The CLI ensures `.envlock/` and `.env*` patterns exist in your local `.gitignore`, reducing the chance of secrets being committed by accident.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Contributing
|
|
218
|
+
|
|
219
|
+
Contributions, issues, and feature requests are welcome.
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
git clone https://github.com/leyroy/envlock_cli.git
|
|
223
|
+
cd envlock_cli
|
|
224
|
+
npm install
|
|
225
|
+
npm link
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`npm link` makes your local changes available as the global `envlock` command, so you can test them directly. Open a pull request once your change is ready; for larger changes, opening an issue first to discuss the approach is appreciated.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Support & Links
|
|
233
|
+
|
|
234
|
+
- Website: envlock.app
|
|
235
|
+
- Repository: github.com/leyroy/envlock_cli
|
|
236
|
+
- Issues: github.com/leyroy/envlock_cli/issues
|
|
237
|
+
- Author: Ley Roy (atiwensolomon@gmail.com)
|
|
238
|
+
- License: MIT
|
package/bin/envlock.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "enylock_cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Encrypted secrets management CLI — pull, run, and manage env vars from EnvLock",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"secrets",
|
|
7
|
+
"env",
|
|
8
|
+
"dotenv",
|
|
9
|
+
"cli",
|
|
10
|
+
"envlock",
|
|
11
|
+
"security"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://envlock.app",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/enylockapp/envlock-cli"
|
|
17
|
+
},
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Ley Roy",
|
|
20
|
+
"email": "atiwensolomon@gmail.com",
|
|
21
|
+
"url": "https://ley-roy.vercel.app/"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./src/index.js",
|
|
26
|
+
"bin": {
|
|
27
|
+
"envlock": "./bin/envlock.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin/",
|
|
31
|
+
"src/"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"chalk": "^5.3.0",
|
|
38
|
+
"commander": "^12.1.0",
|
|
39
|
+
"open": "^10.1.0",
|
|
40
|
+
"ora": "^8.1.1"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import open from "open";
|
|
4
|
+
import { initDevice, pollToken } from "../lib/api.js";
|
|
5
|
+
import { saveCredentials, loadCredentials } from "../lib/credentials.js";
|
|
6
|
+
import { ensureGitignore, detectProjectType } from "../lib/gitignore.js";
|
|
7
|
+
|
|
8
|
+
const POLL_INTERVAL_MS = 5000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `envlock login`
|
|
12
|
+
*
|
|
13
|
+
* Runs the device authorization flow:
|
|
14
|
+
* 1. Request a device code from the API
|
|
15
|
+
* 2. Open the browser to the verification URI
|
|
16
|
+
* 3. Poll until the user approves or the code expires
|
|
17
|
+
* 4. Save the token to .envlock/credentials.json
|
|
18
|
+
*/
|
|
19
|
+
export async function loginCommand() {
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
|
|
22
|
+
// Skip if already logged in
|
|
23
|
+
const existing = loadCredentials(cwd);
|
|
24
|
+
if (existing?.token) {
|
|
25
|
+
console.log(
|
|
26
|
+
chalk.yellow("⚠ Already logged in.") + " Run `envlock logout` first to switch accounts.",
|
|
27
|
+
);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let deviceData;
|
|
32
|
+
try {
|
|
33
|
+
deviceData = await initDevice();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.log(error);
|
|
36
|
+
console.error(chalk.red("✖ Could not reach EnvLock API:"), error.message);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { device_code, user_code, verification_uri, expires_in, interval } = deviceData;
|
|
41
|
+
const pollIntervalMs = Math.max((interval ?? 5) * 1000, POLL_INTERVAL_MS);
|
|
42
|
+
|
|
43
|
+
console.log();
|
|
44
|
+
console.log(chalk.bold(" EnvLock CLI Login"));
|
|
45
|
+
console.log();
|
|
46
|
+
console.log(" 1. Your browser will open to:");
|
|
47
|
+
console.log(" " + chalk.cyan(verification_uri));
|
|
48
|
+
console.log();
|
|
49
|
+
console.log(" 2. Enter this code when prompted:");
|
|
50
|
+
console.log(" " + chalk.bold.green(user_code));
|
|
51
|
+
console.log();
|
|
52
|
+
console.log(chalk.dim(` Code expires in ${Math.round(expires_in / 60)} minutes.`));
|
|
53
|
+
console.log();
|
|
54
|
+
|
|
55
|
+
// Attempt to open browser; non-fatal if it fails
|
|
56
|
+
try {
|
|
57
|
+
await open(`${verification_uri}?code=${encodeURIComponent(user_code)}`);
|
|
58
|
+
} catch {
|
|
59
|
+
console.log(chalk.dim(" (Could not open browser automatically — please open the URL above)"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const spinner = ora("Waiting for browser approval…").start();
|
|
63
|
+
|
|
64
|
+
const deadline = Date.now() + expires_in * 1000;
|
|
65
|
+
|
|
66
|
+
// Poll until approved, denied, or expired
|
|
67
|
+
while (Date.now() < deadline) {
|
|
68
|
+
await sleep(pollIntervalMs);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const result = await pollToken(device_code);
|
|
72
|
+
|
|
73
|
+
if (result.status === "authorization_pending") {
|
|
74
|
+
continue; // still waiting
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (result.status === "approved" && result.access_token) {
|
|
78
|
+
spinner.succeed(chalk.green("Logged in successfully!"));
|
|
79
|
+
|
|
80
|
+
saveCredentials(cwd, {
|
|
81
|
+
token: result.access_token,
|
|
82
|
+
loggedInAt: new Date().toISOString(),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Ensure .gitignore is updated
|
|
86
|
+
const added = ensureGitignore(cwd);
|
|
87
|
+
if (added.length > 0) {
|
|
88
|
+
console.log(chalk.dim(` Added to .gitignore: ${added.join(", ")}`));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const projectType = detectProjectType(cwd);
|
|
92
|
+
if (projectType) {
|
|
93
|
+
console.log(chalk.dim(` Detected ${projectType} project.`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log();
|
|
97
|
+
console.log(chalk.bold(" Next steps:"));
|
|
98
|
+
console.log(" " + chalk.cyan("envlock pull") + " — write secrets to .env");
|
|
99
|
+
console.log(
|
|
100
|
+
" " + chalk.cyan("envlock run -- <cmd>") + " — run a command with secrets injected",
|
|
101
|
+
);
|
|
102
|
+
console.log();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err.status === 400 && (err.body?.error === "denied" || err.body?.error === "expired")) {
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
// Transient network error — keep polling
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
spinner.fail(chalk.red("Login timed out or was denied."));
|
|
114
|
+
console.log(" Run `envlock login` to try again.");
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sleep(ms) {
|
|
119
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
120
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { clearCredentials, loadCredentials } from "../lib/credentials.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `envlock logout`
|
|
6
|
+
*
|
|
7
|
+
* Removes the local credentials file from .envlock/credentials.json.
|
|
8
|
+
* The access token on the server is NOT revoked — this is intentional so
|
|
9
|
+
* automated pipelines that share the same token can continue running.
|
|
10
|
+
* To fully revoke, use the EnvLock dashboard.
|
|
11
|
+
*/
|
|
12
|
+
export function logoutCommand() {
|
|
13
|
+
const cwd = process.cwd();
|
|
14
|
+
const existing = loadCredentials(cwd);
|
|
15
|
+
|
|
16
|
+
if (!existing?.token) {
|
|
17
|
+
console.log(chalk.yellow("Not currently logged in for this directory."));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
clearCredentials(cwd);
|
|
22
|
+
console.log(chalk.green("✔ Logged out.") + " Credentials removed from .envlock/credentials.json");
|
|
23
|
+
console.log(chalk.dim(" Note: To fully revoke the token, visit the EnvLock dashboard."));
|
|
24
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync } from "fs";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { fetchSecrets } from "../lib/api.js";
|
|
5
|
+
import { requireCredentials } from "../lib/credentials.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `envlock pull [--env <name>] [--output <file>] [--overwrite]`
|
|
9
|
+
*
|
|
10
|
+
* Fetches decrypted secrets from the EnvLock API and writes them to a .env file.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} options
|
|
13
|
+
* @param {string} options.env - Environment name (default: "development")
|
|
14
|
+
* @param {string} options.output - Output file path (default: ".env")
|
|
15
|
+
* @param {boolean} options.overwrite - If false and the file exists, exit with a warning
|
|
16
|
+
*/
|
|
17
|
+
export async function pullCommand(options = {}) {
|
|
18
|
+
const cwd = process.cwd();
|
|
19
|
+
const creds = requireCredentials(cwd);
|
|
20
|
+
|
|
21
|
+
const environmentName = options.env ?? "development";
|
|
22
|
+
const outputFile = options.output ?? ".env";
|
|
23
|
+
const overwrite = options.overwrite ?? false;
|
|
24
|
+
|
|
25
|
+
// Guard against accidentally overwriting an existing file
|
|
26
|
+
if (!overwrite && existsSync(outputFile)) {
|
|
27
|
+
const existing = readFileSync(outputFile, "utf8").trim();
|
|
28
|
+
if (existing.length > 0) {
|
|
29
|
+
console.log(chalk.yellow(`⚠ ${outputFile} already exists.`) + " Use --overwrite to replace it.");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const spinner = ora(`Pulling ${chalk.bold(environmentName)} secrets…`).start();
|
|
35
|
+
|
|
36
|
+
let result;
|
|
37
|
+
try {
|
|
38
|
+
result = await fetchSecrets(creds.token, environmentName);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
spinner.fail(chalk.red("Failed to fetch secrets"));
|
|
41
|
+
if (err.status === 401) {
|
|
42
|
+
console.error(" Token invalid or expired. Run `envlock login` to re-authenticate.");
|
|
43
|
+
} else if (err.status === 403) {
|
|
44
|
+
console.error(` Access denied for the "${environmentName}" environment.`);
|
|
45
|
+
} else if (err.status === 404) {
|
|
46
|
+
console.error(` Environment "${environmentName}" not found. Check your project settings.`);
|
|
47
|
+
} else {
|
|
48
|
+
console.error(" ", err.message);
|
|
49
|
+
}
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { secrets } = result;
|
|
54
|
+
const entries = Object.entries(secrets);
|
|
55
|
+
|
|
56
|
+
if (entries.length === 0) {
|
|
57
|
+
spinner.warn(chalk.yellow(`No secrets found in "${environmentName}".`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const content =
|
|
62
|
+
`# Generated by EnvLock — ${new Date().toISOString()}\n` +
|
|
63
|
+
`# Environment: ${environmentName}\n` +
|
|
64
|
+
`# DO NOT COMMIT — this file is in .gitignore\n\n` +
|
|
65
|
+
entries.map(([key, value]) => formatEnvLine(key, value)).join("\n") +
|
|
66
|
+
"\n";
|
|
67
|
+
|
|
68
|
+
writeFileSync(outputFile, content, "utf8");
|
|
69
|
+
spinner.succeed(
|
|
70
|
+
chalk.green(`✔ Wrote ${entries.length} secret${entries.length !== 1 ? "s" : ""}`) +
|
|
71
|
+
` to ${chalk.bold(outputFile)}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Formats a key=value pair, quoting the value if it contains spaces or special chars.
|
|
77
|
+
*/
|
|
78
|
+
function formatEnvLine(key, value) {
|
|
79
|
+
const needsQuotes = /[\s"'\\#$`]/.test(value) || value === "";
|
|
80
|
+
const escaped = needsQuotes ? `"${value.replace(/"/g, '\\"')}"` : value;
|
|
81
|
+
return `${key}=${escaped}`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { fetchSecrets } from "../lib/api.js";
|
|
5
|
+
import { requireCredentials } from "../lib/credentials.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `envlock run [--env <name>] -- <command> [...args]`
|
|
9
|
+
*
|
|
10
|
+
* Injects decrypted secrets as environment variables and spawns a command.
|
|
11
|
+
* Secrets never touch the filesystem — they live only in the child process env.
|
|
12
|
+
*
|
|
13
|
+
* @param {string[]} args - Command and its arguments (everything after --)
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {string} options.env - Environment name (default: "development")
|
|
16
|
+
*/
|
|
17
|
+
export async function runCommand(args, options = {}) {
|
|
18
|
+
if (!args || args.length === 0) {
|
|
19
|
+
console.error(chalk.red("✖ No command provided."));
|
|
20
|
+
console.error(" Usage: envlock run [--env <name>] -- <command> [args...]");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
const creds = requireCredentials(cwd);
|
|
26
|
+
const environmentName = options.env ?? "development";
|
|
27
|
+
|
|
28
|
+
const spinner = ora(`Loading ${chalk.bold(environmentName)} secrets…`).start();
|
|
29
|
+
|
|
30
|
+
let secrets;
|
|
31
|
+
try {
|
|
32
|
+
const result = await fetchSecrets(creds.token, environmentName);
|
|
33
|
+
secrets = result.secrets;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
spinner.fail(chalk.red("Failed to fetch secrets"));
|
|
36
|
+
if (err.status === 401) {
|
|
37
|
+
console.error(" Token invalid or expired. Run `envlock login` to re-authenticate.");
|
|
38
|
+
} else {
|
|
39
|
+
console.error(" ", err.message);
|
|
40
|
+
}
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
spinner.stop();
|
|
45
|
+
|
|
46
|
+
const [cmd, ...cmdArgs] = args;
|
|
47
|
+
|
|
48
|
+
// Merge fetched secrets into the current process environment
|
|
49
|
+
const childEnv = { ...process.env, ...secrets };
|
|
50
|
+
|
|
51
|
+
const child = spawn(cmd, cmdArgs, {
|
|
52
|
+
stdio: "inherit",
|
|
53
|
+
env: childEnv,
|
|
54
|
+
shell: process.platform === "win32", // shell required on Windows for built-ins
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
child.on("error", (err) => {
|
|
58
|
+
console.error(chalk.red(`✖ Failed to start command: ${err.message}`));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
child.on("close", (code) => {
|
|
63
|
+
process.exit(code ?? 0);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { loadCredentials } from "../lib/credentials.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `envlock whoami`
|
|
6
|
+
*
|
|
7
|
+
* Displays information about the currently stored credentials.
|
|
8
|
+
*/
|
|
9
|
+
export function whoamiCommand() {
|
|
10
|
+
const cwd = process.cwd();
|
|
11
|
+
const creds = loadCredentials(cwd);
|
|
12
|
+
|
|
13
|
+
if (!creds?.token) {
|
|
14
|
+
console.log(chalk.yellow("Not logged in.") + " Run `envlock login` to authenticate.");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const tokenPreview =
|
|
19
|
+
creds.token.length > 16
|
|
20
|
+
? creds.token.slice(0, 12) + "…" + creds.token.slice(-4)
|
|
21
|
+
: creds.token;
|
|
22
|
+
|
|
23
|
+
console.log();
|
|
24
|
+
console.log(chalk.bold(" Logged in"));
|
|
25
|
+
console.log();
|
|
26
|
+
console.log(" Token: " + chalk.cyan(tokenPreview));
|
|
27
|
+
if (creds.loggedInAt) {
|
|
28
|
+
console.log(" Since: " + new Date(creds.loggedInAt).toLocaleString());
|
|
29
|
+
}
|
|
30
|
+
console.log(" Scope: " + chalk.dim(process.cwd()));
|
|
31
|
+
console.log();
|
|
32
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { loginCommand } from "./commands/login.js";
|
|
3
|
+
import { logoutCommand } from "./commands/logout.js";
|
|
4
|
+
import { pullCommand } from "./commands/pull.js";
|
|
5
|
+
import { runCommand } from "./commands/run.js";
|
|
6
|
+
import { whoamiCommand } from "./commands/whoami.js";
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name("envlock")
|
|
12
|
+
.description("Encrypted secrets management — pull, run, and manage env vars securely")
|
|
13
|
+
.version("1.0.0")
|
|
14
|
+
.enablePositionalOptions();
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.command("login")
|
|
19
|
+
.description("Authenticate via browser and store credentials for this project directory")
|
|
20
|
+
.action(loginCommand);
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.command("logout")
|
|
24
|
+
.description("Remove stored credentials from .envlock/credentials.json")
|
|
25
|
+
.action(logoutCommand);
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.command("pull")
|
|
29
|
+
.description("Fetch secrets from EnvLock and write them to a .env file")
|
|
30
|
+
.option("-e, --env <name>", "environment to pull (e.g. production, staging)", "development")
|
|
31
|
+
.option("-o, --output <file>", "output file path", ".env")
|
|
32
|
+
.option("--overwrite", "overwrite the output file if it already exists", false)
|
|
33
|
+
.action(pullCommand);
|
|
34
|
+
|
|
35
|
+
program
|
|
36
|
+
.command("run")
|
|
37
|
+
.description("Run a command with secrets injected as environment variables (secrets never touch disk)")
|
|
38
|
+
.option("-e, --env <name>", "environment to load secrets from", "development")
|
|
39
|
+
.passThroughOptions()
|
|
40
|
+
.argument("[args...]", "command and arguments to run (after --)")
|
|
41
|
+
.addHelpText(
|
|
42
|
+
"after",
|
|
43
|
+
`
|
|
44
|
+
Examples:
|
|
45
|
+
envlock run -- node server.js
|
|
46
|
+
envlock run --env production -- npm start
|
|
47
|
+
`
|
|
48
|
+
)
|
|
49
|
+
.action((args, options) => {
|
|
50
|
+
// Strip leading "--" separator if Commander passes it through
|
|
51
|
+
const cleanArgs = args[0] === "--" ? args.slice(1) : args;
|
|
52
|
+
return runCommand(cleanArgs, options);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
program
|
|
57
|
+
.command("whoami")
|
|
58
|
+
.description("Show current authentication status and token info")
|
|
59
|
+
.action(whoamiCommand);
|
|
60
|
+
|
|
61
|
+
program
|
|
62
|
+
.command("help", { isDefault: false, hidden: true })
|
|
63
|
+
.description("Show help information")
|
|
64
|
+
.action(() => program.help());
|
|
65
|
+
|
|
66
|
+
program.parse(process.argv);
|
package/src/lib/api.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Thin fetch wrapper for the EnvLock API.
|
|
2
|
+
// BASE_URL defaults to production; override with ENVLOCK_API_URL for local dev.
|
|
3
|
+
|
|
4
|
+
const BASE_URL = (process.env.ENVLOCK_API_URL ?? "https://envlock.app").replace(/\/$/, "");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generic JSON fetch with error normalisation.
|
|
8
|
+
* Returns the parsed response body on success; throws on non-2xx.
|
|
9
|
+
*/
|
|
10
|
+
async function apiFetch(path, options = {}) {
|
|
11
|
+
const url = `${BASE_URL}${path}`;
|
|
12
|
+
const res = await fetch(url, {
|
|
13
|
+
...options,
|
|
14
|
+
headers: {
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
...(options.headers ?? {}),
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const body = await res.json().catch(() => ({}));
|
|
21
|
+
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
const message = body.error ?? `HTTP ${res.status}`;
|
|
24
|
+
const err = new Error(message);
|
|
25
|
+
err.status = res.status;
|
|
26
|
+
err.body = body;
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return body;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* POST /api/cli/device/init
|
|
35
|
+
* Returns { device_code, user_code, verification_uri, expires_in, interval }
|
|
36
|
+
*/
|
|
37
|
+
export function initDevice() {
|
|
38
|
+
return apiFetch("/api/cli/device/init", {
|
|
39
|
+
method: "POST",
|
|
40
|
+
body: JSON.stringify({ client_id: "envlock-cli", scopes: "secrets:read" }),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* POST /api/cli/device/token
|
|
46
|
+
* Returns { status: "approved", access_token } on success,
|
|
47
|
+
* { status: "authorization_pending" } (428) while waiting,
|
|
48
|
+
* or throws on denied / expired.
|
|
49
|
+
*/
|
|
50
|
+
export async function pollToken(deviceCode) {
|
|
51
|
+
const res = await fetch(`${BASE_URL}/api/cli/device/token`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "Content-Type": "application/json" },
|
|
54
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const body = await res.json().catch(() => ({}));
|
|
58
|
+
|
|
59
|
+
if (res.status === 428) {
|
|
60
|
+
// Still waiting for user to approve in browser
|
|
61
|
+
return { status: "authorization_pending" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const err = new Error(body.error ?? `HTTP ${res.status}`);
|
|
66
|
+
err.status = res.status;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return body; // { status: "approved", access_token: "..." }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* GET /api/cli/secrets?environment=<env>
|
|
75
|
+
* Returns { secrets: Record<string,string>, environment: string }
|
|
76
|
+
*/
|
|
77
|
+
export function fetchSecrets(token, environment = "development") {
|
|
78
|
+
return apiFetch(`/api/cli/secrets?environment=${encodeURIComponent(environment)}`, {
|
|
79
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
const ENVLOCK_DIR = ".envlock";
|
|
5
|
+
const CREDENTIALS_FILE = "credentials.json";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Returns the full path to .envlock/credentials.json relative to the given cwd.
|
|
9
|
+
*/
|
|
10
|
+
function credentialsPath(cwd) {
|
|
11
|
+
return join(cwd, ENVLOCK_DIR, CREDENTIALS_FILE);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Loads stored credentials from .envlock/credentials.json.
|
|
16
|
+
* Returns null if the file doesn't exist or is malformed.
|
|
17
|
+
*/
|
|
18
|
+
export function loadCredentials(cwd = process.cwd()) {
|
|
19
|
+
const filePath = credentialsPath(cwd);
|
|
20
|
+
if (!existsSync(filePath)) return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Persists credentials to .envlock/credentials.json, creating the directory
|
|
30
|
+
* if needed. Writes with mode 0o600 so only the owner can read it.
|
|
31
|
+
*/
|
|
32
|
+
export function saveCredentials(cwd = process.cwd(), data) {
|
|
33
|
+
const dir = join(cwd, ENVLOCK_DIR);
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
const filePath = join(dir, CREDENTIALS_FILE);
|
|
36
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600, encoding: "utf8" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Removes the credentials file. Ignores errors if the file doesn't exist.
|
|
41
|
+
*/
|
|
42
|
+
export function clearCredentials(cwd = process.cwd()) {
|
|
43
|
+
const filePath = credentialsPath(cwd);
|
|
44
|
+
if (existsSync(filePath)) {
|
|
45
|
+
try {
|
|
46
|
+
rmSync(filePath);
|
|
47
|
+
} catch {
|
|
48
|
+
// Non-fatal — the file may have already been removed
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns credentials or throws a user-friendly error instructing login.
|
|
55
|
+
*/
|
|
56
|
+
export function requireCredentials(cwd = process.cwd()) {
|
|
57
|
+
const creds = loadCredentials(cwd);
|
|
58
|
+
if (!creds?.token) {
|
|
59
|
+
console.error("Not logged in. Run `envlock login` first.");
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
return creds;
|
|
63
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
const GITIGNORE_FILE = ".gitignore";
|
|
5
|
+
|
|
6
|
+
// Entries that envlock ensures are present in .gitignore
|
|
7
|
+
const REQUIRED_ENTRIES = [".envlock/", ".env", ".env.*", "!.env.example"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Detects the project language/ecosystem from common config files.
|
|
11
|
+
* Used only for informational messaging — gitignore logic is the same regardless.
|
|
12
|
+
*/
|
|
13
|
+
function detectProjectType(cwd) {
|
|
14
|
+
const markers = {
|
|
15
|
+
"package.json": "Node.js",
|
|
16
|
+
"go.mod": "Go",
|
|
17
|
+
"Cargo.toml": "Rust",
|
|
18
|
+
"requirements.txt": "Python",
|
|
19
|
+
"pyproject.toml": "Python",
|
|
20
|
+
"composer.json": "PHP",
|
|
21
|
+
Gemfile: "Ruby",
|
|
22
|
+
};
|
|
23
|
+
for (const [file, lang] of Object.entries(markers)) {
|
|
24
|
+
if (existsSync(join(cwd, file))) return lang;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Ensures .envlock/ and common .env patterns are in .gitignore.
|
|
31
|
+
* Creates the file if it doesn't exist. Only appends missing entries.
|
|
32
|
+
* Returns an array of the entries that were actually added.
|
|
33
|
+
*/
|
|
34
|
+
export function ensureGitignore(cwd = process.cwd()) {
|
|
35
|
+
const gitignorePath = join(cwd, GITIGNORE_FILE);
|
|
36
|
+
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf8") : "";
|
|
37
|
+
const lines = existing.split("\n").map((l) => l.trim());
|
|
38
|
+
|
|
39
|
+
const missing = REQUIRED_ENTRIES.filter((entry) => !lines.includes(entry));
|
|
40
|
+
if (missing.length === 0) return [];
|
|
41
|
+
|
|
42
|
+
const section = [
|
|
43
|
+
"",
|
|
44
|
+
"# EnvLock — secrets management",
|
|
45
|
+
...missing,
|
|
46
|
+
].join("\n");
|
|
47
|
+
|
|
48
|
+
writeFileSync(gitignorePath, existing.trimEnd() + section + "\n", "utf8");
|
|
49
|
+
return missing;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { detectProjectType };
|