gitignored-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/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1407 -0
- package/package.json +56 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1407 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/login.ts
|
|
7
|
+
import * as crypto from "crypto";
|
|
8
|
+
import open from "open";
|
|
9
|
+
|
|
10
|
+
// src/lib/config.ts
|
|
11
|
+
import * as fs from "fs";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import * as os from "os";
|
|
14
|
+
var CONFIG_DIR = path.join(os.homedir(), ".gitignored");
|
|
15
|
+
var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
16
|
+
var KEYS_DIR = path.join(CONFIG_DIR, "keys");
|
|
17
|
+
var DEFAULT_CONFIG = {
|
|
18
|
+
apiBaseUrl: "http://localhost:3000"
|
|
19
|
+
};
|
|
20
|
+
function ensureConfigDir() {
|
|
21
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
22
|
+
fs.mkdirSync(KEYS_DIR, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
function getConfig() {
|
|
25
|
+
try {
|
|
26
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
|
|
27
|
+
const config = { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
|
|
28
|
+
const envToken = process.env.GITIGNORED_TOKEN;
|
|
29
|
+
if (envToken) {
|
|
30
|
+
config.authToken = envToken;
|
|
31
|
+
}
|
|
32
|
+
return config;
|
|
33
|
+
} catch {
|
|
34
|
+
const config = { ...DEFAULT_CONFIG };
|
|
35
|
+
const envToken = process.env.GITIGNORED_TOKEN;
|
|
36
|
+
if (envToken) {
|
|
37
|
+
config.authToken = envToken;
|
|
38
|
+
}
|
|
39
|
+
return config;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function saveConfig(partial) {
|
|
43
|
+
ensureConfigDir();
|
|
44
|
+
const existing = getConfig();
|
|
45
|
+
const merged = { ...existing, ...partial };
|
|
46
|
+
if (process.env.GITIGNORED_TOKEN && merged.authToken === process.env.GITIGNORED_TOKEN) {
|
|
47
|
+
delete merged.authToken;
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + "\n");
|
|
50
|
+
}
|
|
51
|
+
function clearConfig() {
|
|
52
|
+
const config = getConfig();
|
|
53
|
+
delete config.authToken;
|
|
54
|
+
delete config.userId;
|
|
55
|
+
delete config.email;
|
|
56
|
+
ensureConfigDir();
|
|
57
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/lib/api.ts
|
|
61
|
+
var ApiError = class extends Error {
|
|
62
|
+
constructor(statusCode, message) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.statusCode = statusCode;
|
|
65
|
+
this.name = "ApiError";
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
async function apiClient(path13, options = {}) {
|
|
69
|
+
const config = getConfig();
|
|
70
|
+
const url = `${config.apiBaseUrl}${path13}`;
|
|
71
|
+
const headers = {
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
...options.headers
|
|
74
|
+
};
|
|
75
|
+
if (config.authToken) {
|
|
76
|
+
headers["Authorization"] = `Bearer ${config.authToken}`;
|
|
77
|
+
}
|
|
78
|
+
const response = await fetch(url, {
|
|
79
|
+
...options,
|
|
80
|
+
headers
|
|
81
|
+
});
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
if (response.status === 401) {
|
|
84
|
+
throw new ApiError(401, "Session expired. Please run `gitignored login`.");
|
|
85
|
+
}
|
|
86
|
+
const body = await response.text();
|
|
87
|
+
let message;
|
|
88
|
+
try {
|
|
89
|
+
const json = JSON.parse(body);
|
|
90
|
+
message = json.error || json.message || body;
|
|
91
|
+
} catch {
|
|
92
|
+
message = body;
|
|
93
|
+
}
|
|
94
|
+
throw new ApiError(response.status, message);
|
|
95
|
+
}
|
|
96
|
+
return response.json();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/lib/crypto.ts
|
|
100
|
+
import nacl from "tweetnacl";
|
|
101
|
+
import { decodeBase64, encodeBase64, decodeUTF8, encodeUTF8 } from "tweetnacl-util";
|
|
102
|
+
function generateKeypair() {
|
|
103
|
+
return nacl.box.keyPair();
|
|
104
|
+
}
|
|
105
|
+
function generateProjectKey() {
|
|
106
|
+
return nacl.randomBytes(32);
|
|
107
|
+
}
|
|
108
|
+
function encryptEnv(plaintext, projectKey) {
|
|
109
|
+
const nonce = nacl.randomBytes(24);
|
|
110
|
+
const messageBytes = decodeUTF8(plaintext);
|
|
111
|
+
const ciphertext = nacl.secretbox(messageBytes, nonce, projectKey);
|
|
112
|
+
const combined = new Uint8Array(nonce.length + ciphertext.length);
|
|
113
|
+
combined.set(nonce, 0);
|
|
114
|
+
combined.set(ciphertext, nonce.length);
|
|
115
|
+
return encodeBase64(combined);
|
|
116
|
+
}
|
|
117
|
+
function decryptEnv(encrypted, projectKey) {
|
|
118
|
+
const combined = decodeBase64(encrypted);
|
|
119
|
+
const nonce = combined.slice(0, 24);
|
|
120
|
+
const ciphertext = combined.slice(24);
|
|
121
|
+
const plaintext = nacl.secretbox.open(ciphertext, nonce, projectKey);
|
|
122
|
+
if (!plaintext) {
|
|
123
|
+
throw new Error("Decryption failed. Invalid key or corrupted data.");
|
|
124
|
+
}
|
|
125
|
+
return encodeUTF8(plaintext);
|
|
126
|
+
}
|
|
127
|
+
function encryptProjectKey(projectKey, recipientPublicKey, senderSecretKey) {
|
|
128
|
+
const nonce = nacl.randomBytes(24);
|
|
129
|
+
const ciphertext = nacl.box(
|
|
130
|
+
projectKey,
|
|
131
|
+
nonce,
|
|
132
|
+
recipientPublicKey,
|
|
133
|
+
senderSecretKey
|
|
134
|
+
);
|
|
135
|
+
const combined = new Uint8Array(nonce.length + ciphertext.length);
|
|
136
|
+
combined.set(nonce, 0);
|
|
137
|
+
combined.set(ciphertext, nonce.length);
|
|
138
|
+
return encodeBase64(combined);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/lib/keystore.ts
|
|
142
|
+
import * as fs2 from "fs";
|
|
143
|
+
import * as path2 from "path";
|
|
144
|
+
import { encodeBase64 as encodeBase642, decodeBase64 as decodeBase642 } from "tweetnacl-util";
|
|
145
|
+
function saveIdentityKeypair(keypair) {
|
|
146
|
+
ensureConfigDir();
|
|
147
|
+
const stored = {
|
|
148
|
+
publicKey: encodeBase642(keypair.publicKey),
|
|
149
|
+
secretKey: encodeBase642(keypair.secretKey)
|
|
150
|
+
};
|
|
151
|
+
const filePath = path2.join(KEYS_DIR, "identity.key");
|
|
152
|
+
fs2.writeFileSync(filePath, JSON.stringify(stored, null, 2) + "\n", {
|
|
153
|
+
mode: 384
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function loadIdentityKeypair() {
|
|
157
|
+
const filePath = path2.join(KEYS_DIR, "identity.key");
|
|
158
|
+
try {
|
|
159
|
+
const raw = fs2.readFileSync(filePath, "utf-8");
|
|
160
|
+
const stored = JSON.parse(raw);
|
|
161
|
+
return {
|
|
162
|
+
publicKey: decodeBase642(stored.publicKey),
|
|
163
|
+
secretKey: decodeBase642(stored.secretKey)
|
|
164
|
+
};
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function hasIdentityKeypair() {
|
|
170
|
+
const filePath = path2.join(KEYS_DIR, "identity.key");
|
|
171
|
+
return fs2.existsSync(filePath);
|
|
172
|
+
}
|
|
173
|
+
function saveProjectKey(projectId, key) {
|
|
174
|
+
ensureConfigDir();
|
|
175
|
+
const filePath = path2.join(KEYS_DIR, `${projectId}.key`);
|
|
176
|
+
fs2.writeFileSync(filePath, encodeBase642(key) + "\n", { mode: 384 });
|
|
177
|
+
}
|
|
178
|
+
function loadProjectKey(projectId) {
|
|
179
|
+
const filePath = path2.join(KEYS_DIR, `${projectId}.key`);
|
|
180
|
+
try {
|
|
181
|
+
const raw = fs2.readFileSync(filePath, "utf-8").trim();
|
|
182
|
+
return decodeBase642(raw);
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/lib/ui.ts
|
|
189
|
+
import chalk from "chalk";
|
|
190
|
+
import ora from "ora";
|
|
191
|
+
function success(msg) {
|
|
192
|
+
console.log(chalk.green("\u2714") + " " + msg);
|
|
193
|
+
}
|
|
194
|
+
function error(msg) {
|
|
195
|
+
console.error(chalk.red("\u2718") + " " + msg);
|
|
196
|
+
}
|
|
197
|
+
function warn(msg) {
|
|
198
|
+
console.warn(chalk.yellow("\u26A0") + " " + msg);
|
|
199
|
+
}
|
|
200
|
+
function info(msg) {
|
|
201
|
+
console.log(chalk.blue("\u2139") + " " + msg);
|
|
202
|
+
}
|
|
203
|
+
function createSpinner(text) {
|
|
204
|
+
return ora({ text });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/commands/login.ts
|
|
208
|
+
import { encodeBase64 as encodeBase643 } from "tweetnacl-util";
|
|
209
|
+
function sleep(ms) {
|
|
210
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
211
|
+
}
|
|
212
|
+
function registerLoginCommand(program2) {
|
|
213
|
+
program2.command("login").description("Authenticate with gitignored").action(async () => {
|
|
214
|
+
try {
|
|
215
|
+
ensureConfigDir();
|
|
216
|
+
const code = crypto.randomBytes(16).toString("hex");
|
|
217
|
+
const config = getConfig();
|
|
218
|
+
await apiClient("/api/cli/device", {
|
|
219
|
+
method: "POST",
|
|
220
|
+
body: JSON.stringify({ code })
|
|
221
|
+
});
|
|
222
|
+
const authUrl = `${config.apiBaseUrl}/auth/device?code=${code}`;
|
|
223
|
+
info(`Opening browser to authorize...`);
|
|
224
|
+
await open(authUrl);
|
|
225
|
+
const spinner = createSpinner("Waiting for authorization...").start();
|
|
226
|
+
while (true) {
|
|
227
|
+
await sleep(2e3);
|
|
228
|
+
try {
|
|
229
|
+
const status = await apiClient(
|
|
230
|
+
`/api/cli/device/${code}/status`
|
|
231
|
+
);
|
|
232
|
+
if (status.status === "approved" && status.token) {
|
|
233
|
+
spinner.stop();
|
|
234
|
+
saveConfig({
|
|
235
|
+
authToken: status.token,
|
|
236
|
+
userId: status.userId,
|
|
237
|
+
email: status.email
|
|
238
|
+
});
|
|
239
|
+
if (!hasIdentityKeypair()) {
|
|
240
|
+
const keypair = generateKeypair();
|
|
241
|
+
saveIdentityKeypair(keypair);
|
|
242
|
+
await apiClient("/api/cli/keys", {
|
|
243
|
+
method: "POST",
|
|
244
|
+
body: JSON.stringify({
|
|
245
|
+
publicKey: encodeBase643(keypair.publicKey)
|
|
246
|
+
})
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
success(`Logged in as ${status.email}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err instanceof ApiError && err.statusCode === 404) {
|
|
254
|
+
spinner.stop();
|
|
255
|
+
error("Device code expired. Please try again.");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch (err) {
|
|
261
|
+
error(
|
|
262
|
+
`Login failed: ${err instanceof Error ? err.message : String(err)}`
|
|
263
|
+
);
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/commands/logout.ts
|
|
270
|
+
function registerLogoutCommand(program2) {
|
|
271
|
+
program2.command("logout").description("Sign out and clear credentials").action(async () => {
|
|
272
|
+
clearConfig();
|
|
273
|
+
success("Logged out successfully.");
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/commands/whoami.ts
|
|
278
|
+
function registerWhoamiCommand(program2) {
|
|
279
|
+
program2.command("whoami").description("Show current authenticated user").action(async () => {
|
|
280
|
+
try {
|
|
281
|
+
const me = await apiClient("/api/cli/me");
|
|
282
|
+
success(`Logged in as ${me.email} (${me.userId})`);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
if (err instanceof ApiError && err.statusCode === 401) {
|
|
285
|
+
error("Not logged in. Run `gitignored login` to authenticate.");
|
|
286
|
+
} else {
|
|
287
|
+
error(
|
|
288
|
+
`Failed to fetch user: ${err instanceof Error ? err.message : String(err)}`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
process.exitCode = 1;
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/commands/new.ts
|
|
297
|
+
import * as fs3 from "fs";
|
|
298
|
+
import * as path3 from "path";
|
|
299
|
+
import * as readline from "readline";
|
|
300
|
+
function slugify(name) {
|
|
301
|
+
return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
302
|
+
}
|
|
303
|
+
function promptForName() {
|
|
304
|
+
return new Promise((resolve) => {
|
|
305
|
+
const rl = readline.createInterface({
|
|
306
|
+
input: process.stdin,
|
|
307
|
+
output: process.stdout
|
|
308
|
+
});
|
|
309
|
+
rl.question("Project name: ", (answer) => {
|
|
310
|
+
rl.close();
|
|
311
|
+
resolve(answer.trim());
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
function ensureGitignoreEntries(dir, entries) {
|
|
316
|
+
const gitignorePath = path3.join(dir, ".gitignore");
|
|
317
|
+
let content = "";
|
|
318
|
+
try {
|
|
319
|
+
content = fs3.readFileSync(gitignorePath, "utf-8");
|
|
320
|
+
} catch {
|
|
321
|
+
}
|
|
322
|
+
const lines = content.split("\n");
|
|
323
|
+
const toAdd = entries.filter((entry) => !lines.includes(entry));
|
|
324
|
+
if (toAdd.length > 0) {
|
|
325
|
+
const suffix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
|
|
326
|
+
fs3.writeFileSync(
|
|
327
|
+
gitignorePath,
|
|
328
|
+
content + suffix + toAdd.join("\n") + "\n"
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function registerNewCommand(program2) {
|
|
333
|
+
program2.command("new").description("Create a new project").option("--name <name>", "Project name").action(async (options) => {
|
|
334
|
+
try {
|
|
335
|
+
let name = options.name;
|
|
336
|
+
if (!name) {
|
|
337
|
+
name = await promptForName();
|
|
338
|
+
}
|
|
339
|
+
if (!name) {
|
|
340
|
+
error("Project name is required.");
|
|
341
|
+
process.exitCode = 1;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const slug = slugify(name);
|
|
345
|
+
const keypair = loadIdentityKeypair();
|
|
346
|
+
if (!keypair) {
|
|
347
|
+
error(
|
|
348
|
+
"No identity keypair found. Run `gitignored login` first."
|
|
349
|
+
);
|
|
350
|
+
process.exitCode = 1;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const projectKey = generateProjectKey();
|
|
354
|
+
const encryptedProjectKey = encryptProjectKey(
|
|
355
|
+
projectKey,
|
|
356
|
+
keypair.publicKey,
|
|
357
|
+
keypair.secretKey
|
|
358
|
+
);
|
|
359
|
+
const project = await apiClient(
|
|
360
|
+
"/api/projects",
|
|
361
|
+
{
|
|
362
|
+
method: "POST",
|
|
363
|
+
body: JSON.stringify({
|
|
364
|
+
name,
|
|
365
|
+
slug,
|
|
366
|
+
encryptedProjectKey
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
);
|
|
370
|
+
saveProjectKey(project.id, projectKey);
|
|
371
|
+
const cwd = process.cwd();
|
|
372
|
+
fs3.writeFileSync(
|
|
373
|
+
path3.join(cwd, ".gitignored.json"),
|
|
374
|
+
JSON.stringify(
|
|
375
|
+
{ projectId: project.id, projectSlug: project.slug },
|
|
376
|
+
null,
|
|
377
|
+
2
|
|
378
|
+
) + "\n"
|
|
379
|
+
);
|
|
380
|
+
const envSharedPath = path3.join(cwd, ".env.shared");
|
|
381
|
+
if (!fs3.existsSync(envSharedPath)) {
|
|
382
|
+
fs3.writeFileSync(envSharedPath, "");
|
|
383
|
+
}
|
|
384
|
+
const envLocalPath = path3.join(cwd, ".env.local");
|
|
385
|
+
if (!fs3.existsSync(envLocalPath)) {
|
|
386
|
+
fs3.writeFileSync(envLocalPath, "");
|
|
387
|
+
}
|
|
388
|
+
ensureGitignoreEntries(cwd, [".env.local", ".gitignored.json"]);
|
|
389
|
+
success(
|
|
390
|
+
`Created project "${name}" (${project.slug})`
|
|
391
|
+
);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
error(
|
|
394
|
+
`Failed to create project: ${err instanceof Error ? err.message : String(err)}`
|
|
395
|
+
);
|
|
396
|
+
process.exitCode = 1;
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// src/commands/push.ts
|
|
402
|
+
import * as fs4 from "fs";
|
|
403
|
+
import * as path4 from "path";
|
|
404
|
+
import * as readline2 from "readline";
|
|
405
|
+
import { decodeBase64 as decodeBase643 } from "tweetnacl-util";
|
|
406
|
+
function countVars(content) {
|
|
407
|
+
return content.split("\n").filter((line) => {
|
|
408
|
+
const trimmed = line.trim();
|
|
409
|
+
return trimmed.length > 0 && !trimmed.startsWith("#");
|
|
410
|
+
}).length;
|
|
411
|
+
}
|
|
412
|
+
function loadProjectConfig() {
|
|
413
|
+
try {
|
|
414
|
+
const raw = fs4.readFileSync(
|
|
415
|
+
path4.join(process.cwd(), ".gitignored.json"),
|
|
416
|
+
"utf-8"
|
|
417
|
+
);
|
|
418
|
+
return JSON.parse(raw);
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function saveProjectConfig(config) {
|
|
424
|
+
fs4.writeFileSync(
|
|
425
|
+
path4.join(process.cwd(), ".gitignored.json"),
|
|
426
|
+
JSON.stringify(config, null, 2) + "\n"
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
function prompt(question) {
|
|
430
|
+
const rl = readline2.createInterface({
|
|
431
|
+
input: process.stdin,
|
|
432
|
+
output: process.stdout
|
|
433
|
+
});
|
|
434
|
+
return new Promise((resolve) => {
|
|
435
|
+
rl.question(question, (answer) => {
|
|
436
|
+
rl.close();
|
|
437
|
+
resolve(answer.trim().toLowerCase());
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
async function syncPendingKeys(projectId) {
|
|
442
|
+
try {
|
|
443
|
+
const pending = await apiClient(
|
|
444
|
+
`/api/projects/${projectId}/members/pending-keys`
|
|
445
|
+
);
|
|
446
|
+
if (pending.length === 0) return;
|
|
447
|
+
const keypair = loadIdentityKeypair();
|
|
448
|
+
const projectKey = loadProjectKey(projectId);
|
|
449
|
+
if (!keypair || !projectKey) return;
|
|
450
|
+
let shared = 0;
|
|
451
|
+
for (const member of pending) {
|
|
452
|
+
try {
|
|
453
|
+
const recipientPublicKey = decodeBase643(member.publicKey);
|
|
454
|
+
const encrypted = encryptProjectKey(
|
|
455
|
+
projectKey,
|
|
456
|
+
recipientPublicKey,
|
|
457
|
+
keypair.secretKey
|
|
458
|
+
);
|
|
459
|
+
await apiClient(
|
|
460
|
+
`/api/projects/${projectId}/members/${member.userId}/key`,
|
|
461
|
+
{
|
|
462
|
+
method: "POST",
|
|
463
|
+
body: JSON.stringify({ encryptedProjectKey: encrypted })
|
|
464
|
+
}
|
|
465
|
+
);
|
|
466
|
+
shared++;
|
|
467
|
+
} catch {
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (shared > 0) {
|
|
471
|
+
info(`Shared project key with ${shared} new member${shared !== 1 ? "s" : ""}`);
|
|
472
|
+
}
|
|
473
|
+
} catch {
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function registerPushCommand(program2) {
|
|
477
|
+
program2.command("push").description("Encrypt and push .env to the server").option("-m, --message <message>", "Commit message").option("-f, --force", "Skip conflict warning").action(async (options) => {
|
|
478
|
+
try {
|
|
479
|
+
const config = loadProjectConfig();
|
|
480
|
+
if (!config) {
|
|
481
|
+
error(
|
|
482
|
+
"No .gitignored.json found. Run `gitignored new` to create a project."
|
|
483
|
+
);
|
|
484
|
+
process.exitCode = 1;
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const envPath = path4.join(process.cwd(), ".env.shared");
|
|
488
|
+
let envContent;
|
|
489
|
+
try {
|
|
490
|
+
envContent = fs4.readFileSync(envPath, "utf-8");
|
|
491
|
+
} catch {
|
|
492
|
+
error("No .env.shared file found.");
|
|
493
|
+
process.exitCode = 1;
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const projectKey = loadProjectKey(config.projectId);
|
|
497
|
+
if (!projectKey) {
|
|
498
|
+
error(
|
|
499
|
+
"No project key found. You may need to be invited to this project."
|
|
500
|
+
);
|
|
501
|
+
process.exitCode = 1;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (!options.force) {
|
|
505
|
+
try {
|
|
506
|
+
const versionResult = await apiClient(
|
|
507
|
+
`/api/projects/${config.projectId}/env/version`
|
|
508
|
+
);
|
|
509
|
+
const localVersion = config.lastPushedVersion ?? 0;
|
|
510
|
+
if (versionResult.version > localVersion && localVersion > 0) {
|
|
511
|
+
warn(
|
|
512
|
+
`Server has v${versionResult.version}, you last pushed v${localVersion}. You may be overwriting changes.`
|
|
513
|
+
);
|
|
514
|
+
const answer = await prompt("Continue? [y/N] ");
|
|
515
|
+
if (answer !== "y" && answer !== "yes") {
|
|
516
|
+
console.log(" Push cancelled.");
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const encryptedPayload = encryptEnv(envContent, projectKey);
|
|
524
|
+
const result = await apiClient(
|
|
525
|
+
`/api/projects/${config.projectId}/env`,
|
|
526
|
+
{
|
|
527
|
+
method: "POST",
|
|
528
|
+
body: JSON.stringify({
|
|
529
|
+
encryptedPayload,
|
|
530
|
+
message: options.message
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
);
|
|
534
|
+
config.lastPushedVersion = result.version;
|
|
535
|
+
saveProjectConfig(config);
|
|
536
|
+
const vars = countVars(envContent);
|
|
537
|
+
success(`Pushed v${result.version} (${vars} var${vars !== 1 ? "s" : ""})`);
|
|
538
|
+
await syncPendingKeys(config.projectId);
|
|
539
|
+
} catch (err) {
|
|
540
|
+
error(
|
|
541
|
+
`Push failed: ${err instanceof Error ? err.message : String(err)}`
|
|
542
|
+
);
|
|
543
|
+
process.exitCode = 1;
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/commands/pull.ts
|
|
549
|
+
import * as fs5 from "fs";
|
|
550
|
+
import * as path5 from "path";
|
|
551
|
+
import { decodeBase64 as decodeBase644 } from "tweetnacl-util";
|
|
552
|
+
function countVars2(content) {
|
|
553
|
+
return content.split("\n").filter((line) => {
|
|
554
|
+
const trimmed = line.trim();
|
|
555
|
+
return trimmed.length > 0 && !trimmed.startsWith("#");
|
|
556
|
+
}).length;
|
|
557
|
+
}
|
|
558
|
+
function loadProjectConfig2() {
|
|
559
|
+
try {
|
|
560
|
+
const raw = fs5.readFileSync(
|
|
561
|
+
path5.join(process.cwd(), ".gitignored.json"),
|
|
562
|
+
"utf-8"
|
|
563
|
+
);
|
|
564
|
+
return JSON.parse(raw);
|
|
565
|
+
} catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async function syncPendingKeys2(projectId) {
|
|
570
|
+
try {
|
|
571
|
+
const pending = await apiClient(
|
|
572
|
+
`/api/projects/${projectId}/members/pending-keys`
|
|
573
|
+
);
|
|
574
|
+
if (pending.length === 0) return;
|
|
575
|
+
const keypair = loadIdentityKeypair();
|
|
576
|
+
const projectKey = loadProjectKey(projectId);
|
|
577
|
+
if (!keypair || !projectKey) return;
|
|
578
|
+
let shared = 0;
|
|
579
|
+
for (const member of pending) {
|
|
580
|
+
try {
|
|
581
|
+
const recipientPublicKey = decodeBase644(member.publicKey);
|
|
582
|
+
const encrypted = encryptProjectKey(
|
|
583
|
+
projectKey,
|
|
584
|
+
recipientPublicKey,
|
|
585
|
+
keypair.secretKey
|
|
586
|
+
);
|
|
587
|
+
await apiClient(
|
|
588
|
+
`/api/projects/${projectId}/members/${member.userId}/key`,
|
|
589
|
+
{
|
|
590
|
+
method: "POST",
|
|
591
|
+
body: JSON.stringify({ encryptedProjectKey: encrypted })
|
|
592
|
+
}
|
|
593
|
+
);
|
|
594
|
+
shared++;
|
|
595
|
+
} catch {
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (shared > 0) {
|
|
599
|
+
info(`Shared project key with ${shared} new member${shared !== 1 ? "s" : ""}`);
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function registerPullCommand(program2) {
|
|
605
|
+
program2.command("pull").description("Pull and decrypt .env from the server").option("--token <token>", "Auth token (for CI/CD)").option("--project <slug>", "Project slug (for CI/CD)").action(async (options) => {
|
|
606
|
+
try {
|
|
607
|
+
if (options.token) {
|
|
608
|
+
process.env.GITIGNORED_TOKEN = options.token;
|
|
609
|
+
}
|
|
610
|
+
let projectId;
|
|
611
|
+
if (options.project) {
|
|
612
|
+
const result2 = await apiClient(
|
|
613
|
+
"/api/projects"
|
|
614
|
+
);
|
|
615
|
+
const projects = Array.isArray(result2) ? result2 : result2.projects;
|
|
616
|
+
const project = projects?.find((p) => p.slug === options.project);
|
|
617
|
+
if (!project) {
|
|
618
|
+
error(`Project "${options.project}" not found.`);
|
|
619
|
+
process.exitCode = 1;
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
projectId = project.id;
|
|
623
|
+
} else {
|
|
624
|
+
const config = loadProjectConfig2();
|
|
625
|
+
if (!config) {
|
|
626
|
+
error(
|
|
627
|
+
"No .gitignored.json found. Run `gitignored new` to create a project, or use --project <slug>."
|
|
628
|
+
);
|
|
629
|
+
process.exitCode = 1;
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
projectId = config.projectId;
|
|
633
|
+
}
|
|
634
|
+
const projectKey = loadProjectKey(projectId);
|
|
635
|
+
if (!projectKey) {
|
|
636
|
+
error(
|
|
637
|
+
"No project key found. You may need to be invited to this project."
|
|
638
|
+
);
|
|
639
|
+
process.exitCode = 1;
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const result = await apiClient(
|
|
643
|
+
`/api/projects/${projectId}/env`
|
|
644
|
+
);
|
|
645
|
+
if (!result.encryptedPayload) {
|
|
646
|
+
error("No environment snapshot found. Run `gitignored push` first.");
|
|
647
|
+
process.exitCode = 1;
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const decrypted = decryptEnv(result.encryptedPayload, projectKey);
|
|
651
|
+
fs5.writeFileSync(
|
|
652
|
+
path5.join(process.cwd(), ".env.shared"),
|
|
653
|
+
decrypted
|
|
654
|
+
);
|
|
655
|
+
const vars = countVars2(decrypted);
|
|
656
|
+
success(`Pulled v${result.version} (${vars} var${vars !== 1 ? "s" : ""})`);
|
|
657
|
+
await syncPendingKeys2(projectId);
|
|
658
|
+
} catch (err) {
|
|
659
|
+
error(
|
|
660
|
+
`Pull failed: ${err instanceof Error ? err.message : String(err)}`
|
|
661
|
+
);
|
|
662
|
+
process.exitCode = 1;
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// src/commands/invite.ts
|
|
668
|
+
import * as fs6 from "fs";
|
|
669
|
+
import * as path6 from "path";
|
|
670
|
+
function loadProjectConfig3() {
|
|
671
|
+
try {
|
|
672
|
+
const raw = fs6.readFileSync(
|
|
673
|
+
path6.join(process.cwd(), ".gitignored.json"),
|
|
674
|
+
"utf-8"
|
|
675
|
+
);
|
|
676
|
+
return JSON.parse(raw);
|
|
677
|
+
} catch {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function registerInviteCommand(program2) {
|
|
682
|
+
program2.command("invite <email>").description("Invite a team member to a project").option("--role <role>", "Role to assign (member, readonly)", "member").action(async (email, options) => {
|
|
683
|
+
try {
|
|
684
|
+
const config = loadProjectConfig3();
|
|
685
|
+
if (!config) {
|
|
686
|
+
error(
|
|
687
|
+
"No .gitignored.json found. Run `gitignored new` to create a project."
|
|
688
|
+
);
|
|
689
|
+
process.exitCode = 1;
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const invitation = await apiClient(
|
|
693
|
+
`/api/projects/${config.projectId}/invitations`,
|
|
694
|
+
{
|
|
695
|
+
method: "POST",
|
|
696
|
+
body: JSON.stringify({ email, role: options.role })
|
|
697
|
+
}
|
|
698
|
+
);
|
|
699
|
+
success(`Invitation sent to ${invitation.email} (${invitation.role})`);
|
|
700
|
+
} catch (err) {
|
|
701
|
+
error(
|
|
702
|
+
`Invite failed: ${err instanceof Error ? err.message : String(err)}`
|
|
703
|
+
);
|
|
704
|
+
process.exitCode = 1;
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// src/commands/members.ts
|
|
710
|
+
import * as fs7 from "fs";
|
|
711
|
+
import * as path7 from "path";
|
|
712
|
+
import chalk2 from "chalk";
|
|
713
|
+
function loadProjectConfig4() {
|
|
714
|
+
try {
|
|
715
|
+
const raw = fs7.readFileSync(
|
|
716
|
+
path7.join(process.cwd(), ".gitignored.json"),
|
|
717
|
+
"utf-8"
|
|
718
|
+
);
|
|
719
|
+
return JSON.parse(raw);
|
|
720
|
+
} catch {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
function formatDate(dateStr) {
|
|
725
|
+
const d = new Date(dateStr);
|
|
726
|
+
return d.toLocaleDateString("en-US", {
|
|
727
|
+
year: "numeric",
|
|
728
|
+
month: "short",
|
|
729
|
+
day: "numeric"
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
function registerMembersCommand(program2) {
|
|
733
|
+
program2.command("members").description("List project members").action(async () => {
|
|
734
|
+
try {
|
|
735
|
+
const config = loadProjectConfig4();
|
|
736
|
+
if (!config) {
|
|
737
|
+
error(
|
|
738
|
+
"No .gitignored.json found. Run `gitignored new` to create a project."
|
|
739
|
+
);
|
|
740
|
+
process.exitCode = 1;
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const members = await apiClient(
|
|
744
|
+
`/api/projects/${config.projectId}/members`
|
|
745
|
+
);
|
|
746
|
+
if (members.length === 0) {
|
|
747
|
+
info("No members found.");
|
|
748
|
+
} else {
|
|
749
|
+
console.log(chalk2.bold("\nMembers:"));
|
|
750
|
+
for (const m of members) {
|
|
751
|
+
const role = chalk2.dim(`(${m.role})`);
|
|
752
|
+
const joined = chalk2.dim(`joined ${formatDate(m.joinedAt)}`);
|
|
753
|
+
console.log(` ${m.userId} ${role} ${joined}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const invitations = await apiClient(
|
|
757
|
+
`/api/projects/${config.projectId}/invitations`
|
|
758
|
+
);
|
|
759
|
+
const pending = invitations.filter(
|
|
760
|
+
(inv) => !inv.accepted && new Date(inv.expiresAt) > /* @__PURE__ */ new Date()
|
|
761
|
+
);
|
|
762
|
+
if (pending.length > 0) {
|
|
763
|
+
console.log(chalk2.bold("\nPending Invitations:"));
|
|
764
|
+
for (const inv of pending) {
|
|
765
|
+
const role = chalk2.dim(`(${inv.role})`);
|
|
766
|
+
const expires = chalk2.dim(`expires ${formatDate(inv.expiresAt)}`);
|
|
767
|
+
console.log(` ${inv.email} ${role} ${expires}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
console.log("");
|
|
771
|
+
} catch (err) {
|
|
772
|
+
error(
|
|
773
|
+
`Failed to list members: ${err instanceof Error ? err.message : String(err)}`
|
|
774
|
+
);
|
|
775
|
+
process.exitCode = 1;
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// src/commands/list.ts
|
|
781
|
+
import chalk3 from "chalk";
|
|
782
|
+
function formatDate2(dateStr) {
|
|
783
|
+
if (!dateStr) return chalk3.dim("\u2014");
|
|
784
|
+
const date = new Date(dateStr);
|
|
785
|
+
return date.toLocaleDateString("en-US", {
|
|
786
|
+
year: "numeric",
|
|
787
|
+
month: "short",
|
|
788
|
+
day: "numeric"
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
function registerListCommand(program2) {
|
|
792
|
+
program2.command("list").description("List all projects").action(async () => {
|
|
793
|
+
try {
|
|
794
|
+
const result = await apiClient(
|
|
795
|
+
"/api/projects"
|
|
796
|
+
);
|
|
797
|
+
const projects = Array.isArray(result) ? result : result.projects;
|
|
798
|
+
if (!projects || projects.length === 0) {
|
|
799
|
+
console.log(chalk3.dim(" No projects found. Run `gitignored new` to create one."));
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
console.log();
|
|
803
|
+
console.log(chalk3.bold(" Your projects"));
|
|
804
|
+
console.log();
|
|
805
|
+
console.log(
|
|
806
|
+
" " + chalk3.dim(
|
|
807
|
+
"NAME".padEnd(24) + "SLUG".padEnd(20) + "ROLE".padEnd(12) + "MEMBERS".padEnd(10) + "UPDATED"
|
|
808
|
+
)
|
|
809
|
+
);
|
|
810
|
+
console.log(" " + chalk3.dim("-".repeat(78)));
|
|
811
|
+
for (const project of projects) {
|
|
812
|
+
const name = project.name.padEnd(24);
|
|
813
|
+
const slug = chalk3.cyan(project.slug.padEnd(20));
|
|
814
|
+
const role = (project.role || "owner").padEnd(12);
|
|
815
|
+
const members = String(project.memberCount ?? "\u2014").padEnd(10);
|
|
816
|
+
const updated = chalk3.dim(formatDate2(project.updatedAt || project.createdAt));
|
|
817
|
+
console.log(` ${name}${slug}${role}${members}${updated}`);
|
|
818
|
+
}
|
|
819
|
+
console.log();
|
|
820
|
+
} catch (err) {
|
|
821
|
+
error(
|
|
822
|
+
`List failed: ${err instanceof Error ? err.message : String(err)}`
|
|
823
|
+
);
|
|
824
|
+
process.exitCode = 1;
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// src/commands/switch.ts
|
|
830
|
+
import * as fs8 from "fs";
|
|
831
|
+
import * as path8 from "path";
|
|
832
|
+
function countVars3(content) {
|
|
833
|
+
return content.split("\n").filter((line) => {
|
|
834
|
+
const trimmed = line.trim();
|
|
835
|
+
return trimmed.length > 0 && !trimmed.startsWith("#");
|
|
836
|
+
}).length;
|
|
837
|
+
}
|
|
838
|
+
function registerSwitchCommand(program2) {
|
|
839
|
+
program2.command("switch <slug>").description("Switch active project").action(async (slug) => {
|
|
840
|
+
try {
|
|
841
|
+
const result = await apiClient(
|
|
842
|
+
"/api/projects"
|
|
843
|
+
);
|
|
844
|
+
const projects = Array.isArray(result) ? result : result.projects;
|
|
845
|
+
const project = projects?.find((p) => p.slug === slug);
|
|
846
|
+
if (!project) {
|
|
847
|
+
error(`Project "${slug}" not found.`);
|
|
848
|
+
process.exitCode = 1;
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const configPath = path8.join(process.cwd(), ".gitignored.json");
|
|
852
|
+
const configData = {
|
|
853
|
+
projectId: project.id,
|
|
854
|
+
projectSlug: project.slug
|
|
855
|
+
};
|
|
856
|
+
fs8.writeFileSync(configPath, JSON.stringify(configData, null, 2) + "\n");
|
|
857
|
+
const projectKey = loadProjectKey(project.id);
|
|
858
|
+
if (!projectKey) {
|
|
859
|
+
warn(
|
|
860
|
+
"No project key found locally. You may need to be invited to this project."
|
|
861
|
+
);
|
|
862
|
+
success(`Switched to ${project.name} (${project.slug})`);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
try {
|
|
866
|
+
const pullResult = await apiClient(
|
|
867
|
+
`/api/projects/${project.id}/env`
|
|
868
|
+
);
|
|
869
|
+
if (pullResult.encryptedPayload) {
|
|
870
|
+
const decrypted = decryptEnv(pullResult.encryptedPayload, projectKey);
|
|
871
|
+
fs8.writeFileSync(
|
|
872
|
+
path8.join(process.cwd(), ".env.shared"),
|
|
873
|
+
decrypted
|
|
874
|
+
);
|
|
875
|
+
const vars = countVars3(decrypted);
|
|
876
|
+
info(`Pulled v${pullResult.version} (${vars} var${vars !== 1 ? "s" : ""})`);
|
|
877
|
+
}
|
|
878
|
+
} catch {
|
|
879
|
+
warn("Could not pull latest .env.shared. Run `gitignored pull` manually.");
|
|
880
|
+
}
|
|
881
|
+
success(`Switched to ${project.name} (${project.slug})`);
|
|
882
|
+
} catch (err) {
|
|
883
|
+
error(
|
|
884
|
+
`Switch failed: ${err instanceof Error ? err.message : String(err)}`
|
|
885
|
+
);
|
|
886
|
+
process.exitCode = 1;
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// src/commands/log.ts
|
|
892
|
+
import * as fs9 from "fs";
|
|
893
|
+
import * as path9 from "path";
|
|
894
|
+
import chalk4 from "chalk";
|
|
895
|
+
function loadProjectConfig5() {
|
|
896
|
+
try {
|
|
897
|
+
const raw = fs9.readFileSync(
|
|
898
|
+
path9.join(process.cwd(), ".gitignored.json"),
|
|
899
|
+
"utf-8"
|
|
900
|
+
);
|
|
901
|
+
return JSON.parse(raw);
|
|
902
|
+
} catch {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function formatDate3(dateStr) {
|
|
907
|
+
const date = new Date(dateStr);
|
|
908
|
+
const now = /* @__PURE__ */ new Date();
|
|
909
|
+
const diffMs = now.getTime() - date.getTime();
|
|
910
|
+
const diffDays = Math.floor(diffMs / (1e3 * 60 * 60 * 24));
|
|
911
|
+
if (diffDays === 0) {
|
|
912
|
+
const diffHours = Math.floor(diffMs / (1e3 * 60 * 60));
|
|
913
|
+
if (diffHours === 0) {
|
|
914
|
+
const diffMins = Math.floor(diffMs / (1e3 * 60));
|
|
915
|
+
return diffMins <= 1 ? "just now" : `${diffMins} minutes ago`;
|
|
916
|
+
}
|
|
917
|
+
return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
|
|
918
|
+
}
|
|
919
|
+
if (diffDays === 1) return "yesterday";
|
|
920
|
+
if (diffDays < 7) return `${diffDays} days ago`;
|
|
921
|
+
return date.toLocaleDateString("en-US", {
|
|
922
|
+
year: "numeric",
|
|
923
|
+
month: "short",
|
|
924
|
+
day: "numeric"
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
function registerLogCommand(program2) {
|
|
928
|
+
program2.command("log").description("Show push history for the project").option("-a, --all", "Show full history").action(async (options) => {
|
|
929
|
+
try {
|
|
930
|
+
const config = loadProjectConfig5();
|
|
931
|
+
if (!config) {
|
|
932
|
+
error(
|
|
933
|
+
"No .gitignored.json found. Run `gitignored new` to create a project."
|
|
934
|
+
);
|
|
935
|
+
process.exitCode = 1;
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
const limit = options.all ? 100 : 20;
|
|
939
|
+
let page = 1;
|
|
940
|
+
const allSnapshots = [];
|
|
941
|
+
while (true) {
|
|
942
|
+
const result = await apiClient(
|
|
943
|
+
`/api/projects/${config.projectId}/env/history?page=${page}&limit=${limit}`
|
|
944
|
+
);
|
|
945
|
+
allSnapshots.push(...result.snapshots);
|
|
946
|
+
if (!options.all || allSnapshots.length >= result.total) break;
|
|
947
|
+
page++;
|
|
948
|
+
}
|
|
949
|
+
if (allSnapshots.length === 0) {
|
|
950
|
+
console.log(chalk4.dim(" No history found. Run `gitignored push` to create the first snapshot."));
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
console.log();
|
|
954
|
+
console.log(chalk4.bold(` History for ${config.projectSlug}`));
|
|
955
|
+
console.log();
|
|
956
|
+
console.log(
|
|
957
|
+
" " + chalk4.dim(
|
|
958
|
+
"VERSION".padEnd(10) + "AUTHOR".padEnd(28) + "MESSAGE".padEnd(32) + "DATE"
|
|
959
|
+
)
|
|
960
|
+
);
|
|
961
|
+
console.log(" " + chalk4.dim("-".repeat(85)));
|
|
962
|
+
for (const entry of allSnapshots) {
|
|
963
|
+
const version = chalk4.cyan(`v${entry.version}`.padEnd(10));
|
|
964
|
+
const author = (entry.author?.email || entry.authorId || "unknown").padEnd(28);
|
|
965
|
+
const message = (entry.message || chalk4.dim("no message")).toString().padEnd(32);
|
|
966
|
+
const date = chalk4.dim(formatDate3(entry.createdAt));
|
|
967
|
+
console.log(` ${version}${author}${message}${date}`);
|
|
968
|
+
}
|
|
969
|
+
console.log();
|
|
970
|
+
} catch (err) {
|
|
971
|
+
error(
|
|
972
|
+
`Log failed: ${err instanceof Error ? err.message : String(err)}`
|
|
973
|
+
);
|
|
974
|
+
process.exitCode = 1;
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// src/commands/rollback.ts
|
|
980
|
+
import * as fs10 from "fs";
|
|
981
|
+
import * as path10 from "path";
|
|
982
|
+
import * as readline3 from "readline";
|
|
983
|
+
import chalk5 from "chalk";
|
|
984
|
+
function loadProjectConfig6() {
|
|
985
|
+
try {
|
|
986
|
+
const raw = fs10.readFileSync(
|
|
987
|
+
path10.join(process.cwd(), ".gitignored.json"),
|
|
988
|
+
"utf-8"
|
|
989
|
+
);
|
|
990
|
+
return JSON.parse(raw);
|
|
991
|
+
} catch {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
function prompt2(question) {
|
|
996
|
+
const rl = readline3.createInterface({
|
|
997
|
+
input: process.stdin,
|
|
998
|
+
output: process.stdout
|
|
999
|
+
});
|
|
1000
|
+
return new Promise((resolve) => {
|
|
1001
|
+
rl.question(question, (answer) => {
|
|
1002
|
+
rl.close();
|
|
1003
|
+
resolve(answer.trim().toLowerCase());
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
function showDiff(currentContent, oldContent) {
|
|
1008
|
+
const currentLines = currentContent.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
1009
|
+
const oldLines = oldContent.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
1010
|
+
const currentSet = new Set(currentLines);
|
|
1011
|
+
const oldSet = new Set(oldLines);
|
|
1012
|
+
const removed = currentLines.filter((l) => !oldSet.has(l));
|
|
1013
|
+
const added = oldLines.filter((l) => !currentSet.has(l));
|
|
1014
|
+
if (removed.length === 0 && added.length === 0) {
|
|
1015
|
+
console.log(chalk5.dim(" No differences detected."));
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
console.log();
|
|
1019
|
+
for (const line of removed) {
|
|
1020
|
+
console.log(chalk5.red(` - ${line}`));
|
|
1021
|
+
}
|
|
1022
|
+
for (const line of added) {
|
|
1023
|
+
console.log(chalk5.green(` + ${line}`));
|
|
1024
|
+
}
|
|
1025
|
+
console.log();
|
|
1026
|
+
}
|
|
1027
|
+
function registerRollbackCommand(program2) {
|
|
1028
|
+
program2.command("rollback <version>").description("Rollback to a previous version").action(async (versionStr) => {
|
|
1029
|
+
try {
|
|
1030
|
+
const version = parseInt(versionStr, 10);
|
|
1031
|
+
if (isNaN(version) || version < 1) {
|
|
1032
|
+
error("Version must be a positive integer.");
|
|
1033
|
+
process.exitCode = 1;
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const config = loadProjectConfig6();
|
|
1037
|
+
if (!config) {
|
|
1038
|
+
error(
|
|
1039
|
+
"No .gitignored.json found. Run `gitignored new` to create a project."
|
|
1040
|
+
);
|
|
1041
|
+
process.exitCode = 1;
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const projectKey = loadProjectKey(config.projectId);
|
|
1045
|
+
if (!projectKey) {
|
|
1046
|
+
error(
|
|
1047
|
+
"No project key found. You may need to be invited to this project."
|
|
1048
|
+
);
|
|
1049
|
+
process.exitCode = 1;
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
const oldSnapshot = await apiClient(
|
|
1053
|
+
`/api/projects/${config.projectId}/env/${version}`
|
|
1054
|
+
);
|
|
1055
|
+
if (!oldSnapshot.encryptedPayload) {
|
|
1056
|
+
error(`Version ${version} not found.`);
|
|
1057
|
+
process.exitCode = 1;
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
const oldContent = decryptEnv(oldSnapshot.encryptedPayload, projectKey);
|
|
1061
|
+
const envPath = path10.join(process.cwd(), ".env.shared");
|
|
1062
|
+
let currentContent = "";
|
|
1063
|
+
try {
|
|
1064
|
+
currentContent = fs10.readFileSync(envPath, "utf-8");
|
|
1065
|
+
} catch {
|
|
1066
|
+
}
|
|
1067
|
+
if (currentContent) {
|
|
1068
|
+
console.log(chalk5.bold(`
|
|
1069
|
+
Changes from current to v${version}:`));
|
|
1070
|
+
showDiff(currentContent, oldContent);
|
|
1071
|
+
} else {
|
|
1072
|
+
warn("No local .env.shared found. Will create one from the rollback.");
|
|
1073
|
+
}
|
|
1074
|
+
const answer = await prompt2(`Rollback to v${version}? [y/N] `);
|
|
1075
|
+
if (answer !== "y" && answer !== "yes") {
|
|
1076
|
+
console.log(chalk5.dim(" Rollback cancelled."));
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
const encryptedPayload = encryptEnv(oldContent, projectKey);
|
|
1080
|
+
const result = await apiClient(
|
|
1081
|
+
`/api/projects/${config.projectId}/env`,
|
|
1082
|
+
{
|
|
1083
|
+
method: "POST",
|
|
1084
|
+
body: JSON.stringify({
|
|
1085
|
+
encryptedPayload,
|
|
1086
|
+
message: `Rollback to v${version}`
|
|
1087
|
+
})
|
|
1088
|
+
}
|
|
1089
|
+
);
|
|
1090
|
+
fs10.writeFileSync(envPath, oldContent);
|
|
1091
|
+
success(`Rolled back to v${version} (pushed as v${result.version})`);
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
error(
|
|
1094
|
+
`Rollback failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1095
|
+
);
|
|
1096
|
+
process.exitCode = 1;
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// src/commands/diff.ts
|
|
1102
|
+
import * as fs11 from "fs";
|
|
1103
|
+
import * as path11 from "path";
|
|
1104
|
+
import chalk6 from "chalk";
|
|
1105
|
+
function loadProjectConfig7() {
|
|
1106
|
+
try {
|
|
1107
|
+
const raw = fs11.readFileSync(
|
|
1108
|
+
path11.join(process.cwd(), ".gitignored.json"),
|
|
1109
|
+
"utf-8"
|
|
1110
|
+
);
|
|
1111
|
+
return JSON.parse(raw);
|
|
1112
|
+
} catch {
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
function parseEnvContent(content) {
|
|
1117
|
+
const map = /* @__PURE__ */ new Map();
|
|
1118
|
+
for (const line of content.split("\n")) {
|
|
1119
|
+
const trimmed = line.trim();
|
|
1120
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1121
|
+
const eqIdx = trimmed.indexOf("=");
|
|
1122
|
+
if (eqIdx === -1) continue;
|
|
1123
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
1124
|
+
const value = trimmed.slice(eqIdx + 1).trim();
|
|
1125
|
+
map.set(key, value);
|
|
1126
|
+
}
|
|
1127
|
+
return map;
|
|
1128
|
+
}
|
|
1129
|
+
function registerDiffCommand(program2) {
|
|
1130
|
+
program2.command("diff").description("Compare local .env with remote version").action(async () => {
|
|
1131
|
+
try {
|
|
1132
|
+
const config = loadProjectConfig7();
|
|
1133
|
+
if (!config) {
|
|
1134
|
+
error(
|
|
1135
|
+
"No .gitignored.json found. Run `gitignored new` to create a project."
|
|
1136
|
+
);
|
|
1137
|
+
process.exitCode = 1;
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
const projectKey = loadProjectKey(config.projectId);
|
|
1141
|
+
if (!projectKey) {
|
|
1142
|
+
error(
|
|
1143
|
+
"No project key found. You may need to be invited to this project."
|
|
1144
|
+
);
|
|
1145
|
+
process.exitCode = 1;
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
const envPath = path11.join(process.cwd(), ".env.shared");
|
|
1149
|
+
let localContent;
|
|
1150
|
+
try {
|
|
1151
|
+
localContent = fs11.readFileSync(envPath, "utf-8");
|
|
1152
|
+
} catch {
|
|
1153
|
+
error("No .env.shared file found locally.");
|
|
1154
|
+
process.exitCode = 1;
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
const result = await apiClient(
|
|
1158
|
+
`/api/projects/${config.projectId}/env`
|
|
1159
|
+
);
|
|
1160
|
+
if (!result.encryptedPayload) {
|
|
1161
|
+
error("No environment snapshot found on server.");
|
|
1162
|
+
process.exitCode = 1;
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const remoteContent = decryptEnv(result.encryptedPayload, projectKey);
|
|
1166
|
+
const localVars = parseEnvContent(localContent);
|
|
1167
|
+
const remoteVars = parseEnvContent(remoteContent);
|
|
1168
|
+
const added = [];
|
|
1169
|
+
const removed = [];
|
|
1170
|
+
const changed = [];
|
|
1171
|
+
for (const [key, value] of remoteVars) {
|
|
1172
|
+
if (!localVars.has(key)) {
|
|
1173
|
+
added.push(`${key}=${value}`);
|
|
1174
|
+
} else if (localVars.get(key) !== value) {
|
|
1175
|
+
changed.push({ key, local: localVars.get(key), remote: value });
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
for (const key of localVars.keys()) {
|
|
1179
|
+
if (!remoteVars.has(key)) {
|
|
1180
|
+
removed.push(`${key}=${localVars.get(key)}`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
if (added.length === 0 && removed.length === 0 && changed.length === 0) {
|
|
1184
|
+
info("Local and remote are in sync.");
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
console.log();
|
|
1188
|
+
console.log(chalk6.bold(` Diff: local vs server (v${result.version})`));
|
|
1189
|
+
console.log();
|
|
1190
|
+
if (added.length > 0) {
|
|
1191
|
+
console.log(chalk6.green.bold(" Added on server:"));
|
|
1192
|
+
for (const line of added) {
|
|
1193
|
+
console.log(chalk6.green(` + ${line}`));
|
|
1194
|
+
}
|
|
1195
|
+
console.log();
|
|
1196
|
+
}
|
|
1197
|
+
if (removed.length > 0) {
|
|
1198
|
+
console.log(chalk6.red.bold(" Only in local:"));
|
|
1199
|
+
for (const line of removed) {
|
|
1200
|
+
console.log(chalk6.red(` - ${line}`));
|
|
1201
|
+
}
|
|
1202
|
+
console.log();
|
|
1203
|
+
}
|
|
1204
|
+
if (changed.length > 0) {
|
|
1205
|
+
console.log(chalk6.yellow.bold(" Changed:"));
|
|
1206
|
+
for (const { key, local, remote } of changed) {
|
|
1207
|
+
console.log(chalk6.yellow(` ~ ${key}`));
|
|
1208
|
+
console.log(chalk6.red(` local: ${local}`));
|
|
1209
|
+
console.log(chalk6.green(` remote: ${remote}`));
|
|
1210
|
+
}
|
|
1211
|
+
console.log();
|
|
1212
|
+
}
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
error(
|
|
1215
|
+
`Diff failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1216
|
+
);
|
|
1217
|
+
process.exitCode = 1;
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/commands/start.ts
|
|
1223
|
+
import * as fs12 from "fs";
|
|
1224
|
+
import * as path12 from "path";
|
|
1225
|
+
import * as readline4 from "readline";
|
|
1226
|
+
import chalk7 from "chalk";
|
|
1227
|
+
function countVars4(content) {
|
|
1228
|
+
return content.split("\n").filter((line) => {
|
|
1229
|
+
const trimmed = line.trim();
|
|
1230
|
+
return trimmed.length > 0 && !trimmed.startsWith("#");
|
|
1231
|
+
}).length;
|
|
1232
|
+
}
|
|
1233
|
+
function loadProjectConfig8() {
|
|
1234
|
+
try {
|
|
1235
|
+
const raw = fs12.readFileSync(
|
|
1236
|
+
path12.join(process.cwd(), ".gitignored.json"),
|
|
1237
|
+
"utf-8"
|
|
1238
|
+
);
|
|
1239
|
+
return JSON.parse(raw);
|
|
1240
|
+
} catch {
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
function prompt3(question) {
|
|
1245
|
+
const rl = readline4.createInterface({
|
|
1246
|
+
input: process.stdin,
|
|
1247
|
+
output: process.stdout
|
|
1248
|
+
});
|
|
1249
|
+
return new Promise((resolve) => {
|
|
1250
|
+
rl.question(question, (answer) => {
|
|
1251
|
+
rl.close();
|
|
1252
|
+
resolve(answer.trim().toLowerCase());
|
|
1253
|
+
});
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
function registerStartCommand(program2) {
|
|
1257
|
+
program2.command("start").description("Watch mode: sync .env changes in real-time").action(async () => {
|
|
1258
|
+
const configResult = loadProjectConfig8();
|
|
1259
|
+
if (!configResult) {
|
|
1260
|
+
error(
|
|
1261
|
+
"No .gitignored.json found. Run `gitignored new` to create a project."
|
|
1262
|
+
);
|
|
1263
|
+
process.exitCode = 1;
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
const keyResult = loadProjectKey(configResult.projectId);
|
|
1267
|
+
if (!keyResult) {
|
|
1268
|
+
error(
|
|
1269
|
+
"No project key found. You may need to be invited to this project."
|
|
1270
|
+
);
|
|
1271
|
+
process.exitCode = 1;
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const config = configResult;
|
|
1275
|
+
const projectKey = keyResult;
|
|
1276
|
+
const envPath = path12.join(process.cwd(), ".env.shared");
|
|
1277
|
+
let localVersion = 0;
|
|
1278
|
+
let isPushing = false;
|
|
1279
|
+
let isPrompting = false;
|
|
1280
|
+
try {
|
|
1281
|
+
const result = await apiClient(
|
|
1282
|
+
`/api/projects/${config.projectId}/env`
|
|
1283
|
+
);
|
|
1284
|
+
if (result.encryptedPayload) {
|
|
1285
|
+
const decrypted = decryptEnv(result.encryptedPayload, projectKey);
|
|
1286
|
+
fs12.writeFileSync(envPath, decrypted);
|
|
1287
|
+
localVersion = result.version;
|
|
1288
|
+
const vars = countVars4(decrypted);
|
|
1289
|
+
success(`Pulled v${result.version} (${vars} var${vars !== 1 ? "s" : ""})`);
|
|
1290
|
+
} else {
|
|
1291
|
+
info("No remote snapshot found. Watching for local changes.");
|
|
1292
|
+
}
|
|
1293
|
+
} catch (err) {
|
|
1294
|
+
warn(
|
|
1295
|
+
`Could not pull latest: ${err instanceof Error ? err.message : String(err)}`
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
console.log();
|
|
1299
|
+
console.log(chalk7.cyan(" Watching for changes... (Ctrl+C to stop)"));
|
|
1300
|
+
console.log();
|
|
1301
|
+
const pollInterval = setInterval(async () => {
|
|
1302
|
+
if (isPushing || isPrompting) return;
|
|
1303
|
+
try {
|
|
1304
|
+
const versionResult = await apiClient(
|
|
1305
|
+
`/api/projects/${config.projectId}/env/version`
|
|
1306
|
+
);
|
|
1307
|
+
if (versionResult.version > localVersion) {
|
|
1308
|
+
info(`New version detected: v${versionResult.version}`);
|
|
1309
|
+
const pullResult = await apiClient(
|
|
1310
|
+
`/api/projects/${config.projectId}/env`
|
|
1311
|
+
);
|
|
1312
|
+
if (pullResult.encryptedPayload) {
|
|
1313
|
+
const decrypted = decryptEnv(pullResult.encryptedPayload, projectKey);
|
|
1314
|
+
if (watcher) {
|
|
1315
|
+
watcher.close();
|
|
1316
|
+
}
|
|
1317
|
+
fs12.writeFileSync(envPath, decrypted);
|
|
1318
|
+
localVersion = pullResult.version;
|
|
1319
|
+
const vars = countVars4(decrypted);
|
|
1320
|
+
success(`Auto-pulled v${pullResult.version} (${vars} var${vars !== 1 ? "s" : ""})`);
|
|
1321
|
+
watcher = startWatcher();
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
} catch {
|
|
1325
|
+
}
|
|
1326
|
+
}, 1e4);
|
|
1327
|
+
function startWatcher() {
|
|
1328
|
+
try {
|
|
1329
|
+
return fs12.watch(envPath, { persistent: true }, async (_eventType) => {
|
|
1330
|
+
if (isPushing || isPrompting) return;
|
|
1331
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1332
|
+
isPrompting = true;
|
|
1333
|
+
try {
|
|
1334
|
+
const answer = await prompt3(" Local changes detected. Push? [y/N] ");
|
|
1335
|
+
if (answer === "y" || answer === "yes") {
|
|
1336
|
+
isPushing = true;
|
|
1337
|
+
try {
|
|
1338
|
+
const content = fs12.readFileSync(envPath, "utf-8");
|
|
1339
|
+
const encryptedPayload = encryptEnv(content, projectKey);
|
|
1340
|
+
const result = await apiClient(
|
|
1341
|
+
`/api/projects/${config.projectId}/env`,
|
|
1342
|
+
{
|
|
1343
|
+
method: "POST",
|
|
1344
|
+
body: JSON.stringify({ encryptedPayload })
|
|
1345
|
+
}
|
|
1346
|
+
);
|
|
1347
|
+
localVersion = result.version;
|
|
1348
|
+
const vars = countVars4(content);
|
|
1349
|
+
success(`Pushed v${result.version} (${vars} var${vars !== 1 ? "s" : ""})`);
|
|
1350
|
+
} catch (pushErr) {
|
|
1351
|
+
error(
|
|
1352
|
+
`Push failed: ${pushErr instanceof Error ? pushErr.message : String(pushErr)}`
|
|
1353
|
+
);
|
|
1354
|
+
} finally {
|
|
1355
|
+
isPushing = false;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
} finally {
|
|
1359
|
+
isPrompting = false;
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
} catch {
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
let watcher = startWatcher();
|
|
1367
|
+
const cleanup = () => {
|
|
1368
|
+
console.log();
|
|
1369
|
+
info("Stopping watch mode. Goodbye!");
|
|
1370
|
+
clearInterval(pollInterval);
|
|
1371
|
+
if (watcher) {
|
|
1372
|
+
watcher.close();
|
|
1373
|
+
}
|
|
1374
|
+
process.exit(0);
|
|
1375
|
+
};
|
|
1376
|
+
process.on("SIGINT", cleanup);
|
|
1377
|
+
process.on("SIGTERM", cleanup);
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// src/commands/keys.ts
|
|
1382
|
+
function registerKeysCommand(program2) {
|
|
1383
|
+
const keys = program2.command("keys").description("Manage encryption keys");
|
|
1384
|
+
keys.command("sync").description("Sync encryption keys with the server").action(async () => {
|
|
1385
|
+
info("Not implemented yet");
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/index.ts
|
|
1390
|
+
var program = new Command();
|
|
1391
|
+
program.name("gitignored").description("Zero-knowledge .env sharing for developer teams").version("0.1.0");
|
|
1392
|
+
registerLoginCommand(program);
|
|
1393
|
+
registerLogoutCommand(program);
|
|
1394
|
+
registerWhoamiCommand(program);
|
|
1395
|
+
registerNewCommand(program);
|
|
1396
|
+
registerPushCommand(program);
|
|
1397
|
+
registerPullCommand(program);
|
|
1398
|
+
registerInviteCommand(program);
|
|
1399
|
+
registerMembersCommand(program);
|
|
1400
|
+
registerListCommand(program);
|
|
1401
|
+
registerSwitchCommand(program);
|
|
1402
|
+
registerLogCommand(program);
|
|
1403
|
+
registerRollbackCommand(program);
|
|
1404
|
+
registerDiffCommand(program);
|
|
1405
|
+
registerStartCommand(program);
|
|
1406
|
+
registerKeysCommand(program);
|
|
1407
|
+
program.parse(process.argv);
|