veylix-cli 0.0.0-stage → 1.2.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/LICENSE +21 -0
- package/README.md +216 -2
- package/package.json +37 -4
- package/veylix.mjs +356 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Veylix
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,217 @@
|
|
|
1
|
-
#
|
|
1
|
+
# veylix-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/veylix-cli)
|
|
4
|
+
[](https://nodejs.org)
|
|
5
|
+
[](https://www.npmjs.com/package/veylix-cli?activeTab=dependencies)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
The official command-line tool for **[Veylix](https://veylix-omega.vercel.app)**. It pulls your environment variables into local development, shell sessions and CI/CD pipelines, so you don't have to copy `.env` files around.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install -g veylix-cli
|
|
12
|
+
veylix login --token vlx_…
|
|
13
|
+
veylix link
|
|
14
|
+
veylix run -- npm run dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **No `.env` files on disk.** `veylix run` puts variables straight into your process.
|
|
18
|
+
- **One token for every project.** Choose the project and environment per folder.
|
|
19
|
+
- **CI-ready.** Configure everything through environment variables, with no interactive prompts.
|
|
20
|
+
- **Zero dependencies.** A single ESM file on Node 18+, so it installs in about a second and is easy to audit.
|
|
21
|
+
- **Secure defaults.** The config file is written with `0600` permissions, and secrets go to stdout only when you ask for them.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g veylix-cli # or: pnpm add -g veylix-cli / yarn global add veylix-cli
|
|
29
|
+
veylix --version
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
To run it once without installing:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx veylix-cli pull
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires **Node.js 18 or newer**.
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
**1. Create an API token.** Sign in at **[veylix-omega.vercel.app](https://veylix-omega.vercel.app)** and go to **Settings → API tokens**. A token can cover all your projects or only one, can be read-only or read-write, and can expire.
|
|
43
|
+
|
|
44
|
+
**2. Log in.**
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
veylix login --token vlx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
✓ Logged in to https://veylix-omega.vercel.app as you@company.com: all projects (3), read-write
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**3. Link a folder to a project and environment.**
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cd my-app
|
|
58
|
+
veylix link
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
Which project?
|
|
63
|
+
1 api (3 environments)
|
|
64
|
+
2 web (2 environments)
|
|
65
|
+
Choose 1-2: 1
|
|
66
|
+
Which environment of api?
|
|
67
|
+
1 development (12 variables)
|
|
68
|
+
2 staging (14 variables)
|
|
69
|
+
3 production (14 variables)
|
|
70
|
+
Choose 1-3: 1
|
|
71
|
+
✓ Linked this folder to api › development (.veylix.json)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**4. Use your variables.**
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
veylix run -- npm run dev # inject into a process
|
|
78
|
+
veylix pull > .env # or write a .env file
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Commands
|
|
82
|
+
|
|
83
|
+
| Command | Description |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `veylix login --token <vlx_…> [--url <server>]` | Save your token (and server) after checking it with the API |
|
|
86
|
+
| `veylix logout` | Remove the saved token |
|
|
87
|
+
| `veylix whoami` | Show the account, token scope, server and linked project |
|
|
88
|
+
| `veylix projects` | List projects, environments and variable counts |
|
|
89
|
+
| `veylix link [--project <name>] [--env <name>]` | Link the current folder (writes `.veylix.json`) |
|
|
90
|
+
| `veylix unlink` | Remove the folder link |
|
|
91
|
+
| `veylix envs` | List the environments of the current project |
|
|
92
|
+
| `veylix pull [--format dotenv\|json\|shell] [--output <file>]` | Print or save variables |
|
|
93
|
+
| `veylix run -- <command> [args…]` | Run a command with the variables injected |
|
|
94
|
+
| `veylix push [--file .env] [--replace]` | Upload a `.env` file (needs a read-write token) |
|
|
95
|
+
| `veylix set KEY=value [KEY2=value…]` | Create or update variables |
|
|
96
|
+
| `veylix unset KEY` | Delete a variable |
|
|
97
|
+
| `veylix help` / `veylix --version` | Show help or the version |
|
|
98
|
+
|
|
99
|
+
Every command accepts `--project <name|id>` and `--env <name>` to override the folder link.
|
|
100
|
+
|
|
101
|
+
### Examples
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Run against another environment for a single command
|
|
105
|
+
veylix run --env staging -- npm start
|
|
106
|
+
|
|
107
|
+
# Export as JSON for tooling
|
|
108
|
+
veylix pull --project api --env production --format json
|
|
109
|
+
|
|
110
|
+
# Load into the current shell session
|
|
111
|
+
eval "$(veylix pull --format shell)"
|
|
112
|
+
|
|
113
|
+
# Write a file with 0600 permissions
|
|
114
|
+
veylix pull --output .env.local
|
|
115
|
+
|
|
116
|
+
# Sync a local .env up to Veylix; --replace also deletes keys missing from the file
|
|
117
|
+
veylix push --file .env.production --env production --replace
|
|
118
|
+
|
|
119
|
+
# Quick edits
|
|
120
|
+
veylix set STRIPE_KEY=sk_live_… FEATURE_FLAG=true
|
|
121
|
+
veylix unset LEGACY_API_URL
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Configuration
|
|
125
|
+
|
|
126
|
+
Settings are resolved in this order (first match wins):
|
|
127
|
+
|
|
128
|
+
| Setting | 1. Flag | 2. Environment variable | 3. Saved |
|
|
129
|
+
|---|---|---|---|
|
|
130
|
+
| Token | `--token` | `VEYLIX_TOKEN` | `~/.config/veylix/config.json` |
|
|
131
|
+
| Server URL | `--url` | `VEYLIX_URL` | `~/.config/veylix/config.json`, then `https://veylix-omega.vercel.app` |
|
|
132
|
+
| Project | `--project` | `VEYLIX_PROJECT` | nearest `.veylix.json` |
|
|
133
|
+
| Environment | `--env` | `VEYLIX_ENV` | nearest `.veylix.json` |
|
|
134
|
+
|
|
135
|
+
- **`~/.config/veylix/config.json`** holds your token. It is created with mode `0600` and respects `XDG_CONFIG_HOME`.
|
|
136
|
+
- **`.veylix.json`** holds only the project and environment names, never secrets. **Commit it**, so everyone on the team pulls the same environment. The CLI looks for it in the current folder and every parent folder, so it works in monorepos.
|
|
137
|
+
- You can leave out the project or environment when it's unambiguous, for example when the token can reach only one project.
|
|
138
|
+
|
|
139
|
+
### Self-hosted Veylix
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
veylix login --url https://veylix.your-company.com --token vlx_…
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## CI/CD
|
|
146
|
+
|
|
147
|
+
The CLI needs no login step in CI. Pass everything as environment variables:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
VEYLIX_TOKEN=vlx_… VEYLIX_PROJECT=api VEYLIX_ENV=production \
|
|
151
|
+
npx veylix-cli run -- node server.js
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
<details>
|
|
155
|
+
<summary><b>GitHub Actions</b></summary>
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
- name: Deploy
|
|
159
|
+
env:
|
|
160
|
+
VEYLIX_TOKEN: ${{ secrets.VEYLIX_TOKEN }}
|
|
161
|
+
VEYLIX_PROJECT: api
|
|
162
|
+
VEYLIX_ENV: production
|
|
163
|
+
run: npx veylix-cli run -- npm run deploy
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
</details>
|
|
167
|
+
|
|
168
|
+
<details>
|
|
169
|
+
<summary><b>GitLab CI</b></summary>
|
|
170
|
+
|
|
171
|
+
```yaml
|
|
172
|
+
deploy:
|
|
173
|
+
image: node:20
|
|
174
|
+
variables:
|
|
175
|
+
VEYLIX_PROJECT: api
|
|
176
|
+
VEYLIX_ENV: production
|
|
177
|
+
script:
|
|
178
|
+
- npx veylix-cli run -- npm run deploy # VEYLIX_TOKEN set as a masked CI/CD variable
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
</details>
|
|
182
|
+
|
|
183
|
+
<details>
|
|
184
|
+
<summary><b>Docker</b></summary>
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
veylix pull --env production --output .env.production
|
|
188
|
+
docker run --env-file .env.production my-image
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
</details>
|
|
192
|
+
|
|
193
|
+
For CI, use a **read-only token scoped to one project**, and set an expiry.
|
|
194
|
+
|
|
195
|
+
## Security
|
|
196
|
+
|
|
197
|
+
- Tokens are sent only to your Veylix server, in the `Authorization` header (HTTPS on the hosted service).
|
|
198
|
+
- The server looks tokens up by SHA-256 hash and keeps them encrypted at rest. You can revoke, regenerate and expire them in **Settings → API tokens**.
|
|
199
|
+
- `veylix run` keeps secrets in the child process's memory only. Nothing is written to disk.
|
|
200
|
+
- `pull --output` creates files with mode `0600`. Add `.env*` to your `.gitignore`.
|
|
201
|
+
- Read-only tokens can't change anything, even if they leak.
|
|
202
|
+
|
|
203
|
+
## Troubleshooting
|
|
204
|
+
|
|
205
|
+
| Message | Fix |
|
|
206
|
+
|---|---|
|
|
207
|
+
| `Not logged in` | Run `veylix login --token …` or set `VEYLIX_TOKEN` |
|
|
208
|
+
| `Can't reach Veylix at …` | Check `--url` / `VEYLIX_URL` and your network |
|
|
209
|
+
| `Which project? Pass it as a flag` | Run `veylix link`, or pass `--project` / set `VEYLIX_PROJECT` in CI |
|
|
210
|
+
| `[HTTP 401]` | The token is invalid, revoked or expired. Create a new one |
|
|
211
|
+
| `[HTTP 403]` | The token is read-only. Use a read-write token for `push`, `set` and `unset` |
|
|
212
|
+
| `[HTTP 404]` | The project or environment doesn't exist or isn't covered by the token. Run `veylix projects` |
|
|
213
|
+
| `[HTTP 429]` | Rate limited. Wait a moment and retry |
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
[MIT](./LICENSE) © Veylix
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "veylix-cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Veylix CLI: pull, inject and sync environment variables from Veylix into your apps, shells and CI pipelines. Zero dependencies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"veylix",
|
|
7
|
+
"env",
|
|
8
|
+
"dotenv",
|
|
9
|
+
"environment-variables",
|
|
10
|
+
"secrets",
|
|
11
|
+
"secrets-management",
|
|
12
|
+
"config",
|
|
13
|
+
"cli",
|
|
14
|
+
"ci",
|
|
15
|
+
"devops"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://veylix-omega.vercel.app",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/Dhamodharan-coder/veylix.git",
|
|
21
|
+
"directory": "cli"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/Dhamodharan-coder/veylix/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"veylix": "veylix.mjs"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"veylix.mjs",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|
package/veylix.mjs
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Veylix CLI: pull Env Manager variables into apps, shells and CI pipelines.
|
|
3
|
+
// Zero dependencies; requires Node 18+ (global fetch).
|
|
4
|
+
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join, relative } from "node:path";
|
|
9
|
+
import { createInterface } from "node:readline/promises";
|
|
10
|
+
|
|
11
|
+
const VERSION = "1.2.0";
|
|
12
|
+
const DEFAULT_URL = "https://veylix-omega.vercel.app";
|
|
13
|
+
const CONFIG_PATH = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "veylix", "config.json");
|
|
14
|
+
// Per-folder project/environment choice (no secrets, safe to commit).
|
|
15
|
+
const LINK_FILE = ".veylix.json";
|
|
16
|
+
|
|
17
|
+
const HELP = `veylix ${VERSION}: environment variables from Veylix
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
veylix login --token <vlx_…> [--url <server>] Server defaults to ${DEFAULT_URL}
|
|
21
|
+
veylix logout
|
|
22
|
+
veylix whoami Show your account, token access and linked project
|
|
23
|
+
veylix projects List projects and environments you can use
|
|
24
|
+
veylix link [--project <name>] [--env <name>]
|
|
25
|
+
Choose the project & environment for this folder
|
|
26
|
+
veylix unlink Remove this folder's link
|
|
27
|
+
veylix envs List environments of the project with variable counts
|
|
28
|
+
veylix pull [--format dotenv|json|shell] [--output <file>]
|
|
29
|
+
veylix run -- <command> [args…] Run a command with the variables injected
|
|
30
|
+
veylix push [--file .env] [--replace]
|
|
31
|
+
Upload a .env file (read-write token)
|
|
32
|
+
veylix set KEY=value [KEY2=value…]
|
|
33
|
+
veylix unset KEY
|
|
34
|
+
|
|
35
|
+
Every command accepts --project <name> and --env <name> to override the link.
|
|
36
|
+
|
|
37
|
+
Configuration (in order of priority):
|
|
38
|
+
Token / server: --token / --url › VEYLIX_TOKEN / VEYLIX_URL › ${CONFIG_PATH.replace(homedir(), "~")}
|
|
39
|
+
Project / env: --project / --env › VEYLIX_PROJECT / VEYLIX_ENV › ${LINK_FILE} (from \`veylix link\`)
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
veylix link # pick interactively
|
|
43
|
+
veylix pull > .env
|
|
44
|
+
veylix run --env staging -- npm start
|
|
45
|
+
veylix pull --project api --env production --format json
|
|
46
|
+
VEYLIX_TOKEN=vlx_… VEYLIX_PROJECT=api VEYLIX_ENV=production veylix run -- node server.js # CI
|
|
47
|
+
|
|
48
|
+
Create a token at ${DEFAULT_URL}/settings`;
|
|
49
|
+
|
|
50
|
+
// ---------- args ----------
|
|
51
|
+
|
|
52
|
+
function parseArgs(argv) {
|
|
53
|
+
const flags = {};
|
|
54
|
+
const positional = [];
|
|
55
|
+
let rest = [];
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i];
|
|
58
|
+
if (a === "--") {
|
|
59
|
+
rest = argv.slice(i + 1);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (a.startsWith("--")) {
|
|
63
|
+
const [key, inline] = a.slice(2).split(/=(.*)/s);
|
|
64
|
+
if (inline !== undefined) flags[key] = inline;
|
|
65
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith("--")) flags[key] = argv[++i];
|
|
66
|
+
else flags[key] = true;
|
|
67
|
+
} else if (a === "-h") flags.help = true;
|
|
68
|
+
else positional.push(a);
|
|
69
|
+
}
|
|
70
|
+
return { command: positional[0], args: positional.slice(1), flags, rest };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------- config ----------
|
|
74
|
+
|
|
75
|
+
function readConfig() {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function writeConfig(config) {
|
|
84
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
85
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
86
|
+
chmodSync(CONFIG_PATH, 0o600); // the token is a secret
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function settings(flags) {
|
|
90
|
+
const config = readConfig();
|
|
91
|
+
const url = String(flags.url || process.env.VEYLIX_URL || config.url || DEFAULT_URL).replace(/\/$/, "");
|
|
92
|
+
const token = flags.token || process.env.VEYLIX_TOKEN || config.token;
|
|
93
|
+
if (!token) fail("Not logged in. Run `veylix login --token vlx_…` or set VEYLIX_TOKEN.");
|
|
94
|
+
return { url, token: String(token) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Nearest .veylix.json walking up from the current folder. */
|
|
98
|
+
function findLink() {
|
|
99
|
+
for (let dir = process.cwd(); ; dir = dirname(dir)) {
|
|
100
|
+
const file = join(dir, LINK_FILE);
|
|
101
|
+
if (existsSync(file)) {
|
|
102
|
+
try {
|
|
103
|
+
return { file, ...JSON.parse(readFileSync(file, "utf8")) };
|
|
104
|
+
} catch {
|
|
105
|
+
fail(`Couldn't read ${file}. Run \`veylix link\` again.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (dirname(dir) === dir) return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Project/environment for a command: flags › env vars › link file. Either may be undefined (the server picks when unambiguous). */
|
|
113
|
+
function target(flags) {
|
|
114
|
+
const link = findLink();
|
|
115
|
+
const chosen = flags.project || process.env.VEYLIX_PROJECT;
|
|
116
|
+
// The link's environment only applies to the linked project.
|
|
117
|
+
const sameProject = !chosen || chosen === link?.projectId || String(chosen).toLowerCase() === link?.project?.toLowerCase();
|
|
118
|
+
const project = chosen || link?.projectId || link?.project;
|
|
119
|
+
const env = flags.env || process.env.VEYLIX_ENV || (sameProject ? link?.env : undefined);
|
|
120
|
+
return { project: project ? String(project) : undefined, env: env ? String(env) : undefined };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------- http ----------
|
|
124
|
+
|
|
125
|
+
async function api(flags, path, init = {}) {
|
|
126
|
+
const { url, token } = settings(flags);
|
|
127
|
+
let res;
|
|
128
|
+
try {
|
|
129
|
+
res = await fetch(`${url}${path}`, {
|
|
130
|
+
...init,
|
|
131
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": `veylix-cli/${VERSION}`, ...init.headers },
|
|
132
|
+
});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
fail(`Can't reach Veylix at ${url} (${e.cause?.code || e.message})`);
|
|
135
|
+
}
|
|
136
|
+
const text = await res.text();
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
let message = text;
|
|
139
|
+
try {
|
|
140
|
+
message = JSON.parse(text).error || text;
|
|
141
|
+
} catch {}
|
|
142
|
+
fail(`${message} [HTTP ${res.status}]`);
|
|
143
|
+
}
|
|
144
|
+
return text;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function targetQuery(flags, extra = {}) {
|
|
148
|
+
const { project, env } = target(flags);
|
|
149
|
+
const q = new URLSearchParams(Object.entries({ ...extra, project, env }).filter(([, v]) => v));
|
|
150
|
+
const qs = q.toString();
|
|
151
|
+
return qs ? `?${qs}` : "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const fetchSecrets = async (flags) => JSON.parse(await api(flags, `/api/v1/secrets${targetQuery(flags)}`));
|
|
155
|
+
const fetchMe = async (flags) => JSON.parse(await api(flags, "/api/v1/me"));
|
|
156
|
+
|
|
157
|
+
/** Asks the user to pick one of `items` (numbered list on stderr) by number or name. */
|
|
158
|
+
async function choose(question, items, label) {
|
|
159
|
+
if (items.length === 1) return items[0];
|
|
160
|
+
if (!process.stdin.isTTY) fail(`${question} Pass it as a flag (one of: ${items.map((item) => item.name).join(", ")}).`);
|
|
161
|
+
info(c(1, question));
|
|
162
|
+
items.forEach((item, i) => info(` ${c(36, String(i + 1).padStart(2))} ${label(item)}`));
|
|
163
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
164
|
+
try {
|
|
165
|
+
for (;;) {
|
|
166
|
+
const answer = (await rl.question(`Choose 1-${items.length}: `)).trim();
|
|
167
|
+
const n = Number(answer);
|
|
168
|
+
if (Number.isInteger(n) && n >= 1 && n <= items.length) return items[n - 1];
|
|
169
|
+
const byName = items.find((item) => item.name.toLowerCase() === answer.toLowerCase());
|
|
170
|
+
if (byName) return byName;
|
|
171
|
+
}
|
|
172
|
+
} catch (e) {
|
|
173
|
+
if (e.name === "AbortError") fail("Cancelled");
|
|
174
|
+
throw e;
|
|
175
|
+
} finally {
|
|
176
|
+
rl.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const where = (res) => `${res.project} › ${res.environment}`;
|
|
181
|
+
|
|
182
|
+
// ---------- formats ----------
|
|
183
|
+
|
|
184
|
+
const toDotenv = (secrets) => Object.entries(secrets).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join("\n") + "\n";
|
|
185
|
+
const toShell = (secrets) => Object.entries(secrets).map(([k, v]) => `export ${k}='${v.replace(/'/g, `'\\''`)}'`).join("\n") + "\n";
|
|
186
|
+
|
|
187
|
+
function parseDotenv(content) {
|
|
188
|
+
const out = {};
|
|
189
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
190
|
+
const line = raw.trim();
|
|
191
|
+
if (!line || line.startsWith("#")) continue;
|
|
192
|
+
const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
|
|
193
|
+
if (!m) continue;
|
|
194
|
+
let value = m[2];
|
|
195
|
+
const quoted = value.match(/^(['"])([\s\S]*)\1$/);
|
|
196
|
+
if (quoted) {
|
|
197
|
+
value = quoted[2];
|
|
198
|
+
if (quoted[1] === '"') value = value.replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
199
|
+
} else value = value.replace(/\s+#.*$/, "");
|
|
200
|
+
out[m[1]] = value;
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------- output ----------
|
|
206
|
+
|
|
207
|
+
const isTTY = process.stderr.isTTY;
|
|
208
|
+
const c = (code, s) => (isTTY ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
209
|
+
const info = (msg) => process.stderr.write(`${msg}\n`);
|
|
210
|
+
const success = (msg) => info(`${c(32, "✓")} ${msg}`);
|
|
211
|
+
function fail(msg) {
|
|
212
|
+
process.stderr.write(`${c(31, "✗")} ${msg}\n`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------- commands ----------
|
|
217
|
+
|
|
218
|
+
const commands = {
|
|
219
|
+
async login(_, flags) {
|
|
220
|
+
if (!flags.token || flags.token === true) fail("Usage: veylix login --token vlx_… [--url https://your-veylix]");
|
|
221
|
+
const config = { url: String(flags.url || readConfig().url || DEFAULT_URL).replace(/\/$/, ""), token: String(flags.token) };
|
|
222
|
+
const me = await fetchMe({ ...flags, ...config });
|
|
223
|
+
writeConfig(config);
|
|
224
|
+
const scope = me.scope === "all-projects" ? `all projects (${me.projects.length})` : `project ${c(1, me.projects[0]?.name)}`;
|
|
225
|
+
success(`Logged in to ${config.url} as ${me.user?.email ?? "you"}: ${scope}, ${me.access}`);
|
|
226
|
+
info(`Next: run ${c(1, "veylix link")} in your app's folder to choose a project and environment.`);
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
async logout() {
|
|
230
|
+
if (existsSync(CONFIG_PATH)) writeConfig({ url: readConfig().url });
|
|
231
|
+
success("Logged out");
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async whoami(_, flags) {
|
|
235
|
+
const me = await fetchMe(flags);
|
|
236
|
+
const link = findLink();
|
|
237
|
+
const t = target(flags);
|
|
238
|
+
const project = me.projects.find((p) => p.id === t.project || p.name.toLowerCase() === t.project?.toLowerCase());
|
|
239
|
+
console.log(`Account: ${me.user ? `${me.user.name} <${me.user.email}>` : "-"}`);
|
|
240
|
+
console.log(`Server: ${settings(flags).url}`);
|
|
241
|
+
console.log(`Token: ${me.scope === "all-projects" ? "all projects" : me.projects[0]?.name}, ${me.access}`);
|
|
242
|
+
console.log(`Using: ${project ? `${project.name} › ${t.env ?? "(no environment)"}` : "nothing linked; run `veylix link`"}`);
|
|
243
|
+
if (link) console.log(`Linked: ${relative(process.cwd(), link.file) || LINK_FILE}`);
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async projects(_, flags) {
|
|
247
|
+
const me = await fetchMe(flags);
|
|
248
|
+
if (!me.projects.length) return info("No projects yet. Create one in Veylix first.");
|
|
249
|
+
const t = target(flags);
|
|
250
|
+
let marked = false;
|
|
251
|
+
const width = Math.max(...me.projects.map((p) => p.name.length), 7);
|
|
252
|
+
console.log(`${"PROJECT".padEnd(width)} ENVIRONMENTS (variables)`);
|
|
253
|
+
for (const p of me.projects) {
|
|
254
|
+
const linked = p.id === t.project || p.name.toLowerCase() === t.project?.toLowerCase();
|
|
255
|
+
marked ||= linked;
|
|
256
|
+
const envs = p.environments.map((e) => `${linked && e.name === t.env ? c(32, `${e.name}*`) : e.name} (${e.variables})`).join(", ");
|
|
257
|
+
console.log(`${(linked ? c(32, p.name) : p.name) + " ".repeat(width - p.name.length)} ${envs || c(2, "no environments")}`);
|
|
258
|
+
}
|
|
259
|
+
if (marked && t.env) info(c(2, "\n* currently used in this folder"));
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async link(_, flags) {
|
|
263
|
+
const me = await fetchMe(flags);
|
|
264
|
+
if (!me.projects.length) fail("No projects yet. Create one in Veylix first.");
|
|
265
|
+
let project;
|
|
266
|
+
if (flags.project) {
|
|
267
|
+
const ref = String(flags.project).toLowerCase();
|
|
268
|
+
project = me.projects.find((p) => p.id === flags.project || p.name.toLowerCase() === ref);
|
|
269
|
+
if (!project) fail(`Project "${flags.project}" not found. Available: ${me.projects.map((p) => p.name).join(", ")}`);
|
|
270
|
+
} else project = await choose("Which project?", me.projects, (p) => `${p.name} ${c(2, `(${p.environments.length} environment${p.environments.length === 1 ? "" : "s"})`)}`);
|
|
271
|
+
|
|
272
|
+
if (!project.environments.length) fail(`${project.name} has no environments yet.`);
|
|
273
|
+
let env;
|
|
274
|
+
if (flags.env) {
|
|
275
|
+
env = project.environments.find((e) => e.name === String(flags.env).toLowerCase());
|
|
276
|
+
if (!env) fail(`Environment "${flags.env}" not found in ${project.name}. Available: ${project.environments.map((e) => e.name).join(", ")}`);
|
|
277
|
+
} else env = await choose(`Which environment of ${project.name}?`, project.environments, (e) => `${e.name} ${c(2, `(${e.variables} variable${e.variables === 1 ? "" : "s"})`)}`);
|
|
278
|
+
|
|
279
|
+
writeFileSync(LINK_FILE, JSON.stringify({ projectId: project.id, project: project.name, env: env.name }, null, 2) + "\n");
|
|
280
|
+
success(`Linked this folder to ${c(1, `${project.name} › ${env.name}`)} (${LINK_FILE})`);
|
|
281
|
+
info(c(2, "Now try: veylix pull > .env or veylix run -- npm start"));
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
async unlink() {
|
|
285
|
+
const link = findLink();
|
|
286
|
+
if (!link) return info("This folder isn't linked.");
|
|
287
|
+
rmSync(link.file);
|
|
288
|
+
success(`Removed ${relative(process.cwd(), link.file) || LINK_FILE}`);
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
async envs(_, flags) {
|
|
292
|
+
const me = await fetchMe(flags);
|
|
293
|
+
const t = target(flags);
|
|
294
|
+
const project = t.project
|
|
295
|
+
? me.projects.find((p) => p.id === t.project || p.name.toLowerCase() === t.project.toLowerCase())
|
|
296
|
+
: me.projects.length === 1 ? me.projects[0] : null;
|
|
297
|
+
if (!project) fail(t.project ? `Project "${t.project}" not found.` : "Which project? Run `veylix link` or pass --project <name>.");
|
|
298
|
+
const width = Math.max(...project.environments.map((e) => e.name.length), 11);
|
|
299
|
+
info(c(2, project.name));
|
|
300
|
+
console.log(`${"ENVIRONMENT".padEnd(width)} VARIABLES`);
|
|
301
|
+
for (const e of project.environments) console.log(`${e.name.padEnd(width)} ${e.variables}`);
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
async pull(_, flags) {
|
|
305
|
+
const res = await fetchSecrets(flags);
|
|
306
|
+
const { secrets } = res;
|
|
307
|
+
const format = flags.format || "dotenv";
|
|
308
|
+
const body = format === "json" ? JSON.stringify(secrets, null, 2) + "\n" : format === "shell" ? toShell(secrets) : toDotenv(secrets);
|
|
309
|
+
if (flags.output) {
|
|
310
|
+
writeFileSync(String(flags.output), body, { mode: 0o600 });
|
|
311
|
+
success(`Wrote ${Object.keys(secrets).length} variables from ${where(res)} to ${flags.output}`);
|
|
312
|
+
} else process.stdout.write(body);
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
async run(_, flags, rest) {
|
|
316
|
+
if (!rest.length) fail("Usage: veylix run [--project <name>] [--env <name>] -- <command> [args…]");
|
|
317
|
+
const res = await fetchSecrets(flags);
|
|
318
|
+
const { secrets } = res;
|
|
319
|
+
info(c(2, `veylix: injecting ${Object.keys(secrets).length} variables from ${where(res)}`));
|
|
320
|
+
const child = spawn(rest[0], rest.slice(1), { stdio: "inherit", env: { ...process.env, ...secrets }, shell: process.platform === "win32" });
|
|
321
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => child.kill(sig));
|
|
322
|
+
child.on("error", (e) => fail(`Couldn't start ${rest[0]}: ${e.message}`));
|
|
323
|
+
child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
async push(_, flags) {
|
|
327
|
+
const file = String(flags.file || ".env");
|
|
328
|
+
if (!existsSync(file)) fail(`File not found: ${file}`);
|
|
329
|
+
const secrets = parseDotenv(readFileSync(file, "utf8"));
|
|
330
|
+
if (!Object.keys(secrets).length) fail(`No KEY=value lines found in ${file}`);
|
|
331
|
+
const res = JSON.parse(await api(flags, "/api/v1/secrets", { method: "PUT", body: JSON.stringify({ ...target(flags), secrets, replace: !!flags.replace }) }));
|
|
332
|
+
success(`${where(res)}: ${res.created} created, ${res.updated} updated${res.deleted ? `, ${res.deleted} deleted` : ""}`);
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
async set(args, flags) {
|
|
336
|
+
const pairs = args.map((a) => a.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s));
|
|
337
|
+
if (!pairs.length || pairs.some((p) => !p)) fail("Usage: veylix set KEY=value [KEY2=value…]");
|
|
338
|
+
const secrets = Object.fromEntries(pairs.map((p) => [p[1], p[2]]));
|
|
339
|
+
const res = JSON.parse(await api(flags, "/api/v1/secrets", { method: "PUT", body: JSON.stringify({ ...target(flags), secrets }) }));
|
|
340
|
+
success(`${where(res)}: ${res.created} created, ${res.updated} updated`);
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
async unset(args, flags) {
|
|
344
|
+
if (!args[0]) fail("Usage: veylix unset KEY");
|
|
345
|
+
const res = JSON.parse(await api(flags, `/api/v1/secrets${targetQuery(flags, { key: args[0] })}`, { method: "DELETE" }));
|
|
346
|
+
success(`Deleted ${res.deleted} from ${where(res)}`);
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// ---------- main ----------
|
|
351
|
+
|
|
352
|
+
const { command, args, flags, rest } = parseArgs(process.argv.slice(2));
|
|
353
|
+
if (flags.version || command === "version") console.log(VERSION);
|
|
354
|
+
else if (!command || flags.help || command === "help") console.log(HELP);
|
|
355
|
+
else if (!commands[command]) fail(`Unknown command "${command}". Run \`veylix help\`.`);
|
|
356
|
+
else await commands[command](args, flags, rest);
|