xtra-cli 0.1.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 +87 -0
- package/dist/bin/xtra.js +124 -0
- package/dist/commands/access.js +107 -0
- package/dist/commands/admin.js +118 -0
- package/dist/commands/audit.js +67 -0
- package/dist/commands/branch.js +216 -0
- package/dist/commands/checkout.js +74 -0
- package/dist/commands/ci.js +330 -0
- package/dist/commands/completion.js +227 -0
- package/dist/commands/diff.js +163 -0
- package/dist/commands/doctor.js +176 -0
- package/dist/commands/env.js +70 -0
- package/dist/commands/export.js +84 -0
- package/dist/commands/generate.js +180 -0
- package/dist/commands/history.js +77 -0
- package/dist/commands/import.js +122 -0
- package/dist/commands/init.js +162 -0
- package/dist/commands/integration.js +188 -0
- package/dist/commands/local.js +198 -0
- package/dist/commands/login.js +176 -0
- package/dist/commands/login.test.js +51 -0
- package/dist/commands/logs.js +121 -0
- package/dist/commands/profile.js +184 -0
- package/dist/commands/project.js +98 -0
- package/dist/commands/rollback.js +96 -0
- package/dist/commands/rotate.js +94 -0
- package/dist/commands/run.js +215 -0
- package/dist/commands/scan.js +127 -0
- package/dist/commands/secrets.js +265 -0
- package/dist/commands/simulate.js +92 -0
- package/dist/commands/status.js +94 -0
- package/dist/commands/template.js +276 -0
- package/dist/commands/ui.js +218 -0
- package/dist/commands/watch.js +121 -0
- package/dist/lib/api.js +172 -0
- package/dist/lib/api.test.js +89 -0
- package/dist/lib/audit.js +136 -0
- package/dist/lib/config.js +42 -0
- package/dist/lib/config.test.js +47 -0
- package/dist/lib/crypto.js +50 -0
- package/dist/lib/manifest.js +52 -0
- package/dist/lib/profiles.js +103 -0
- package/package.json +67 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.runCommand = void 0;
|
|
40
|
+
const commander_1 = require("commander");
|
|
41
|
+
const api_1 = require("../lib/api");
|
|
42
|
+
const child_process_1 = require("child_process");
|
|
43
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
44
|
+
const ora_1 = __importDefault(require("ora"));
|
|
45
|
+
const config_1 = require("../lib/config");
|
|
46
|
+
const crypto_1 = require("../lib/crypto");
|
|
47
|
+
const fs = __importStar(require("fs"));
|
|
48
|
+
const path = __importStar(require("path"));
|
|
49
|
+
const dotenv_1 = __importDefault(require("dotenv"));
|
|
50
|
+
exports.runCommand = new commander_1.Command("run")
|
|
51
|
+
.description("Run a command with injected secrets")
|
|
52
|
+
.option("-p, --project <projectId>", "Project ID")
|
|
53
|
+
.option("-e, --env <environment>", "Environment (development, staging, production)", "development")
|
|
54
|
+
.option("-b, --branch <branchName>", "Branch Name")
|
|
55
|
+
.option("--shell", "Enable shell mode (needed for npm run, shell built-ins on Windows)", false)
|
|
56
|
+
.argument("<command>", "Command to run")
|
|
57
|
+
.argument("[args...]", "Command arguments")
|
|
58
|
+
.addHelpText("after", `
|
|
59
|
+
Examples:
|
|
60
|
+
$ xtra run npm start
|
|
61
|
+
$ xtra run -e production -- npm run build
|
|
62
|
+
$ xtra run -p proj_123 -b feature-branch -- python script.py
|
|
63
|
+
$ xtra run --shell "echo $SECRET_KEY"
|
|
64
|
+
`)
|
|
65
|
+
.action(async (command, args, options) => {
|
|
66
|
+
// console.log("Run Options:", options);
|
|
67
|
+
let { project, env, branch, shell: useShell } = options;
|
|
68
|
+
// Use active branch from config if not specified
|
|
69
|
+
if (!branch) {
|
|
70
|
+
branch = (0, config_1.getConfigValue)("branch") || "main";
|
|
71
|
+
}
|
|
72
|
+
// Normalize Env
|
|
73
|
+
const envMap = { dev: "development", stg: "staging", prod: "production" };
|
|
74
|
+
env = envMap[env] || env;
|
|
75
|
+
// We might want to support default project in config later
|
|
76
|
+
if (!project) {
|
|
77
|
+
project = (0, config_1.getConfigValue)("project");
|
|
78
|
+
}
|
|
79
|
+
if (!project) {
|
|
80
|
+
console.error(chalk_1.default.red("Error: Project ID is required. Use -p <id> or checkout a branch."));
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
const spinner = (0, ora_1.default)(`Fetching secrets for ${env} (branch: ${branch})...`).start();
|
|
84
|
+
let secrets = null;
|
|
85
|
+
// ── Local mode override ──────────────────────────────────────────────────
|
|
86
|
+
// If XTRA_LOCAL_MODE=true or localMode config flag is set,
|
|
87
|
+
// read from .env.local instead of calling the API.
|
|
88
|
+
const isLocal = (0, config_1.getConfigValue)("localMode") === true
|
|
89
|
+
|| process.env.XTRA_LOCAL_MODE === "true";
|
|
90
|
+
if (isLocal) {
|
|
91
|
+
const localFile = path.resolve(process.cwd(), ".env.local");
|
|
92
|
+
if (!fs.existsSync(localFile)) {
|
|
93
|
+
spinner.fail("Local mode is ON but .env.local not found. Run 'xtra local sync' first.");
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
const parsed = dotenv_1.default.parse(fs.readFileSync(localFile, "utf8"));
|
|
97
|
+
secrets = parsed;
|
|
98
|
+
spinner.succeed(chalk_1.default.yellow(`🔌 Local mode: loaded ${Object.keys(secrets).length} secrets from .env.local`));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
try {
|
|
102
|
+
// 1. Fetch Secrets
|
|
103
|
+
secrets = await api_1.api.getSecrets(project, env, branch);
|
|
104
|
+
if (!secrets || Object.keys(secrets).length === 0) {
|
|
105
|
+
spinner.warn(chalk_1.default.yellow("No secrets found for this environment."));
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
spinner.succeed(`Loaded ${Object.keys(secrets).length} secrets (Online).`);
|
|
109
|
+
// Update manifest
|
|
110
|
+
try {
|
|
111
|
+
const { hash } = require("../lib/crypto");
|
|
112
|
+
const { updateManifest } = require("../lib/manifest");
|
|
113
|
+
updateManifest(project, env, secrets, hash);
|
|
114
|
+
}
|
|
115
|
+
catch (mErr) {
|
|
116
|
+
// Manifest update failed
|
|
117
|
+
}
|
|
118
|
+
// Cache secrets locally
|
|
119
|
+
try {
|
|
120
|
+
const cacheKey = `cache.${project}.${env}.${branch}`;
|
|
121
|
+
const encrypted = (0, crypto_1.encrypt)(JSON.stringify(secrets));
|
|
122
|
+
(0, config_1.setConfig)(cacheKey, encrypted);
|
|
123
|
+
// console.log(chalk.gray("Secrets cached successfully."));
|
|
124
|
+
}
|
|
125
|
+
catch (cacheErr) {
|
|
126
|
+
console.error(chalk_1.default.yellow("Warning: Failed to cache secrets."));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
// Offline fallback
|
|
132
|
+
const cacheKey = `cache.${project}.${env}.${branch}`;
|
|
133
|
+
const cachedData = (0, config_1.getConfigValue)(cacheKey);
|
|
134
|
+
if (cachedData) {
|
|
135
|
+
try {
|
|
136
|
+
const decrypted = (0, crypto_1.decrypt)(cachedData);
|
|
137
|
+
secrets = JSON.parse(decrypted);
|
|
138
|
+
spinner.warn(chalk_1.default.yellow(`Offline Mode: Loaded ${Object.keys(secrets).length} secrets from cache.`));
|
|
139
|
+
}
|
|
140
|
+
catch (decryptErr) {
|
|
141
|
+
spinner.fail("Failed to fetch secrets and cache is corrupt.");
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
spinner.fail("Failed to fetch secrets and no local cache available.");
|
|
147
|
+
if (error.response) {
|
|
148
|
+
if (error.response.status === 404) {
|
|
149
|
+
console.error(chalk_1.default.red(`\nError: Project '${project}' not found or you don't have access.`));
|
|
150
|
+
}
|
|
151
|
+
else if (error.response.status === 401) {
|
|
152
|
+
console.error(chalk_1.default.red(`\nError: Unauthorized. Please run 'xtra login' again.`));
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
console.error(chalk_1.default.red(`\nError: ${error.response.data.error || error.message}`));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
console.error(chalk_1.default.red(`\nError: ${error.message}`));
|
|
160
|
+
}
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
} // end try/catch (cloud mode)
|
|
164
|
+
} // end else (cloud mode)
|
|
165
|
+
// Log Audit (Always)
|
|
166
|
+
try {
|
|
167
|
+
const { logAudit } = require("../lib/audit");
|
|
168
|
+
// Only log if we have secrets
|
|
169
|
+
if (secrets && Object.keys(secrets).length > 0) {
|
|
170
|
+
logAudit("SECRET_ACCESS", project, env, { method: "run", keys: Object.keys(secrets), branch });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
console.error("Audit Error:", e);
|
|
175
|
+
}
|
|
176
|
+
// 2. Prepare Environment
|
|
177
|
+
const envVars = {
|
|
178
|
+
...process.env,
|
|
179
|
+
...secrets, // Overwrite local env with injected secrets
|
|
180
|
+
};
|
|
181
|
+
// 3. Spawn Child Process — shell: false by default to prevent command injection
|
|
182
|
+
// When --shell is passed (e.g. for npm run start on Windows), allow shell mode
|
|
183
|
+
const SHELL_UNSAFE = /[;&|`$<>\\\n]/g;
|
|
184
|
+
if (!useShell && SHELL_UNSAFE.test(command)) {
|
|
185
|
+
console.error(chalk_1.default.red("Error: Command contains unsafe characters. Use --shell if intentional."));
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
// Production confirmation gate
|
|
189
|
+
if (env === "production") {
|
|
190
|
+
const inquirer = require("inquirer");
|
|
191
|
+
const { confirm } = await inquirer.prompt([{
|
|
192
|
+
type: "confirm",
|
|
193
|
+
name: "confirm",
|
|
194
|
+
message: chalk_1.default.red(`âš You are about to run a command in PRODUCTION with ${Object.keys(secrets || {}).length} injected secrets. Continue?`),
|
|
195
|
+
default: false,
|
|
196
|
+
}]);
|
|
197
|
+
if (!confirm) {
|
|
198
|
+
console.log(chalk_1.default.yellow("Aborted."));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
console.log(chalk_1.default.gray(`> ${command} ${args.join(" ")}`));
|
|
203
|
+
const child = (0, child_process_1.spawn)(command, args, {
|
|
204
|
+
env: envVars,
|
|
205
|
+
stdio: "inherit",
|
|
206
|
+
shell: useShell, // false by default; --shell enables for npm scripts / Windows
|
|
207
|
+
});
|
|
208
|
+
child.on("exit", (code) => {
|
|
209
|
+
process.exit(code ?? 0);
|
|
210
|
+
});
|
|
211
|
+
child.on("error", (err) => {
|
|
212
|
+
console.error(chalk_1.default.red(`Failed to start command: ${err.message}`));
|
|
213
|
+
process.exit(1);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.scanCommand = void 0;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const child_process_1 = require("child_process");
|
|
12
|
+
exports.scanCommand = new commander_1.Command("scan")
|
|
13
|
+
.description("Scan project for secrets and configuration leaks")
|
|
14
|
+
.option("--staged", "Scan only staged files (for pre-commit hooks)", false)
|
|
15
|
+
.option("--install-hook", "Install the git pre-commit hook")
|
|
16
|
+
.action(async (options) => {
|
|
17
|
+
const { staged, installHook } = options;
|
|
18
|
+
// Install Hook
|
|
19
|
+
if (installHook) {
|
|
20
|
+
installGitHook();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// Scanning Logic
|
|
24
|
+
console.log(chalk_1.default.bold("XtraSync Secret Scan"));
|
|
25
|
+
let issues = 0;
|
|
26
|
+
// 1. Check .env tracking
|
|
27
|
+
if (isEnvFileTracked()) {
|
|
28
|
+
console.error(chalk_1.default.red("CRITICAL: .env file is tracked by git!"));
|
|
29
|
+
issues++;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
// console.log(chalk.green("✔ .env file is safe (not tracked)."));
|
|
33
|
+
}
|
|
34
|
+
// 2. Scan Files
|
|
35
|
+
const filesToScan = staged ? getStagedFiles() : getAllFiles();
|
|
36
|
+
// Pattern: xs_ (Looking for API keys or similar tokens if we had a specific format)
|
|
37
|
+
// Also naive check for "password", "secret", "key" = "..."
|
|
38
|
+
const patterns = [
|
|
39
|
+
{ name: "Xtra API Key", regex: /xs_[a-zA-Z0-9]{10,}/ },
|
|
40
|
+
// Add more patterns here
|
|
41
|
+
];
|
|
42
|
+
filesToScan.forEach(file => {
|
|
43
|
+
try {
|
|
44
|
+
// Skip binary or large files?
|
|
45
|
+
if (!fs_1.default.existsSync(file))
|
|
46
|
+
return;
|
|
47
|
+
const content = fs_1.default.readFileSync(file, "utf-8");
|
|
48
|
+
patterns.forEach(p => {
|
|
49
|
+
if (p.regex.test(content)) {
|
|
50
|
+
console.error(chalk_1.default.red(`[${file}] Potential Secret Detected: ${p.name}`));
|
|
51
|
+
issues++;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
// Ignore (e.g. dir or binary)
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
if (issues > 0) {
|
|
60
|
+
console.error(chalk_1.default.red(`\n✖ Scan failed. found ${issues} issues.`));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
console.log(chalk_1.default.green("\n✔ Scan passed. No secrets found."));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
function installGitHook() {
|
|
68
|
+
const hookDir = path_1.default.join(process.cwd(), ".git", "hooks");
|
|
69
|
+
const hookPath = path_1.default.join(hookDir, "pre-commit");
|
|
70
|
+
if (!fs_1.default.existsSync(hookDir)) {
|
|
71
|
+
console.error(chalk_1.default.red("Error: .git directory not found. Is this a git repository?"));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const hookContent = `#!/bin/sh
|
|
75
|
+
# XtraSync Secret Scan
|
|
76
|
+
echo "Running XtraSync Secret Scan..."
|
|
77
|
+
|
|
78
|
+
# Check if 'xtra' is globally installed or use local script
|
|
79
|
+
if command -v xtra >/dev/null 2>&1; then
|
|
80
|
+
xtra scan --staged
|
|
81
|
+
elif [ -f "./node_modules/.bin/xtra" ]; then
|
|
82
|
+
./node_modules/.bin/xtra scan --staged
|
|
83
|
+
else
|
|
84
|
+
# Using the binary directly if running in dev environment (unlikely in prod use case)
|
|
85
|
+
# For dev testing:
|
|
86
|
+
if [ -f "./dist/bin/xtra.js" ]; then
|
|
87
|
+
node ./dist/bin/xtra.js scan --staged
|
|
88
|
+
else
|
|
89
|
+
echo "Warning: XtraSync CLI not found. Skipping scan."
|
|
90
|
+
fi
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# Exit code of scan is passed through
|
|
94
|
+
`;
|
|
95
|
+
fs_1.default.writeFileSync(hookPath, hookContent, { mode: 0o755 });
|
|
96
|
+
console.log(chalk_1.default.green("✔ Pre-commit hook installed successfully."));
|
|
97
|
+
}
|
|
98
|
+
function isEnvFileTracked() {
|
|
99
|
+
try {
|
|
100
|
+
// git ls-files --error-unmatch .env
|
|
101
|
+
(0, child_process_1.execSync)("git ls-files --error-unmatch .env", { stdio: "ignore" });
|
|
102
|
+
return true; // Command succeeded, file is tracked
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
return false; // Command failed, file not tracked
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function getStagedFiles() {
|
|
109
|
+
try {
|
|
110
|
+
const output = (0, child_process_1.execSync)("git diff --cached --name-only", { encoding: "utf-8" });
|
|
111
|
+
return output.split("\n").filter(f => f.trim().length > 0);
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function getAllFiles() {
|
|
118
|
+
// Naive implementation for now, or recurse.
|
|
119
|
+
// Usually 'git ls-files' is better if generic scan.
|
|
120
|
+
try {
|
|
121
|
+
const output = (0, child_process_1.execSync)("git ls-files", { encoding: "utf-8" });
|
|
122
|
+
return output.split("\n").filter(f => f.trim().length > 0);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.secretsCommand = void 0;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const api_1 = require("../lib/api");
|
|
9
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
10
|
+
const ora_1 = __importDefault(require("ora"));
|
|
11
|
+
const table_1 = require("table");
|
|
12
|
+
const config_1 = require("../lib/config");
|
|
13
|
+
exports.secretsCommand = new commander_1.Command("secrets")
|
|
14
|
+
.description("Manage secrets (List, Set, Delete)")
|
|
15
|
+
.option("-p, --project <projectId>", "Project ID")
|
|
16
|
+
.option("-e, --env <environment>", "Environment (development, staging, production)", "development")
|
|
17
|
+
.option("-b, --branch <branchName>", "Branch Name");
|
|
18
|
+
// LIST
|
|
19
|
+
exports.secretsCommand
|
|
20
|
+
.command("list")
|
|
21
|
+
.description("List all secrets for a project/environment")
|
|
22
|
+
.option("--show", "Reveal secret values", false)
|
|
23
|
+
.addHelpText("after", `
|
|
24
|
+
Examples:
|
|
25
|
+
$ xtra secrets list
|
|
26
|
+
$ xtra secrets list -e production --show
|
|
27
|
+
`)
|
|
28
|
+
.action(async (options) => {
|
|
29
|
+
const parentOpts = exports.secretsCommand.opts();
|
|
30
|
+
let { project, env, branch } = parentOpts;
|
|
31
|
+
// Use active branch from config if not specified
|
|
32
|
+
if (!branch) {
|
|
33
|
+
branch = (0, config_1.getConfigValue)("branch") || "main";
|
|
34
|
+
}
|
|
35
|
+
// Normalize Env
|
|
36
|
+
const envMap = { dev: "development", stg: "staging", prod: "production" };
|
|
37
|
+
env = envMap[env] || env;
|
|
38
|
+
const { show } = options;
|
|
39
|
+
if (!project) {
|
|
40
|
+
project = (0, config_1.getConfigValue)("project");
|
|
41
|
+
}
|
|
42
|
+
if (!project) {
|
|
43
|
+
console.error(chalk_1.default.red("Error: Project ID is required. Use -p <id> or checkout a branch."));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const spinner = (0, ora_1.default)(`Fetching secrets for ${env} (branch: ${branch})...`).start();
|
|
47
|
+
try {
|
|
48
|
+
const secrets = await api_1.api.getSecrets(project, env, branch);
|
|
49
|
+
spinner.stop();
|
|
50
|
+
if (!secrets || Object.keys(secrets).length === 0) {
|
|
51
|
+
console.log(chalk_1.default.yellow("No secrets found."));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (show) {
|
|
55
|
+
console.log(chalk_1.default.red("âš Warning: Secret values will be visible in your terminal and shell history!"));
|
|
56
|
+
}
|
|
57
|
+
const data = [
|
|
58
|
+
[chalk_1.default.bold("Key"), chalk_1.default.bold("Value"), chalk_1.default.bold("Env")]
|
|
59
|
+
];
|
|
60
|
+
Object.entries(secrets).forEach(([key, value]) => {
|
|
61
|
+
data.push([
|
|
62
|
+
key,
|
|
63
|
+
show ? value : "********",
|
|
64
|
+
env
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
console.log((0, table_1.table)(data));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
spinner.fail("Failed to fetch secrets");
|
|
71
|
+
console.error(chalk_1.default.red(error.message));
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
// SET
|
|
75
|
+
exports.secretsCommand
|
|
76
|
+
.command("set")
|
|
77
|
+
.description("Set one or more secrets (KEY=VALUE)")
|
|
78
|
+
.argument("<secrets...>", "Secrets to set (format: KEY=VALUE)")
|
|
79
|
+
.option("-f, --force", "Force update (overwrite remote changes without warning)", false)
|
|
80
|
+
.addHelpText("after", `
|
|
81
|
+
Examples:
|
|
82
|
+
$ xtra secrets set API_KEY=xyz
|
|
83
|
+
$ xtra secrets set DB_USER=admin DB_PASS=secret -e staging
|
|
84
|
+
`)
|
|
85
|
+
.action(async (args, options) => {
|
|
86
|
+
const parentOpts = exports.secretsCommand.opts();
|
|
87
|
+
let { project, env, branch } = parentOpts;
|
|
88
|
+
// Use active branch from config if not specified
|
|
89
|
+
if (!branch) {
|
|
90
|
+
branch = (0, config_1.getConfigValue)("branch") || "main";
|
|
91
|
+
}
|
|
92
|
+
// Normalize Env
|
|
93
|
+
const envMap = { dev: "development", stg: "staging", prod: "production" };
|
|
94
|
+
env = envMap[env] || env;
|
|
95
|
+
if (!project) {
|
|
96
|
+
project = (0, config_1.getConfigValue)("project");
|
|
97
|
+
}
|
|
98
|
+
if (!project) {
|
|
99
|
+
console.error(chalk_1.default.red("Error: Project ID is required. Use -p <id> or run 'xtra project set' first."));
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
// Parse key=value pairs
|
|
103
|
+
const payload = {};
|
|
104
|
+
args.forEach((arg) => {
|
|
105
|
+
const idx = arg.indexOf("=");
|
|
106
|
+
if (idx === -1) {
|
|
107
|
+
console.warn(chalk_1.default.yellow(`Skipping invalid format: ${arg} (expected KEY=VALUE)`));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const key = arg.substring(0, idx);
|
|
111
|
+
const value = arg.substring(idx + 1);
|
|
112
|
+
payload[key] = value;
|
|
113
|
+
});
|
|
114
|
+
if (Object.keys(payload).length === 0) {
|
|
115
|
+
console.error(chalk_1.default.red("No valid secrets provided."));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// 🔒 Production gate: require explicit confirmation before writing to production
|
|
119
|
+
if (env === "production" && !options.force) {
|
|
120
|
+
const inquirer = require("inquirer");
|
|
121
|
+
const { confirm } = await inquirer.prompt([{
|
|
122
|
+
type: "confirm",
|
|
123
|
+
name: "confirm",
|
|
124
|
+
message: chalk_1.default.red(`âš You are about to SET ${Object.keys(payload).length} secret(s) in PRODUCTION. Are you sure?`),
|
|
125
|
+
default: false,
|
|
126
|
+
}]);
|
|
127
|
+
if (!confirm) {
|
|
128
|
+
console.log(chalk_1.default.yellow("Aborted."));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const spinner = (0, ora_1.default)(`Setting ${Object.keys(payload).length} secrets for ${env} (branch: ${branch})...`);
|
|
133
|
+
if (options.force) {
|
|
134
|
+
spinner.start();
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
let expectedVersions = undefined;
|
|
138
|
+
if (!options.force) {
|
|
139
|
+
// Fetch current versions for optimistic locking
|
|
140
|
+
spinner.text = "Fetching current versions...";
|
|
141
|
+
spinner.start();
|
|
142
|
+
try {
|
|
143
|
+
const remoteSecrets = await api_1.api.getSecretVersions(project, env, branch);
|
|
144
|
+
expectedVersions = {};
|
|
145
|
+
spinner.stop();
|
|
146
|
+
// Populate expected versions for keys we are updating
|
|
147
|
+
Object.keys(payload).forEach(key => {
|
|
148
|
+
if (remoteSecrets[key]) {
|
|
149
|
+
expectedVersions[key] = remoteSecrets[key].version;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (verErr) {
|
|
154
|
+
spinner.warn(chalk_1.default.yellow("Could not fetch remote versions. Conflict detection disabled."));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
spinner.start(`Updating secrets...`);
|
|
158
|
+
await api_1.api.setSecrets(project, env, payload, expectedVersions, branch);
|
|
159
|
+
spinner.succeed("Secrets updated successfully.");
|
|
160
|
+
// Log Audit
|
|
161
|
+
try {
|
|
162
|
+
const { logAudit } = require("../lib/audit");
|
|
163
|
+
logAudit("SECRET_UPDATE", project, env, { keys: Object.keys(payload), branch });
|
|
164
|
+
}
|
|
165
|
+
catch (e) { }
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
spinner.stop();
|
|
169
|
+
if (error.response && error.response.status === 409) {
|
|
170
|
+
// Conflict Detected
|
|
171
|
+
const conflicts = error.response.data.conflicts || [];
|
|
172
|
+
console.log(chalk_1.default.red("\nâš Conflict Detected! The following secrets have changed remotely:\n"));
|
|
173
|
+
const conflictTable = [[chalk_1.default.bold("Key"), chalk_1.default.bold("Remote Version"), chalk_1.default.bold("Remote Value")]];
|
|
174
|
+
conflicts.forEach((c) => {
|
|
175
|
+
conflictTable.push([c.key, c.actual, c.remoteValue]);
|
|
176
|
+
});
|
|
177
|
+
console.log((0, table_1.table)(conflictTable));
|
|
178
|
+
const inquirer = require("inquirer");
|
|
179
|
+
const { confirm } = await inquirer.prompt([{
|
|
180
|
+
type: "confirm",
|
|
181
|
+
name: "confirm",
|
|
182
|
+
message: "Do you want to overwrite these changes exactly as you specified?",
|
|
183
|
+
default: false
|
|
184
|
+
}]);
|
|
185
|
+
if (confirm) {
|
|
186
|
+
const retrySpinner = (0, ora_1.default)("Overwriting secrets...").start();
|
|
187
|
+
try {
|
|
188
|
+
// Start retry with Force (no expectedVersions)
|
|
189
|
+
await api_1.api.setSecrets(project, env, payload, undefined, branch);
|
|
190
|
+
retrySpinner.succeed("Secrets overwritten successfully.");
|
|
191
|
+
// Log Audit Force
|
|
192
|
+
try {
|
|
193
|
+
const { logAudit } = require("../lib/audit");
|
|
194
|
+
logAudit("SECRET_UPDATE_FORCE", project, env, { keys: Object.keys(payload), branch });
|
|
195
|
+
}
|
|
196
|
+
catch (e) { }
|
|
197
|
+
}
|
|
198
|
+
catch (retryErr) {
|
|
199
|
+
retrySpinner.fail("Failed to overwrite secrets.");
|
|
200
|
+
console.error(chalk_1.default.red(retryErr.message));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
console.log(chalk_1.default.yellow("Update cancelled."));
|
|
205
|
+
}
|
|
206
|
+
return; // Handled
|
|
207
|
+
}
|
|
208
|
+
if (spinner.isSpinning)
|
|
209
|
+
spinner.fail("Failed to update secrets");
|
|
210
|
+
if (error.response && error.response.data && error.response.data.error) {
|
|
211
|
+
console.error(chalk_1.default.red(`Server Error: ${error.response.data.error}`));
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
console.error(chalk_1.default.red(error.message));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
// LINK
|
|
219
|
+
exports.secretsCommand
|
|
220
|
+
.command("link")
|
|
221
|
+
.description("Link a secret to another secret (Reference)")
|
|
222
|
+
.argument("<key>", "Key of the secret to create (e.g. DB_URL)")
|
|
223
|
+
.requiredOption("--source <source>", "Source path (format: project/env/key)")
|
|
224
|
+
.addHelpText("after", `
|
|
225
|
+
Examples:
|
|
226
|
+
$ xtra secrets link SHARED_DB_URL --source shared-proj/production/DB_URL
|
|
227
|
+
`)
|
|
228
|
+
.action(async (key, options) => {
|
|
229
|
+
const parentOpts = exports.secretsCommand.opts();
|
|
230
|
+
let { project, env } = parentOpts;
|
|
231
|
+
// Normalize Env
|
|
232
|
+
const envMap = { dev: "development", stg: "staging", prod: "production" };
|
|
233
|
+
env = envMap[env] || env;
|
|
234
|
+
const { source } = options;
|
|
235
|
+
if (!project) {
|
|
236
|
+
project = (0, config_1.getConfigValue)("project");
|
|
237
|
+
}
|
|
238
|
+
if (!project) {
|
|
239
|
+
console.error(chalk_1.default.red("Error: Project ID is required. Use -p <id> or run 'xtra project set' first."));
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
// Parse Source: "proj-123/prod/DATABASE_URL"
|
|
243
|
+
const parts = source.split("/");
|
|
244
|
+
if (parts.length !== 3) {
|
|
245
|
+
console.error(chalk_1.default.red("Error: Source must be in format 'projectId/env/key'"));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const [sourceProjectId, sourceEnv, sourceKey] = parts;
|
|
249
|
+
const spinner = (0, ora_1.default)(`Linking ${key} to ${source}...`).start();
|
|
250
|
+
try {
|
|
251
|
+
await api_1.api.linkSecret(project, env, key, sourceProjectId, sourceEnv, sourceKey);
|
|
252
|
+
spinner.succeed(chalk_1.default.green(`Secret '${key}' successfully linked to '${sourceKey}'`));
|
|
253
|
+
// Audit log
|
|
254
|
+
try {
|
|
255
|
+
const { logAudit } = require("../lib/audit");
|
|
256
|
+
logAudit("SECRET_LINKED", project, env, { key, source });
|
|
257
|
+
}
|
|
258
|
+
catch (e) { }
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
spinner.fail("Failed to link secret");
|
|
262
|
+
const safeErr = error?.response?.data?.error || error.message || "Unknown error";
|
|
263
|
+
console.error(chalk_1.default.red(`Server Error: ${safeErr}`));
|
|
264
|
+
}
|
|
265
|
+
});
|