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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -0
  3. package/dist/bin/xtra.js +124 -0
  4. package/dist/commands/access.js +107 -0
  5. package/dist/commands/admin.js +118 -0
  6. package/dist/commands/audit.js +67 -0
  7. package/dist/commands/branch.js +216 -0
  8. package/dist/commands/checkout.js +74 -0
  9. package/dist/commands/ci.js +330 -0
  10. package/dist/commands/completion.js +227 -0
  11. package/dist/commands/diff.js +163 -0
  12. package/dist/commands/doctor.js +176 -0
  13. package/dist/commands/env.js +70 -0
  14. package/dist/commands/export.js +84 -0
  15. package/dist/commands/generate.js +180 -0
  16. package/dist/commands/history.js +77 -0
  17. package/dist/commands/import.js +122 -0
  18. package/dist/commands/init.js +162 -0
  19. package/dist/commands/integration.js +188 -0
  20. package/dist/commands/local.js +198 -0
  21. package/dist/commands/login.js +176 -0
  22. package/dist/commands/login.test.js +51 -0
  23. package/dist/commands/logs.js +121 -0
  24. package/dist/commands/profile.js +184 -0
  25. package/dist/commands/project.js +98 -0
  26. package/dist/commands/rollback.js +96 -0
  27. package/dist/commands/rotate.js +94 -0
  28. package/dist/commands/run.js +215 -0
  29. package/dist/commands/scan.js +127 -0
  30. package/dist/commands/secrets.js +265 -0
  31. package/dist/commands/simulate.js +92 -0
  32. package/dist/commands/status.js +94 -0
  33. package/dist/commands/template.js +276 -0
  34. package/dist/commands/ui.js +218 -0
  35. package/dist/commands/watch.js +121 -0
  36. package/dist/lib/api.js +172 -0
  37. package/dist/lib/api.test.js +89 -0
  38. package/dist/lib/audit.js +136 -0
  39. package/dist/lib/config.js +42 -0
  40. package/dist/lib/config.test.js +47 -0
  41. package/dist/lib/crypto.js +50 -0
  42. package/dist/lib/manifest.js +52 -0
  43. package/dist/lib/profiles.js +103 -0
  44. package/package.json +67 -0
