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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -2
  3. package/package.json +37 -4
  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
- # Temporary Holding Version
1
+ # veylix-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/veylix-cli.svg)](https://www.npmjs.com/package/veylix-cli)
4
+ [![node](https://img.shields.io/node/v/veylix-cli.svg)](https://nodejs.org)
5
+ [![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](https://www.npmjs.com/package/veylix-cli?activeTab=dependencies)
6
+ [![license](https://img.shields.io/npm/l/veylix-cli.svg)](./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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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);