@uagents/syncenv-cli 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -0
- package/dist/chunk-F7ZZUTRW.js +403 -0
- package/dist/chunk-JBMZAAVP.js +176 -0
- package/dist/chunk-NV6H5OGL.js +218 -0
- package/dist/chunk-OVEYHV4C.js +333 -0
- package/dist/cookie-store-Z6DNTUGS.js +16 -0
- package/dist/crypto-X7MZU7DV.js +58 -0
- package/dist/index.js +2091 -0
- package/dist/interactive-GOIXZ6UH.js +6 -0
- package/dist/secure-storage-UEK3LD5L.js +35 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2091 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
decryptContent,
|
|
4
|
+
decryptDEK,
|
|
5
|
+
encryptContent,
|
|
6
|
+
generateContentHash,
|
|
7
|
+
reencryptUserKEK,
|
|
8
|
+
setupUserKEK,
|
|
9
|
+
unlockUserKEK
|
|
10
|
+
} from "./chunk-NV6H5OGL.js";
|
|
11
|
+
import {
|
|
12
|
+
getKEKTimeRemaining,
|
|
13
|
+
getUserKEK,
|
|
14
|
+
isKEKCached,
|
|
15
|
+
lockKEK,
|
|
16
|
+
unlockAndStoreKEK
|
|
17
|
+
} from "./chunk-JBMZAAVP.js";
|
|
18
|
+
import {
|
|
19
|
+
clearAuthState,
|
|
20
|
+
client,
|
|
21
|
+
getConfig,
|
|
22
|
+
getConfigPath,
|
|
23
|
+
getOrUnlockUserKEK,
|
|
24
|
+
hasProjectConfig,
|
|
25
|
+
isAuthenticated,
|
|
26
|
+
loadConfig,
|
|
27
|
+
loadProjectConfig,
|
|
28
|
+
saveProjectConfig,
|
|
29
|
+
setConfig,
|
|
30
|
+
withAuthGuard
|
|
31
|
+
} from "./chunk-OVEYHV4C.js";
|
|
32
|
+
import {
|
|
33
|
+
applyMergeStrategy,
|
|
34
|
+
interactiveMerge,
|
|
35
|
+
threeWayMerge
|
|
36
|
+
} from "./chunk-F7ZZUTRW.js";
|
|
37
|
+
|
|
38
|
+
// src/index.ts
|
|
39
|
+
import chalk8 from "chalk";
|
|
40
|
+
import { Command as Command7 } from "commander";
|
|
41
|
+
|
|
42
|
+
// src/commands/auth.ts
|
|
43
|
+
import chalk2 from "chalk";
|
|
44
|
+
import { Command } from "commander";
|
|
45
|
+
import inquirer from "inquirer";
|
|
46
|
+
|
|
47
|
+
// src/utils/index.ts
|
|
48
|
+
import chalk from "chalk";
|
|
49
|
+
import ora from "ora";
|
|
50
|
+
function info(...args) {
|
|
51
|
+
console.log(chalk.blue("\u2139"), ...args);
|
|
52
|
+
}
|
|
53
|
+
function success(...args) {
|
|
54
|
+
console.log(chalk.green("\u2713"), ...args);
|
|
55
|
+
}
|
|
56
|
+
function warning(...args) {
|
|
57
|
+
console.log(chalk.yellow("\u26A0"), ...args);
|
|
58
|
+
}
|
|
59
|
+
function error(...args) {
|
|
60
|
+
console.error(chalk.red("\u2717"), ...args);
|
|
61
|
+
}
|
|
62
|
+
function createSpinner(text) {
|
|
63
|
+
return ora(text);
|
|
64
|
+
}
|
|
65
|
+
function formatBytes(bytes) {
|
|
66
|
+
if (bytes === 0) return "0 B";
|
|
67
|
+
const k = 1024;
|
|
68
|
+
const sizes = ["B", "KB", "MB", "GB"];
|
|
69
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
70
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
71
|
+
}
|
|
72
|
+
function formatRelativeTime(date) {
|
|
73
|
+
const d = new Date(date);
|
|
74
|
+
const now = /* @__PURE__ */ new Date();
|
|
75
|
+
const diff = now.getTime() - d.getTime();
|
|
76
|
+
const seconds = Math.floor(diff / 1e3);
|
|
77
|
+
const minutes = Math.floor(seconds / 60);
|
|
78
|
+
const hours = Math.floor(minutes / 60);
|
|
79
|
+
const days = Math.floor(hours / 24);
|
|
80
|
+
if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
|
|
81
|
+
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
|
|
82
|
+
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
|
|
83
|
+
return "just now";
|
|
84
|
+
}
|
|
85
|
+
function parseEnvFile(content) {
|
|
86
|
+
const env = {};
|
|
87
|
+
const lines = content.split("\n");
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const trimmed = line.trim();
|
|
90
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
91
|
+
const eqIndex = trimmed.indexOf("=");
|
|
92
|
+
if (eqIndex === -1) continue;
|
|
93
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
94
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
95
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
96
|
+
value = value.slice(1, -1);
|
|
97
|
+
}
|
|
98
|
+
env[key] = value;
|
|
99
|
+
}
|
|
100
|
+
return env;
|
|
101
|
+
}
|
|
102
|
+
function stringifyEnvFile(env) {
|
|
103
|
+
const lines = [];
|
|
104
|
+
for (const [key, value] of Object.entries(env)) {
|
|
105
|
+
if (value.includes(" ") || value.includes("#") || value.includes("$")) {
|
|
106
|
+
lines.push(`${key}="${value}"`);
|
|
107
|
+
} else {
|
|
108
|
+
lines.push(`${key}=${value}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return lines.join("\n");
|
|
112
|
+
}
|
|
113
|
+
function diffEnvFiles(local, remote) {
|
|
114
|
+
const localKeys = Object.keys(local);
|
|
115
|
+
const remoteKeys = Object.keys(remote);
|
|
116
|
+
const added = localKeys.filter((k) => !remoteKeys.includes(k));
|
|
117
|
+
const removed = remoteKeys.filter((k) => !localKeys.includes(k));
|
|
118
|
+
const modified = localKeys.filter((k) => remoteKeys.includes(k) && local[k] !== remote[k]);
|
|
119
|
+
return { added, removed, modified };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/commands/auth.ts
|
|
123
|
+
var authCommands = new Command("auth").description("Authentication commands");
|
|
124
|
+
authCommands.command("signup").description("Guide to create a new account via web app").action(async () => {
|
|
125
|
+
console.log(chalk2.bold("\n\u{1F4CB} Account Registration\n"));
|
|
126
|
+
info(
|
|
127
|
+
"To ensure a smooth registration experience, please create your account via our web application."
|
|
128
|
+
);
|
|
129
|
+
info("The web app provides:");
|
|
130
|
+
console.log(" \u2022 Email verification flow");
|
|
131
|
+
console.log(" \u2022 Secure password setup");
|
|
132
|
+
console.log(" \u2022 User-friendly interface");
|
|
133
|
+
console.log();
|
|
134
|
+
const apiUrl = getConfig("apiUrl") || "https://syncenv.uagents.app";
|
|
135
|
+
const signupUrl = `${apiUrl}/signup`;
|
|
136
|
+
console.log(chalk2.cyan("\u{1F310} Please visit:"));
|
|
137
|
+
console.log(chalk2.bold(signupUrl));
|
|
138
|
+
console.log();
|
|
139
|
+
info("After registering and verifying your email:");
|
|
140
|
+
console.log(" 1. Return to the CLI");
|
|
141
|
+
console.log(" 2. Run: syncenv auth login");
|
|
142
|
+
console.log(" 3. This device will be automatically registered");
|
|
143
|
+
console.log();
|
|
144
|
+
const { openBrowser } = await inquirer.prompt([
|
|
145
|
+
{
|
|
146
|
+
type: "confirm",
|
|
147
|
+
name: "openBrowser",
|
|
148
|
+
message: "Open the signup page in your browser?",
|
|
149
|
+
default: true
|
|
150
|
+
}
|
|
151
|
+
]);
|
|
152
|
+
if (openBrowser) {
|
|
153
|
+
const { default: open } = await import("open");
|
|
154
|
+
await open(signupUrl);
|
|
155
|
+
info("Browser opened. Please complete registration there.");
|
|
156
|
+
}
|
|
157
|
+
console.log();
|
|
158
|
+
success("Waiting for you to complete registration...");
|
|
159
|
+
info("Once done, run: syncenv auth login");
|
|
160
|
+
});
|
|
161
|
+
authCommands.command("login").description("Login to your account").option("-e, --email <email>", "email address").action(async (options) => {
|
|
162
|
+
try {
|
|
163
|
+
let email = options.email;
|
|
164
|
+
if (!email) {
|
|
165
|
+
const answer = await inquirer.prompt([
|
|
166
|
+
{
|
|
167
|
+
type: "input",
|
|
168
|
+
name: "email",
|
|
169
|
+
message: "Email:",
|
|
170
|
+
validate: (input) => {
|
|
171
|
+
if (!input.includes("@")) return "Please enter a valid email";
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
]);
|
|
176
|
+
email = answer.email;
|
|
177
|
+
}
|
|
178
|
+
const { password } = await inquirer.prompt([
|
|
179
|
+
{
|
|
180
|
+
type: "password",
|
|
181
|
+
name: "password",
|
|
182
|
+
message: "Master Password:",
|
|
183
|
+
mask: "*"
|
|
184
|
+
}
|
|
185
|
+
]);
|
|
186
|
+
const spinner = createSpinner("Authenticating...");
|
|
187
|
+
spinner.start();
|
|
188
|
+
try {
|
|
189
|
+
const apiUrl = getConfig("apiUrl");
|
|
190
|
+
const loginResponse = await fetch(`${apiUrl}/api/auth/sign-in/email`, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: { "Content-Type": "application/json" },
|
|
193
|
+
body: JSON.stringify({ email, password })
|
|
194
|
+
});
|
|
195
|
+
if (!loginResponse.ok) {
|
|
196
|
+
const errorData = await loginResponse.json().catch(() => ({ error: "Login failed" }));
|
|
197
|
+
throw new Error(errorData.error || errorData.message || "Login failed");
|
|
198
|
+
}
|
|
199
|
+
const setCookie = loginResponse.headers.getSetCookie?.() || (loginResponse.headers.get("set-cookie") ? [loginResponse.headers.get("set-cookie")] : []);
|
|
200
|
+
if (setCookie.length > 0) {
|
|
201
|
+
const { storeCookies } = await import("./cookie-store-Z6DNTUGS.js");
|
|
202
|
+
storeCookies(setCookie);
|
|
203
|
+
}
|
|
204
|
+
const result = await loginResponse.json();
|
|
205
|
+
setConfig("userId", result.user.id);
|
|
206
|
+
setConfig("userEmail", result.user.email);
|
|
207
|
+
spinner.succeed("Authenticated");
|
|
208
|
+
success("Session active");
|
|
209
|
+
} catch (err) {
|
|
210
|
+
spinner.fail("Authentication failed");
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
error("Login failed:", err.message);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
authCommands.command("logout").description("Logout and clear session").action(async () => {
|
|
219
|
+
try {
|
|
220
|
+
if (!isAuthenticated()) {
|
|
221
|
+
info("Not logged in.");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const spinner = createSpinner("Logging out...");
|
|
225
|
+
spinner.start();
|
|
226
|
+
try {
|
|
227
|
+
await client.request("POST", "/api/auth/signout");
|
|
228
|
+
} catch {
|
|
229
|
+
}
|
|
230
|
+
await clearAuthState();
|
|
231
|
+
spinner.succeed("Logged out");
|
|
232
|
+
success("Session terminated and local keys cleared");
|
|
233
|
+
} catch (err) {
|
|
234
|
+
error("Logout failed:", err.message);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
authCommands.command("status").description("Check authentication status").action(async () => {
|
|
239
|
+
try {
|
|
240
|
+
if (!isAuthenticated()) {
|
|
241
|
+
info("Not authenticated. Run `syncenv auth login` to login.");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const { data: session } = await withAuthGuard(
|
|
245
|
+
() => client.request("GET", "/api/auth/get-session")
|
|
246
|
+
);
|
|
247
|
+
if (session.session) {
|
|
248
|
+
console.log(
|
|
249
|
+
`${chalk2.green("\u2713")} Authenticated as ${chalk2.cyan(session.session.user.email)}`
|
|
250
|
+
);
|
|
251
|
+
console.log(` User ID: ${chalk2.dim(session.session.user.id)}`);
|
|
252
|
+
} else {
|
|
253
|
+
info("Session expired. Please login again.");
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
error("Failed to check status:", err.message);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
authCommands.command("change-password").description("Change your account password").action(async () => {
|
|
261
|
+
try {
|
|
262
|
+
if (!isAuthenticated()) {
|
|
263
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
const { currentPassword } = await inquirer.prompt([
|
|
267
|
+
{
|
|
268
|
+
type: "password",
|
|
269
|
+
name: "currentPassword",
|
|
270
|
+
message: "Current password:",
|
|
271
|
+
mask: "*",
|
|
272
|
+
validate: (input) => input.length > 0 || "Current password is required"
|
|
273
|
+
}
|
|
274
|
+
]);
|
|
275
|
+
const { newPassword } = await inquirer.prompt([
|
|
276
|
+
{
|
|
277
|
+
type: "password",
|
|
278
|
+
name: "newPassword",
|
|
279
|
+
message: "New password:",
|
|
280
|
+
mask: "*",
|
|
281
|
+
validate: (input) => {
|
|
282
|
+
if (input.length < 8) return "Password must be at least 8 characters";
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
]);
|
|
287
|
+
const { confirmPassword } = await inquirer.prompt([
|
|
288
|
+
{
|
|
289
|
+
type: "password",
|
|
290
|
+
name: "confirmPassword",
|
|
291
|
+
message: "Confirm new password:",
|
|
292
|
+
mask: "*",
|
|
293
|
+
validate: (input) => {
|
|
294
|
+
if (input !== newPassword) return "Passwords do not match";
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
]);
|
|
299
|
+
const answers = { currentPassword, newPassword, confirmPassword };
|
|
300
|
+
const spinner = createSpinner("Changing password...");
|
|
301
|
+
spinner.start();
|
|
302
|
+
try {
|
|
303
|
+
await withAuthGuard(
|
|
304
|
+
() => client.request("POST", "/api/auth/change-password", {
|
|
305
|
+
body: {
|
|
306
|
+
currentPassword: answers.currentPassword,
|
|
307
|
+
newPassword: answers.newPassword,
|
|
308
|
+
revokeOtherSessions: false
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
);
|
|
312
|
+
try {
|
|
313
|
+
const userKeys = await withAuthGuard(() => client.userKeys.get());
|
|
314
|
+
spinner.text = "Re-encrypting encryption keys...";
|
|
315
|
+
const { unlockUserKEK: unlockUserKEK2, reencryptUserKEK: reencryptUserKEK2 } = await import("./crypto-X7MZU7DV.js");
|
|
316
|
+
const userKek = await unlockUserKEK2(
|
|
317
|
+
answers.currentPassword,
|
|
318
|
+
userKeys.encryptedUserKek,
|
|
319
|
+
userKeys.kekIv,
|
|
320
|
+
userKeys.kekSalt
|
|
321
|
+
);
|
|
322
|
+
if (!userKek) {
|
|
323
|
+
spinner.fail("Password changed but failed to re-encrypt keys");
|
|
324
|
+
warning("Your password was changed, but your encryption keys need to be updated.");
|
|
325
|
+
info("Run `syncenv user-keys rotate` to fix this.");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const { encryptedUserKek, kekIv, kekSalt } = await reencryptUserKEK2(
|
|
329
|
+
userKek,
|
|
330
|
+
answers.newPassword
|
|
331
|
+
);
|
|
332
|
+
await withAuthGuard(
|
|
333
|
+
() => client.userKeys.update({
|
|
334
|
+
encryptedUserKek,
|
|
335
|
+
kekIv,
|
|
336
|
+
kekSalt
|
|
337
|
+
})
|
|
338
|
+
);
|
|
339
|
+
const { saveEncryptedKEK, saveKEKPassword, getKEKPassword } = await import("./secure-storage-UEK3LD5L.js");
|
|
340
|
+
saveEncryptedKEK({ encryptedUserKek, kekIv, kekSalt });
|
|
341
|
+
const existingPassword = await getKEKPassword();
|
|
342
|
+
if (existingPassword) {
|
|
343
|
+
await saveKEKPassword(answers.newPassword);
|
|
344
|
+
}
|
|
345
|
+
} catch (err) {
|
|
346
|
+
if (!err.message?.includes("404")) {
|
|
347
|
+
throw err;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
spinner.succeed("Password changed successfully");
|
|
351
|
+
success("Your account password has been updated");
|
|
352
|
+
} catch (err) {
|
|
353
|
+
spinner.fail("Failed to change password");
|
|
354
|
+
throw err;
|
|
355
|
+
}
|
|
356
|
+
} catch (err) {
|
|
357
|
+
error("Change password failed:", err.message);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// src/commands/doctor.ts
|
|
363
|
+
import fs from "fs/promises";
|
|
364
|
+
import chalk3 from "chalk";
|
|
365
|
+
import { Command as Command2 } from "commander";
|
|
366
|
+
var doctorCommand = new Command2("doctor").description("Diagnose configuration and connectivity issues").action(async () => {
|
|
367
|
+
let exitCode = 0;
|
|
368
|
+
console.log(chalk3.bold("\n\u{1F50D} SyncEnv Doctor\n"));
|
|
369
|
+
const nodeVersion = process.version;
|
|
370
|
+
const majorVersion = parseInt(nodeVersion.slice(1).split(".")[0]);
|
|
371
|
+
console.log(chalk3.dim("Checking Node.js version..."));
|
|
372
|
+
if (majorVersion >= 18) {
|
|
373
|
+
success(`Node.js ${nodeVersion}`);
|
|
374
|
+
} else {
|
|
375
|
+
error(`Node.js ${nodeVersion} (>= 18 required)`);
|
|
376
|
+
exitCode = 1;
|
|
377
|
+
}
|
|
378
|
+
console.log(chalk3.dim("\nChecking configuration..."));
|
|
379
|
+
const configPath = getConfigPath();
|
|
380
|
+
try {
|
|
381
|
+
await fs.access(configPath);
|
|
382
|
+
success(`Config file exists: ${configPath}`);
|
|
383
|
+
} catch {
|
|
384
|
+
warning(`Config file not found: ${configPath}`);
|
|
385
|
+
}
|
|
386
|
+
console.log(chalk3.dim("\nChecking project configuration..."));
|
|
387
|
+
const hasConfig = await hasProjectConfig();
|
|
388
|
+
if (hasConfig) {
|
|
389
|
+
try {
|
|
390
|
+
const config = await loadProjectConfig();
|
|
391
|
+
if (config) {
|
|
392
|
+
success(`.envsyncrc found`);
|
|
393
|
+
info(` Project: ${config.project.name}`);
|
|
394
|
+
if (config.project.id) {
|
|
395
|
+
info(` Project ID: ${config.project.id}`);
|
|
396
|
+
} else {
|
|
397
|
+
warning(` Project ID not set - run \`syncenv project create\``);
|
|
398
|
+
}
|
|
399
|
+
info(` Default env: ${config.defaults.environment}`);
|
|
400
|
+
}
|
|
401
|
+
} catch (err) {
|
|
402
|
+
error(`.envsyncrc exists but is invalid: ${err.message}`);
|
|
403
|
+
exitCode = 1;
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
warning(`.envsyncrc not found - run \`syncenv init\``);
|
|
407
|
+
}
|
|
408
|
+
console.log(chalk3.dim("\nChecking authentication..."));
|
|
409
|
+
if (isAuthenticated()) {
|
|
410
|
+
const userEmail = getConfig("userEmail");
|
|
411
|
+
success(`Authenticated as ${userEmail}`);
|
|
412
|
+
try {
|
|
413
|
+
const session = await withAuthGuard(
|
|
414
|
+
() => client.request("GET", "/api/auth/get-session")
|
|
415
|
+
);
|
|
416
|
+
if (session.data.session) {
|
|
417
|
+
success(`Server session valid`);
|
|
418
|
+
} else {
|
|
419
|
+
warning(`Server session expired`);
|
|
420
|
+
}
|
|
421
|
+
} catch (err) {
|
|
422
|
+
error(`Failed to verify session: ${err.message}`);
|
|
423
|
+
exitCode = 1;
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
warning(`Not authenticated - run \`syncenv auth login\``);
|
|
427
|
+
}
|
|
428
|
+
console.log(chalk3.dim("\nChecking API connectivity..."));
|
|
429
|
+
const apiUrl = process.env.SYNCENV_API_URL || getConfig("apiUrl") || "http://localhost:8787";
|
|
430
|
+
try {
|
|
431
|
+
const response = await fetch(apiUrl);
|
|
432
|
+
if (response.ok) {
|
|
433
|
+
const data = await response.json();
|
|
434
|
+
success(`API reachable at ${apiUrl}`);
|
|
435
|
+
info(` Server: ${data.name || "Unknown"} v${data.version || "?"}`);
|
|
436
|
+
} else {
|
|
437
|
+
error(`API returned ${response.status}`);
|
|
438
|
+
exitCode = 1;
|
|
439
|
+
}
|
|
440
|
+
} catch (err) {
|
|
441
|
+
error(`Cannot connect to API at ${apiUrl}`);
|
|
442
|
+
info(` Error: ${err.message}`);
|
|
443
|
+
exitCode = 1;
|
|
444
|
+
}
|
|
445
|
+
console.log("");
|
|
446
|
+
if (exitCode === 0) {
|
|
447
|
+
success("All checks passed!\n");
|
|
448
|
+
} else {
|
|
449
|
+
error("Some checks failed. Please fix the issues above.\n");
|
|
450
|
+
}
|
|
451
|
+
process.exit(exitCode);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// src/commands/env.ts
|
|
455
|
+
import fs2 from "fs/promises";
|
|
456
|
+
import chalk4 from "chalk";
|
|
457
|
+
import { Command as Command3 } from "commander";
|
|
458
|
+
import inquirer2 from "inquirer";
|
|
459
|
+
|
|
460
|
+
// src/state/index.ts
|
|
461
|
+
import { existsSync } from "fs";
|
|
462
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
463
|
+
import { homedir } from "os";
|
|
464
|
+
import { join, dirname } from "path";
|
|
465
|
+
var STATE_VERSION = 1;
|
|
466
|
+
var STATE_DIR = ".envsync";
|
|
467
|
+
var STATE_FILE = "state.json";
|
|
468
|
+
var StateManager = class {
|
|
469
|
+
statePath;
|
|
470
|
+
state = null;
|
|
471
|
+
loaded = false;
|
|
472
|
+
constructor(options = {}) {
|
|
473
|
+
if (options.projectRoot) {
|
|
474
|
+
this.statePath = join(options.projectRoot, STATE_DIR, STATE_FILE);
|
|
475
|
+
} else {
|
|
476
|
+
this.statePath = join(homedir(), ".config", "syncenv", STATE_FILE);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Load state from disk
|
|
481
|
+
*/
|
|
482
|
+
async load() {
|
|
483
|
+
if (this.loaded && this.state) {
|
|
484
|
+
return this.state;
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
if (existsSync(this.statePath)) {
|
|
488
|
+
const content = await readFile(this.statePath, "utf-8");
|
|
489
|
+
this.state = JSON.parse(content);
|
|
490
|
+
this.state = this.migrate(this.state);
|
|
491
|
+
} else {
|
|
492
|
+
this.state = this.createEmptyState();
|
|
493
|
+
}
|
|
494
|
+
} catch (err) {
|
|
495
|
+
console.warn("Failed to load sync state, creating new:", err);
|
|
496
|
+
this.state = this.createEmptyState();
|
|
497
|
+
}
|
|
498
|
+
this.loaded = true;
|
|
499
|
+
return this.state;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Save state to disk
|
|
503
|
+
*/
|
|
504
|
+
async save() {
|
|
505
|
+
if (!this.state) {
|
|
506
|
+
throw new Error("State not loaded");
|
|
507
|
+
}
|
|
508
|
+
await mkdir(dirname(this.statePath), { recursive: true });
|
|
509
|
+
await writeFile(this.statePath, JSON.stringify(this.state, null, 2), "utf-8");
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Get environment sync state
|
|
513
|
+
*/
|
|
514
|
+
async getEnvironmentState(projectId, envId) {
|
|
515
|
+
const state = await this.load();
|
|
516
|
+
return state.projects[projectId]?.environments[envId] ?? null;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Update environment sync state
|
|
520
|
+
*/
|
|
521
|
+
async updateEnvironmentState(projectId, envId, update) {
|
|
522
|
+
const state = await this.load();
|
|
523
|
+
if (!state.projects[projectId]) {
|
|
524
|
+
state.projects[projectId] = { environments: {} };
|
|
525
|
+
}
|
|
526
|
+
const current = state.projects[projectId].environments[envId] ?? this.createEmptyEntry();
|
|
527
|
+
if (typeof update === "function") {
|
|
528
|
+
state.projects[projectId].environments[envId] = update(current);
|
|
529
|
+
} else {
|
|
530
|
+
state.projects[projectId].environments[envId] = {
|
|
531
|
+
...current,
|
|
532
|
+
...update,
|
|
533
|
+
// Merge history arrays
|
|
534
|
+
history: [...current.history || [], ...update.history || []].slice(-20)
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
await this.save();
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Record a successful sync operation
|
|
541
|
+
*/
|
|
542
|
+
async recordSync(projectId, envId, version, contentHash, source) {
|
|
543
|
+
await this.updateEnvironmentState(projectId, envId, (current) => ({
|
|
544
|
+
lastSync: {
|
|
545
|
+
version,
|
|
546
|
+
contentHash,
|
|
547
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
548
|
+
source
|
|
549
|
+
},
|
|
550
|
+
pendingMerge: void 0,
|
|
551
|
+
// Clear any pending merge
|
|
552
|
+
history: [
|
|
553
|
+
...current?.history || [],
|
|
554
|
+
{
|
|
555
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
556
|
+
action: source,
|
|
557
|
+
version
|
|
558
|
+
}
|
|
559
|
+
].slice(-20)
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Save a pending merge session
|
|
564
|
+
*/
|
|
565
|
+
async savePendingMerge(projectId, envId, baseVersion, remoteVersion, localBackupPath, conflicts) {
|
|
566
|
+
await this.updateEnvironmentState(projectId, envId, {
|
|
567
|
+
pendingMerge: {
|
|
568
|
+
baseVersion,
|
|
569
|
+
remoteVersion,
|
|
570
|
+
localBackupPath,
|
|
571
|
+
conflicts
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Clear pending merge
|
|
577
|
+
*/
|
|
578
|
+
async clearPendingMerge(projectId, envId) {
|
|
579
|
+
await this.updateEnvironmentState(projectId, envId, (current) => {
|
|
580
|
+
const updated = {
|
|
581
|
+
lastSync: current?.lastSync ?? {
|
|
582
|
+
version: 0,
|
|
583
|
+
contentHash: "",
|
|
584
|
+
timestamp: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
585
|
+
source: "pull"
|
|
586
|
+
},
|
|
587
|
+
history: current?.history ?? []
|
|
588
|
+
};
|
|
589
|
+
return updated;
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Check if there's a pending merge
|
|
594
|
+
*/
|
|
595
|
+
async hasPendingMerge(projectId, envId) {
|
|
596
|
+
const state = await this.getEnvironmentState(projectId, envId);
|
|
597
|
+
return state?.pendingMerge !== void 0;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Get state file path (for debugging)
|
|
601
|
+
*/
|
|
602
|
+
getStatePath() {
|
|
603
|
+
return this.statePath;
|
|
604
|
+
}
|
|
605
|
+
createEmptyState() {
|
|
606
|
+
return {
|
|
607
|
+
version: STATE_VERSION,
|
|
608
|
+
projects: {}
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
createEmptyEntry() {
|
|
612
|
+
return {
|
|
613
|
+
lastSync: {
|
|
614
|
+
version: 0,
|
|
615
|
+
contentHash: "",
|
|
616
|
+
timestamp: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
617
|
+
source: "pull"
|
|
618
|
+
},
|
|
619
|
+
history: []
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
migrate(state) {
|
|
623
|
+
if (!state) {
|
|
624
|
+
return this.createEmptyState();
|
|
625
|
+
}
|
|
626
|
+
if (!state.version) {
|
|
627
|
+
state.version = STATE_VERSION;
|
|
628
|
+
}
|
|
629
|
+
if (!state.projects) {
|
|
630
|
+
state.projects = {};
|
|
631
|
+
}
|
|
632
|
+
return state;
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
function createStateManager(options) {
|
|
636
|
+
return new StateManager(options);
|
|
637
|
+
}
|
|
638
|
+
var globalState = createStateManager();
|
|
639
|
+
|
|
640
|
+
// src/commands/env.ts
|
|
641
|
+
var envCommands = new Command3("env").description("Environment variable commands");
|
|
642
|
+
envCommands.command("push").description("Push .env file to server").option("-p, --project <id>", "project ID").option("-e, --env <name>", "environment name").option("-f, --file <path>", "file path").option("-m, --message <message>", "change description").option("--force", "force push without conflict check").option("--strategy <strategy>", "merge strategy on conflict: local-wins, remote-wins, fail-on-conflict").action(async (options) => {
|
|
643
|
+
try {
|
|
644
|
+
if (!isAuthenticated()) {
|
|
645
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
646
|
+
process.exit(1);
|
|
647
|
+
}
|
|
648
|
+
const config = await loadProjectConfig();
|
|
649
|
+
const projectId = options.project || config?.project.id || getConfig("defaultProject");
|
|
650
|
+
if (!projectId) {
|
|
651
|
+
error("No project specified. Use --project or run `syncenv init`");
|
|
652
|
+
process.exit(1);
|
|
653
|
+
}
|
|
654
|
+
const envName = options.env || config?.defaults.environment || "dev";
|
|
655
|
+
const envConfig = config?.environments[envName];
|
|
656
|
+
const filePath = options.file || envConfig?.file || `.env.${envName}`;
|
|
657
|
+
const defaultFilePath = ".env";
|
|
658
|
+
let actualFilePath = filePath;
|
|
659
|
+
try {
|
|
660
|
+
await fs2.access(filePath);
|
|
661
|
+
} catch {
|
|
662
|
+
try {
|
|
663
|
+
await fs2.access(defaultFilePath);
|
|
664
|
+
actualFilePath = defaultFilePath;
|
|
665
|
+
} catch {
|
|
666
|
+
error(`No env file found. Tried: ${filePath}, ${defaultFilePath}`);
|
|
667
|
+
process.exit(1);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const content = await fs2.readFile(actualFilePath, "utf-8");
|
|
671
|
+
const contentHash = generateContentHash(content);
|
|
672
|
+
info(`Detected env file: ${actualFilePath}`);
|
|
673
|
+
info(`Project: ${projectId}`);
|
|
674
|
+
info(`Environment: ${envName}`);
|
|
675
|
+
const stateManager = createStateManager({ projectRoot: process.cwd() });
|
|
676
|
+
const userKek = await getOrUnlockUserKEK();
|
|
677
|
+
if (!userKek) {
|
|
678
|
+
error("Your encryption keys are locked.");
|
|
679
|
+
info("Run `syncenv user-keys unlock` to unlock your keys.");
|
|
680
|
+
process.exit(1);
|
|
681
|
+
}
|
|
682
|
+
const spinner = createSpinner("Checking environment...");
|
|
683
|
+
spinner.start();
|
|
684
|
+
const { data: environments } = await withAuthGuard(
|
|
685
|
+
() => client.environments.listByProject(projectId)
|
|
686
|
+
);
|
|
687
|
+
let environment = environments.find((e) => e.name === envName);
|
|
688
|
+
const project = await withAuthGuard(() => client.projects.get(projectId));
|
|
689
|
+
let dek;
|
|
690
|
+
try {
|
|
691
|
+
dek = decryptDEK(project.encryptedDek, project.dekIv, userKek);
|
|
692
|
+
} catch (err) {
|
|
693
|
+
spinner.fail(`Failed to decrypt project key: ${err}`);
|
|
694
|
+
error("Could not decrypt the project encryption key. Your User KEK may be incorrect.");
|
|
695
|
+
process.exit(1);
|
|
696
|
+
}
|
|
697
|
+
const encrypted = encryptContent(content, dek);
|
|
698
|
+
if (environment && !options.force) {
|
|
699
|
+
const localState = await stateManager.getEnvironmentState(projectId, environment.id);
|
|
700
|
+
if (localState) {
|
|
701
|
+
const remoteVersion = environment.currentVersion || 1;
|
|
702
|
+
const localVersion = localState.lastSync.version;
|
|
703
|
+
if (remoteVersion > localVersion) {
|
|
704
|
+
spinner.stop();
|
|
705
|
+
warning(`Remote has been modified (version ${localVersion} \u2192 ${remoteVersion})`);
|
|
706
|
+
info("Attempting to merge...\n");
|
|
707
|
+
try {
|
|
708
|
+
const baseDownload = await client.environments.downloadContent(environment.id, localVersion);
|
|
709
|
+
const baseContent = decryptContent(baseDownload.content, baseDownload.encryptionIv, dek);
|
|
710
|
+
const remoteDownload = await client.environments.downloadContent(environment.id, remoteVersion);
|
|
711
|
+
const remoteContent = decryptContent(remoteDownload.content, remoteDownload.encryptionIv, dek);
|
|
712
|
+
const mergeInput = {
|
|
713
|
+
base: parseEnvFile(baseContent),
|
|
714
|
+
local: parseEnvFile(content),
|
|
715
|
+
remote: parseEnvFile(remoteContent)
|
|
716
|
+
};
|
|
717
|
+
const mergeResult = threeWayMerge(mergeInput);
|
|
718
|
+
if (mergeResult.autoMerged) {
|
|
719
|
+
info(chalk4.green("\u2713 Auto-merged successfully"));
|
|
720
|
+
info(chalk4.dim(` Added: ${mergeResult.statistics.added}`));
|
|
721
|
+
info(chalk4.dim(` Modified: ${mergeResult.statistics.modified}`));
|
|
722
|
+
info(chalk4.dim(` Deleted: ${mergeResult.statistics.deleted}`));
|
|
723
|
+
const mergedEncrypted = encryptContent(mergeResult.mergedContent, dek);
|
|
724
|
+
spinner.start("Uploading merged content...");
|
|
725
|
+
const envId = environment.id;
|
|
726
|
+
const updateResult = await withAuthGuard(
|
|
727
|
+
() => client.environments.update(envId, {
|
|
728
|
+
encryptedContent: mergedEncrypted.encrypted,
|
|
729
|
+
contentHash: generateContentHash(mergeResult.mergedContent),
|
|
730
|
+
encryptionIv: mergedEncrypted.iv,
|
|
731
|
+
changeSummary: options.message || `Merged with remote version ${remoteVersion}`
|
|
732
|
+
})
|
|
733
|
+
);
|
|
734
|
+
await stateManager.recordSync(projectId, envId, updateResult.version, generateContentHash(mergeResult.mergedContent), "merge");
|
|
735
|
+
spinner.succeed("Merged and uploaded");
|
|
736
|
+
success(`Environment "${envName}" updated to version ${updateResult.version}`);
|
|
737
|
+
return;
|
|
738
|
+
} else {
|
|
739
|
+
const { interactiveMerge: interactiveMerge2 } = await import("./interactive-GOIXZ6UH.js");
|
|
740
|
+
const interactiveResult = await interactiveMerge2(mergeInput, mergeResult, {
|
|
741
|
+
allowEdit: true,
|
|
742
|
+
allowBoth: true,
|
|
743
|
+
allowSkip: true
|
|
744
|
+
});
|
|
745
|
+
if (interactiveResult.cancelled) {
|
|
746
|
+
info("Push cancelled. Your local changes are preserved.");
|
|
747
|
+
process.exit(0);
|
|
748
|
+
}
|
|
749
|
+
const resolvedEncrypted = encryptContent(interactiveResult.content, dek);
|
|
750
|
+
spinner.start("Uploading resolved content...");
|
|
751
|
+
const envId = environment.id;
|
|
752
|
+
const updateResult = await withAuthGuard(
|
|
753
|
+
() => client.environments.update(envId, {
|
|
754
|
+
encryptedContent: resolvedEncrypted.encrypted,
|
|
755
|
+
contentHash: generateContentHash(interactiveResult.content),
|
|
756
|
+
encryptionIv: resolvedEncrypted.iv,
|
|
757
|
+
changeSummary: options.message || `Merged with remote version ${remoteVersion} (with conflict resolution)`
|
|
758
|
+
})
|
|
759
|
+
);
|
|
760
|
+
await stateManager.recordSync(projectId, envId, updateResult.version, generateContentHash(interactiveResult.content), "merge");
|
|
761
|
+
spinner.succeed("Resolved and uploaded");
|
|
762
|
+
success(`Environment "${envName}" updated to version ${updateResult.version}`);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
} catch (mergeErr) {
|
|
766
|
+
spinner.fail("Merge failed");
|
|
767
|
+
error("Failed to merge with remote changes:", mergeErr.message);
|
|
768
|
+
info("Use --force to overwrite remote changes (may lose data)");
|
|
769
|
+
info("Use --strategy=local-wins to automatically use your changes");
|
|
770
|
+
process.exit(1);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
if (!environment) {
|
|
776
|
+
spinner.text = "Creating environment...";
|
|
777
|
+
const result = await withAuthGuard(
|
|
778
|
+
() => client.environments.create(projectId, {
|
|
779
|
+
projectId,
|
|
780
|
+
name: envName,
|
|
781
|
+
description: `${envName} environment`,
|
|
782
|
+
encryptedContent: encrypted.encrypted,
|
|
783
|
+
contentHash,
|
|
784
|
+
contentSize: content.length,
|
|
785
|
+
encryptionIv: encrypted.iv
|
|
786
|
+
})
|
|
787
|
+
);
|
|
788
|
+
environment = result;
|
|
789
|
+
} else {
|
|
790
|
+
spinner.text = "Uploading...";
|
|
791
|
+
const envId = environment.id;
|
|
792
|
+
const result = await withAuthGuard(
|
|
793
|
+
() => client.environments.update(envId, {
|
|
794
|
+
encryptedContent: encrypted.encrypted,
|
|
795
|
+
contentHash,
|
|
796
|
+
encryptionIv: encrypted.iv,
|
|
797
|
+
changeSummary: options.message
|
|
798
|
+
})
|
|
799
|
+
);
|
|
800
|
+
await stateManager.recordSync(
|
|
801
|
+
projectId,
|
|
802
|
+
environment.id,
|
|
803
|
+
result.version,
|
|
804
|
+
contentHash,
|
|
805
|
+
"push"
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
spinner.succeed("Encrypted and uploaded");
|
|
809
|
+
success(`Environment "${envName}" updated`);
|
|
810
|
+
info(`Version: ${(environment.currentVersion || 0) + 1}`);
|
|
811
|
+
info(
|
|
812
|
+
`Size: ${formatBytes(content.length)} \u2192 ${formatBytes(Buffer.from(encrypted.encrypted, "base64").length)} (encrypted)`
|
|
813
|
+
);
|
|
814
|
+
if (options.message) {
|
|
815
|
+
info(`Message: ${options.message}`);
|
|
816
|
+
}
|
|
817
|
+
} catch (err) {
|
|
818
|
+
error("Failed to push:", err.message);
|
|
819
|
+
process.exit(1);
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
envCommands.command("pull").description("Pull .env file from server").option("-p, --project <id>", "project ID").option("-e, --env <name>", "environment name").option("-f, --file <path>", "output file path").option("-v, --version <number>", "specific version to pull").option("-m, --merge", "merge with existing file (using three-way merge if possible)").option("-y, --yes", "skip confirmation").action(async (options) => {
|
|
823
|
+
try {
|
|
824
|
+
if (!isAuthenticated()) {
|
|
825
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
826
|
+
process.exit(1);
|
|
827
|
+
}
|
|
828
|
+
const config = await loadProjectConfig();
|
|
829
|
+
const projectId = options.project || config?.project.id || getConfig("defaultProject");
|
|
830
|
+
if (!projectId) {
|
|
831
|
+
error("No project specified. Use --project or run `syncenv init`");
|
|
832
|
+
process.exit(1);
|
|
833
|
+
}
|
|
834
|
+
const envName = options.env || config?.defaults.environment || "dev";
|
|
835
|
+
const envConfig = config?.environments[envName];
|
|
836
|
+
const filePath = options.file || envConfig?.file || (envName === "dev" ? ".env" : `.env.${envName}`);
|
|
837
|
+
info(`Project: ${projectId}`);
|
|
838
|
+
info(`Environment: ${envName}`);
|
|
839
|
+
const userKek = await getOrUnlockUserKEK();
|
|
840
|
+
if (!userKek) {
|
|
841
|
+
error("Your encryption keys are locked.");
|
|
842
|
+
info("Run `syncenv user-keys unlock` to unlock your keys.");
|
|
843
|
+
process.exit(1);
|
|
844
|
+
}
|
|
845
|
+
const stateManager = createStateManager({ projectRoot: process.cwd() });
|
|
846
|
+
const spinner = createSpinner("Fetching environment...");
|
|
847
|
+
spinner.start();
|
|
848
|
+
const { data: environments } = await withAuthGuard(
|
|
849
|
+
() => client.environments.listByProject(projectId)
|
|
850
|
+
);
|
|
851
|
+
const environment = environments.find((e) => e.name === envName);
|
|
852
|
+
if (!environment) {
|
|
853
|
+
spinner.fail(`Environment "${envName}" not found`);
|
|
854
|
+
process.exit(1);
|
|
855
|
+
}
|
|
856
|
+
spinner.text = "Downloading...";
|
|
857
|
+
const versionNum = options.version ? parseInt(options.version) : void 0;
|
|
858
|
+
const download = await withAuthGuard(
|
|
859
|
+
() => client.environments.downloadContent(environment.id, versionNum)
|
|
860
|
+
);
|
|
861
|
+
spinner.text = "Decrypting...";
|
|
862
|
+
const project = await withAuthGuard(() => client.projects.get(projectId));
|
|
863
|
+
let dek;
|
|
864
|
+
try {
|
|
865
|
+
dek = decryptDEK(project.encryptedDek, project.dekIv, userKek);
|
|
866
|
+
} catch (err) {
|
|
867
|
+
console.error(`Failed to decrypt project key: ${err}`);
|
|
868
|
+
spinner.fail(`Failed to decrypt project key`);
|
|
869
|
+
error("Could not decrypt the project encryption key. Your User KEK may be incorrect.");
|
|
870
|
+
process.exit(1);
|
|
871
|
+
}
|
|
872
|
+
const decrypted = decryptContent(download.content, download.encryptionIv, dek);
|
|
873
|
+
spinner.stop();
|
|
874
|
+
let existingContent = "";
|
|
875
|
+
let shouldMerge = false;
|
|
876
|
+
try {
|
|
877
|
+
existingContent = await fs2.readFile(filePath, "utf-8");
|
|
878
|
+
if (!options.yes && !options.merge) {
|
|
879
|
+
const { action } = await inquirer2.prompt([
|
|
880
|
+
{
|
|
881
|
+
type: "list",
|
|
882
|
+
name: "action",
|
|
883
|
+
message: `File ${filePath} exists. What would you like to do?`,
|
|
884
|
+
choices: [
|
|
885
|
+
{ name: "Overwrite", value: "overwrite" },
|
|
886
|
+
{ name: "Merge", value: "merge" },
|
|
887
|
+
{ name: "Cancel", value: "cancel" }
|
|
888
|
+
]
|
|
889
|
+
}
|
|
890
|
+
]);
|
|
891
|
+
if (action === "cancel") {
|
|
892
|
+
info("Cancelled.");
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
shouldMerge = action === "merge";
|
|
896
|
+
} else if (options.merge) {
|
|
897
|
+
shouldMerge = true;
|
|
898
|
+
}
|
|
899
|
+
} catch {
|
|
900
|
+
}
|
|
901
|
+
if (shouldMerge && existingContent) {
|
|
902
|
+
const localEnv = parseEnvFile(existingContent);
|
|
903
|
+
const remoteEnv = parseEnvFile(decrypted);
|
|
904
|
+
const diff = diffEnvFiles(localEnv, remoteEnv);
|
|
905
|
+
if (diff.added.length === 0 && diff.removed.length === 0 && diff.modified.length === 0) {
|
|
906
|
+
info("No changes to merge.");
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
console.log(chalk4.bold("\nMerge changes:"));
|
|
910
|
+
for (const key of diff.added) {
|
|
911
|
+
console.log(chalk4.green(` + ${key}=${localEnv[key]}`));
|
|
912
|
+
}
|
|
913
|
+
for (const key of diff.removed) {
|
|
914
|
+
console.log(chalk4.red(` - ${key}`));
|
|
915
|
+
}
|
|
916
|
+
for (const key of diff.modified) {
|
|
917
|
+
console.log(chalk4.yellow(` ~ ${key}`));
|
|
918
|
+
console.log(chalk4.dim(` local: ${localEnv[key]}`));
|
|
919
|
+
console.log(chalk4.dim(` remote: ${remoteEnv[key]}`));
|
|
920
|
+
}
|
|
921
|
+
const { mergeAction } = await inquirer2.prompt([
|
|
922
|
+
{
|
|
923
|
+
type: "list",
|
|
924
|
+
name: "mergeAction",
|
|
925
|
+
message: "How would you like to merge?",
|
|
926
|
+
choices: [
|
|
927
|
+
{ name: "Keep local (use local values)", value: "local" },
|
|
928
|
+
{ name: "Use remote (overwrite with remote)", value: "remote" },
|
|
929
|
+
{ name: "Manual (open editor)", value: "manual" }
|
|
930
|
+
]
|
|
931
|
+
}
|
|
932
|
+
]);
|
|
933
|
+
let mergedEnv;
|
|
934
|
+
if (mergeAction === "local") {
|
|
935
|
+
mergedEnv = { ...remoteEnv, ...localEnv };
|
|
936
|
+
} else if (mergeAction === "remote") {
|
|
937
|
+
mergedEnv = { ...localEnv, ...remoteEnv };
|
|
938
|
+
} else {
|
|
939
|
+
mergedEnv = { ...localEnv, ...remoteEnv };
|
|
940
|
+
warning("Manual merge not implemented yet. Using remote values for conflicts.");
|
|
941
|
+
}
|
|
942
|
+
const mergedContent = stringifyEnvFile(mergedEnv);
|
|
943
|
+
await fs2.writeFile(filePath, mergedContent, "utf-8");
|
|
944
|
+
success(`Merged and written to ${filePath}`);
|
|
945
|
+
} else {
|
|
946
|
+
await fs2.writeFile(filePath, decrypted, "utf-8");
|
|
947
|
+
success(`Written to ${filePath}`);
|
|
948
|
+
}
|
|
949
|
+
info(`Version: ${download.version}`);
|
|
950
|
+
await stateManager.recordSync(
|
|
951
|
+
projectId,
|
|
952
|
+
environment.id,
|
|
953
|
+
download.version || 1,
|
|
954
|
+
download.contentHash || "",
|
|
955
|
+
"pull"
|
|
956
|
+
);
|
|
957
|
+
} catch (err) {
|
|
958
|
+
error("Failed to pull:", err.message);
|
|
959
|
+
process.exit(1);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
envCommands.command("history").description("Show version history").option("-p, --project <id>", "project ID").option("-e, --env <name>", "environment name").option("-l, --limit <number>", "number of versions to show", "10").action(async (options) => {
|
|
963
|
+
try {
|
|
964
|
+
if (!isAuthenticated()) {
|
|
965
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
966
|
+
process.exit(1);
|
|
967
|
+
}
|
|
968
|
+
const config = await loadProjectConfig();
|
|
969
|
+
const projectId = options.project || config?.project.id || getConfig("defaultProject");
|
|
970
|
+
if (!projectId) {
|
|
971
|
+
error("No project specified.");
|
|
972
|
+
process.exit(1);
|
|
973
|
+
}
|
|
974
|
+
const envName = options.env || config?.defaults.environment || "dev";
|
|
975
|
+
const limit = parseInt(options.limit);
|
|
976
|
+
const spinner = createSpinner("Fetching history...");
|
|
977
|
+
spinner.start();
|
|
978
|
+
const { data: environments } = await withAuthGuard(
|
|
979
|
+
() => client.environments.listByProject(projectId)
|
|
980
|
+
);
|
|
981
|
+
const environment = environments.find((e) => e.name === envName);
|
|
982
|
+
if (!environment) {
|
|
983
|
+
spinner.fail(`Environment "${envName}" not found`);
|
|
984
|
+
process.exit(1);
|
|
985
|
+
}
|
|
986
|
+
const versions = await withAuthGuard(() => client.sync.getVersions(environment.id, parseInt(options.limit)));
|
|
987
|
+
spinner.stop();
|
|
988
|
+
if (versions.length === 0) {
|
|
989
|
+
info("No versions found.");
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
console.log(chalk4.bold("\nVERSION DATE AUTHOR MESSAGE"));
|
|
993
|
+
console.log(chalk4.dim("\u2500".repeat(70)));
|
|
994
|
+
for (const version of versions.slice(0, limit)) {
|
|
995
|
+
const ver = String(version.versionNumber).padEnd(9);
|
|
996
|
+
const date = formatRelativeTime(version.createdAt).padEnd(17);
|
|
997
|
+
const author = (version.createdByUser?.slice(0, 12) || "unknown").padEnd(13);
|
|
998
|
+
const message = version.changeSummary || "";
|
|
999
|
+
console.log(`${ver} ${date} ${author} ${message}`);
|
|
1000
|
+
}
|
|
1001
|
+
console.log("");
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
error("Failed to get history:", err.message);
|
|
1004
|
+
process.exit(1);
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
envCommands.command("diff <v1> <v2>").description("Compare two versions (not supported yet)").option("-p, --project <id>", "project ID").option("-e, --env <name>", "environment name").action(async (v1, v2, options) => {
|
|
1008
|
+
try {
|
|
1009
|
+
if (!isAuthenticated()) {
|
|
1010
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1011
|
+
process.exit(1);
|
|
1012
|
+
}
|
|
1013
|
+
const config = await loadProjectConfig();
|
|
1014
|
+
const projectId = options.project || config?.project.id || getConfig("defaultProject");
|
|
1015
|
+
if (!projectId) {
|
|
1016
|
+
error("No project specified.");
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
}
|
|
1019
|
+
const envName = options.env || config?.defaults.environment || "dev";
|
|
1020
|
+
const version1 = parseInt(v1);
|
|
1021
|
+
const version2 = parseInt(v2);
|
|
1022
|
+
const spinner = createSpinner("Comparing versions...");
|
|
1023
|
+
spinner.start();
|
|
1024
|
+
const { data: environments } = await withAuthGuard(
|
|
1025
|
+
() => client.environments.listByProject(projectId)
|
|
1026
|
+
);
|
|
1027
|
+
const environment = environments.find((e) => e.name === envName);
|
|
1028
|
+
if (!environment) {
|
|
1029
|
+
spinner.fail(`Environment "${envName}" not found`);
|
|
1030
|
+
process.exit(1);
|
|
1031
|
+
}
|
|
1032
|
+
const [download1, download2] = await Promise.all([
|
|
1033
|
+
client.environments.downloadContent(environment.id, version1),
|
|
1034
|
+
client.environments.downloadContent(environment.id, version2)
|
|
1035
|
+
]);
|
|
1036
|
+
spinner.stop();
|
|
1037
|
+
console.log(chalk4.bold(`
|
|
1038
|
+
Comparing versions ${version1} and ${version2}`));
|
|
1039
|
+
console.log(chalk4.dim("\u2500".repeat(50)));
|
|
1040
|
+
console.log(chalk4.dim(`
|
|
1041
|
+
Version ${version1}:`));
|
|
1042
|
+
console.log(` Content hash: ${generateContentHash(download1.content).slice(0, 16)}...`);
|
|
1043
|
+
console.log(` Date: ${(/* @__PURE__ */ new Date()).toLocaleString()}`);
|
|
1044
|
+
console.log(chalk4.dim(`
|
|
1045
|
+
Version ${version2}:`));
|
|
1046
|
+
console.log(` Content hash: ${generateContentHash(download2.content).slice(0, 16)}...`);
|
|
1047
|
+
console.log(` Date: ${(/* @__PURE__ */ new Date()).toLocaleString()}`);
|
|
1048
|
+
if (download1.content !== download2.content) {
|
|
1049
|
+
console.log(chalk4.yellow("\n\u26A0 Content differs between versions"));
|
|
1050
|
+
info("Use `syncenv env pull --version <number>` to view a specific version");
|
|
1051
|
+
} else {
|
|
1052
|
+
console.log(chalk4.green("\n\u2713 Content is identical"));
|
|
1053
|
+
}
|
|
1054
|
+
console.log("");
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
error("Failed to compare versions:", err.message);
|
|
1057
|
+
process.exit(1);
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
envCommands.command("rollback <version>").description("Rollback to a specific version").option("-p, --project <id>", "project ID").option("-e, --env <name>", "environment name").option("-y, --yes", "skip confirmation").action(async (version, options) => {
|
|
1061
|
+
try {
|
|
1062
|
+
if (!isAuthenticated()) {
|
|
1063
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
}
|
|
1066
|
+
const config = await loadProjectConfig();
|
|
1067
|
+
const projectId = options.project || config?.project.id || getConfig("defaultProject");
|
|
1068
|
+
if (!projectId) {
|
|
1069
|
+
error("No project specified.");
|
|
1070
|
+
process.exit(1);
|
|
1071
|
+
}
|
|
1072
|
+
const envName = options.env || config?.defaults.environment || "dev";
|
|
1073
|
+
const targetVersion = parseInt(version);
|
|
1074
|
+
if (!options.yes) {
|
|
1075
|
+
const { confirm } = await inquirer2.prompt([
|
|
1076
|
+
{
|
|
1077
|
+
type: "confirm",
|
|
1078
|
+
name: "confirm",
|
|
1079
|
+
message: `Rollback "${envName}" to version ${targetVersion}? This will create a new version.`,
|
|
1080
|
+
default: false
|
|
1081
|
+
}
|
|
1082
|
+
]);
|
|
1083
|
+
if (!confirm) {
|
|
1084
|
+
info("Cancelled.");
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
const userKek = await getOrUnlockUserKEK();
|
|
1089
|
+
if (!userKek) {
|
|
1090
|
+
error("Your encryption keys are locked.");
|
|
1091
|
+
info("Run `syncenv user-keys unlock` to unlock your keys.");
|
|
1092
|
+
process.exit(1);
|
|
1093
|
+
}
|
|
1094
|
+
const spinner = createSpinner("Rolling back...");
|
|
1095
|
+
spinner.start();
|
|
1096
|
+
const { data: environments } = await withAuthGuard(
|
|
1097
|
+
() => client.environments.listByProject(projectId)
|
|
1098
|
+
);
|
|
1099
|
+
const environment = environments.find((e) => e.name === envName);
|
|
1100
|
+
if (!environment) {
|
|
1101
|
+
spinner.fail(`Environment "${envName}" not found`);
|
|
1102
|
+
process.exit(1);
|
|
1103
|
+
}
|
|
1104
|
+
const download = await client.environments.downloadContent(environment.id, targetVersion);
|
|
1105
|
+
const project = await withAuthGuard(() => client.projects.get(projectId));
|
|
1106
|
+
let dek;
|
|
1107
|
+
try {
|
|
1108
|
+
dek = decryptDEK(project.encryptedDek, project.dekIv, userKek);
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
console.error(`Failed to decrypt project key: ${err}`);
|
|
1111
|
+
spinner.fail("Failed to decrypt project key");
|
|
1112
|
+
error("Could not decrypt the project encryption key. Your User KEK may be incorrect.");
|
|
1113
|
+
process.exit(1);
|
|
1114
|
+
}
|
|
1115
|
+
const oldContent = decryptContent(download.content, download.encryptionIv, dek);
|
|
1116
|
+
const encrypted = encryptContent(oldContent, dek);
|
|
1117
|
+
const contentHash = generateContentHash(oldContent);
|
|
1118
|
+
const result = await client.environments.update(environment.id, {
|
|
1119
|
+
encryptedContent: encrypted.encrypted,
|
|
1120
|
+
contentHash,
|
|
1121
|
+
encryptionIv: encrypted.iv,
|
|
1122
|
+
changeSummary: `Rollback to version ${targetVersion}`
|
|
1123
|
+
});
|
|
1124
|
+
spinner.succeed(`Rolled back to version ${targetVersion}`);
|
|
1125
|
+
success(`Created new version ${result.version}`);
|
|
1126
|
+
} catch (err) {
|
|
1127
|
+
error("Failed to rollback:", err.message);
|
|
1128
|
+
process.exit(1);
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
envCommands.command("list-envs").description("List all environments for a project").option("-p, --project <id>", "project ID").action(async (options) => {
|
|
1132
|
+
try {
|
|
1133
|
+
if (!isAuthenticated()) {
|
|
1134
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1135
|
+
process.exit(1);
|
|
1136
|
+
}
|
|
1137
|
+
const config = await loadProjectConfig();
|
|
1138
|
+
const projectId = options.project || config?.project.id || getConfig("defaultProject");
|
|
1139
|
+
if (!projectId) {
|
|
1140
|
+
error("No project specified. Use --project or run `syncenv init`");
|
|
1141
|
+
process.exit(1);
|
|
1142
|
+
}
|
|
1143
|
+
const spinner = createSpinner("Fetching environments...");
|
|
1144
|
+
spinner.start();
|
|
1145
|
+
const { data: environments } = await withAuthGuard(
|
|
1146
|
+
() => client.environments.listByProject(projectId)
|
|
1147
|
+
);
|
|
1148
|
+
spinner.stop();
|
|
1149
|
+
if (environments.length === 0) {
|
|
1150
|
+
info("No environments found for this project.");
|
|
1151
|
+
info("Create one with `syncenv env create-env`");
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
const project = await withAuthGuard(() => client.projects.get(projectId));
|
|
1155
|
+
console.log(chalk4.bold(`
|
|
1156
|
+
Environments for ${project.name}`));
|
|
1157
|
+
console.log(chalk4.dim("\u2500".repeat(70)));
|
|
1158
|
+
console.log(chalk4.bold("NAME VERSION SIZE LAST MODIFIED DESCRIPTION"));
|
|
1159
|
+
console.log(chalk4.dim("\u2500".repeat(70)));
|
|
1160
|
+
for (const env of environments) {
|
|
1161
|
+
const name = env.name.padEnd(11).slice(0, 11);
|
|
1162
|
+
const version = `v${env.currentVersion || 1}`.padEnd(9);
|
|
1163
|
+
const size = formatBytes(env.contentSize || 0).padEnd(9);
|
|
1164
|
+
const modified = env.lastModifiedAt ? formatRelativeTime(env.lastModifiedAt).padEnd(17) : "--".padEnd(17);
|
|
1165
|
+
const description = env.description || "";
|
|
1166
|
+
console.log(`${name} ${version} ${size} ${modified} ${description}`);
|
|
1167
|
+
}
|
|
1168
|
+
console.log("");
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
error("Failed to list environments:", err.message);
|
|
1171
|
+
process.exit(1);
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
envCommands.command("create-env").description("Create a new environment for a project").option("-p, --project <id>", "project ID").option("-n, --name <name>", "environment name").option("-d, --description <description>", "environment description").action(async (options) => {
|
|
1175
|
+
try {
|
|
1176
|
+
if (!isAuthenticated()) {
|
|
1177
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1178
|
+
process.exit(1);
|
|
1179
|
+
}
|
|
1180
|
+
const config = await loadProjectConfig();
|
|
1181
|
+
const projectId = options.project || config?.project.id || getConfig("defaultProject");
|
|
1182
|
+
if (!projectId) {
|
|
1183
|
+
error("No project specified. Use --project or run `syncenv init`");
|
|
1184
|
+
process.exit(1);
|
|
1185
|
+
}
|
|
1186
|
+
let envName = options.name;
|
|
1187
|
+
if (!envName) {
|
|
1188
|
+
const answer = await inquirer2.prompt([
|
|
1189
|
+
{
|
|
1190
|
+
type: "input",
|
|
1191
|
+
name: "name",
|
|
1192
|
+
message: "Environment name:",
|
|
1193
|
+
validate: (input) => {
|
|
1194
|
+
if (!input.trim()) return "Environment name is required";
|
|
1195
|
+
if (!/^[a-z0-9_-]+$/i.test(input))
|
|
1196
|
+
return "Name can only contain letters, numbers, hyphens, and underscores";
|
|
1197
|
+
return true;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
]);
|
|
1201
|
+
envName = answer.name;
|
|
1202
|
+
}
|
|
1203
|
+
let description = options.description;
|
|
1204
|
+
if (!description) {
|
|
1205
|
+
const answer = await inquirer2.prompt([
|
|
1206
|
+
{
|
|
1207
|
+
type: "input",
|
|
1208
|
+
name: "description",
|
|
1209
|
+
message: "Description (optional):"
|
|
1210
|
+
}
|
|
1211
|
+
]);
|
|
1212
|
+
description = answer.description;
|
|
1213
|
+
}
|
|
1214
|
+
const spinner = createSpinner("Creating environment...");
|
|
1215
|
+
spinner.start();
|
|
1216
|
+
try {
|
|
1217
|
+
const { encryptContent: encryptContent2, generateContentHash: generateContentHash2 } = await import("./crypto-X7MZU7DV.js");
|
|
1218
|
+
const placeholderContent = `# ${envName} environment
|
|
1219
|
+
# Created at ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1220
|
+
`;
|
|
1221
|
+
const contentSize = placeholderContent.length;
|
|
1222
|
+
const encrypted = encryptContent2(placeholderContent, Buffer.alloc(32));
|
|
1223
|
+
const result = await withAuthGuard(
|
|
1224
|
+
() => client.environments.create(projectId, {
|
|
1225
|
+
projectId,
|
|
1226
|
+
name: envName,
|
|
1227
|
+
description: description || void 0,
|
|
1228
|
+
encryptedContent: encrypted.encrypted,
|
|
1229
|
+
contentHash: generateContentHash2(placeholderContent),
|
|
1230
|
+
contentSize,
|
|
1231
|
+
encryptionIv: encrypted.iv
|
|
1232
|
+
})
|
|
1233
|
+
);
|
|
1234
|
+
spinner.succeed(`Environment "${envName}" created`);
|
|
1235
|
+
success(`Environment ID: ${result.id}`);
|
|
1236
|
+
if (config) {
|
|
1237
|
+
config.environments[envName] = {
|
|
1238
|
+
file: envName === "dev" ? ".env" : `.env.${envName}`,
|
|
1239
|
+
requireConfirmation: envName === "production"
|
|
1240
|
+
};
|
|
1241
|
+
await saveProjectConfig(config);
|
|
1242
|
+
info(`Updated .envsyncrc with environment "${envName}"`);
|
|
1243
|
+
}
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
spinner.fail("Failed to create environment");
|
|
1246
|
+
throw err;
|
|
1247
|
+
}
|
|
1248
|
+
} catch (err) {
|
|
1249
|
+
error("Create failed:", err.message);
|
|
1250
|
+
process.exit(1);
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
envCommands.command("delete-env <id>").description("Delete an environment").option("-f, --force", "skip confirmation").action(async (id, options) => {
|
|
1254
|
+
try {
|
|
1255
|
+
if (!isAuthenticated()) {
|
|
1256
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1257
|
+
process.exit(1);
|
|
1258
|
+
}
|
|
1259
|
+
const env = await withAuthGuard(() => client.environments.get(id));
|
|
1260
|
+
if (!options.force) {
|
|
1261
|
+
const { confirm } = await inquirer2.prompt([
|
|
1262
|
+
{
|
|
1263
|
+
type: "input",
|
|
1264
|
+
name: "confirm",
|
|
1265
|
+
message: `Type "${env.name}" to confirm deletion:`,
|
|
1266
|
+
validate: (input) => input === env.name || "Type the exact environment name to confirm"
|
|
1267
|
+
}
|
|
1268
|
+
]);
|
|
1269
|
+
if (!confirm) {
|
|
1270
|
+
info("Cancelled.");
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
const spinner = createSpinner("Deleting environment...");
|
|
1275
|
+
spinner.start();
|
|
1276
|
+
await withAuthGuard(() => client.environments.delete(id));
|
|
1277
|
+
spinner.succeed(`Environment "${env.name}" deleted`);
|
|
1278
|
+
const config = await loadProjectConfig();
|
|
1279
|
+
if (config && config.environments[env.name]) {
|
|
1280
|
+
delete config.environments[env.name];
|
|
1281
|
+
await saveProjectConfig(config);
|
|
1282
|
+
info("Updated .envsyncrc");
|
|
1283
|
+
}
|
|
1284
|
+
} catch (err) {
|
|
1285
|
+
error("Failed to delete environment:", err.message);
|
|
1286
|
+
process.exit(1);
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
envCommands.command("sync").description("Sync with remote (pull + merge + push in one command)").option("-p, --project <id>", "project ID").option("-e, --env <name>", "environment name").option("-f, --file <path>", "file path").option("--dry-run", "preview changes without applying").option("--strategy <strategy>", "conflict resolution: local-wins, remote-wins, fail-on-conflict", "interactive").option("-y, --yes", "skip confirmation prompts").action(async (options) => {
|
|
1290
|
+
try {
|
|
1291
|
+
if (!isAuthenticated()) {
|
|
1292
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1293
|
+
process.exit(1);
|
|
1294
|
+
}
|
|
1295
|
+
const config = await loadProjectConfig();
|
|
1296
|
+
const projectId = options.project || config?.project.id || getConfig("defaultProject");
|
|
1297
|
+
if (!projectId) {
|
|
1298
|
+
error("No project specified. Use --project or run `syncenv init`");
|
|
1299
|
+
process.exit(1);
|
|
1300
|
+
}
|
|
1301
|
+
const envName = options.env || config?.defaults.environment || "dev";
|
|
1302
|
+
const envConfig = config?.environments[envName];
|
|
1303
|
+
const filePath = options.file || envConfig?.file || (envName === "dev" ? ".env" : `.env.${envName}`);
|
|
1304
|
+
info(`Project: ${projectId}`);
|
|
1305
|
+
info(`Environment: ${envName}`);
|
|
1306
|
+
info(`File: ${filePath}`);
|
|
1307
|
+
const userKek = await getOrUnlockUserKEK();
|
|
1308
|
+
if (!userKek) {
|
|
1309
|
+
error("Your encryption keys are locked.");
|
|
1310
|
+
info("Run `syncenv user-keys unlock` to unlock your keys.");
|
|
1311
|
+
process.exit(1);
|
|
1312
|
+
}
|
|
1313
|
+
const stateManager = createStateManager({ projectRoot: process.cwd() });
|
|
1314
|
+
const spinner = createSpinner("Checking remote status...");
|
|
1315
|
+
spinner.start();
|
|
1316
|
+
const { data: environments } = await withAuthGuard(
|
|
1317
|
+
() => client.environments.listByProject(projectId)
|
|
1318
|
+
);
|
|
1319
|
+
const environment = environments.find((e) => e.name === envName);
|
|
1320
|
+
if (!environment) {
|
|
1321
|
+
spinner.fail(`Environment "${envName}" not found`);
|
|
1322
|
+
info("Use `syncenv env push` to create it first");
|
|
1323
|
+
process.exit(1);
|
|
1324
|
+
}
|
|
1325
|
+
let localContent;
|
|
1326
|
+
try {
|
|
1327
|
+
localContent = await fs2.readFile(filePath, "utf-8");
|
|
1328
|
+
} catch {
|
|
1329
|
+
spinner.text = "Local file not found, pulling from remote...";
|
|
1330
|
+
const download = await client.environments.downloadContent(environment.id);
|
|
1331
|
+
const project2 = await client.projects.get(projectId);
|
|
1332
|
+
const dek2 = decryptDEK(project2.encryptedDek, project2.dekIv, userKek);
|
|
1333
|
+
const decrypted = decryptContent(download.content, download.encryptionIv, dek2);
|
|
1334
|
+
if (!options.dryRun) {
|
|
1335
|
+
await fs2.writeFile(filePath, decrypted, "utf-8");
|
|
1336
|
+
await stateManager.recordSync(projectId, environment.id, download.version || 1, download.contentHash || "", "pull");
|
|
1337
|
+
}
|
|
1338
|
+
spinner.succeed("Pulled from remote");
|
|
1339
|
+
success(`Written to ${filePath}`);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
const localState = await stateManager.getEnvironmentState(projectId, environment.id);
|
|
1343
|
+
const localContentHash = generateContentHash(localContent);
|
|
1344
|
+
if (localState && localState.lastSync.contentHash === localContentHash) {
|
|
1345
|
+
spinner.succeed("Local file is up to date");
|
|
1346
|
+
info("No changes to sync");
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
spinner.text = "Downloading remote content...";
|
|
1350
|
+
const project = await withAuthGuard(() => client.projects.get(projectId));
|
|
1351
|
+
const dek = decryptDEK(project.encryptedDek, project.dekIv, userKek);
|
|
1352
|
+
const remoteVersion = environment.currentVersion || 1;
|
|
1353
|
+
const remoteDownload = await client.environments.downloadContent(environment.id);
|
|
1354
|
+
const remoteContent = decryptContent(remoteDownload.content, remoteDownload.encryptionIv, dek);
|
|
1355
|
+
let mergedContent;
|
|
1356
|
+
let mergeSource = "push";
|
|
1357
|
+
if (localState && localState.lastSync.version < remoteVersion) {
|
|
1358
|
+
spinner.text = "Merging changes...";
|
|
1359
|
+
const baseDownload = await client.environments.downloadContent(environment.id, localState.lastSync.version);
|
|
1360
|
+
const baseContent = decryptContent(baseDownload.content, baseDownload.encryptionIv, dek);
|
|
1361
|
+
const mergeInput = {
|
|
1362
|
+
base: parseEnvFile(baseContent),
|
|
1363
|
+
local: parseEnvFile(localContent),
|
|
1364
|
+
remote: parseEnvFile(remoteContent)
|
|
1365
|
+
};
|
|
1366
|
+
const mergeResult = threeWayMerge(mergeInput);
|
|
1367
|
+
if (options.dryRun) {
|
|
1368
|
+
spinner.stop();
|
|
1369
|
+
info("\nDry run - would perform the following merge:");
|
|
1370
|
+
info(` Added: ${mergeResult.statistics.added}`);
|
|
1371
|
+
info(` Modified: ${mergeResult.statistics.modified}`);
|
|
1372
|
+
info(` Deleted: ${mergeResult.statistics.deleted}`);
|
|
1373
|
+
info(` Conflicts: ${mergeResult.statistics.conflicts}`);
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (!mergeResult.autoMerged) {
|
|
1377
|
+
spinner.stop();
|
|
1378
|
+
if (options.strategy === "fail-on-conflict") {
|
|
1379
|
+
error(`Merge failed: ${mergeResult.conflicts.length} conflict(s)`);
|
|
1380
|
+
info("Use --strategy=local-wins or --strategy=remote-wins to auto-resolve");
|
|
1381
|
+
process.exit(1);
|
|
1382
|
+
}
|
|
1383
|
+
if (options.strategy === "local-wins") {
|
|
1384
|
+
mergedContent = applyMergeStrategy(mergeInput, "local-wins").mergedContent;
|
|
1385
|
+
} else if (options.strategy === "remote-wins") {
|
|
1386
|
+
mergedContent = applyMergeStrategy(mergeInput, "remote-wins").mergedContent;
|
|
1387
|
+
} else {
|
|
1388
|
+
const interactiveResult = await interactiveMerge(mergeInput, mergeResult, {
|
|
1389
|
+
allowEdit: true,
|
|
1390
|
+
allowBoth: true,
|
|
1391
|
+
allowSkip: true
|
|
1392
|
+
});
|
|
1393
|
+
if (interactiveResult.cancelled) {
|
|
1394
|
+
info("Sync cancelled.");
|
|
1395
|
+
process.exit(0);
|
|
1396
|
+
}
|
|
1397
|
+
mergedContent = interactiveResult.content;
|
|
1398
|
+
}
|
|
1399
|
+
mergeSource = "merge";
|
|
1400
|
+
} else {
|
|
1401
|
+
mergedContent = mergeResult.mergedContent;
|
|
1402
|
+
mergeSource = "merge";
|
|
1403
|
+
}
|
|
1404
|
+
await fs2.writeFile(filePath, mergedContent, "utf-8");
|
|
1405
|
+
success("Merged and updated local file");
|
|
1406
|
+
} else {
|
|
1407
|
+
spinner.stop();
|
|
1408
|
+
if (options.dryRun) {
|
|
1409
|
+
info("\nDry run - would push local changes");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
mergedContent = localContent;
|
|
1413
|
+
if (!options.yes) {
|
|
1414
|
+
const { confirm } = await inquirer2.prompt([{
|
|
1415
|
+
type: "confirm",
|
|
1416
|
+
name: "confirm",
|
|
1417
|
+
message: `Push local changes to "${envName}"?`,
|
|
1418
|
+
default: true
|
|
1419
|
+
}]);
|
|
1420
|
+
if (!confirm) {
|
|
1421
|
+
info("Cancelled.");
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
spinner.start("Pushing to remote...");
|
|
1427
|
+
const encrypted = encryptContent(mergedContent, dek);
|
|
1428
|
+
const newContentHash = generateContentHash(mergedContent);
|
|
1429
|
+
const result = await withAuthGuard(
|
|
1430
|
+
() => client.environments.update(environment.id, {
|
|
1431
|
+
encryptedContent: encrypted.encrypted,
|
|
1432
|
+
contentHash: newContentHash,
|
|
1433
|
+
encryptionIv: encrypted.iv,
|
|
1434
|
+
changeSummary: mergeSource === "merge" ? "Synced with merge" : "Synced local changes"
|
|
1435
|
+
})
|
|
1436
|
+
);
|
|
1437
|
+
await stateManager.recordSync(projectId, environment.id, result.version, newContentHash, mergeSource);
|
|
1438
|
+
spinner.succeed("Sync complete");
|
|
1439
|
+
success(`Environment "${envName}" updated to version ${result.version}`);
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
error("Sync failed:", err.message);
|
|
1442
|
+
process.exit(1);
|
|
1443
|
+
}
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
// src/commands/init.ts
|
|
1447
|
+
import path from "path";
|
|
1448
|
+
import chalk5 from "chalk";
|
|
1449
|
+
import { Command as Command4 } from "commander";
|
|
1450
|
+
import inquirer3 from "inquirer";
|
|
1451
|
+
var initCommand = new Command4("init").description("Initialize a new project configuration").option("-y, --yes", "skip prompts and use defaults").action(async (options) => {
|
|
1452
|
+
try {
|
|
1453
|
+
if (await hasProjectConfig()) {
|
|
1454
|
+
const { overwrite } = await inquirer3.prompt([
|
|
1455
|
+
{
|
|
1456
|
+
type: "confirm",
|
|
1457
|
+
name: "overwrite",
|
|
1458
|
+
message: "Project already initialized. Overwrite?",
|
|
1459
|
+
default: false
|
|
1460
|
+
}
|
|
1461
|
+
]);
|
|
1462
|
+
if (!overwrite) {
|
|
1463
|
+
info("Cancelled.");
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
let projectName;
|
|
1468
|
+
let environments;
|
|
1469
|
+
if (options.yes) {
|
|
1470
|
+
projectName = path.basename(process.cwd());
|
|
1471
|
+
environments = ["dev", "staging", "production"];
|
|
1472
|
+
} else {
|
|
1473
|
+
const answers = await inquirer3.prompt([
|
|
1474
|
+
{
|
|
1475
|
+
type: "input",
|
|
1476
|
+
name: "projectName",
|
|
1477
|
+
message: "Project name:",
|
|
1478
|
+
default: path.basename(process.cwd()),
|
|
1479
|
+
validate: (input) => input.length > 0 || "Project name is required"
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
type: "checkbox",
|
|
1483
|
+
name: "environments",
|
|
1484
|
+
message: "Select default environments:",
|
|
1485
|
+
choices: [
|
|
1486
|
+
{ name: "dev", checked: true },
|
|
1487
|
+
{ name: "staging", checked: true },
|
|
1488
|
+
{ name: "production", checked: true },
|
|
1489
|
+
{ name: "test", checked: false }
|
|
1490
|
+
],
|
|
1491
|
+
validate: (input) => input.length > 0 || "Select at least one environment"
|
|
1492
|
+
},
|
|
1493
|
+
{
|
|
1494
|
+
type: "confirm",
|
|
1495
|
+
name: "pushOnChange",
|
|
1496
|
+
message: "Auto-push on change?",
|
|
1497
|
+
default: false
|
|
1498
|
+
},
|
|
1499
|
+
{
|
|
1500
|
+
type: "confirm",
|
|
1501
|
+
name: "confirmOverwrite",
|
|
1502
|
+
message: "Confirm before overwriting files?",
|
|
1503
|
+
default: true
|
|
1504
|
+
}
|
|
1505
|
+
]);
|
|
1506
|
+
projectName = answers.projectName;
|
|
1507
|
+
environments = answers.environments;
|
|
1508
|
+
const envConfig = {};
|
|
1509
|
+
for (const env of environments) {
|
|
1510
|
+
const fileName = env === "dev" ? ".env" : `.env.${env}`;
|
|
1511
|
+
envConfig[env] = {
|
|
1512
|
+
file: fileName,
|
|
1513
|
+
requireConfirmation: env === "production"
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
const config = {
|
|
1517
|
+
project: {
|
|
1518
|
+
name: projectName,
|
|
1519
|
+
id: ""
|
|
1520
|
+
// Will be set after creating project on server
|
|
1521
|
+
},
|
|
1522
|
+
defaults: {
|
|
1523
|
+
environment: "dev",
|
|
1524
|
+
pushOnChange: answers.pushOnChange,
|
|
1525
|
+
confirmOverwrite: answers.confirmOverwrite
|
|
1526
|
+
},
|
|
1527
|
+
encryption: {
|
|
1528
|
+
algorithm: "AES-256-GCM",
|
|
1529
|
+
keyDerivation: "Argon2id"
|
|
1530
|
+
},
|
|
1531
|
+
environments: envConfig
|
|
1532
|
+
};
|
|
1533
|
+
await saveProjectConfig(config);
|
|
1534
|
+
success(`Created ${chalk5.cyan(".envsyncrc")}`);
|
|
1535
|
+
console.log("");
|
|
1536
|
+
console.log(chalk5.dim("Next steps:"));
|
|
1537
|
+
console.log(chalk5.dim(" 1. Run `syncenv signup` to create an account"));
|
|
1538
|
+
console.log(
|
|
1539
|
+
chalk5.dim(" 2. Run `syncenv project create` to create the project on the server")
|
|
1540
|
+
);
|
|
1541
|
+
console.log(chalk5.dim(" 3. Run `syncenv push` to upload your .env file"));
|
|
1542
|
+
}
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
error("Failed to initialize project:", err.message);
|
|
1545
|
+
process.exit(1);
|
|
1546
|
+
}
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
// src/commands/project.ts
|
|
1550
|
+
import chalk6 from "chalk";
|
|
1551
|
+
import { Command as Command5 } from "commander";
|
|
1552
|
+
import inquirer4 from "inquirer";
|
|
1553
|
+
var projectCommands = new Command5("project").description("Project management commands");
|
|
1554
|
+
projectCommands.command("list").alias("ls").description("List all projects").option("-s, --search <query>", "search projects by name").option("-l, --limit <number>", "number of projects to show", "20").option("--cursor <cursor>", "cursor for pagination").action(async (options) => {
|
|
1555
|
+
try {
|
|
1556
|
+
if (!isAuthenticated()) {
|
|
1557
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1558
|
+
process.exit(1);
|
|
1559
|
+
}
|
|
1560
|
+
const spinner = createSpinner("Fetching projects...");
|
|
1561
|
+
spinner.start();
|
|
1562
|
+
const limit = parseInt(options.limit) || 20;
|
|
1563
|
+
const result = await withAuthGuard(
|
|
1564
|
+
() => client.projects.list({
|
|
1565
|
+
limit,
|
|
1566
|
+
search: options.search,
|
|
1567
|
+
cursor: options.cursor,
|
|
1568
|
+
sortBy: "updatedAt",
|
|
1569
|
+
sortOrder: "desc"
|
|
1570
|
+
})
|
|
1571
|
+
);
|
|
1572
|
+
spinner.stop();
|
|
1573
|
+
if (result.data.length === 0) {
|
|
1574
|
+
if (options.search) {
|
|
1575
|
+
info(`No projects found matching "${options.search}"`);
|
|
1576
|
+
} else {
|
|
1577
|
+
info("No projects found. Create one with `syncenv project create`");
|
|
1578
|
+
}
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
console.log(chalk6.bold("\nNAME DESCRIPTION UPDATED"));
|
|
1582
|
+
console.log(chalk6.dim("\u2500".repeat(70)));
|
|
1583
|
+
for (const project of result.data) {
|
|
1584
|
+
const name = project.name.padEnd(13).slice(0, 13);
|
|
1585
|
+
const description = (project.description || "-").padEnd(24).slice(0, 24);
|
|
1586
|
+
const updated = project.updatedAt ? formatRelativeTime(project.updatedAt) : "--";
|
|
1587
|
+
console.log(`${name} ${description} ${updated}`);
|
|
1588
|
+
}
|
|
1589
|
+
if (result.hasMore) {
|
|
1590
|
+
console.log(chalk6.dim(`
|
|
1591
|
+
... and more projects available`));
|
|
1592
|
+
info(`Use --cursor ${result.nextCursor} to see more`);
|
|
1593
|
+
}
|
|
1594
|
+
console.log("");
|
|
1595
|
+
} catch (err) {
|
|
1596
|
+
error("Failed to list projects:", err.message);
|
|
1597
|
+
process.exit(1);
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
projectCommands.command("create [name]").description("Create a new project").option("-d, --description <description>", "project description").action(async (name, options) => {
|
|
1601
|
+
try {
|
|
1602
|
+
if (!isAuthenticated()) {
|
|
1603
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1604
|
+
process.exit(1);
|
|
1605
|
+
}
|
|
1606
|
+
let projectName = name;
|
|
1607
|
+
let description = options.description;
|
|
1608
|
+
if (!projectName) {
|
|
1609
|
+
const existingConfig = await loadProjectConfig();
|
|
1610
|
+
if (existingConfig) {
|
|
1611
|
+
projectName = existingConfig.project.name;
|
|
1612
|
+
info(`Using project name from .envsyncrc: ${projectName}`);
|
|
1613
|
+
} else {
|
|
1614
|
+
const answer = await inquirer4.prompt([
|
|
1615
|
+
{
|
|
1616
|
+
type: "input",
|
|
1617
|
+
name: "name",
|
|
1618
|
+
message: "Project name:",
|
|
1619
|
+
validate: (input) => input.length > 0 || "Name is required"
|
|
1620
|
+
}
|
|
1621
|
+
]);
|
|
1622
|
+
projectName = answer.name;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
if (!description) {
|
|
1626
|
+
const answer = await inquirer4.prompt([
|
|
1627
|
+
{
|
|
1628
|
+
type: "input",
|
|
1629
|
+
name: "description",
|
|
1630
|
+
message: "Description (optional):"
|
|
1631
|
+
}
|
|
1632
|
+
]);
|
|
1633
|
+
description = answer.description;
|
|
1634
|
+
}
|
|
1635
|
+
const userKek = await getOrUnlockUserKEK();
|
|
1636
|
+
if (!userKek) {
|
|
1637
|
+
error("Your encryption keys are locked.");
|
|
1638
|
+
info("Run `syncenv user-keys unlock` to unlock your keys before creating a project.");
|
|
1639
|
+
process.exit(1);
|
|
1640
|
+
}
|
|
1641
|
+
const spinner = createSpinner("Creating project...");
|
|
1642
|
+
spinner.start();
|
|
1643
|
+
const { generateDEK, encryptDEK } = await import("./crypto-X7MZU7DV.js");
|
|
1644
|
+
const dek = generateDEK();
|
|
1645
|
+
const dekEncryption = encryptDEK(dek, userKek);
|
|
1646
|
+
const project = await withAuthGuard(
|
|
1647
|
+
() => client.projects.create({
|
|
1648
|
+
name: projectName,
|
|
1649
|
+
slug: projectName.toLowerCase().replace(/\s+/g, "-"),
|
|
1650
|
+
description,
|
|
1651
|
+
encryptedDek: dekEncryption.encrypted,
|
|
1652
|
+
dekIv: dekEncryption.iv
|
|
1653
|
+
})
|
|
1654
|
+
);
|
|
1655
|
+
spinner.succeed(`Project "${projectName}" created`);
|
|
1656
|
+
const config = await loadProjectConfig();
|
|
1657
|
+
if (config) {
|
|
1658
|
+
config.project.id = project.id;
|
|
1659
|
+
await saveProjectConfig(config);
|
|
1660
|
+
success("Updated .envsyncrc with project ID");
|
|
1661
|
+
} else {
|
|
1662
|
+
await saveProjectConfig({
|
|
1663
|
+
project: {
|
|
1664
|
+
name: projectName,
|
|
1665
|
+
id: project.id
|
|
1666
|
+
},
|
|
1667
|
+
defaults: {
|
|
1668
|
+
environment: "dev",
|
|
1669
|
+
pushOnChange: false,
|
|
1670
|
+
confirmOverwrite: true
|
|
1671
|
+
},
|
|
1672
|
+
encryption: {
|
|
1673
|
+
algorithm: "AES-256-GCM",
|
|
1674
|
+
keyDerivation: "Argon2id"
|
|
1675
|
+
},
|
|
1676
|
+
environments: {
|
|
1677
|
+
dev: { file: ".env" }
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
success("Created .envsyncrc");
|
|
1681
|
+
}
|
|
1682
|
+
info(`Project ID: ${project.id}`);
|
|
1683
|
+
info("Default environments: dev, staging, prod");
|
|
1684
|
+
} catch (err) {
|
|
1685
|
+
error("Failed to create project:", err.message);
|
|
1686
|
+
process.exit(1);
|
|
1687
|
+
}
|
|
1688
|
+
});
|
|
1689
|
+
projectCommands.command("get <id>").description("Get project details").action(async (id) => {
|
|
1690
|
+
try {
|
|
1691
|
+
if (!isAuthenticated()) {
|
|
1692
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1693
|
+
process.exit(1);
|
|
1694
|
+
}
|
|
1695
|
+
const spinner = createSpinner("Fetching project...");
|
|
1696
|
+
spinner.start();
|
|
1697
|
+
const project = await withAuthGuard(() => client.projects.get(id));
|
|
1698
|
+
spinner.stop();
|
|
1699
|
+
console.log(chalk6.bold("\nProject Details"));
|
|
1700
|
+
console.log(chalk6.dim("\u2500".repeat(40)));
|
|
1701
|
+
console.log(`Name: ${project.name}`);
|
|
1702
|
+
console.log(`ID: ${project.id}`);
|
|
1703
|
+
console.log(`Slug: ${project.slug}`);
|
|
1704
|
+
if (project.description) {
|
|
1705
|
+
console.log(`Description: ${project.description}`);
|
|
1706
|
+
}
|
|
1707
|
+
console.log(`Created: ${new Date(project.createdAt).toLocaleString()}`);
|
|
1708
|
+
console.log(`Updated: ${new Date(project.updatedAt).toLocaleString()}`);
|
|
1709
|
+
const { data: environments } = await withAuthGuard(
|
|
1710
|
+
() => client.environments.listByProject(id)
|
|
1711
|
+
);
|
|
1712
|
+
if (environments?.length > 0) {
|
|
1713
|
+
console.log(chalk6.bold("\nEnvironments:"));
|
|
1714
|
+
for (const env of environments) {
|
|
1715
|
+
console.log(` \u2022 ${env.name} (v${env.currentVersion || 1})`);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
} catch (err) {
|
|
1719
|
+
error("Failed to get project:", err.message);
|
|
1720
|
+
process.exit(1);
|
|
1721
|
+
}
|
|
1722
|
+
});
|
|
1723
|
+
projectCommands.command("delete <id>").description("Delete a project").option("-f, --force", "skip confirmation").action(async (id, options) => {
|
|
1724
|
+
try {
|
|
1725
|
+
if (!isAuthenticated()) {
|
|
1726
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1727
|
+
process.exit(1);
|
|
1728
|
+
}
|
|
1729
|
+
const project = await withAuthGuard(() => client.projects.get(id));
|
|
1730
|
+
if (!options.force) {
|
|
1731
|
+
const { confirm } = await inquirer4.prompt([
|
|
1732
|
+
{
|
|
1733
|
+
type: "input",
|
|
1734
|
+
name: "confirm",
|
|
1735
|
+
message: `Type "${project.name}" to confirm deletion:`,
|
|
1736
|
+
validate: (input) => input === project.name || "Type the exact project name to confirm"
|
|
1737
|
+
}
|
|
1738
|
+
]);
|
|
1739
|
+
if (!confirm) {
|
|
1740
|
+
info("Cancelled.");
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
const spinner = createSpinner("Deleting project...");
|
|
1745
|
+
spinner.start();
|
|
1746
|
+
await withAuthGuard(() => client.projects.delete(id));
|
|
1747
|
+
spinner.succeed(`Project "${project.name}" deleted`);
|
|
1748
|
+
const config = await loadProjectConfig();
|
|
1749
|
+
if (config && config.project.id === id) {
|
|
1750
|
+
config.project.id = "";
|
|
1751
|
+
await saveProjectConfig(config);
|
|
1752
|
+
info("Cleared project ID from .envsyncrc");
|
|
1753
|
+
}
|
|
1754
|
+
} catch (err) {
|
|
1755
|
+
error("Failed to delete project:", err.message);
|
|
1756
|
+
process.exit(1);
|
|
1757
|
+
}
|
|
1758
|
+
});
|
|
1759
|
+
projectCommands.command("use <id>").description("Set default project").action(async (id) => {
|
|
1760
|
+
try {
|
|
1761
|
+
if (!isAuthenticated()) {
|
|
1762
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1763
|
+
process.exit(1);
|
|
1764
|
+
}
|
|
1765
|
+
const project = await withAuthGuard(() => client.projects.get(id));
|
|
1766
|
+
setConfig("defaultProject", id);
|
|
1767
|
+
success(`Set "${project.name}" as default project`);
|
|
1768
|
+
} catch (err) {
|
|
1769
|
+
error("Failed to set default project:", err.message);
|
|
1770
|
+
process.exit(1);
|
|
1771
|
+
}
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
// src/commands/user-keys.ts
|
|
1775
|
+
import chalk7 from "chalk";
|
|
1776
|
+
import { Command as Command6 } from "commander";
|
|
1777
|
+
import inquirer5 from "inquirer";
|
|
1778
|
+
var userKeysCommands = new Command6("user-keys").description(
|
|
1779
|
+
"User encryption key management commands"
|
|
1780
|
+
);
|
|
1781
|
+
async function hasUserKeys() {
|
|
1782
|
+
try {
|
|
1783
|
+
await withAuthGuard(() => client.userKeys.get());
|
|
1784
|
+
return true;
|
|
1785
|
+
} catch (err) {
|
|
1786
|
+
if (err.message?.includes("404")) {
|
|
1787
|
+
return false;
|
|
1788
|
+
}
|
|
1789
|
+
throw err;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
userKeysCommands.command("setup").description("Initialize your encryption keys (first time setup)").action(async () => {
|
|
1793
|
+
try {
|
|
1794
|
+
if (!isAuthenticated()) {
|
|
1795
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1796
|
+
process.exit(1);
|
|
1797
|
+
}
|
|
1798
|
+
const hasKeys = await hasUserKeys();
|
|
1799
|
+
if (hasKeys) {
|
|
1800
|
+
warning("You already have encryption keys set up.");
|
|
1801
|
+
const { overwrite } = await inquirer5.prompt([
|
|
1802
|
+
{
|
|
1803
|
+
type: "confirm",
|
|
1804
|
+
name: "overwrite",
|
|
1805
|
+
message: "Do you want to re-setup your keys? This will invalidate existing project access.",
|
|
1806
|
+
default: false
|
|
1807
|
+
}
|
|
1808
|
+
]);
|
|
1809
|
+
if (!overwrite) {
|
|
1810
|
+
info("Cancelled.");
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
const { password } = await inquirer5.prompt([
|
|
1815
|
+
{
|
|
1816
|
+
type: "password",
|
|
1817
|
+
name: "password",
|
|
1818
|
+
message: "Create encryption password:",
|
|
1819
|
+
mask: "*",
|
|
1820
|
+
validate: (input) => {
|
|
1821
|
+
if (input.length < 8) return "Password must be at least 8 characters";
|
|
1822
|
+
return true;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
]);
|
|
1826
|
+
await inquirer5.prompt([
|
|
1827
|
+
{
|
|
1828
|
+
type: "password",
|
|
1829
|
+
name: "confirmPassword",
|
|
1830
|
+
message: "Confirm encryption password:",
|
|
1831
|
+
mask: "*",
|
|
1832
|
+
validate: (input) => {
|
|
1833
|
+
if (input !== password) return "Passwords do not match";
|
|
1834
|
+
return true;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
]);
|
|
1838
|
+
const spinner = createSpinner("Setting up encryption keys...");
|
|
1839
|
+
spinner.start();
|
|
1840
|
+
try {
|
|
1841
|
+
const { encryptedUserKek, kekIv, kekSalt } = await setupUserKEK(password);
|
|
1842
|
+
await withAuthGuard(
|
|
1843
|
+
() => client.userKeys.create({
|
|
1844
|
+
encryptedUserKek,
|
|
1845
|
+
kekIv,
|
|
1846
|
+
kekSalt
|
|
1847
|
+
})
|
|
1848
|
+
);
|
|
1849
|
+
await unlockAndStoreKEK(
|
|
1850
|
+
password,
|
|
1851
|
+
{ encryptedUserKek, kekIv, kekSalt },
|
|
1852
|
+
false
|
|
1853
|
+
// Don't remember, just cache in memory
|
|
1854
|
+
);
|
|
1855
|
+
spinner.succeed("Encryption keys set up successfully");
|
|
1856
|
+
success("Your encryption keys are ready");
|
|
1857
|
+
info("Your keys are encrypted with your password and stored securely");
|
|
1858
|
+
warning("Remember your encryption password! It cannot be recovered.");
|
|
1859
|
+
} catch (err) {
|
|
1860
|
+
spinner.fail("Failed to set up encryption keys");
|
|
1861
|
+
throw err;
|
|
1862
|
+
}
|
|
1863
|
+
} catch (err) {
|
|
1864
|
+
error("Setup failed:", err.message);
|
|
1865
|
+
process.exit(1);
|
|
1866
|
+
}
|
|
1867
|
+
});
|
|
1868
|
+
userKeysCommands.command("unlock").description("Unlock your encryption keys for this session").option("--remember", "Remember KEK in system keychain (allows unlocking without password)").action(async (options) => {
|
|
1869
|
+
try {
|
|
1870
|
+
if (!isAuthenticated()) {
|
|
1871
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1872
|
+
process.exit(1);
|
|
1873
|
+
}
|
|
1874
|
+
const hasKeys = await hasUserKeys();
|
|
1875
|
+
if (!hasKeys) {
|
|
1876
|
+
error("You do not have encryption keys set up yet.");
|
|
1877
|
+
info("Run `syncenv user-keys setup` to create your keys.");
|
|
1878
|
+
process.exit(1);
|
|
1879
|
+
}
|
|
1880
|
+
const cached = await getUserKEK();
|
|
1881
|
+
if (cached) {
|
|
1882
|
+
const remaining = Math.round(getKEKTimeRemaining() / 6e4);
|
|
1883
|
+
info(`Your encryption keys are already unlocked (${remaining} minutes remaining).`);
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
const userKeys = await withAuthGuard(() => client.userKeys.get());
|
|
1887
|
+
const { password } = await inquirer5.prompt([
|
|
1888
|
+
{
|
|
1889
|
+
type: "password",
|
|
1890
|
+
name: "password",
|
|
1891
|
+
message: "Enter your encryption password:",
|
|
1892
|
+
mask: "*"
|
|
1893
|
+
}
|
|
1894
|
+
]);
|
|
1895
|
+
const spinner = createSpinner("Unlocking encryption keys...");
|
|
1896
|
+
spinner.start();
|
|
1897
|
+
try {
|
|
1898
|
+
await unlockAndStoreKEK(password, userKeys, options.remember);
|
|
1899
|
+
spinner.succeed("Encryption keys unlocked");
|
|
1900
|
+
success("Your keys are now available for this session");
|
|
1901
|
+
if (options.remember) {
|
|
1902
|
+
info("Keys are stored in system keychain for future sessions");
|
|
1903
|
+
}
|
|
1904
|
+
info("Keys will auto-lock after 30 minutes of inactivity");
|
|
1905
|
+
info("Run `syncenv user-keys lock` to lock immediately");
|
|
1906
|
+
} catch (err) {
|
|
1907
|
+
console.error(`Faild to unlock encryption keys: ${err}`);
|
|
1908
|
+
spinner.fail("Failed to unlock encryption keys");
|
|
1909
|
+
error("Incorrect password. Please try again.");
|
|
1910
|
+
process.exit(1);
|
|
1911
|
+
}
|
|
1912
|
+
} catch (err) {
|
|
1913
|
+
error("Unlock failed:", err.message);
|
|
1914
|
+
process.exit(1);
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1917
|
+
userKeysCommands.command("lock").description("Lock (clear) your encryption keys from memory").option("--forget", "Also remove from system keychain").action(async (options) => {
|
|
1918
|
+
try {
|
|
1919
|
+
await lockKEK(options.forget);
|
|
1920
|
+
success("Encryption keys locked");
|
|
1921
|
+
if (options.forget) {
|
|
1922
|
+
info("Keys removed from system keychain");
|
|
1923
|
+
} else {
|
|
1924
|
+
info("Keys cleared from memory (still in keychain for next unlock)");
|
|
1925
|
+
}
|
|
1926
|
+
info("Run `syncenv user-keys unlock` to unlock again");
|
|
1927
|
+
} catch (err) {
|
|
1928
|
+
error("Lock failed:", err.message);
|
|
1929
|
+
process.exit(1);
|
|
1930
|
+
}
|
|
1931
|
+
});
|
|
1932
|
+
userKeysCommands.command("status").description("Check your encryption key status").action(async () => {
|
|
1933
|
+
try {
|
|
1934
|
+
if (!isAuthenticated()) {
|
|
1935
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1936
|
+
process.exit(1);
|
|
1937
|
+
}
|
|
1938
|
+
console.log(chalk7.bold("\nEncryption Key Status"));
|
|
1939
|
+
console.log(chalk7.dim("\u2500".repeat(40)));
|
|
1940
|
+
const hasKeys = await hasUserKeys();
|
|
1941
|
+
if (!hasKeys) {
|
|
1942
|
+
console.log(`Setup: ${chalk7.yellow("Not set up")}`);
|
|
1943
|
+
info("\nRun `syncenv user-keys setup` to create your encryption keys");
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
console.log(`Setup: ${chalk7.green("\u2713 Complete")}`);
|
|
1947
|
+
const cached = isKEKCached();
|
|
1948
|
+
if (cached) {
|
|
1949
|
+
const remaining = Math.round(getKEKTimeRemaining() / 6e4);
|
|
1950
|
+
console.log(`Session: ${chalk7.green("\u2713 Unlocked")} (${remaining} min remaining)`);
|
|
1951
|
+
info("\nYour encryption keys are active for this session");
|
|
1952
|
+
} else {
|
|
1953
|
+
console.log(`Session: ${chalk7.yellow("\u25CB Locked")}`);
|
|
1954
|
+
info("\nRun `syncenv user-keys unlock` to unlock your keys");
|
|
1955
|
+
}
|
|
1956
|
+
const userKeys = await withAuthGuard(() => client.userKeys.get());
|
|
1957
|
+
console.log(`
|
|
1958
|
+
Key Version: ${userKeys.version || 1}`);
|
|
1959
|
+
console.log(`Algorithm: AES-256-GCM with PBKDF2`);
|
|
1960
|
+
console.log("");
|
|
1961
|
+
} catch (err) {
|
|
1962
|
+
error("Status check failed:", err.message);
|
|
1963
|
+
process.exit(1);
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
userKeysCommands.command("rotate").description("Re-encrypt your keys with a new password").action(async () => {
|
|
1967
|
+
try {
|
|
1968
|
+
if (!isAuthenticated()) {
|
|
1969
|
+
error("Not authenticated. Run `syncenv auth login` first.");
|
|
1970
|
+
process.exit(1);
|
|
1971
|
+
}
|
|
1972
|
+
const hasKeys = await hasUserKeys();
|
|
1973
|
+
if (!hasKeys) {
|
|
1974
|
+
error("You do not have encryption keys set up yet.");
|
|
1975
|
+
info("Run `syncenv user-keys setup` to create your keys.");
|
|
1976
|
+
process.exit(1);
|
|
1977
|
+
}
|
|
1978
|
+
const userKeys = await withAuthGuard(() => client.userKeys.get());
|
|
1979
|
+
const { oldPassword } = await inquirer5.prompt([
|
|
1980
|
+
{
|
|
1981
|
+
type: "password",
|
|
1982
|
+
name: "oldPassword",
|
|
1983
|
+
message: "Enter your current encryption password:",
|
|
1984
|
+
mask: "*"
|
|
1985
|
+
}
|
|
1986
|
+
]);
|
|
1987
|
+
const { newPassword } = await inquirer5.prompt([
|
|
1988
|
+
{
|
|
1989
|
+
type: "password",
|
|
1990
|
+
name: "newPassword",
|
|
1991
|
+
message: "Enter your new encryption password:",
|
|
1992
|
+
mask: "*",
|
|
1993
|
+
validate: (input) => {
|
|
1994
|
+
if (input.length < 8) return "Password must be at least 8 characters";
|
|
1995
|
+
return true;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
]);
|
|
1999
|
+
await inquirer5.prompt([
|
|
2000
|
+
{
|
|
2001
|
+
type: "password",
|
|
2002
|
+
name: "confirmPassword",
|
|
2003
|
+
message: "Confirm your new encryption password:",
|
|
2004
|
+
mask: "*",
|
|
2005
|
+
validate: (input) => {
|
|
2006
|
+
if (input !== newPassword) return "Passwords do not match";
|
|
2007
|
+
return true;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
]);
|
|
2011
|
+
const spinner = createSpinner("Re-encrypting keys...");
|
|
2012
|
+
spinner.start();
|
|
2013
|
+
try {
|
|
2014
|
+
const userKek = await unlockUserKEK(
|
|
2015
|
+
oldPassword,
|
|
2016
|
+
userKeys.encryptedUserKek,
|
|
2017
|
+
userKeys.kekIv,
|
|
2018
|
+
userKeys.kekSalt
|
|
2019
|
+
);
|
|
2020
|
+
if (!userKek) {
|
|
2021
|
+
spinner.fail("Failed to unlock keys");
|
|
2022
|
+
error("Incorrect current password.");
|
|
2023
|
+
process.exit(1);
|
|
2024
|
+
}
|
|
2025
|
+
const { encryptedUserKek, kekIv, kekSalt } = await reencryptUserKEK(userKek, newPassword);
|
|
2026
|
+
await withAuthGuard(
|
|
2027
|
+
() => client.userKeys.update({
|
|
2028
|
+
encryptedUserKek,
|
|
2029
|
+
kekIv,
|
|
2030
|
+
kekSalt
|
|
2031
|
+
})
|
|
2032
|
+
);
|
|
2033
|
+
const { saveEncryptedKEK, saveKEKPassword, getKEKPassword } = await import("./secure-storage-UEK3LD5L.js");
|
|
2034
|
+
saveEncryptedKEK({ encryptedUserKek, kekIv, kekSalt });
|
|
2035
|
+
const existingPassword = await getKEKPassword();
|
|
2036
|
+
if (existingPassword) {
|
|
2037
|
+
await saveKEKPassword(newPassword);
|
|
2038
|
+
}
|
|
2039
|
+
spinner.succeed("Keys re-encrypted successfully");
|
|
2040
|
+
success("Your encryption password has been changed");
|
|
2041
|
+
warning("Remember your new password! It cannot be recovered.");
|
|
2042
|
+
} catch (err) {
|
|
2043
|
+
spinner.fail("Failed to re-encrypt keys");
|
|
2044
|
+
throw err;
|
|
2045
|
+
}
|
|
2046
|
+
} catch (err) {
|
|
2047
|
+
error("Rotation failed:", err.message);
|
|
2048
|
+
process.exit(1);
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
// src/index.ts
|
|
2053
|
+
async function main() {
|
|
2054
|
+
const program = new Command7();
|
|
2055
|
+
await loadConfig();
|
|
2056
|
+
program.name("syncenv").description("CLI for SyncEnv - Secure environment variable synchronization").version("0.1.0").option("-v, --verbose", "enable verbose logging").option("--api-url <url>", "API base URL", process.env.SYNCENV_API_URL || "http://localhost:8787").hook("preAction", (thisCommand) => {
|
|
2057
|
+
const opts = thisCommand.opts();
|
|
2058
|
+
if (opts.verbose) {
|
|
2059
|
+
process.env.SYNCENV_VERBOSE = "true";
|
|
2060
|
+
}
|
|
2061
|
+
if (opts.apiUrl) {
|
|
2062
|
+
process.env.SYNCENV_API_URL = opts.apiUrl;
|
|
2063
|
+
}
|
|
2064
|
+
});
|
|
2065
|
+
program.addCommand(authCommands);
|
|
2066
|
+
program.addCommand(projectCommands);
|
|
2067
|
+
program.addCommand(envCommands);
|
|
2068
|
+
program.addCommand(userKeysCommands);
|
|
2069
|
+
program.addCommand(initCommand);
|
|
2070
|
+
program.addCommand(doctorCommand);
|
|
2071
|
+
program.exitOverride();
|
|
2072
|
+
try {
|
|
2073
|
+
await program.parseAsync(process.argv);
|
|
2074
|
+
} catch (error2) {
|
|
2075
|
+
if (error2.code === "commander.help") {
|
|
2076
|
+
process.exit(0);
|
|
2077
|
+
}
|
|
2078
|
+
if (error2.code === "commander.version") {
|
|
2079
|
+
process.exit(0);
|
|
2080
|
+
}
|
|
2081
|
+
if (error2.code === "commander.helpDisplayed") {
|
|
2082
|
+
process.exit(0);
|
|
2083
|
+
}
|
|
2084
|
+
console.error(chalk8.red("\nError:"), error2.message || error2);
|
|
2085
|
+
if (process.env.SYNCENV_VERBOSE) {
|
|
2086
|
+
console.error(error2.stack);
|
|
2087
|
+
}
|
|
2088
|
+
process.exit(1);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
main();
|