@@ -0,0 +1,216 @@
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.branchCommand = 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
+ const inquirer_1 = __importDefault(require("inquirer"));
14
+ exports.branchCommand = new commander_1.Command("branch")
15
+ .description("Manage branches")
16
+ .option("-p, --project <projectId>", "Project ID");
17
+ // LIST
18
+ exports.branchCommand
19
+ .command("list")
20
+ .description("List all branches")
21
+ .action(async (options) => {
22
+ // Access parent options (project ID)
23
+ const parentOpts = exports.branchCommand.opts();
24
+ let { project } = parentOpts;
25
+ if (!project) {
26
+ project = (0, config_1.getConfigValue)("project");
27
+ }
28
+ if (!project) {
29
+ console.error(chalk_1.default.red("Error: Project ID is required. Use -p <id> or checkout a branch."));
30
+ process.exit(1);
31
+ }
32
+ const spinner = (0, ora_1.default)("Fetching branches...").start();
33
+ try {
34
+ const branches = await api_1.api.getBranches(project);
35
+ spinner.stop();
36
+ if (!branches) {
37
+ console.log(chalk_1.default.yellow("No branches found."));
38
+ return;
39
+ }
40
+ if (!Array.isArray(branches)) {
41
+ // Check for error object
42
+ if (branches.error) {
43
+ console.error(chalk_1.default.red(`Error: ${branches.error}`));
44
+ }
45
+ else {
46
+ console.error(chalk_1.default.red("Error: Unexpected API response format (not an array)."));
47
+ }
48
+ return;
49
+ }
50
+ if (branches.length === 0) {
51
+ console.log(chalk_1.default.yellow("No branches found."));
52
+ return;
53
+ }
54
+ const data = [[chalk_1.default.bold("Name"), chalk_1.default.bold("ID"), chalk_1.default.bold("Created By")]];
55
+ branches.forEach((b) => {
56
+ const createdBy = b.user?.email || b.user?.name || b.createdBy || "-";
57
+ data.push([b.name, b.id, createdBy]);
58
+ });
59
+ console.log((0, table_1.table)(data));
60
+ }
61
+ catch (error) {
62
+ spinner.fail("Failed to fetch branches");
63
+ if (error.response && error.response.data && error.response.data.error) {
64
+ console.error(chalk_1.default.red(`Server Error: ${error.response.data.error}`));
65
+ }
66
+ else {
67
+ console.error(chalk_1.default.red(error.message));
68
+ }
69
+ }
70
+ });
71
+ // CREATE
72
+ exports.branchCommand
73
+ .command("create")
74
+ .description("Create a new branch")
75
+ .argument("<name>", "Branch name")
76
+ .option("-d, --description <text>", "Description")
77
+ .action(async (name, options) => {
78
+ const parentOpts = exports.branchCommand.opts();
79
+ let { project } = parentOpts;
80
+ const { description } = options;
81
+ if (!project) {
82
+ project = (0, config_1.getConfigValue)("project");
83
+ }
84
+ if (!project) {
85
+ console.error(chalk_1.default.red("Error: Project ID is required. Use -p <id> or run 'xtra project set' first."));
86
+ process.exit(1);
87
+ }
88
+ const spinner = (0, ora_1.default)(`Creating branch '${name}'...`).start();
89
+ try {
90
+ await api_1.api.createBranch(project, name, description);
91
+ spinner.succeed(`Branch '${name}' created successfully.`);
92
+ }
93
+ catch (error) {
94
+ spinner.fail("Failed to create branch");
95
+ if (error.response && error.response.data && error.response.data.error) {
96
+ console.error(chalk_1.default.red(`Server Error: ${error.response.data.error}`));
97
+ }
98
+ else {
99
+ console.error(chalk_1.default.red(error.message));
100
+ }
101
+ }
102
+ });
103
+ // DELETE
104
+ exports.branchCommand
105
+ .command("delete")
106
+ .description("Delete a branch")
107
+ .argument("<name>", "Branch name")
108
+ .option("-y, --yes", "Skip confirmation prompt", false)
109
+ .action(async (name, options) => {
110
+ const parentOpts = exports.branchCommand.opts();
111
+ let { project } = parentOpts;
112
+ const { yes } = options;
113
+ if (!project) {
114
+ project = (0, config_1.getConfigValue)("project");
115
+ }
116
+ if (!project) {
117
+ console.error(chalk_1.default.red("Error: Project ID is required. Use -p <id> or run 'xtra project set' first."));
118
+ process.exit(1);
119
+ }
120
+ if (name === "main") {
121
+ console.error(chalk_1.default.red("Error: Cannot delete the 'main' branch."));
122
+ return;
123
+ }
124
+ const spinner = (0, ora_1.default)("Resolving branch...").start();
125
+ try {
126
+ // 1. Resolve Name to ID
127
+ const branches = await api_1.api.getBranches(project);
128
+ const target = branches.find((b) => b.name === name);
129
+ if (!target) {
130
+ spinner.fail(`Branch '${name}' not found.`);
131
+ return;
132
+ }
133
+ spinner.stop();
134
+ // 2. Confirm
135
+ if (!yes) {
136
+ const { confirm } = await inquirer_1.default.prompt([{
137
+ type: "confirm",
138
+ name: "confirm",
139
+ message: `Are you sure you want to DELETE branch '${name}'? This action cannot be undone.`,
140
+ default: false
141
+ }]);
142
+ if (!confirm) {
143
+ console.log(chalk_1.default.gray("Operation cancelled."));
144
+ return;
145
+ }
146
+ }
147
+ // 3. Delete
148
+ spinner.start("Deleting branch...");
149
+ await api_1.api.deleteBranch(target.id);
150
+ spinner.succeed(`Branch '${name}' deleted successfully.`);
151
+ // Audit log
152
+ try {
153
+ const { logAudit } = require("../lib/audit");
154
+ logAudit("BRANCH_DELETED", project, null, { branchName: name, branchId: target.id });
155
+ }
156
+ catch (e) { }
157
+ }
158
+ catch (error) {
159
+ spinner.fail("Failed to delete branch");
160
+ if (error.response && error.response.data && error.response.data.error) {
161
+ console.error(chalk_1.default.red(`Server Error: ${error.response.data.error}`));
162
+ }
163
+ else {
164
+ console.error(chalk_1.default.red(error.message));
165
+ }
166
+ }
167
+ });
168
+ // UPDATE
169
+ exports.branchCommand
170
+ .command("update")
171
+ .description("Update a branch")
172
+ .argument("<name>", "Current branch name")
173
+ .option("-n, --new-name <newName>", "New branch name")
174
+ .option("-d, --description <description>", "New description")
175
+ .action(async (name, options) => {
176
+ const parentOpts = exports.branchCommand.opts();
177
+ let { project } = parentOpts;
178
+ const { newName, description } = options;
179
+ if (!project) {
180
+ project = (0, config_1.getConfigValue)("project");
181
+ }
182
+ if (!project) {
183
+ console.error(chalk_1.default.red("Error: Project ID is required. Use -p <id> or checkout a branch."));
184
+ process.exit(1);
185
+ }
186
+ if (!newName && !description) {
187
+ console.log(chalk_1.default.yellow("No updates specified. Use --new-name or --description."));
188
+ return;
189
+ }
190
+ const spinner = (0, ora_1.default)("Resolving branch...").start();
191
+ try {
192
+ // 1. Resolve Name to ID
193
+ const branches = await api_1.api.getBranches(project);
194
+ const target = branches.find((b) => b.name === name);
195
+ if (!target) {
196
+ spinner.fail(`Branch '${name}' not found.`);
197
+ return;
198
+ }
199
+ spinner.text = "Updating branch...";
200
+ // 2. Update
201
+ await api_1.api.updateBranch(target.id, {
202
+ name: newName,
203
+ description
204
+ });
205
+ spinner.succeed(`Branch updated successfully.`);
206
+ }
207
+ catch (error) {
208
+ spinner.fail("Failed to update branch");
209
+ if (error.response && error.response.data && error.response.data.error) {
210
+ console.error(chalk_1.default.red(`Server Error: ${error.response.data.error}`));
211
+ }
212
+ else {
213
+ console.error(chalk_1.default.red(error.message));
214
+ }
215
+ }
216
+ });
@@ -0,0 +1,74 @@
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.checkoutCommand = void 0;
7
+ const commander_1 = require("commander");
8
+ const api_1 = require("../lib/api");
9
+ const config_1 = require("../lib/config");
10
+ const chalk_1 = __importDefault(require("chalk"));
11
+ const ora_1 = __importDefault(require("ora"));
12
+ const inquirer_1 = __importDefault(require("inquirer"));
13
+ exports.checkoutCommand = new commander_1.Command("checkout")
14
+ .description("Switch the active branch context")
15
+ .argument("[branchName]", "Branch name to switch to (optional - will show list if not provided)")
16
+ .option("-p, --project <projectId>", "Project ID")
17
+ .action(async (branchName, options) => {
18
+ let { project } = options;
19
+ // Try to get project from config if not provided
20
+ if (!project) {
21
+ project = (0, config_1.getConfigValue)("project");
22
+ }
23
+ if (!project) {
24
+ console.error(chalk_1.default.red("Error: Project ID is required. Use -p <id> or run 'xtra project set' first."));
25
+ process.exit(1);
26
+ }
27
+ const spinner = (0, ora_1.default)("Fetching branches...").start();
28
+ try {
29
+ // Fetch branches
30
+ const branches = await api_1.api.getBranches(project);
31
+ spinner.stop();
32
+ if (!Array.isArray(branches) || branches.length === 0) {
33
+ console.log(chalk_1.default.yellow("No branches found in this project."));
34
+ return;
35
+ }
36
+ let selectedBranch = branchName;
37
+ // If no branch name provided, show interactive selector
38
+ if (!selectedBranch) {
39
+ const choices = branches.map((b) => ({
40
+ name: b.name,
41
+ value: b.name
42
+ }));
43
+ const { selected } = await inquirer_1.default.prompt([{
44
+ type: "list",
45
+ name: "selected",
46
+ message: "Select a branch:",
47
+ choices
48
+ }]);
49
+ selectedBranch = selected;
50
+ }
51
+ else {
52
+ // Verify branch exists
53
+ const exists = branches.find((b) => b.name === selectedBranch);
54
+ if (!exists) {
55
+ console.error(chalk_1.default.red(`Branch '${selectedBranch}' does not exist.`));
56
+ console.log(chalk_1.default.yellow(`Use 'xtra branch create ${selectedBranch}' to create it.`));
57
+ return;
58
+ }
59
+ }
60
+ // Save to config
61
+ (0, config_1.setConfig)("branch", selectedBranch);
62
+ (0, config_1.setConfig)("project", project);
63
+ console.log(chalk_1.default.green(`✔ Switched to branch '${selectedBranch}'`));
64
+ }
65
+ catch (error) {
66
+ spinner.fail("Failed to fetch branches");
67
+ if (error.response?.data?.error) {
68
+ console.error(chalk_1.default.red(`Error: ${error.response.data.error}`));
69
+ }
70
+ else {
71
+ console.error(chalk_1.default.red(error.message));
72
+ }
73
+ }
74
+ });
@@ -0,0 +1,330 @@
1
+ "use strict";
2
+ /**
3
+ * ci.ts - CI/CD Machine User Mode for xtra-cli
4
+ *
5
+ * This command is designed for headless pipeline execution:
6
+ * - No interactive prompts
7
+ * - No colors or spinners
8
+ * - Output is pure clean JSON (pipe-friendly with `jq`)
9
+ * - Auth via XTRA_MACHINE_TOKEN env var (no stored token needed)
10
+ * - Non-zero exit codes on any failure
11
+ *
12
+ * Usage:
13
+ * XTRA_MACHINE_TOKEN=tok_... xtra ci secrets --project <id> --env production
14
+ * XTRA_MACHINE_TOKEN=tok_... xtra ci secrets --project <id> --env production | jq '.DATABASE_URL'
15
+ * XTRA_MACHINE_TOKEN=tok_... xtra ci set --project <id> --env production KEY=VALUE
16
+ * XTRA_MACHINE_TOKEN=tok_... xtra ci export --project <id> --env production --format dotenv > .env
17
+ * XTRA_MACHINE_TOKEN=tok_... xtra ci run --project <id> --env production node app.js
18
+ */
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
52
+ var __importDefault = (this && this.__importDefault) || function (mod) {
53
+ return (mod && mod.__esModule) ? mod : { "default": mod };
54
+ };
55
+ Object.defineProperty(exports, "__esModule", { value: true });
56
+ exports.ciCommand = void 0;
57
+ const commander_1 = require("commander");
58
+ const child_process_1 = require("child_process");
59
+ const axios_1 = __importDefault(require("axios"));
60
+ const fs = __importStar(require("fs"));
61
+ // ─── CI API Client ─────────────────────────────────────────────────────────────
62
+ // Standalone, no Conf dependency — reads everything from environment variables
63
+ function getCiClient() {
64
+ const token = process.env.XTRA_MACHINE_TOKEN;
65
+ const apiUrl = process.env.XTRA_API_URL || "http://localhost:3000/api";
66
+ if (!token) {
67
+ ciError("XTRA_MACHINE_TOKEN environment variable is required for CI mode.");
68
+ }
69
+ return axios_1.default.create({
70
+ baseURL: apiUrl,
71
+ headers: {
72
+ "Content-Type": "application/json",
73
+ Authorization: `Bearer ${token}`,
74
+ "X-CI-Mode": "true",
75
+ },
76
+ });
77
+ }
78
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
79
+ /** Emit a JSON error to stderr and exit 1 */
80
+ function ciError(message, detail) {
81
+ process.stderr.write(JSON.stringify({ ok: false, error: message, detail: detail || null }, null, 2) + "\n");
82
+ process.exit(1);
83
+ }
84
+ /** Emit clean JSON to stdout */
85
+ function ciOut(data) {
86
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
87
+ }
88
+ /** Validate required CLI env vars and options */
89
+ function requireMachineToken() {
90
+ if (!process.env.XTRA_MACHINE_TOKEN) {
91
+ ciError("XTRA_MACHINE_TOKEN is not set. Export it before running CI commands.", {
92
+ hint: "export XTRA_MACHINE_TOKEN=your_service_account_token"
93
+ });
94
+ }
95
+ }
96
+ function requireProject(project) {
97
+ if (!project) {
98
+ ciError("--project <id> is required in CI mode. Set it explicitly (no config fallback).");
99
+ }
100
+ return project;
101
+ }
102
+ function requireEnv(env) {
103
+ if (!env) {
104
+ ciError("--env <environment> is required in CI mode.");
105
+ }
106
+ return env;
107
+ }
108
+ // ─── xtra ci ──────────────────────────────────────────────────────────────────
109
+ exports.ciCommand = new commander_1.Command("ci")
110
+ .description("CI/CD headless mode — JSON output, no prompts, XTRA_MACHINE_TOKEN auth")
111
+ .addHelpText("after", `
112
+ Environment Variables:
113
+ XTRA_MACHINE_TOKEN Required. Service account token for authentication.
114
+ XTRA_API_URL Optional. Override the API base URL.
115
+ XTRA_PROFILE Optional. Use a specific named profile.
116
+
117
+ Subcommands:
118
+ xtra ci secrets Fetch secrets as JSON
119
+ xtra ci set Set one or more secrets KEY=VALUE
120
+ xtra ci export Export secrets in dotenv/json/yaml format to a file
121
+ xtra ci run Run a command with secrets injected as env vars
122
+
123
+ Examples:
124
+ XTRA_MACHINE_TOKEN=tok_... xtra ci secrets -p proj123 -e production
125
+ XTRA_MACHINE_TOKEN=tok_... xtra ci secrets -p proj123 -e production | jq '.DATABASE_URL'
126
+ XTRA_MACHINE_TOKEN=tok_... xtra ci set -p proj123 -e staging DEPLOY_VERSION=1.2.3
127
+ XTRA_MACHINE_TOKEN=tok_... xtra ci export -p proj123 -e production -f dotenv -o .env
128
+ XTRA_MACHINE_TOKEN=tok_... xtra ci run -p proj123 -e production -- node app.js
129
+ `);
130
+ // ── ci secrets ────────────────────────────────────────────────────────────────
131
+ exports.ciCommand
132
+ .command("secrets")
133
+ .description("Fetch secrets as JSON (pipe-friendly)")
134
+ .requiredOption("-p, --project <id>", "Project ID")
135
+ .requiredOption("-e, --env <environment>", "Environment")
136
+ .option("-b, --branch <branch>", "Branch name", "main")
137
+ .option("--keys <keys>", "Comma-separated list of keys to include (default: all)")
138
+ .action(async (options) => {
139
+ requireMachineToken();
140
+ const project = requireProject(options.project);
141
+ const env = requireEnv(options.env);
142
+ try {
143
+ const client = getCiClient();
144
+ const res = await client.get(`/projects/${project}/envs/${env}/secrets?branch=${options.branch}`);
145
+ let secrets = res.data;
146
+ // Filter to specific keys if requested
147
+ if (options.keys) {
148
+ const allowedKeys = options.keys.split(",").map((k) => k.trim());
149
+ const filtered = {};
150
+ for (const k of allowedKeys) {
151
+ if (secrets[k] !== undefined)
152
+ filtered[k] = secrets[k];
153
+ else
154
+ process.stderr.write(`Warning: key '${k}' not found\n`);
155
+ }
156
+ secrets = filtered;
157
+ }
158
+ ciOut({ ok: true, project, env, branch: options.branch, secrets });
159
+ }
160
+ catch (e) {
161
+ const status = e?.response?.status;
162
+ const message = e?.response?.data?.error || e.message;
163
+ if (status === 401)
164
+ ciError("Unauthorized. Check your XTRA_MACHINE_TOKEN.", { status });
165
+ if (status === 403)
166
+ ciError("Forbidden. This token lacks permission to read secrets.", { status });
167
+ if (status === 404)
168
+ ciError(`Project '${project}' or environment '${env}' not found.`, { status });
169
+ ciError(message, { status });
170
+ }
171
+ });
172
+ // ── ci set ────────────────────────────────────────────────────────────────────
173
+ exports.ciCommand
174
+ .command("set")
175
+ .description("Set one or more secrets in CI mode (KEY=VALUE ...)")
176
+ .requiredOption("-p, --project <id>", "Project ID")
177
+ .requiredOption("-e, --env <environment>", "Environment")
178
+ .option("-b, --branch <branch>", "Branch name", "main")
179
+ .argument("<secrets...>", "Secrets to set (format: KEY=VALUE)")
180
+ .action(async (args, options) => {
181
+ requireMachineToken();
182
+ const project = requireProject(options.project);
183
+ const env = requireEnv(options.env);
184
+ const payload = {};
185
+ for (const arg of args) {
186
+ const idx = arg.indexOf("=");
187
+ if (idx === -1) {
188
+ ciError(`Invalid format: '${arg}'. Expected KEY=VALUE`);
189
+ }
190
+ payload[arg.substring(0, idx)] = arg.substring(idx + 1);
191
+ }
192
+ if (Object.keys(payload).length === 0) {
193
+ ciError("No valid KEY=VALUE pairs provided.");
194
+ }
195
+ try {
196
+ const client = getCiClient();
197
+ await client.post(`/projects/${project}/envs/${env}/secrets`, {
198
+ secrets: payload,
199
+ branch: options.branch,
200
+ });
201
+ ciOut({
202
+ ok: true,
203
+ project,
204
+ env,
205
+ branch: options.branch,
206
+ updated: Object.keys(payload),
207
+ count: Object.keys(payload).length,
208
+ });
209
+ }
210
+ catch (e) {
211
+ const status = e?.response?.status;
212
+ ciError(e?.response?.data?.error || e.message, { status });
213
+ }
214
+ });
215
+ // ── ci export ─────────────────────────────────────────────────────────────────
216
+ exports.ciCommand
217
+ .command("export")
218
+ .description("Export secrets to a file (dotenv, json, or github-actions format)")
219
+ .requiredOption("-p, --project <id>", "Project ID")
220
+ .requiredOption("-e, --env <environment>", "Environment")
221
+ .option("-b, --branch <branch>", "Branch name", "main")
222
+ .option("-f, --format <format>", "Output format: dotenv | json | github", "dotenv")
223
+ .option("-o, --output <file>", "Output file path (default: stdout)")
224
+ .action(async (options) => {
225
+ requireMachineToken();
226
+ const project = requireProject(options.project);
227
+ const env = requireEnv(options.env);
228
+ const { format, output, branch } = options;
229
+ const validFormats = ["dotenv", "json", "github"];
230
+ if (!validFormats.includes(format)) {
231
+ ciError(`Invalid format '${format}'. Must be one of: ${validFormats.join(", ")}`);
232
+ }
233
+ try {
234
+ const client = getCiClient();
235
+ const res = await client.get(`/projects/${project}/envs/${env}/secrets?branch=${branch}`);
236
+ const secrets = res.data;
237
+ let content;
238
+ switch (format) {
239
+ case "dotenv":
240
+ content = Object.entries(secrets)
241
+ .map(([k, v]) => `${k}=${v}`)
242
+ .join("\n") + "\n";
243
+ break;
244
+ case "json":
245
+ content = JSON.stringify(secrets, null, 2) + "\n";
246
+ break;
247
+ case "github":
248
+ // Format for GitHub Actions $GITHUB_ENV / $GITHUB_OUTPUT
249
+ content = Object.entries(secrets)
250
+ .map(([k, v]) => `${k}=${v}`)
251
+ .join("\n") + "\n";
252
+ break;
253
+ default:
254
+ ciError("Unreachable format error.");
255
+ }
256
+ if (output) {
257
+ fs.writeFileSync(output, content, "utf8");
258
+ // Write metadata to stderr so it doesn't pollute file output
259
+ process.stderr.write(JSON.stringify({ ok: true, file: output, keys: Object.keys(secrets).length }) + "\n");
260
+ }
261
+ else {
262
+ // Write raw content to stdout (pipe-ready)
263
+ process.stdout.write(content);
264
+ }
265
+ }
266
+ catch (e) {
267
+ const status = e?.response?.status;
268
+ ciError(e?.response?.data?.error || e.message, { status });
269
+ }
270
+ });
271
+ // ── ci run ────────────────────────────────────────────────────────────────────
272
+ exports.ciCommand
273
+ .command("run")
274
+ .description("Run a command with secrets injected as environment variables")
275
+ .requiredOption("-p, --project <id>", "Project ID")
276
+ .requiredOption("-e, --env <environment>", "Environment")
277
+ .option("-b, --branch <branch>", "Branch name", "main")
278
+ .argument("<command>", "Command to execute")
279
+ .argument("[args...]", "Command arguments")
280
+ .action(async (command, args, options) => {
281
+ requireMachineToken();
282
+ const project = requireProject(options.project);
283
+ const env = requireEnv(options.env);
284
+ // Security: prevent command injection
285
+ const SHELL_UNSAFE = /[;&|`$<>\\\n]/g;
286
+ if (SHELL_UNSAFE.test(command)) {
287
+ ciError("Command contains unsafe characters. Aborting for security.");
288
+ }
289
+ try {
290
+ const client = getCiClient();
291
+ const res = await client.get(`/projects/${project}/envs/${env}/secrets?branch=${options.branch}`);
292
+ const secrets = res.data;
293
+ process.stderr.write(`[xtra ci] Injecting ${Object.keys(secrets).length} secrets into process env\n`);
294
+ const envVars = { ...process.env, ...secrets };
295
+ const child = (0, child_process_1.spawn)(command, args, {
296
+ env: envVars,
297
+ stdio: "inherit",
298
+ shell: false, // NO shell:true — prevents injection
299
+ });
300
+ child.on("exit", (code) => process.exit(code ?? 0));
301
+ child.on("error", (err) => ciError(`Failed to start process: ${err.message}`));
302
+ }
303
+ catch (e) {
304
+ const status = e?.response?.status;
305
+ ciError(e?.response?.data?.error || e.message, { status });
306
+ }
307
+ });
308
+ // ── ci validate ───────────────────────────────────────────────────────────────
309
+ exports.ciCommand
310
+ .command("validate")
311
+ .description("Validate that XTRA_MACHINE_TOKEN is set and working")
312
+ .action(async () => {
313
+ requireMachineToken();
314
+ try {
315
+ const client = getCiClient();
316
+ const res = await client.get("/project");
317
+ ciOut({
318
+ ok: true,
319
+ authenticated: true,
320
+ projects: res.data.length,
321
+ message: "XTRA_MACHINE_TOKEN is valid and working.",
322
+ });
323
+ }
324
+ catch (e) {
325
+ const status = e?.response?.status;
326
+ if (status === 401)
327
+ ciError("XTRA_MACHINE_TOKEN is invalid or expired.", { status });
328
+ ciError(e?.response?.data?.error || e.message, { status });
329
+ }
330
+ });