@secretstash/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +321 -0
- package/bin/vault.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4159 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import chalk3 from "chalk";
|
|
6
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
9
|
+
|
|
10
|
+
// src/commands/auth.ts
|
|
11
|
+
import { prompt } from "enquirer";
|
|
12
|
+
import qrcode from "qrcode-terminal";
|
|
13
|
+
|
|
14
|
+
// src/config.ts
|
|
15
|
+
import Conf from "conf";
|
|
16
|
+
import { homedir, platform } from "os";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, chmodSync } from "fs";
|
|
19
|
+
import { randomBytes } from "crypto";
|
|
20
|
+
function getOrCreateEncryptionKey() {
|
|
21
|
+
if (process.env.VAULT_CONFIG_KEY) {
|
|
22
|
+
return process.env.VAULT_CONFIG_KEY;
|
|
23
|
+
}
|
|
24
|
+
const keyDir = join(homedir(), ".secretstash");
|
|
25
|
+
const keyPath = join(keyDir, ".config-key");
|
|
26
|
+
if (existsSync(keyPath)) {
|
|
27
|
+
const key2 = readFileSync(keyPath, "utf-8").trim();
|
|
28
|
+
if (platform() !== "win32") {
|
|
29
|
+
try {
|
|
30
|
+
const stats = statSync(keyPath);
|
|
31
|
+
const mode = stats.mode & 511;
|
|
32
|
+
if (mode !== 384) {
|
|
33
|
+
console.warn(
|
|
34
|
+
`WARNING: Config key file ${keyPath} has insecure permissions (${mode.toString(8)}). Fixing to 0600.`
|
|
35
|
+
);
|
|
36
|
+
chmodSync(keyPath, 384);
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return key2;
|
|
42
|
+
}
|
|
43
|
+
const key = randomBytes(32).toString("hex");
|
|
44
|
+
if (!existsSync(keyDir)) {
|
|
45
|
+
mkdirSync(keyDir, { mode: 448, recursive: true });
|
|
46
|
+
}
|
|
47
|
+
writeFileSync(keyPath, key + "\n", { mode: 384 });
|
|
48
|
+
if (platform() !== "win32") {
|
|
49
|
+
try {
|
|
50
|
+
chmodSync(keyPath, 384);
|
|
51
|
+
} catch {
|
|
52
|
+
console.warn(`WARNING: Could not set permissions on ${keyPath}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return key;
|
|
56
|
+
}
|
|
57
|
+
var config = new Conf({
|
|
58
|
+
projectName: "secretstash",
|
|
59
|
+
schema: {
|
|
60
|
+
apiUrl: {
|
|
61
|
+
type: "string",
|
|
62
|
+
default: "http://localhost:3000"
|
|
63
|
+
},
|
|
64
|
+
accessToken: {
|
|
65
|
+
type: "string"
|
|
66
|
+
},
|
|
67
|
+
refreshToken: {
|
|
68
|
+
type: "string"
|
|
69
|
+
},
|
|
70
|
+
tokenExpiresAt: {
|
|
71
|
+
type: "number"
|
|
72
|
+
},
|
|
73
|
+
userId: {
|
|
74
|
+
type: "string"
|
|
75
|
+
},
|
|
76
|
+
email: {
|
|
77
|
+
type: "string"
|
|
78
|
+
},
|
|
79
|
+
currentTeam: {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {
|
|
82
|
+
id: { type: "string" },
|
|
83
|
+
name: { type: "string" },
|
|
84
|
+
slug: { type: "string" }
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
currentProject: {
|
|
88
|
+
type: "object",
|
|
89
|
+
properties: {
|
|
90
|
+
id: { type: "string" },
|
|
91
|
+
name: { type: "string" },
|
|
92
|
+
slug: { type: "string" }
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
currentEnvironment: {
|
|
96
|
+
type: "string"
|
|
97
|
+
},
|
|
98
|
+
serviceToken: {
|
|
99
|
+
type: "string"
|
|
100
|
+
},
|
|
101
|
+
serviceTokenInfo: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
name: { type: "string" },
|
|
105
|
+
environmentId: { type: "string" },
|
|
106
|
+
permission: { type: "string" },
|
|
107
|
+
expiresAt: { type: "string" }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
// Always use encryption for config store - key is auto-generated if not provided
|
|
112
|
+
encryptionKey: getOrCreateEncryptionKey()
|
|
113
|
+
});
|
|
114
|
+
var runtimeState = {
|
|
115
|
+
quiet: false
|
|
116
|
+
};
|
|
117
|
+
var configManager = {
|
|
118
|
+
// Quiet mode (runtime only, not persisted)
|
|
119
|
+
setQuiet(quiet) {
|
|
120
|
+
runtimeState.quiet = quiet;
|
|
121
|
+
},
|
|
122
|
+
isQuiet() {
|
|
123
|
+
return runtimeState.quiet;
|
|
124
|
+
},
|
|
125
|
+
// API URL
|
|
126
|
+
getApiUrl() {
|
|
127
|
+
return process.env.VAULT_API_URL || config.get("apiUrl");
|
|
128
|
+
},
|
|
129
|
+
setApiUrl(url) {
|
|
130
|
+
config.set("apiUrl", url);
|
|
131
|
+
},
|
|
132
|
+
// Authentication
|
|
133
|
+
getAccessToken() {
|
|
134
|
+
const serviceToken = this.getServiceToken();
|
|
135
|
+
if (serviceToken) {
|
|
136
|
+
return serviceToken;
|
|
137
|
+
}
|
|
138
|
+
return config.get("accessToken");
|
|
139
|
+
},
|
|
140
|
+
getRefreshToken() {
|
|
141
|
+
return config.get("refreshToken");
|
|
142
|
+
},
|
|
143
|
+
setTokens(accessToken, refreshToken, expiresInSeconds) {
|
|
144
|
+
config.set("accessToken", accessToken);
|
|
145
|
+
config.set("refreshToken", refreshToken);
|
|
146
|
+
config.set("tokenExpiresAt", Date.now() + expiresInSeconds * 1e3);
|
|
147
|
+
},
|
|
148
|
+
isTokenExpired() {
|
|
149
|
+
const expiresAt = config.get("tokenExpiresAt");
|
|
150
|
+
if (!expiresAt) return true;
|
|
151
|
+
return Date.now() > expiresAt - 3e4;
|
|
152
|
+
},
|
|
153
|
+
clearTokens() {
|
|
154
|
+
config.delete("accessToken");
|
|
155
|
+
config.delete("refreshToken");
|
|
156
|
+
config.delete("tokenExpiresAt");
|
|
157
|
+
},
|
|
158
|
+
// User info
|
|
159
|
+
setUser(userId, email) {
|
|
160
|
+
config.set("userId", userId);
|
|
161
|
+
config.set("email", email);
|
|
162
|
+
},
|
|
163
|
+
getUser() {
|
|
164
|
+
return {
|
|
165
|
+
userId: config.get("userId"),
|
|
166
|
+
email: config.get("email")
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
clearUser() {
|
|
170
|
+
config.delete("userId");
|
|
171
|
+
config.delete("email");
|
|
172
|
+
},
|
|
173
|
+
// Team context
|
|
174
|
+
setCurrentTeam(team) {
|
|
175
|
+
config.set("currentTeam", team);
|
|
176
|
+
},
|
|
177
|
+
getCurrentTeam() {
|
|
178
|
+
return config.get("currentTeam");
|
|
179
|
+
},
|
|
180
|
+
clearCurrentTeam() {
|
|
181
|
+
config.delete("currentTeam");
|
|
182
|
+
},
|
|
183
|
+
// Project context
|
|
184
|
+
setCurrentProject(project) {
|
|
185
|
+
config.set("currentProject", project);
|
|
186
|
+
},
|
|
187
|
+
getCurrentProject() {
|
|
188
|
+
return config.get("currentProject");
|
|
189
|
+
},
|
|
190
|
+
clearCurrentProject() {
|
|
191
|
+
config.delete("currentProject");
|
|
192
|
+
},
|
|
193
|
+
// Environment context
|
|
194
|
+
setCurrentEnvironment(env) {
|
|
195
|
+
config.set("currentEnvironment", env);
|
|
196
|
+
},
|
|
197
|
+
getCurrentEnvironment() {
|
|
198
|
+
return config.get("currentEnvironment");
|
|
199
|
+
},
|
|
200
|
+
clearCurrentEnvironment() {
|
|
201
|
+
config.delete("currentEnvironment");
|
|
202
|
+
},
|
|
203
|
+
// Full logout
|
|
204
|
+
logout() {
|
|
205
|
+
this.clearTokens();
|
|
206
|
+
this.clearUser();
|
|
207
|
+
this.clearCurrentTeam();
|
|
208
|
+
this.clearCurrentProject();
|
|
209
|
+
this.clearCurrentEnvironment();
|
|
210
|
+
this.clearServiceToken();
|
|
211
|
+
},
|
|
212
|
+
// Service token authentication (for CI/CD)
|
|
213
|
+
setServiceToken(token, info) {
|
|
214
|
+
config.set("serviceToken", token);
|
|
215
|
+
config.set("serviceTokenInfo", info);
|
|
216
|
+
},
|
|
217
|
+
getServiceToken() {
|
|
218
|
+
if (process.env.SECRETSTASH_TOKEN) {
|
|
219
|
+
return process.env.SECRETSTASH_TOKEN;
|
|
220
|
+
}
|
|
221
|
+
return config.get("serviceToken");
|
|
222
|
+
},
|
|
223
|
+
getServiceTokenInfo() {
|
|
224
|
+
return config.get("serviceTokenInfo");
|
|
225
|
+
},
|
|
226
|
+
clearServiceToken() {
|
|
227
|
+
config.delete("serviceToken");
|
|
228
|
+
config.delete("serviceTokenInfo");
|
|
229
|
+
},
|
|
230
|
+
isServiceTokenAuth() {
|
|
231
|
+
return !!this.getServiceToken();
|
|
232
|
+
},
|
|
233
|
+
// Check if authenticated
|
|
234
|
+
isAuthenticated() {
|
|
235
|
+
if (this.isServiceTokenAuth()) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
return !!this.getAccessToken() && !this.isTokenExpired();
|
|
239
|
+
},
|
|
240
|
+
// Config file path (for debugging)
|
|
241
|
+
getConfigPath() {
|
|
242
|
+
return config.path;
|
|
243
|
+
},
|
|
244
|
+
// Clear all config
|
|
245
|
+
clear() {
|
|
246
|
+
config.clear();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
var projectConfig = {
|
|
250
|
+
getLocalConfigPath() {
|
|
251
|
+
return join(process.cwd(), ".secretstash.json");
|
|
252
|
+
},
|
|
253
|
+
hasLocalConfig() {
|
|
254
|
+
return existsSync(this.getLocalConfigPath());
|
|
255
|
+
},
|
|
256
|
+
getLocalConfig() {
|
|
257
|
+
const configPath = this.getLocalConfigPath();
|
|
258
|
+
if (!existsSync(configPath)) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const content = readFileSync(configPath, "utf-8");
|
|
263
|
+
return JSON.parse(content);
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
// Get effective project (local config overrides global)
|
|
269
|
+
getEffectiveProject() {
|
|
270
|
+
const local = this.getLocalConfig();
|
|
271
|
+
if (local?.project) return local.project;
|
|
272
|
+
return configManager.getCurrentProject()?.slug;
|
|
273
|
+
},
|
|
274
|
+
// Get effective environment (local config overrides global)
|
|
275
|
+
getEffectiveEnvironment() {
|
|
276
|
+
const local = this.getLocalConfig();
|
|
277
|
+
if (local?.environment) return local.environment;
|
|
278
|
+
return configManager.getCurrentEnvironment();
|
|
279
|
+
},
|
|
280
|
+
// Get effective team (local config overrides global)
|
|
281
|
+
getEffectiveTeam() {
|
|
282
|
+
const local = this.getLocalConfig();
|
|
283
|
+
if (local?.teamSlug) return local.teamSlug;
|
|
284
|
+
return configManager.getCurrentTeam()?.slug;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
var envPaths = {
|
|
288
|
+
getEnvPath(environment) {
|
|
289
|
+
if (!environment || environment === "development") {
|
|
290
|
+
return join(process.cwd(), ".env");
|
|
291
|
+
}
|
|
292
|
+
return join(process.cwd(), `.env.${environment}`);
|
|
293
|
+
},
|
|
294
|
+
getLocalEnvPath() {
|
|
295
|
+
return join(process.cwd(), ".env.local");
|
|
296
|
+
},
|
|
297
|
+
getExampleEnvPath() {
|
|
298
|
+
return join(process.cwd(), ".env.example");
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// src/api-client.ts
|
|
303
|
+
var ApiClient = class {
|
|
304
|
+
async refreshAccessToken() {
|
|
305
|
+
const refreshToken = configManager.getRefreshToken();
|
|
306
|
+
if (!refreshToken) return false;
|
|
307
|
+
try {
|
|
308
|
+
const response = await fetch(`${configManager.getApiUrl()}/api/auth/refresh`, {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: {
|
|
311
|
+
"Content-Type": "application/json"
|
|
312
|
+
},
|
|
313
|
+
body: JSON.stringify({ refreshToken })
|
|
314
|
+
});
|
|
315
|
+
if (!response.ok) {
|
|
316
|
+
configManager.logout();
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
const data = await response.json();
|
|
320
|
+
configManager.setTokens(
|
|
321
|
+
data.accessToken,
|
|
322
|
+
data.refreshToken,
|
|
323
|
+
data.expiresIn || 900
|
|
324
|
+
);
|
|
325
|
+
return true;
|
|
326
|
+
} catch {
|
|
327
|
+
configManager.logout();
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async request(endpoint, options = {}) {
|
|
332
|
+
const { method = "GET", body, headers = {}, requireAuth = true } = options;
|
|
333
|
+
if (requireAuth && configManager.isTokenExpired()) {
|
|
334
|
+
const refreshed = await this.refreshAccessToken();
|
|
335
|
+
if (!refreshed) {
|
|
336
|
+
return {
|
|
337
|
+
error: {
|
|
338
|
+
code: "UNAUTHORIZED",
|
|
339
|
+
message: "Session expired. Please log in again."
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const requestHeaders = {
|
|
345
|
+
"Content-Type": "application/json",
|
|
346
|
+
...headers
|
|
347
|
+
};
|
|
348
|
+
if (requireAuth) {
|
|
349
|
+
const token = configManager.getAccessToken();
|
|
350
|
+
if (token) {
|
|
351
|
+
requestHeaders["Authorization"] = `Bearer ${token}`;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
const response = await fetch(`${configManager.getApiUrl()}${endpoint}`, {
|
|
356
|
+
method,
|
|
357
|
+
headers: requestHeaders,
|
|
358
|
+
body: body ? JSON.stringify(body) : void 0
|
|
359
|
+
});
|
|
360
|
+
const data = await response.json();
|
|
361
|
+
if (!response.ok) {
|
|
362
|
+
return {
|
|
363
|
+
error: {
|
|
364
|
+
code: data.code || "API_ERROR",
|
|
365
|
+
message: data.message || `Request failed with status ${response.status}`,
|
|
366
|
+
details: data.details
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return { data };
|
|
371
|
+
} catch (error) {
|
|
372
|
+
return {
|
|
373
|
+
error: {
|
|
374
|
+
code: "NETWORK_ERROR",
|
|
375
|
+
message: error instanceof Error ? error.message : "Network request failed"
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Auth endpoints
|
|
381
|
+
async login(email, password) {
|
|
382
|
+
return this.request("/api/auth/login", {
|
|
383
|
+
method: "POST",
|
|
384
|
+
body: { email, password, client: "cli" },
|
|
385
|
+
requireAuth: false
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
async verify2FA(tempToken, code) {
|
|
389
|
+
return this.request("/api/auth/2fa/verify", {
|
|
390
|
+
method: "POST",
|
|
391
|
+
body: { tempToken, code },
|
|
392
|
+
requireAuth: false
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
async register(email, password) {
|
|
396
|
+
return this.request("/api/auth/register", {
|
|
397
|
+
method: "POST",
|
|
398
|
+
body: { email, password },
|
|
399
|
+
requireAuth: false
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
async logout() {
|
|
403
|
+
const refreshToken = configManager.getRefreshToken();
|
|
404
|
+
if (refreshToken) {
|
|
405
|
+
await this.request("/api/auth/logout", {
|
|
406
|
+
method: "POST",
|
|
407
|
+
body: { refreshToken }
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
configManager.logout();
|
|
411
|
+
}
|
|
412
|
+
async getMe() {
|
|
413
|
+
return this.request("/api/users/me");
|
|
414
|
+
}
|
|
415
|
+
// Team endpoints
|
|
416
|
+
async getTeams() {
|
|
417
|
+
return this.request("/api/teams");
|
|
418
|
+
}
|
|
419
|
+
async createTeam(name) {
|
|
420
|
+
return this.request("/api/teams", {
|
|
421
|
+
method: "POST",
|
|
422
|
+
body: { name }
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// Project endpoints
|
|
426
|
+
async getProjects(teamId) {
|
|
427
|
+
return this.request(`/api/teams/${teamId}/projects`);
|
|
428
|
+
}
|
|
429
|
+
async createProject(teamId, name, description) {
|
|
430
|
+
return this.request(`/api/teams/${teamId}/projects`, {
|
|
431
|
+
method: "POST",
|
|
432
|
+
body: { name, description }
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
async deleteProject(projectId) {
|
|
436
|
+
return this.request(`/api/projects/${projectId}`, {
|
|
437
|
+
method: "DELETE"
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
// Environment endpoints
|
|
441
|
+
async getEnvironments(projectId) {
|
|
442
|
+
return this.request(`/api/projects/${projectId}/environments`);
|
|
443
|
+
}
|
|
444
|
+
async createEnvironment(projectId, name, parentId) {
|
|
445
|
+
return this.request(`/api/projects/${projectId}/environments`, {
|
|
446
|
+
method: "POST",
|
|
447
|
+
body: { name, parentId }
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
async deleteEnvironment(environmentId) {
|
|
451
|
+
return this.request(`/api/v1/environments/${environmentId}`, {
|
|
452
|
+
method: "DELETE"
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
// Secret endpoints (keys only, no decryption)
|
|
456
|
+
async getSecrets(environmentId) {
|
|
457
|
+
return this.request(`/api/environments/${environmentId}/secrets`);
|
|
458
|
+
}
|
|
459
|
+
// Decrypted secrets (requires password)
|
|
460
|
+
async getDecryptedSecrets(environmentId, password) {
|
|
461
|
+
return this.request(`/api/environments/${environmentId}/secrets/decrypt`, {
|
|
462
|
+
method: "POST",
|
|
463
|
+
body: { password }
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
async createSecret(environmentId, key, value, password, description) {
|
|
467
|
+
return this.request(`/api/environments/${environmentId}/secrets`, {
|
|
468
|
+
method: "POST",
|
|
469
|
+
body: { key, value, password, description }
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
async updateSecret(secretId, value, password, description) {
|
|
473
|
+
return this.request(`/api/secrets/${secretId}`, {
|
|
474
|
+
method: "PATCH",
|
|
475
|
+
body: { value, password, description }
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
async deleteSecret(secretId) {
|
|
479
|
+
return this.request(`/api/secrets/${secretId}`, {
|
|
480
|
+
method: "DELETE"
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
// Bulk secrets operations (require password for encryption/decryption)
|
|
484
|
+
async pullSecrets(teamSlug, projectSlug, environment, password, options) {
|
|
485
|
+
const includeInherited = options?.includeInherited ?? true;
|
|
486
|
+
const query = includeInherited ? "" : "?includeInherited=false";
|
|
487
|
+
return this.request(`/api/teams/${teamSlug}/projects/${projectSlug}/environments/${environment}/secrets/decrypt${query}`, {
|
|
488
|
+
method: "POST",
|
|
489
|
+
body: { password }
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
async pushSecrets(teamSlug, projectSlug, environment, secrets, password) {
|
|
493
|
+
return this.request(`/api/teams/${teamSlug}/projects/${projectSlug}/environments/${environment}/secrets/bulk`, {
|
|
494
|
+
method: "PUT",
|
|
495
|
+
body: { secrets, password }
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
// 2FA endpoints
|
|
499
|
+
async setup2FA() {
|
|
500
|
+
return this.request("/api/users/me/2fa/setup", {
|
|
501
|
+
method: "POST"
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
async enable2FA(code) {
|
|
505
|
+
return this.request("/api/users/me/2fa/enable", {
|
|
506
|
+
method: "POST",
|
|
507
|
+
body: { code }
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
async disable2FA(code) {
|
|
511
|
+
return this.request("/api/users/me/2fa/disable", {
|
|
512
|
+
method: "POST",
|
|
513
|
+
body: { code }
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
// Team member endpoints
|
|
517
|
+
async getTeamMembers(teamId) {
|
|
518
|
+
return this.request(`/api/teams/${teamId}/members`);
|
|
519
|
+
}
|
|
520
|
+
async inviteTeamMember(teamId, email, role) {
|
|
521
|
+
return this.request(`/api/teams/${teamId}/invitations`, {
|
|
522
|
+
method: "POST",
|
|
523
|
+
body: { email, role }
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
async removeTeamMember(teamId, memberId) {
|
|
527
|
+
return this.request(`/api/teams/${teamId}/members/${memberId}`, {
|
|
528
|
+
method: "DELETE"
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
async updateTeamMemberRole(teamId, memberId, role) {
|
|
532
|
+
return this.request(`/api/teams/${teamId}/members/${memberId}`, {
|
|
533
|
+
method: "PATCH",
|
|
534
|
+
body: { role }
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
async getPendingInvitations(teamId) {
|
|
538
|
+
return this.request(`/api/teams/${teamId}/invitations`);
|
|
539
|
+
}
|
|
540
|
+
// Share link endpoints
|
|
541
|
+
async createShare(teamSlug, projectSlug, environment, secretKey, options) {
|
|
542
|
+
return this.request("/api/v1/shares", {
|
|
543
|
+
method: "POST",
|
|
544
|
+
body: {
|
|
545
|
+
teamSlug,
|
|
546
|
+
projectSlug,
|
|
547
|
+
environment,
|
|
548
|
+
secretKey,
|
|
549
|
+
...options
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
async listShares(options) {
|
|
554
|
+
const query = options?.includeExpired ? "?includeExpired=true" : "";
|
|
555
|
+
return this.request(`/api/v1/shares${query}`);
|
|
556
|
+
}
|
|
557
|
+
async revokeShare(shareId) {
|
|
558
|
+
return this.request(`/api/v1/shares/${shareId}`, {
|
|
559
|
+
method: "DELETE"
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
// Service token verification - make a request to validate the token works
|
|
563
|
+
async verifyServiceToken(token) {
|
|
564
|
+
return this.request("/api/teams", {
|
|
565
|
+
headers: {
|
|
566
|
+
"Authorization": `Bearer ${token}`
|
|
567
|
+
},
|
|
568
|
+
requireAuth: false
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
// Secret version endpoints
|
|
572
|
+
async getSecretVersions(secretId, password, limit) {
|
|
573
|
+
return this.request(`/api/v1/secrets/${secretId}/versions/decrypt`, {
|
|
574
|
+
method: "POST",
|
|
575
|
+
body: { password, limit }
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
async rollbackSecret(secretId, version2, password) {
|
|
579
|
+
return this.request(`/api/v1/secrets/${secretId}/rollback`, {
|
|
580
|
+
method: "POST",
|
|
581
|
+
body: { version: version2, password }
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
// Tag endpoints
|
|
585
|
+
async getTags(teamId) {
|
|
586
|
+
return this.request(`/api/v1/teams/${teamId}/tags`);
|
|
587
|
+
}
|
|
588
|
+
async createTag(teamId, name, color) {
|
|
589
|
+
return this.request(`/api/v1/teams/${teamId}/tags`, {
|
|
590
|
+
method: "POST",
|
|
591
|
+
body: { name, color }
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
async deleteTag(tagId) {
|
|
595
|
+
return this.request(`/api/v1/tags/${tagId}`, {
|
|
596
|
+
method: "DELETE"
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
async getSecretTags(secretId) {
|
|
600
|
+
return this.request(`/api/v1/secrets/${secretId}/tags`);
|
|
601
|
+
}
|
|
602
|
+
async addTagToSecret(secretId, tagId) {
|
|
603
|
+
return this.request(`/api/v1/secrets/${secretId}/tags`, {
|
|
604
|
+
method: "POST",
|
|
605
|
+
body: { tagId }
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
async removeTagFromSecret(secretId, tagId) {
|
|
609
|
+
return this.request(`/api/v1/secrets/${secretId}/tags/${tagId}`, {
|
|
610
|
+
method: "DELETE"
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
// Expiring secrets endpoint
|
|
614
|
+
async getExpiringSecrets(teamId, days = 7) {
|
|
615
|
+
return this.request(`/api/v1/teams/${teamId}/secrets/expiring?days=${days}`);
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
var apiClient = new ApiClient();
|
|
619
|
+
|
|
620
|
+
// src/ui.ts
|
|
621
|
+
import chalk from "chalk";
|
|
622
|
+
import ora from "ora";
|
|
623
|
+
import { table } from "table";
|
|
624
|
+
var colors = {
|
|
625
|
+
primary: chalk.hex("#6366f1"),
|
|
626
|
+
// electric-500
|
|
627
|
+
secondary: chalk.hex("#8b5cf6"),
|
|
628
|
+
// vault-500
|
|
629
|
+
success: chalk.hex("#22c55e"),
|
|
630
|
+
// emerald-500
|
|
631
|
+
warning: chalk.hex("#f59e0b"),
|
|
632
|
+
// amber-500
|
|
633
|
+
error: chalk.hex("#ef4444"),
|
|
634
|
+
// red-500
|
|
635
|
+
muted: chalk.hex("#71717a"),
|
|
636
|
+
// text-muted
|
|
637
|
+
highlight: chalk.hex("#38bdf8")
|
|
638
|
+
// sky-400
|
|
639
|
+
};
|
|
640
|
+
var SilentSpinner = class {
|
|
641
|
+
text = "";
|
|
642
|
+
succeed(_text) {
|
|
643
|
+
return this;
|
|
644
|
+
}
|
|
645
|
+
fail(_text) {
|
|
646
|
+
return this;
|
|
647
|
+
}
|
|
648
|
+
stop() {
|
|
649
|
+
return this;
|
|
650
|
+
}
|
|
651
|
+
start() {
|
|
652
|
+
return this;
|
|
653
|
+
}
|
|
654
|
+
warn(_text) {
|
|
655
|
+
return this;
|
|
656
|
+
}
|
|
657
|
+
info(_text) {
|
|
658
|
+
return this;
|
|
659
|
+
}
|
|
660
|
+
stopAndPersist() {
|
|
661
|
+
return this;
|
|
662
|
+
}
|
|
663
|
+
clear() {
|
|
664
|
+
return this;
|
|
665
|
+
}
|
|
666
|
+
render() {
|
|
667
|
+
return this;
|
|
668
|
+
}
|
|
669
|
+
frame() {
|
|
670
|
+
return "";
|
|
671
|
+
}
|
|
672
|
+
isSpinning = false;
|
|
673
|
+
indent = 0;
|
|
674
|
+
color = "cyan";
|
|
675
|
+
prefixText = "";
|
|
676
|
+
suffixText = "";
|
|
677
|
+
spinner = { frames: [], interval: 0 };
|
|
678
|
+
hideCursor = false;
|
|
679
|
+
discardStdin = false;
|
|
680
|
+
};
|
|
681
|
+
var ui = {
|
|
682
|
+
// Headings (decorative - skip in quiet mode)
|
|
683
|
+
heading(text) {
|
|
684
|
+
if (configManager.isQuiet()) return;
|
|
685
|
+
console.log();
|
|
686
|
+
console.log(colors.primary.bold(`\u25C6 ${text}`));
|
|
687
|
+
console.log();
|
|
688
|
+
},
|
|
689
|
+
subheading(text) {
|
|
690
|
+
if (configManager.isQuiet()) return;
|
|
691
|
+
console.log(colors.muted(` ${text}`));
|
|
692
|
+
},
|
|
693
|
+
// Status messages
|
|
694
|
+
// success is kept - it's a final result
|
|
695
|
+
success(text) {
|
|
696
|
+
console.log(colors.success(`\u2713 ${text}`));
|
|
697
|
+
},
|
|
698
|
+
// error is always shown - critical information
|
|
699
|
+
error(text) {
|
|
700
|
+
console.log(colors.error(`\u2717 ${text}`));
|
|
701
|
+
},
|
|
702
|
+
// warning is skipped in quiet mode - non-essential
|
|
703
|
+
warning(text) {
|
|
704
|
+
if (configManager.isQuiet()) return;
|
|
705
|
+
console.log(colors.warning(`\u26A0 ${text}`));
|
|
706
|
+
},
|
|
707
|
+
// info is skipped in quiet mode - non-essential
|
|
708
|
+
info(text) {
|
|
709
|
+
if (configManager.isQuiet()) return;
|
|
710
|
+
console.log(colors.highlight(`\u2139 ${text}`));
|
|
711
|
+
},
|
|
712
|
+
// Key-value display (decorative - skip in quiet mode)
|
|
713
|
+
keyValue(key, value) {
|
|
714
|
+
if (configManager.isQuiet()) return;
|
|
715
|
+
console.log(` ${colors.muted(key + ":")} ${value}`);
|
|
716
|
+
},
|
|
717
|
+
// Lists (decorative - skip in quiet mode)
|
|
718
|
+
list(items) {
|
|
719
|
+
if (configManager.isQuiet()) return;
|
|
720
|
+
items.forEach((item) => {
|
|
721
|
+
console.log(` ${colors.muted("\u2022")} ${item}`);
|
|
722
|
+
});
|
|
723
|
+
},
|
|
724
|
+
// Code/values - formatting helpers, always return value
|
|
725
|
+
code(text) {
|
|
726
|
+
return chalk.cyan(text);
|
|
727
|
+
},
|
|
728
|
+
dim(text) {
|
|
729
|
+
return colors.muted(text);
|
|
730
|
+
},
|
|
731
|
+
bold(text) {
|
|
732
|
+
return chalk.bold(text);
|
|
733
|
+
},
|
|
734
|
+
// Tables - final results, keep them
|
|
735
|
+
table(data, options) {
|
|
736
|
+
if (configManager.isQuiet()) return;
|
|
737
|
+
const tableData = options?.header ? [options.header.map((h) => colors.primary.bold(h)), ...data] : data;
|
|
738
|
+
console.log(table(tableData, {
|
|
739
|
+
border: {
|
|
740
|
+
topBody: colors.muted("\u2500"),
|
|
741
|
+
topJoin: colors.muted("\u252C"),
|
|
742
|
+
topLeft: colors.muted("\u250C"),
|
|
743
|
+
topRight: colors.muted("\u2510"),
|
|
744
|
+
bottomBody: colors.muted("\u2500"),
|
|
745
|
+
bottomJoin: colors.muted("\u2534"),
|
|
746
|
+
bottomLeft: colors.muted("\u2514"),
|
|
747
|
+
bottomRight: colors.muted("\u2518"),
|
|
748
|
+
bodyLeft: colors.muted("\u2502"),
|
|
749
|
+
bodyRight: colors.muted("\u2502"),
|
|
750
|
+
bodyJoin: colors.muted("\u2502"),
|
|
751
|
+
joinBody: colors.muted("\u2500"),
|
|
752
|
+
joinLeft: colors.muted("\u251C"),
|
|
753
|
+
joinRight: colors.muted("\u2524"),
|
|
754
|
+
joinJoin: colors.muted("\u253C")
|
|
755
|
+
}
|
|
756
|
+
}));
|
|
757
|
+
},
|
|
758
|
+
// Spinners - in quiet mode, return silent spinner
|
|
759
|
+
spinner(text) {
|
|
760
|
+
if (configManager.isQuiet()) {
|
|
761
|
+
return new SilentSpinner();
|
|
762
|
+
}
|
|
763
|
+
return ora({
|
|
764
|
+
text,
|
|
765
|
+
color: "cyan",
|
|
766
|
+
spinner: "dots"
|
|
767
|
+
}).start();
|
|
768
|
+
},
|
|
769
|
+
// Empty line (decorative - skip in quiet mode)
|
|
770
|
+
br() {
|
|
771
|
+
if (configManager.isQuiet()) return;
|
|
772
|
+
console.log();
|
|
773
|
+
},
|
|
774
|
+
// Box for important info (decorative - skip in quiet mode)
|
|
775
|
+
box(title, content) {
|
|
776
|
+
if (configManager.isQuiet()) return;
|
|
777
|
+
const width = Math.max(title.length + 4, ...content.map((c) => c.length + 4));
|
|
778
|
+
const border = colors.muted("\u2500".repeat(width));
|
|
779
|
+
console.log();
|
|
780
|
+
console.log(colors.muted("\u250C") + border + colors.muted("\u2510"));
|
|
781
|
+
console.log(colors.muted("\u2502") + " " + colors.primary.bold(title.padEnd(width - 1)) + colors.muted("\u2502"));
|
|
782
|
+
console.log(colors.muted("\u251C") + border + colors.muted("\u2524"));
|
|
783
|
+
content.forEach((line) => {
|
|
784
|
+
console.log(colors.muted("\u2502") + " " + line.padEnd(width - 1) + colors.muted("\u2502"));
|
|
785
|
+
});
|
|
786
|
+
console.log(colors.muted("\u2514") + border + colors.muted("\u2518"));
|
|
787
|
+
console.log();
|
|
788
|
+
},
|
|
789
|
+
// Secret value display (masked by default)
|
|
790
|
+
secret(value, reveal = false) {
|
|
791
|
+
if (reveal) {
|
|
792
|
+
return chalk.yellow(value);
|
|
793
|
+
}
|
|
794
|
+
return colors.muted("\u2022".repeat(Math.min(value.length, 20)));
|
|
795
|
+
},
|
|
796
|
+
// Environment badge
|
|
797
|
+
envBadge(env) {
|
|
798
|
+
const badges = {
|
|
799
|
+
production: chalk.bgRed.white,
|
|
800
|
+
staging: chalk.bgYellow.black,
|
|
801
|
+
development: chalk.bgGreen.white,
|
|
802
|
+
testing: chalk.bgBlue.white
|
|
803
|
+
};
|
|
804
|
+
const badge = badges[env.toLowerCase()] || chalk.bgGray.white;
|
|
805
|
+
return badge(` ${env.toUpperCase()} `);
|
|
806
|
+
},
|
|
807
|
+
// Role badge
|
|
808
|
+
roleBadge(role) {
|
|
809
|
+
const badges = {
|
|
810
|
+
owner: chalk.bgMagenta.white,
|
|
811
|
+
admin: chalk.bgBlue.white,
|
|
812
|
+
member: chalk.bgGray.white
|
|
813
|
+
};
|
|
814
|
+
const badge = badges[role.toLowerCase()] || chalk.bgGray.white;
|
|
815
|
+
return badge(` ${role} `);
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
// src/commands/auth.ts
|
|
820
|
+
var SERVICE_TOKEN_PREFIX = "stk_";
|
|
821
|
+
async function handleServiceTokenLogin(token, fromEnvVar = false) {
|
|
822
|
+
ui.heading("SecretStash Service Token Login");
|
|
823
|
+
if (!token.startsWith(SERVICE_TOKEN_PREFIX)) {
|
|
824
|
+
ui.error(`Invalid token format. Service tokens must start with "${SERVICE_TOKEN_PREFIX}"`);
|
|
825
|
+
process.exit(1);
|
|
826
|
+
}
|
|
827
|
+
const spinner = ui.spinner("Validating service token...");
|
|
828
|
+
const result = await apiClient.verifyServiceToken(token);
|
|
829
|
+
if (result.error) {
|
|
830
|
+
spinner.fail();
|
|
831
|
+
ui.error(result.error.message);
|
|
832
|
+
if (result.error.code === "UNAUTHORIZED") {
|
|
833
|
+
ui.info("The service token may be expired, revoked, or invalid");
|
|
834
|
+
}
|
|
835
|
+
process.exit(1);
|
|
836
|
+
}
|
|
837
|
+
spinner.succeed("Service token validated successfully");
|
|
838
|
+
if (!fromEnvVar) {
|
|
839
|
+
configManager.setServiceToken(token, {
|
|
840
|
+
name: "Service Token",
|
|
841
|
+
environmentId: "scoped",
|
|
842
|
+
permission: "environment-scoped",
|
|
843
|
+
expiresAt: "see-token-metadata"
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
ui.br();
|
|
847
|
+
ui.keyValue("Auth Type", colors.highlight("Service Token"));
|
|
848
|
+
if (fromEnvVar) {
|
|
849
|
+
ui.keyValue("Source", "SECRETSTASH_TOKEN environment variable");
|
|
850
|
+
ui.info("Token will be used from environment variable (not stored in config)");
|
|
851
|
+
} else {
|
|
852
|
+
ui.keyValue("Token", `${token.substring(0, 12)}...`);
|
|
853
|
+
ui.keyValue("Config", configManager.getConfigPath());
|
|
854
|
+
}
|
|
855
|
+
ui.br();
|
|
856
|
+
ui.box("CI/CD Authentication", [
|
|
857
|
+
"Service tokens are scoped to a specific environment.",
|
|
858
|
+
"Use this token in your CI/CD pipeline:",
|
|
859
|
+
"",
|
|
860
|
+
` ${colors.muted("# Set as environment variable")}`,
|
|
861
|
+
` ${colors.highlight("export SECRETSTASH_TOKEN=" + token.substring(0, 16) + "...")}`,
|
|
862
|
+
"",
|
|
863
|
+
` ${colors.muted("# Then run CLI commands normally")}`,
|
|
864
|
+
` ${colors.highlight("sstash pull -q")}`
|
|
865
|
+
]);
|
|
866
|
+
}
|
|
867
|
+
function registerAuthCommands(program2) {
|
|
868
|
+
program2.command("login").description("Authenticate with SecretStash").option("-e, --email <email>", "Email address").option("-t, --token <token>", "Service token for CI/CD authentication (must start with stk_)").action(async (options) => {
|
|
869
|
+
if (options.token) {
|
|
870
|
+
await handleServiceTokenLogin(options.token);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (process.env.SECRETSTASH_TOKEN) {
|
|
874
|
+
await handleServiceTokenLogin(process.env.SECRETSTASH_TOKEN, true);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
ui.heading("SecretStash Login");
|
|
878
|
+
let email = options.email;
|
|
879
|
+
if (!email) {
|
|
880
|
+
const response = await prompt({
|
|
881
|
+
type: "input",
|
|
882
|
+
name: "email",
|
|
883
|
+
message: "Email:",
|
|
884
|
+
validate: (value) => {
|
|
885
|
+
if (!value) return "Email is required";
|
|
886
|
+
if (!value.includes("@")) return "Invalid email format";
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
email = response.email;
|
|
891
|
+
}
|
|
892
|
+
const { password } = await prompt({
|
|
893
|
+
type: "password",
|
|
894
|
+
name: "password",
|
|
895
|
+
message: "Password:",
|
|
896
|
+
validate: (value) => value ? true : "Password is required"
|
|
897
|
+
});
|
|
898
|
+
const spinner = ui.spinner("Authenticating...");
|
|
899
|
+
const result = await apiClient.login(email, password);
|
|
900
|
+
if (result.error) {
|
|
901
|
+
spinner.fail();
|
|
902
|
+
if (result.error.code === "CLI_2FA_REQUIRED") {
|
|
903
|
+
ui.br();
|
|
904
|
+
ui.box("Two-Factor Authentication Required", [
|
|
905
|
+
"CLI access requires two-factor authentication.",
|
|
906
|
+
"",
|
|
907
|
+
"To set up 2FA:",
|
|
908
|
+
` 1. Visit ${colors.highlight("https://app.secretstash.dev/settings/security")}`,
|
|
909
|
+
" 2. Enable two-factor authentication",
|
|
910
|
+
" 3. Return here and try again",
|
|
911
|
+
"",
|
|
912
|
+
colors.muted("For CI/CD pipelines, use service tokens instead:"),
|
|
913
|
+
` ${colors.highlight("sstash login --token <service-token>")}`
|
|
914
|
+
]);
|
|
915
|
+
process.exit(1);
|
|
916
|
+
}
|
|
917
|
+
ui.error(result.error.message);
|
|
918
|
+
process.exit(1);
|
|
919
|
+
}
|
|
920
|
+
if (result.data?.requires2FA) {
|
|
921
|
+
spinner.stop();
|
|
922
|
+
ui.info("Two-factor authentication required");
|
|
923
|
+
const { code } = await prompt({
|
|
924
|
+
type: "input",
|
|
925
|
+
name: "code",
|
|
926
|
+
message: "2FA Code:",
|
|
927
|
+
validate: (value) => {
|
|
928
|
+
if (!value) return "2FA code is required";
|
|
929
|
+
if (!/^\d{6}$/.test(value)) return "Code must be 6 digits";
|
|
930
|
+
return true;
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
const spinner2 = ui.spinner("Verifying...");
|
|
934
|
+
const twoFAResult = await apiClient.verify2FA(
|
|
935
|
+
result.data.tempToken,
|
|
936
|
+
code
|
|
937
|
+
);
|
|
938
|
+
if (twoFAResult.error) {
|
|
939
|
+
spinner2.fail();
|
|
940
|
+
ui.error(twoFAResult.error.message);
|
|
941
|
+
process.exit(1);
|
|
942
|
+
}
|
|
943
|
+
configManager.setTokens(
|
|
944
|
+
twoFAResult.data.accessToken,
|
|
945
|
+
twoFAResult.data.refreshToken,
|
|
946
|
+
twoFAResult.data.expiresIn
|
|
947
|
+
);
|
|
948
|
+
configManager.setUser(twoFAResult.data.user.id, twoFAResult.data.user.email);
|
|
949
|
+
spinner2.succeed("Logged in successfully");
|
|
950
|
+
} else {
|
|
951
|
+
configManager.setTokens(
|
|
952
|
+
result.data.accessToken,
|
|
953
|
+
result.data.refreshToken,
|
|
954
|
+
result.data.expiresIn
|
|
955
|
+
);
|
|
956
|
+
configManager.setUser(result.data.user.id, result.data.user.email);
|
|
957
|
+
spinner.succeed("Logged in successfully");
|
|
958
|
+
}
|
|
959
|
+
ui.br();
|
|
960
|
+
ui.keyValue("Email", email);
|
|
961
|
+
ui.keyValue("Config", configManager.getConfigPath());
|
|
962
|
+
ui.br();
|
|
963
|
+
ui.info(`Run ${ui.code("sstash teams")} to see your teams`);
|
|
964
|
+
});
|
|
965
|
+
program2.command("logout").description("Log out from SecretStash").action(async () => {
|
|
966
|
+
if (!configManager.isAuthenticated()) {
|
|
967
|
+
ui.warning("You are not logged in");
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (configManager.isServiceTokenAuth()) {
|
|
971
|
+
if (process.env.SECRETSTASH_TOKEN) {
|
|
972
|
+
ui.warning("Using SECRETSTASH_TOKEN environment variable");
|
|
973
|
+
ui.info("Unset the environment variable to log out:");
|
|
974
|
+
ui.info(` ${ui.code("unset SECRETSTASH_TOKEN")}`);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
configManager.clearServiceToken();
|
|
978
|
+
ui.success("Service token cleared successfully");
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const spinner = ui.spinner("Logging out...");
|
|
982
|
+
await apiClient.logout();
|
|
983
|
+
spinner.succeed("Logged out successfully");
|
|
984
|
+
});
|
|
985
|
+
program2.command("whoami").description("Display current user information").action(async () => {
|
|
986
|
+
if (!configManager.isAuthenticated()) {
|
|
987
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
988
|
+
process.exit(1);
|
|
989
|
+
}
|
|
990
|
+
if (configManager.isServiceTokenAuth()) {
|
|
991
|
+
ui.heading("Current Authentication");
|
|
992
|
+
ui.keyValue("Auth Type", colors.highlight("Service Token"));
|
|
993
|
+
const serviceToken = configManager.getServiceToken();
|
|
994
|
+
if (serviceToken) {
|
|
995
|
+
ui.keyValue("Token", `${serviceToken.substring(0, 12)}...`);
|
|
996
|
+
}
|
|
997
|
+
if (process.env.SECRETSTASH_TOKEN) {
|
|
998
|
+
ui.keyValue("Source", "SECRETSTASH_TOKEN environment variable");
|
|
999
|
+
} else {
|
|
1000
|
+
ui.keyValue("Source", "Stored in config");
|
|
1001
|
+
}
|
|
1002
|
+
ui.br();
|
|
1003
|
+
ui.info("Service tokens are scoped to a specific environment");
|
|
1004
|
+
ui.info("User information is not available with service token auth");
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const spinner = ui.spinner("Fetching user info...");
|
|
1008
|
+
const result = await apiClient.getMe();
|
|
1009
|
+
if (result.error) {
|
|
1010
|
+
spinner.fail();
|
|
1011
|
+
ui.error(result.error.message);
|
|
1012
|
+
process.exit(1);
|
|
1013
|
+
}
|
|
1014
|
+
spinner.stop();
|
|
1015
|
+
ui.heading("Current User");
|
|
1016
|
+
ui.keyValue("Email", result.data.email);
|
|
1017
|
+
ui.keyValue("User ID", result.data.id);
|
|
1018
|
+
ui.keyValue("2FA", result.data.totpEnabled ? colors.success("Enabled") : colors.warning("Disabled"));
|
|
1019
|
+
ui.keyValue("Member since", new Date(result.data.createdAt).toLocaleDateString());
|
|
1020
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
1021
|
+
const currentProject = configManager.getCurrentProject();
|
|
1022
|
+
const currentEnv = configManager.getCurrentEnvironment();
|
|
1023
|
+
if (currentTeam || currentProject || currentEnv) {
|
|
1024
|
+
ui.br();
|
|
1025
|
+
ui.subheading("Current Context");
|
|
1026
|
+
if (currentTeam) ui.keyValue("Team", currentTeam.name);
|
|
1027
|
+
if (currentProject) ui.keyValue("Project", currentProject.name);
|
|
1028
|
+
if (currentEnv) ui.keyValue("Environment", ui.envBadge(currentEnv));
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
program2.command("2fa").description("Manage two-factor authentication").command("setup").description("Set up two-factor authentication").action(async () => {
|
|
1032
|
+
if (!configManager.isAuthenticated()) {
|
|
1033
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1034
|
+
process.exit(1);
|
|
1035
|
+
}
|
|
1036
|
+
const spinner = ui.spinner("Setting up 2FA...");
|
|
1037
|
+
const result = await apiClient.setup2FA();
|
|
1038
|
+
if (result.error) {
|
|
1039
|
+
spinner.fail();
|
|
1040
|
+
ui.error(result.error.message);
|
|
1041
|
+
process.exit(1);
|
|
1042
|
+
}
|
|
1043
|
+
spinner.stop();
|
|
1044
|
+
ui.heading("Two-Factor Authentication Setup");
|
|
1045
|
+
ui.info("Scan this QR code with your authenticator app:");
|
|
1046
|
+
ui.br();
|
|
1047
|
+
qrcode.generate(result.data.qrCodeUrl, { small: true });
|
|
1048
|
+
ui.br();
|
|
1049
|
+
ui.subheading("Or enter this secret manually:");
|
|
1050
|
+
console.log(colors.primary.bold(` ${result.data.secret}`));
|
|
1051
|
+
ui.br();
|
|
1052
|
+
ui.box("Backup Codes", [
|
|
1053
|
+
"Save these codes in a secure location.",
|
|
1054
|
+
"Each code can only be used once.",
|
|
1055
|
+
"",
|
|
1056
|
+
...result.data.backupCodes.map((code2) => ` ${colors.highlight(code2)}`)
|
|
1057
|
+
]);
|
|
1058
|
+
const { code } = await prompt({
|
|
1059
|
+
type: "input",
|
|
1060
|
+
name: "code",
|
|
1061
|
+
message: "Enter a code from your authenticator app to verify:",
|
|
1062
|
+
validate: (value) => {
|
|
1063
|
+
if (!value) return "Code is required";
|
|
1064
|
+
if (!/^\d{6}$/.test(value)) return "Code must be 6 digits";
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
const verifySpinner = ui.spinner("Verifying...");
|
|
1069
|
+
const enableResult = await apiClient.enable2FA(code);
|
|
1070
|
+
if (enableResult.error) {
|
|
1071
|
+
verifySpinner.fail();
|
|
1072
|
+
ui.error(enableResult.error.message);
|
|
1073
|
+
process.exit(1);
|
|
1074
|
+
}
|
|
1075
|
+
verifySpinner.succeed("2FA enabled successfully");
|
|
1076
|
+
ui.br();
|
|
1077
|
+
ui.success("Your account is now protected with two-factor authentication");
|
|
1078
|
+
});
|
|
1079
|
+
program2.command("register").description("Create a new SecretStash account").option("-e, --email <email>", "Email address").action(async (options) => {
|
|
1080
|
+
ui.heading("Create SecretStash Account");
|
|
1081
|
+
let email = options.email;
|
|
1082
|
+
if (!email) {
|
|
1083
|
+
const response = await prompt({
|
|
1084
|
+
type: "input",
|
|
1085
|
+
name: "email",
|
|
1086
|
+
message: "Email:",
|
|
1087
|
+
validate: (value) => {
|
|
1088
|
+
if (!value) return "Email is required";
|
|
1089
|
+
if (!value.includes("@")) return "Invalid email format";
|
|
1090
|
+
return true;
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
email = response.email;
|
|
1094
|
+
}
|
|
1095
|
+
const { password } = await prompt({
|
|
1096
|
+
type: "password",
|
|
1097
|
+
name: "password",
|
|
1098
|
+
message: "Password:",
|
|
1099
|
+
validate: (value) => {
|
|
1100
|
+
if (!value) return "Password is required";
|
|
1101
|
+
if (value.length < 12) return "Password must be at least 12 characters";
|
|
1102
|
+
if (!/[A-Z]/.test(value)) return "Password must contain an uppercase letter";
|
|
1103
|
+
if (!/[a-z]/.test(value)) return "Password must contain a lowercase letter";
|
|
1104
|
+
if (!/[0-9]/.test(value)) return "Password must contain a number";
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
const { confirmPassword } = await prompt({
|
|
1109
|
+
type: "password",
|
|
1110
|
+
name: "confirmPassword",
|
|
1111
|
+
message: "Confirm Password:",
|
|
1112
|
+
validate: (value) => value === password ? true : "Passwords do not match"
|
|
1113
|
+
});
|
|
1114
|
+
const spinner = ui.spinner("Creating account...");
|
|
1115
|
+
const result = await apiClient.register(email, password);
|
|
1116
|
+
if (result.error) {
|
|
1117
|
+
spinner.fail();
|
|
1118
|
+
ui.error(result.error.message);
|
|
1119
|
+
process.exit(1);
|
|
1120
|
+
}
|
|
1121
|
+
configManager.setTokens(
|
|
1122
|
+
result.data.accessToken,
|
|
1123
|
+
result.data.refreshToken,
|
|
1124
|
+
result.data.expiresIn
|
|
1125
|
+
);
|
|
1126
|
+
configManager.setUser(result.data.user.id, result.data.user.email);
|
|
1127
|
+
spinner.succeed("Account created successfully");
|
|
1128
|
+
ui.br();
|
|
1129
|
+
ui.keyValue("Email", email);
|
|
1130
|
+
ui.br();
|
|
1131
|
+
ui.info("We recommend setting up 2FA for added security");
|
|
1132
|
+
ui.info(`Run ${ui.code("sstash 2fa setup")} to enable two-factor authentication`);
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// src/commands/teams.ts
|
|
1137
|
+
import { prompt as prompt2 } from "enquirer";
|
|
1138
|
+
function registerTeamCommands(program2) {
|
|
1139
|
+
const teams = program2.command("teams").description("Manage teams");
|
|
1140
|
+
teams.command("list").alias("ls").description("List all teams you belong to").action(async () => {
|
|
1141
|
+
if (!configManager.isAuthenticated()) {
|
|
1142
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1143
|
+
process.exit(1);
|
|
1144
|
+
}
|
|
1145
|
+
const spinner = ui.spinner("Fetching teams...");
|
|
1146
|
+
const result = await apiClient.getTeams();
|
|
1147
|
+
if (result.error) {
|
|
1148
|
+
spinner.fail();
|
|
1149
|
+
ui.error(result.error.message);
|
|
1150
|
+
process.exit(1);
|
|
1151
|
+
}
|
|
1152
|
+
spinner.stop();
|
|
1153
|
+
if (!result.data?.teams.length) {
|
|
1154
|
+
ui.warning("You are not a member of any teams");
|
|
1155
|
+
ui.info(`Create one with ${ui.code("sstash teams create")}`);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
ui.heading("Your Teams");
|
|
1159
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
1160
|
+
const tableData = result.data.teams.map((team) => [
|
|
1161
|
+
team.id === currentTeam?.id ? "\u2192" : " ",
|
|
1162
|
+
team.name,
|
|
1163
|
+
team.slug,
|
|
1164
|
+
ui.roleBadge(team.role)
|
|
1165
|
+
]);
|
|
1166
|
+
ui.table(tableData, {
|
|
1167
|
+
header: ["", "Name", "Slug", "Role"]
|
|
1168
|
+
});
|
|
1169
|
+
ui.br();
|
|
1170
|
+
ui.info(`Switch teams with ${ui.code("sstash teams use <slug>")}`);
|
|
1171
|
+
});
|
|
1172
|
+
teams.command("create").description("Create a new team").option("-n, --name <name>", "Team name").action(async (options) => {
|
|
1173
|
+
if (!configManager.isAuthenticated()) {
|
|
1174
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1175
|
+
process.exit(1);
|
|
1176
|
+
}
|
|
1177
|
+
let name = options.name;
|
|
1178
|
+
if (!name) {
|
|
1179
|
+
const response = await prompt2({
|
|
1180
|
+
type: "input",
|
|
1181
|
+
name: "name",
|
|
1182
|
+
message: "Team name:",
|
|
1183
|
+
validate: (value) => {
|
|
1184
|
+
if (!value) return "Name is required";
|
|
1185
|
+
if (value.length < 2) return "Name must be at least 2 characters";
|
|
1186
|
+
return true;
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
name = response.name;
|
|
1190
|
+
}
|
|
1191
|
+
const spinner = ui.spinner("Creating team...");
|
|
1192
|
+
const result = await apiClient.createTeam(name);
|
|
1193
|
+
if (result.error) {
|
|
1194
|
+
spinner.fail();
|
|
1195
|
+
ui.error(result.error.message);
|
|
1196
|
+
process.exit(1);
|
|
1197
|
+
}
|
|
1198
|
+
configManager.setCurrentTeam({
|
|
1199
|
+
id: result.data.id,
|
|
1200
|
+
name: result.data.name,
|
|
1201
|
+
slug: result.data.slug
|
|
1202
|
+
});
|
|
1203
|
+
spinner.succeed(`Team "${name}" created successfully`);
|
|
1204
|
+
ui.br();
|
|
1205
|
+
ui.keyValue("ID", result.data.id);
|
|
1206
|
+
ui.keyValue("Slug", result.data.slug);
|
|
1207
|
+
ui.br();
|
|
1208
|
+
ui.info(`Create your first project with ${ui.code("sstash projects create")}`);
|
|
1209
|
+
});
|
|
1210
|
+
teams.command("use <slug>").description("Switch to a different team").action(async (slug) => {
|
|
1211
|
+
if (!configManager.isAuthenticated()) {
|
|
1212
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1213
|
+
process.exit(1);
|
|
1214
|
+
}
|
|
1215
|
+
const spinner = ui.spinner("Fetching teams...");
|
|
1216
|
+
const result = await apiClient.getTeams();
|
|
1217
|
+
if (result.error) {
|
|
1218
|
+
spinner.fail();
|
|
1219
|
+
ui.error(result.error.message);
|
|
1220
|
+
process.exit(1);
|
|
1221
|
+
}
|
|
1222
|
+
const team = result.data?.teams.find((t) => t.slug === slug);
|
|
1223
|
+
if (!team) {
|
|
1224
|
+
spinner.fail();
|
|
1225
|
+
ui.error(`Team "${slug}" not found or you don't have access`);
|
|
1226
|
+
process.exit(1);
|
|
1227
|
+
}
|
|
1228
|
+
configManager.setCurrentTeam({
|
|
1229
|
+
id: team.id,
|
|
1230
|
+
name: team.name,
|
|
1231
|
+
slug: team.slug
|
|
1232
|
+
});
|
|
1233
|
+
configManager.clearCurrentProject();
|
|
1234
|
+
configManager.clearCurrentEnvironment();
|
|
1235
|
+
spinner.succeed(`Switched to team "${team.name}"`);
|
|
1236
|
+
});
|
|
1237
|
+
teams.command("current").description("Show the current team").action(() => {
|
|
1238
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
1239
|
+
if (!currentTeam) {
|
|
1240
|
+
ui.warning("No team selected");
|
|
1241
|
+
ui.info(`Select one with ${ui.code("sstash teams use <slug>")}`);
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
ui.heading("Current Team");
|
|
1245
|
+
ui.keyValue("Name", currentTeam.name);
|
|
1246
|
+
ui.keyValue("Slug", currentTeam.slug);
|
|
1247
|
+
ui.keyValue("ID", currentTeam.id);
|
|
1248
|
+
});
|
|
1249
|
+
teams.command("invite <email>").description("Invite a member to the current team").option("-r, --role <role>", "Role for the invited member (member|admin)", "member").action(async (email, options) => {
|
|
1250
|
+
if (!configManager.isAuthenticated()) {
|
|
1251
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1252
|
+
process.exit(1);
|
|
1253
|
+
}
|
|
1254
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
1255
|
+
if (!currentTeam) {
|
|
1256
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
1257
|
+
process.exit(1);
|
|
1258
|
+
}
|
|
1259
|
+
const role = options.role.toLowerCase();
|
|
1260
|
+
if (role !== "member" && role !== "admin") {
|
|
1261
|
+
ui.error('Invalid role. Must be "member" or "admin".');
|
|
1262
|
+
process.exit(1);
|
|
1263
|
+
}
|
|
1264
|
+
const spinner = ui.spinner(`Inviting ${email} to ${currentTeam.name}...`);
|
|
1265
|
+
const result = await apiClient.inviteTeamMember(currentTeam.id, email, role);
|
|
1266
|
+
if (result.error) {
|
|
1267
|
+
spinner.fail();
|
|
1268
|
+
ui.error(result.error.message);
|
|
1269
|
+
process.exit(1);
|
|
1270
|
+
}
|
|
1271
|
+
spinner.succeed(`Invitation sent to ${email}`);
|
|
1272
|
+
ui.br();
|
|
1273
|
+
ui.keyValue("Team", currentTeam.name);
|
|
1274
|
+
ui.keyValue("Email", email);
|
|
1275
|
+
ui.keyValue("Role", ui.roleBadge(role));
|
|
1276
|
+
ui.br();
|
|
1277
|
+
ui.info("The invitation link has been generated. Share it with the invitee.");
|
|
1278
|
+
ui.info(`Invitation token: ${result.data?.token}`);
|
|
1279
|
+
});
|
|
1280
|
+
teams.command("members").description("List members of the current team").action(async () => {
|
|
1281
|
+
if (!configManager.isAuthenticated()) {
|
|
1282
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1283
|
+
process.exit(1);
|
|
1284
|
+
}
|
|
1285
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
1286
|
+
if (!currentTeam) {
|
|
1287
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
1288
|
+
process.exit(1);
|
|
1289
|
+
}
|
|
1290
|
+
const spinner = ui.spinner("Fetching team members...");
|
|
1291
|
+
const result = await apiClient.getTeamMembers(currentTeam.id);
|
|
1292
|
+
if (result.error) {
|
|
1293
|
+
spinner.fail();
|
|
1294
|
+
ui.error(result.error.message);
|
|
1295
|
+
process.exit(1);
|
|
1296
|
+
}
|
|
1297
|
+
spinner.stop();
|
|
1298
|
+
if (!result.data?.members.length) {
|
|
1299
|
+
ui.warning("No members found");
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
ui.heading(`Members of ${currentTeam.name}`);
|
|
1303
|
+
const tableData = result.data.members.map((member) => {
|
|
1304
|
+
const joinedDate = new Date(member.createdAt).toLocaleDateString();
|
|
1305
|
+
const twoFAStatus = member.user.totpEnabled ? ui.code("enabled") : ui.dim("disabled");
|
|
1306
|
+
return [
|
|
1307
|
+
member.user.email,
|
|
1308
|
+
ui.roleBadge(member.role),
|
|
1309
|
+
joinedDate,
|
|
1310
|
+
twoFAStatus
|
|
1311
|
+
];
|
|
1312
|
+
});
|
|
1313
|
+
ui.table(tableData, {
|
|
1314
|
+
header: ["Email", "Role", "Joined", "2FA"]
|
|
1315
|
+
});
|
|
1316
|
+
});
|
|
1317
|
+
teams.command("remove <email>").description("Remove a member from the current team").option("-f, --force", "Skip confirmation prompt").action(async (email, options) => {
|
|
1318
|
+
if (!configManager.isAuthenticated()) {
|
|
1319
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1320
|
+
process.exit(1);
|
|
1321
|
+
}
|
|
1322
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
1323
|
+
if (!currentTeam) {
|
|
1324
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
1325
|
+
process.exit(1);
|
|
1326
|
+
}
|
|
1327
|
+
const spinner = ui.spinner("Looking up member...");
|
|
1328
|
+
const membersResult = await apiClient.getTeamMembers(currentTeam.id);
|
|
1329
|
+
if (membersResult.error) {
|
|
1330
|
+
spinner.fail();
|
|
1331
|
+
ui.error(membersResult.error.message);
|
|
1332
|
+
process.exit(1);
|
|
1333
|
+
}
|
|
1334
|
+
const member = membersResult.data?.members.find(
|
|
1335
|
+
(m) => m.user.email.toLowerCase() === email.toLowerCase()
|
|
1336
|
+
);
|
|
1337
|
+
if (!member) {
|
|
1338
|
+
spinner.fail();
|
|
1339
|
+
ui.error(`Member "${email}" not found in team "${currentTeam.name}"`);
|
|
1340
|
+
process.exit(1);
|
|
1341
|
+
}
|
|
1342
|
+
const currentUser = configManager.getUser();
|
|
1343
|
+
if (member.user.email.toLowerCase() === currentUser.email?.toLowerCase()) {
|
|
1344
|
+
spinner.fail();
|
|
1345
|
+
ui.error("You cannot remove yourself from the team");
|
|
1346
|
+
process.exit(1);
|
|
1347
|
+
}
|
|
1348
|
+
if (member.role.toLowerCase() === "owner") {
|
|
1349
|
+
spinner.fail();
|
|
1350
|
+
ui.error("Cannot remove the team owner");
|
|
1351
|
+
process.exit(1);
|
|
1352
|
+
}
|
|
1353
|
+
spinner.stop();
|
|
1354
|
+
if (!options.force) {
|
|
1355
|
+
const response = await prompt2({
|
|
1356
|
+
type: "confirm",
|
|
1357
|
+
name: "confirm",
|
|
1358
|
+
message: `Are you sure you want to remove ${email} from ${currentTeam.name}?`,
|
|
1359
|
+
initial: false
|
|
1360
|
+
});
|
|
1361
|
+
if (!response.confirm) {
|
|
1362
|
+
ui.warning("Operation cancelled");
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
const removeSpinner = ui.spinner(`Removing ${email}...`);
|
|
1367
|
+
const removeResult = await apiClient.removeTeamMember(currentTeam.id, member.id);
|
|
1368
|
+
if (removeResult.error) {
|
|
1369
|
+
removeSpinner.fail();
|
|
1370
|
+
ui.error(removeResult.error.message);
|
|
1371
|
+
process.exit(1);
|
|
1372
|
+
}
|
|
1373
|
+
removeSpinner.succeed(`${email} has been removed from ${currentTeam.name}`);
|
|
1374
|
+
});
|
|
1375
|
+
teams.command("role <email> <role>").description("Change a member's role in the current team").option("-f, --force", "Skip confirmation prompt").action(async (email, role, options) => {
|
|
1376
|
+
if (!configManager.isAuthenticated()) {
|
|
1377
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1378
|
+
process.exit(1);
|
|
1379
|
+
}
|
|
1380
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
1381
|
+
if (!currentTeam) {
|
|
1382
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
1383
|
+
process.exit(1);
|
|
1384
|
+
}
|
|
1385
|
+
const validRoles = ["admin", "member"];
|
|
1386
|
+
const normalizedRole = role.toLowerCase();
|
|
1387
|
+
if (!validRoles.includes(normalizedRole)) {
|
|
1388
|
+
ui.error(`Invalid role "${role}". Must be one of: admin, member`);
|
|
1389
|
+
ui.info("Note: Owner role cannot be assigned via this command.");
|
|
1390
|
+
process.exit(1);
|
|
1391
|
+
}
|
|
1392
|
+
const spinner = ui.spinner("Looking up member...");
|
|
1393
|
+
const membersResult = await apiClient.getTeamMembers(currentTeam.id);
|
|
1394
|
+
if (membersResult.error) {
|
|
1395
|
+
spinner.fail();
|
|
1396
|
+
ui.error(membersResult.error.message);
|
|
1397
|
+
process.exit(1);
|
|
1398
|
+
}
|
|
1399
|
+
const member = membersResult.data?.members.find(
|
|
1400
|
+
(m) => m.user.email.toLowerCase() === email.toLowerCase()
|
|
1401
|
+
);
|
|
1402
|
+
if (!member) {
|
|
1403
|
+
spinner.fail();
|
|
1404
|
+
ui.error(`Member "${email}" not found in team "${currentTeam.name}"`);
|
|
1405
|
+
process.exit(1);
|
|
1406
|
+
}
|
|
1407
|
+
if (member.role.toLowerCase() === "owner") {
|
|
1408
|
+
spinner.fail();
|
|
1409
|
+
ui.error("Cannot change the role of the team owner");
|
|
1410
|
+
process.exit(1);
|
|
1411
|
+
}
|
|
1412
|
+
if (member.role.toLowerCase() === normalizedRole) {
|
|
1413
|
+
spinner.stop();
|
|
1414
|
+
ui.warning(`${email} is already a ${normalizedRole}`);
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
spinner.stop();
|
|
1418
|
+
if (!options.force) {
|
|
1419
|
+
const response = await prompt2({
|
|
1420
|
+
type: "confirm",
|
|
1421
|
+
name: "confirm",
|
|
1422
|
+
message: `Change ${email}'s role from ${member.role} to ${normalizedRole}?`,
|
|
1423
|
+
initial: false
|
|
1424
|
+
});
|
|
1425
|
+
if (!response.confirm) {
|
|
1426
|
+
ui.warning("Operation cancelled");
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
const updateSpinner = ui.spinner(`Updating role for ${email}...`);
|
|
1431
|
+
const updateResult = await apiClient.updateTeamMemberRole(
|
|
1432
|
+
currentTeam.id,
|
|
1433
|
+
member.id,
|
|
1434
|
+
normalizedRole
|
|
1435
|
+
);
|
|
1436
|
+
if (updateResult.error) {
|
|
1437
|
+
updateSpinner.fail();
|
|
1438
|
+
ui.error(updateResult.error.message);
|
|
1439
|
+
process.exit(1);
|
|
1440
|
+
}
|
|
1441
|
+
updateSpinner.succeed(`${email}'s role has been changed to ${normalizedRole}`);
|
|
1442
|
+
ui.br();
|
|
1443
|
+
ui.keyValue("Member", email);
|
|
1444
|
+
ui.keyValue("New Role", ui.roleBadge(normalizedRole));
|
|
1445
|
+
});
|
|
1446
|
+
teams.action(async () => {
|
|
1447
|
+
await teams.commands.find((c) => c.name() === "list")?.parseAsync([], { from: "user" });
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// src/commands/projects.ts
|
|
1452
|
+
import { prompt as prompt3 } from "enquirer";
|
|
1453
|
+
function requireTeam() {
|
|
1454
|
+
const team = configManager.getCurrentTeam();
|
|
1455
|
+
if (!team) {
|
|
1456
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
1457
|
+
process.exit(1);
|
|
1458
|
+
}
|
|
1459
|
+
return team;
|
|
1460
|
+
}
|
|
1461
|
+
function registerProjectCommands(program2) {
|
|
1462
|
+
const projects = program2.command("projects").description("Manage projects");
|
|
1463
|
+
projects.command("list").alias("ls").description("List all projects in the current team").action(async () => {
|
|
1464
|
+
if (!configManager.isAuthenticated()) {
|
|
1465
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1466
|
+
process.exit(1);
|
|
1467
|
+
}
|
|
1468
|
+
const team = requireTeam();
|
|
1469
|
+
const spinner = ui.spinner("Fetching projects...");
|
|
1470
|
+
const result = await apiClient.getProjects(team.id);
|
|
1471
|
+
if (result.error) {
|
|
1472
|
+
spinner.fail();
|
|
1473
|
+
ui.error(result.error.message);
|
|
1474
|
+
process.exit(1);
|
|
1475
|
+
}
|
|
1476
|
+
spinner.stop();
|
|
1477
|
+
if (!result.data?.projects.length) {
|
|
1478
|
+
ui.warning(`No projects in team "${team.name}"`);
|
|
1479
|
+
ui.info(`Create one with ${ui.code("sstash projects create")}`);
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
ui.heading(`Projects in ${team.name}`);
|
|
1483
|
+
const currentProject = configManager.getCurrentProject();
|
|
1484
|
+
const tableData = result.data.projects.map((project) => [
|
|
1485
|
+
project.id === currentProject?.id ? "\u2192" : " ",
|
|
1486
|
+
project.name,
|
|
1487
|
+
project.slug,
|
|
1488
|
+
String(project.environmentCount),
|
|
1489
|
+
String(project.secretCount)
|
|
1490
|
+
]);
|
|
1491
|
+
ui.table(tableData, {
|
|
1492
|
+
header: ["", "Name", "Slug", "Environments", "Secrets"]
|
|
1493
|
+
});
|
|
1494
|
+
ui.br();
|
|
1495
|
+
ui.info(`Switch projects with ${ui.code("sstash projects use <slug>")}`);
|
|
1496
|
+
});
|
|
1497
|
+
projects.command("create").description("Create a new project").option("-n, --name <name>", "Project name").option("-d, --description <description>", "Project description").action(async (options) => {
|
|
1498
|
+
if (!configManager.isAuthenticated()) {
|
|
1499
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1500
|
+
process.exit(1);
|
|
1501
|
+
}
|
|
1502
|
+
const team = requireTeam();
|
|
1503
|
+
let name = options.name;
|
|
1504
|
+
let description = options.description;
|
|
1505
|
+
if (!name) {
|
|
1506
|
+
const response = await prompt3([
|
|
1507
|
+
{
|
|
1508
|
+
type: "input",
|
|
1509
|
+
name: "name",
|
|
1510
|
+
message: "Project name:",
|
|
1511
|
+
validate: (value) => {
|
|
1512
|
+
if (!value) return "Name is required";
|
|
1513
|
+
if (value.length < 2) return "Name must be at least 2 characters";
|
|
1514
|
+
return true;
|
|
1515
|
+
}
|
|
1516
|
+
},
|
|
1517
|
+
{
|
|
1518
|
+
type: "input",
|
|
1519
|
+
name: "description",
|
|
1520
|
+
message: "Description (optional):"
|
|
1521
|
+
}
|
|
1522
|
+
]);
|
|
1523
|
+
name = response.name;
|
|
1524
|
+
description = response.description || void 0;
|
|
1525
|
+
}
|
|
1526
|
+
const spinner = ui.spinner("Creating project...");
|
|
1527
|
+
const result = await apiClient.createProject(team.id, name, description);
|
|
1528
|
+
if (result.error) {
|
|
1529
|
+
spinner.fail();
|
|
1530
|
+
ui.error(result.error.message);
|
|
1531
|
+
process.exit(1);
|
|
1532
|
+
}
|
|
1533
|
+
configManager.setCurrentProject({
|
|
1534
|
+
id: result.data.id,
|
|
1535
|
+
name: result.data.name,
|
|
1536
|
+
slug: result.data.slug
|
|
1537
|
+
});
|
|
1538
|
+
spinner.succeed(`Project "${name}" created successfully`);
|
|
1539
|
+
ui.br();
|
|
1540
|
+
ui.keyValue("ID", result.data.id);
|
|
1541
|
+
ui.keyValue("Slug", result.data.slug);
|
|
1542
|
+
ui.br();
|
|
1543
|
+
ui.info(`Create environments with ${ui.code("sstash environments create")}`);
|
|
1544
|
+
});
|
|
1545
|
+
projects.command("use <slug>").description("Switch to a different project").action(async (slug) => {
|
|
1546
|
+
if (!configManager.isAuthenticated()) {
|
|
1547
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1548
|
+
process.exit(1);
|
|
1549
|
+
}
|
|
1550
|
+
const team = requireTeam();
|
|
1551
|
+
const spinner = ui.spinner("Fetching projects...");
|
|
1552
|
+
const result = await apiClient.getProjects(team.id);
|
|
1553
|
+
if (result.error) {
|
|
1554
|
+
spinner.fail();
|
|
1555
|
+
ui.error(result.error.message);
|
|
1556
|
+
process.exit(1);
|
|
1557
|
+
}
|
|
1558
|
+
const project = result.data?.projects.find((p) => p.slug === slug);
|
|
1559
|
+
if (!project) {
|
|
1560
|
+
spinner.fail();
|
|
1561
|
+
ui.error(`Project "${slug}" not found in team "${team.name}"`);
|
|
1562
|
+
process.exit(1);
|
|
1563
|
+
}
|
|
1564
|
+
configManager.setCurrentProject({
|
|
1565
|
+
id: project.id,
|
|
1566
|
+
name: project.name,
|
|
1567
|
+
slug: project.slug
|
|
1568
|
+
});
|
|
1569
|
+
configManager.clearCurrentEnvironment();
|
|
1570
|
+
spinner.succeed(`Switched to project "${project.name}"`);
|
|
1571
|
+
});
|
|
1572
|
+
projects.command("current").description("Show the current project").action(() => {
|
|
1573
|
+
const currentProject = configManager.getCurrentProject();
|
|
1574
|
+
if (!currentProject) {
|
|
1575
|
+
ui.warning("No project selected");
|
|
1576
|
+
ui.info(`Select one with ${ui.code("sstash projects use <slug>")}`);
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
ui.heading("Current Project");
|
|
1580
|
+
ui.keyValue("Name", currentProject.name);
|
|
1581
|
+
ui.keyValue("Slug", currentProject.slug);
|
|
1582
|
+
ui.keyValue("ID", currentProject.id);
|
|
1583
|
+
});
|
|
1584
|
+
projects.command("delete <slug>").alias("rm").description("Delete a project and all its environments and secrets").option("-f, --force", "Skip confirmation prompt").action(async (slug, options) => {
|
|
1585
|
+
if (!configManager.isAuthenticated()) {
|
|
1586
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1587
|
+
process.exit(1);
|
|
1588
|
+
}
|
|
1589
|
+
const team = requireTeam();
|
|
1590
|
+
const spinner = ui.spinner("Fetching project details...");
|
|
1591
|
+
const result = await apiClient.getProjects(team.id);
|
|
1592
|
+
if (result.error) {
|
|
1593
|
+
spinner.fail();
|
|
1594
|
+
ui.error(result.error.message);
|
|
1595
|
+
process.exit(1);
|
|
1596
|
+
}
|
|
1597
|
+
const project = result.data?.projects.find((p) => p.slug === slug);
|
|
1598
|
+
if (!project) {
|
|
1599
|
+
spinner.fail();
|
|
1600
|
+
ui.error(`Project "${slug}" not found in team "${team.name}"`);
|
|
1601
|
+
process.exit(1);
|
|
1602
|
+
}
|
|
1603
|
+
spinner.stop();
|
|
1604
|
+
ui.br();
|
|
1605
|
+
ui.warning("This action will permanently delete:");
|
|
1606
|
+
ui.br();
|
|
1607
|
+
ui.keyValue("Project", project.name);
|
|
1608
|
+
ui.keyValue("Environments", String(project.environmentCount));
|
|
1609
|
+
ui.keyValue("Secrets", String(project.secretCount));
|
|
1610
|
+
ui.br();
|
|
1611
|
+
ui.error("This action cannot be undone!");
|
|
1612
|
+
ui.br();
|
|
1613
|
+
if (!options.force) {
|
|
1614
|
+
const { confirmName } = await prompt3({
|
|
1615
|
+
type: "input",
|
|
1616
|
+
name: "confirmName",
|
|
1617
|
+
message: `Type the project name "${project.name}" to confirm deletion:`
|
|
1618
|
+
});
|
|
1619
|
+
if (confirmName !== project.name) {
|
|
1620
|
+
ui.error("Project name does not match. Deletion cancelled.");
|
|
1621
|
+
process.exit(1);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
const deleteSpinner = ui.spinner(`Deleting project "${project.name}"...`);
|
|
1625
|
+
const deleteResult = await apiClient.deleteProject(project.id);
|
|
1626
|
+
if (deleteResult.error) {
|
|
1627
|
+
deleteSpinner.fail();
|
|
1628
|
+
ui.error(deleteResult.error.message);
|
|
1629
|
+
process.exit(1);
|
|
1630
|
+
}
|
|
1631
|
+
const currentProject = configManager.getCurrentProject();
|
|
1632
|
+
if (currentProject?.id === project.id) {
|
|
1633
|
+
configManager.clearCurrentProject();
|
|
1634
|
+
configManager.clearCurrentEnvironment();
|
|
1635
|
+
}
|
|
1636
|
+
deleteSpinner.succeed(`Project "${project.name}" deleted successfully`);
|
|
1637
|
+
ui.br();
|
|
1638
|
+
ui.keyValue("Environments deleted", String(project.environmentCount));
|
|
1639
|
+
ui.keyValue("Secrets deleted", String(project.secretCount));
|
|
1640
|
+
});
|
|
1641
|
+
projects.action(async () => {
|
|
1642
|
+
await projects.commands.find((c) => c.name() === "list")?.parseAsync([], { from: "user" });
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// src/commands/environments.ts
|
|
1647
|
+
import { prompt as prompt4 } from "enquirer";
|
|
1648
|
+
function requireProject() {
|
|
1649
|
+
const project = configManager.getCurrentProject();
|
|
1650
|
+
if (!project) {
|
|
1651
|
+
ui.error("No project selected. Run `sstash projects use <slug>` first.");
|
|
1652
|
+
process.exit(1);
|
|
1653
|
+
}
|
|
1654
|
+
return project;
|
|
1655
|
+
}
|
|
1656
|
+
function registerEnvironmentCommands(program2) {
|
|
1657
|
+
const envs = program2.command("environments").alias("envs").description("Manage environments");
|
|
1658
|
+
envs.command("list").alias("ls").description("List all environments in the current project").action(async () => {
|
|
1659
|
+
if (!configManager.isAuthenticated()) {
|
|
1660
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1661
|
+
process.exit(1);
|
|
1662
|
+
}
|
|
1663
|
+
const project = requireProject();
|
|
1664
|
+
const spinner = ui.spinner("Fetching environments...");
|
|
1665
|
+
const result = await apiClient.getEnvironments(project.id);
|
|
1666
|
+
if (result.error) {
|
|
1667
|
+
spinner.fail();
|
|
1668
|
+
ui.error(result.error.message);
|
|
1669
|
+
process.exit(1);
|
|
1670
|
+
}
|
|
1671
|
+
spinner.stop();
|
|
1672
|
+
if (!result.data?.environments.length) {
|
|
1673
|
+
ui.warning(`No environments in project "${project.name}"`);
|
|
1674
|
+
ui.info(`Create one with ${ui.code("sstash environments create")}`);
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
ui.heading(`Environments in ${project.name}`);
|
|
1678
|
+
const currentEnv = configManager.getCurrentEnvironment();
|
|
1679
|
+
const envMap = new Map(result.data.environments.map((e) => [e.id, e.slug]));
|
|
1680
|
+
const tableData = result.data.environments.map((env) => {
|
|
1681
|
+
const parentSlug = env.parentId ? envMap.get(env.parentId) : null;
|
|
1682
|
+
return [
|
|
1683
|
+
env.slug === currentEnv ? "\u2192" : " ",
|
|
1684
|
+
ui.envBadge(env.name),
|
|
1685
|
+
env.slug,
|
|
1686
|
+
String(env.secretCount),
|
|
1687
|
+
parentSlug ? ui.dim(`\u2190 ${parentSlug}`) : ""
|
|
1688
|
+
];
|
|
1689
|
+
});
|
|
1690
|
+
ui.table(tableData, {
|
|
1691
|
+
header: ["", "Name", "Slug", "Secrets", "Inherits From"]
|
|
1692
|
+
});
|
|
1693
|
+
ui.br();
|
|
1694
|
+
ui.info(`Switch environments with ${ui.code("sstash environments use <slug>")}`);
|
|
1695
|
+
});
|
|
1696
|
+
envs.command("create").description("Create a new environment").option("-n, --name <name>", "Environment name").option("--inherit-from <slug>", "Inherit secrets from parent environment (e.g., development)").action(async (options) => {
|
|
1697
|
+
if (!configManager.isAuthenticated()) {
|
|
1698
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1699
|
+
process.exit(1);
|
|
1700
|
+
}
|
|
1701
|
+
const project = requireProject();
|
|
1702
|
+
let name = options.name;
|
|
1703
|
+
if (!name) {
|
|
1704
|
+
const response = await prompt4({
|
|
1705
|
+
type: "select",
|
|
1706
|
+
name: "name",
|
|
1707
|
+
message: "Environment name:",
|
|
1708
|
+
choices: ["development", "staging", "production", "testing", "other"]
|
|
1709
|
+
});
|
|
1710
|
+
if (response.name === "other") {
|
|
1711
|
+
const custom = await prompt4({
|
|
1712
|
+
type: "input",
|
|
1713
|
+
name: "customName",
|
|
1714
|
+
message: "Custom environment name:",
|
|
1715
|
+
validate: (value) => {
|
|
1716
|
+
if (!value) return "Name is required";
|
|
1717
|
+
if (!/^[a-z][a-z0-9-]*$/.test(value)) {
|
|
1718
|
+
return "Name must start with a letter and contain only lowercase letters, numbers, and hyphens";
|
|
1719
|
+
}
|
|
1720
|
+
return true;
|
|
1721
|
+
}
|
|
1722
|
+
});
|
|
1723
|
+
name = custom.customName;
|
|
1724
|
+
} else {
|
|
1725
|
+
name = response.name;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
let parentId = null;
|
|
1729
|
+
if (options.inheritFrom) {
|
|
1730
|
+
const envsResult = await apiClient.getEnvironments(project.id);
|
|
1731
|
+
if (envsResult.error) {
|
|
1732
|
+
ui.error(envsResult.error.message);
|
|
1733
|
+
process.exit(1);
|
|
1734
|
+
}
|
|
1735
|
+
const parentEnv = envsResult.data?.environments.find((e) => e.slug === options.inheritFrom);
|
|
1736
|
+
if (!parentEnv) {
|
|
1737
|
+
ui.error(`Parent environment "${options.inheritFrom}" not found in project "${project.name}"`);
|
|
1738
|
+
process.exit(1);
|
|
1739
|
+
}
|
|
1740
|
+
parentId = parentEnv.id;
|
|
1741
|
+
}
|
|
1742
|
+
const spinner = ui.spinner("Creating environment...");
|
|
1743
|
+
const result = await apiClient.createEnvironment(project.id, name, parentId);
|
|
1744
|
+
if (result.error) {
|
|
1745
|
+
spinner.fail();
|
|
1746
|
+
ui.error(result.error.message);
|
|
1747
|
+
process.exit(1);
|
|
1748
|
+
}
|
|
1749
|
+
configManager.setCurrentEnvironment(result.data.slug);
|
|
1750
|
+
spinner.succeed(`Environment "${name}" created successfully`);
|
|
1751
|
+
ui.br();
|
|
1752
|
+
ui.keyValue("ID", result.data.id);
|
|
1753
|
+
ui.keyValue("Slug", result.data.slug);
|
|
1754
|
+
if (options.inheritFrom) {
|
|
1755
|
+
ui.keyValue("Inherits from", options.inheritFrom);
|
|
1756
|
+
}
|
|
1757
|
+
ui.br();
|
|
1758
|
+
ui.info(`Add secrets with ${ui.code("sstash secrets create")} or ${ui.code("sstash push")}`);
|
|
1759
|
+
});
|
|
1760
|
+
envs.command("use <slug>").description("Switch to a different environment").action(async (slug) => {
|
|
1761
|
+
if (!configManager.isAuthenticated()) {
|
|
1762
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1763
|
+
process.exit(1);
|
|
1764
|
+
}
|
|
1765
|
+
const project = requireProject();
|
|
1766
|
+
const spinner = ui.spinner("Fetching environments...");
|
|
1767
|
+
const result = await apiClient.getEnvironments(project.id);
|
|
1768
|
+
if (result.error) {
|
|
1769
|
+
spinner.fail();
|
|
1770
|
+
ui.error(result.error.message);
|
|
1771
|
+
process.exit(1);
|
|
1772
|
+
}
|
|
1773
|
+
const env = result.data?.environments.find((e) => e.slug === slug);
|
|
1774
|
+
if (!env) {
|
|
1775
|
+
spinner.fail();
|
|
1776
|
+
ui.error(`Environment "${slug}" not found in project "${project.name}"`);
|
|
1777
|
+
process.exit(1);
|
|
1778
|
+
}
|
|
1779
|
+
configManager.setCurrentEnvironment(env.slug);
|
|
1780
|
+
spinner.succeed(`Switched to environment ${ui.envBadge(env.name)}`);
|
|
1781
|
+
});
|
|
1782
|
+
envs.command("current").description("Show the current environment").action(() => {
|
|
1783
|
+
const currentEnv = configManager.getCurrentEnvironment();
|
|
1784
|
+
if (!currentEnv) {
|
|
1785
|
+
ui.warning("No environment selected");
|
|
1786
|
+
ui.info(`Select one with ${ui.code("sstash environments use <slug>")}`);
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
ui.heading("Current Environment");
|
|
1790
|
+
console.log(` ${ui.envBadge(currentEnv)}`);
|
|
1791
|
+
});
|
|
1792
|
+
envs.command("clone <source> <target>").description("Clone an environment with all its secrets").action(async (source, target) => {
|
|
1793
|
+
if (!configManager.isAuthenticated()) {
|
|
1794
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1795
|
+
process.exit(1);
|
|
1796
|
+
}
|
|
1797
|
+
const teamSlug = projectConfig.getEffectiveTeam();
|
|
1798
|
+
const projectSlug = projectConfig.getEffectiveProject();
|
|
1799
|
+
if (!teamSlug) {
|
|
1800
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
1801
|
+
process.exit(1);
|
|
1802
|
+
}
|
|
1803
|
+
if (!projectSlug) {
|
|
1804
|
+
ui.error("No project selected. Run `sstash projects use <slug>` first.");
|
|
1805
|
+
process.exit(1);
|
|
1806
|
+
}
|
|
1807
|
+
const { password } = await prompt4({
|
|
1808
|
+
type: "password",
|
|
1809
|
+
name: "password",
|
|
1810
|
+
message: "Enter your password to decrypt/encrypt secrets:"
|
|
1811
|
+
});
|
|
1812
|
+
const spinner = ui.spinner(`Fetching secrets from ${ui.envBadge(source)}...`);
|
|
1813
|
+
const pullResult = await apiClient.pullSecrets(teamSlug, projectSlug, source, password);
|
|
1814
|
+
if (pullResult.error) {
|
|
1815
|
+
spinner.fail();
|
|
1816
|
+
ui.error(pullResult.error.message);
|
|
1817
|
+
process.exit(1);
|
|
1818
|
+
}
|
|
1819
|
+
const secrets = pullResult.data?.secrets || [];
|
|
1820
|
+
if (secrets.length === 0) {
|
|
1821
|
+
spinner.fail();
|
|
1822
|
+
ui.warning(`No secrets found in source environment "${source}"`);
|
|
1823
|
+
process.exit(1);
|
|
1824
|
+
}
|
|
1825
|
+
spinner.text = `Checking if target environment "${target}" exists...`;
|
|
1826
|
+
const project = requireProject();
|
|
1827
|
+
const envsResult = await apiClient.getEnvironments(project.id);
|
|
1828
|
+
if (envsResult.error) {
|
|
1829
|
+
spinner.fail();
|
|
1830
|
+
ui.error(envsResult.error.message);
|
|
1831
|
+
process.exit(1);
|
|
1832
|
+
}
|
|
1833
|
+
const targetEnv = envsResult.data?.environments.find((e) => e.slug === target);
|
|
1834
|
+
let targetCreated = false;
|
|
1835
|
+
if (!targetEnv) {
|
|
1836
|
+
spinner.text = `Creating target environment "${target}"...`;
|
|
1837
|
+
const createResult = await apiClient.createEnvironment(project.id, target);
|
|
1838
|
+
if (createResult.error) {
|
|
1839
|
+
spinner.fail();
|
|
1840
|
+
ui.error(createResult.error.message);
|
|
1841
|
+
process.exit(1);
|
|
1842
|
+
}
|
|
1843
|
+
targetCreated = true;
|
|
1844
|
+
}
|
|
1845
|
+
spinner.text = `Copying ${secrets.length} secrets to ${ui.envBadge(target)}...`;
|
|
1846
|
+
const pushResult = await apiClient.pushSecrets(
|
|
1847
|
+
teamSlug,
|
|
1848
|
+
projectSlug,
|
|
1849
|
+
target,
|
|
1850
|
+
secrets.map((s) => ({ key: s.key, value: s.value })),
|
|
1851
|
+
password
|
|
1852
|
+
);
|
|
1853
|
+
if (pushResult.error) {
|
|
1854
|
+
spinner.fail();
|
|
1855
|
+
ui.error(pushResult.error.message);
|
|
1856
|
+
process.exit(1);
|
|
1857
|
+
}
|
|
1858
|
+
spinner.succeed(`Cloned ${secrets.length} secrets from "${source}" to "${target}"`);
|
|
1859
|
+
ui.br();
|
|
1860
|
+
ui.heading("Clone Summary");
|
|
1861
|
+
ui.keyValue("Source", ui.envBadge(source));
|
|
1862
|
+
ui.keyValue("Target", ui.envBadge(target));
|
|
1863
|
+
if (targetCreated) {
|
|
1864
|
+
console.log(` ${colors.success("+")} Created new environment "${target}"`);
|
|
1865
|
+
}
|
|
1866
|
+
ui.keyValue("Secrets copied", String(secrets.length));
|
|
1867
|
+
ui.br();
|
|
1868
|
+
console.log(ui.dim(" Copied secrets:"));
|
|
1869
|
+
for (const secret of secrets) {
|
|
1870
|
+
console.log(` ${colors.success("+")} ${secret.key}`);
|
|
1871
|
+
}
|
|
1872
|
+
ui.br();
|
|
1873
|
+
ui.info(`Switch to the new environment with ${ui.code(`sstash environments use ${target}`)}`);
|
|
1874
|
+
});
|
|
1875
|
+
envs.command("delete <slug>").description("Delete an environment").option("-f, --force", "Skip confirmation prompts").action(async (slug, options) => {
|
|
1876
|
+
if (!configManager.isAuthenticated()) {
|
|
1877
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
1878
|
+
process.exit(1);
|
|
1879
|
+
}
|
|
1880
|
+
const project = requireProject();
|
|
1881
|
+
const spinner = ui.spinner("Fetching environment details...");
|
|
1882
|
+
const result = await apiClient.getEnvironments(project.id);
|
|
1883
|
+
if (result.error) {
|
|
1884
|
+
spinner.fail();
|
|
1885
|
+
ui.error(result.error.message);
|
|
1886
|
+
process.exit(1);
|
|
1887
|
+
}
|
|
1888
|
+
const env = result.data?.environments.find((e) => e.slug === slug);
|
|
1889
|
+
if (!env) {
|
|
1890
|
+
spinner.fail();
|
|
1891
|
+
ui.error(`Environment "${slug}" not found in project "${project.name}"`);
|
|
1892
|
+
process.exit(1);
|
|
1893
|
+
}
|
|
1894
|
+
spinner.stop();
|
|
1895
|
+
if (env.secretCount > 0) {
|
|
1896
|
+
ui.br();
|
|
1897
|
+
ui.warning(`This environment contains ${env.secretCount} secret${env.secretCount === 1 ? "" : "s"} that will be permanently deleted.`);
|
|
1898
|
+
ui.br();
|
|
1899
|
+
}
|
|
1900
|
+
if (env.isProtected) {
|
|
1901
|
+
ui.br();
|
|
1902
|
+
console.log(colors.error.bold(" !! WARNING: This is a protected environment !!"));
|
|
1903
|
+
ui.br();
|
|
1904
|
+
}
|
|
1905
|
+
if (!options.force) {
|
|
1906
|
+
const { confirm } = await prompt4({
|
|
1907
|
+
type: "confirm",
|
|
1908
|
+
name: "confirm",
|
|
1909
|
+
message: `Are you sure you want to delete the "${env.name}" environment?`,
|
|
1910
|
+
initial: false
|
|
1911
|
+
});
|
|
1912
|
+
if (!confirm) {
|
|
1913
|
+
ui.info("Deletion cancelled");
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
if (env.isProtected) {
|
|
1917
|
+
const { confirmProtected } = await prompt4({
|
|
1918
|
+
type: "input",
|
|
1919
|
+
name: "confirmProtected",
|
|
1920
|
+
message: `Type "${env.slug}" to confirm deletion of this protected environment:`
|
|
1921
|
+
});
|
|
1922
|
+
if (confirmProtected !== env.slug) {
|
|
1923
|
+
ui.error("Environment name does not match. Deletion cancelled.");
|
|
1924
|
+
process.exit(1);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
const deleteSpinner = ui.spinner("Deleting environment...");
|
|
1929
|
+
const deleteResult = await apiClient.deleteEnvironment(env.id);
|
|
1930
|
+
if (deleteResult.error) {
|
|
1931
|
+
deleteSpinner.fail();
|
|
1932
|
+
ui.error(deleteResult.error.message);
|
|
1933
|
+
process.exit(1);
|
|
1934
|
+
}
|
|
1935
|
+
if (configManager.getCurrentEnvironment() === env.slug) {
|
|
1936
|
+
configManager.clearCurrentEnvironment();
|
|
1937
|
+
}
|
|
1938
|
+
deleteSpinner.succeed(`Environment "${env.name}" deleted successfully`);
|
|
1939
|
+
});
|
|
1940
|
+
envs.action(async () => {
|
|
1941
|
+
await envs.commands.find((c) => c.name() === "list")?.parseAsync([], { from: "user" });
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// src/commands/secrets.ts
|
|
1946
|
+
import { prompt as prompt5 } from "enquirer";
|
|
1947
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
|
|
1948
|
+
import { spawn } from "child_process";
|
|
1949
|
+
import yaml from "js-yaml";
|
|
1950
|
+
function requireContext() {
|
|
1951
|
+
const teamSlug = projectConfig.getEffectiveTeam();
|
|
1952
|
+
const projectSlug = projectConfig.getEffectiveProject();
|
|
1953
|
+
const environment = projectConfig.getEffectiveEnvironment();
|
|
1954
|
+
if (!teamSlug) {
|
|
1955
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
1956
|
+
process.exit(1);
|
|
1957
|
+
}
|
|
1958
|
+
if (!projectSlug) {
|
|
1959
|
+
ui.error("No project selected. Run `sstash projects use <slug>` first.");
|
|
1960
|
+
process.exit(1);
|
|
1961
|
+
}
|
|
1962
|
+
if (!environment) {
|
|
1963
|
+
ui.error("No environment selected. Run `sstash environments use <slug>` first.");
|
|
1964
|
+
process.exit(1);
|
|
1965
|
+
}
|
|
1966
|
+
return { teamSlug, projectSlug, environment };
|
|
1967
|
+
}
|
|
1968
|
+
function parseEnvFile(content) {
|
|
1969
|
+
const secrets = /* @__PURE__ */ new Map();
|
|
1970
|
+
const lines = content.split("\n");
|
|
1971
|
+
for (const line of lines) {
|
|
1972
|
+
const trimmed = line.trim();
|
|
1973
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1974
|
+
const equalIndex = trimmed.indexOf("=");
|
|
1975
|
+
if (equalIndex === -1) continue;
|
|
1976
|
+
const key = trimmed.substring(0, equalIndex).trim();
|
|
1977
|
+
let value = trimmed.substring(equalIndex + 1).trim();
|
|
1978
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
1979
|
+
value = value.slice(1, -1);
|
|
1980
|
+
}
|
|
1981
|
+
if (key) {
|
|
1982
|
+
secrets.set(key, value);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
return secrets;
|
|
1986
|
+
}
|
|
1987
|
+
function formatEnvFile(secrets) {
|
|
1988
|
+
const lines = [
|
|
1989
|
+
"# Generated by SecretStash CLI",
|
|
1990
|
+
`# Environment: ${projectConfig.getEffectiveEnvironment()}`,
|
|
1991
|
+
`# Generated at: ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
1992
|
+
""
|
|
1993
|
+
];
|
|
1994
|
+
const sorted = [...secrets].sort((a, b) => a.key.localeCompare(b.key));
|
|
1995
|
+
for (const secret of sorted) {
|
|
1996
|
+
const needsQuotes = /[\s#"'$\\]/.test(secret.value);
|
|
1997
|
+
const value = needsQuotes ? `"${secret.value.replace(/"/g, '\\"')}"` : secret.value;
|
|
1998
|
+
lines.push(`${secret.key}=${value}`);
|
|
1999
|
+
}
|
|
2000
|
+
return lines.join("\n") + "\n";
|
|
2001
|
+
}
|
|
2002
|
+
function registerSecretCommands(program2) {
|
|
2003
|
+
const secrets = program2.command("secrets").description("Manage secrets");
|
|
2004
|
+
secrets.command("list").alias("ls").description("List all secrets in the current environment").option("-r, --reveal", "Show secret values").option("-f, --format <format>", "Output format: table, json, or env", "table").option("--no-inherited", "Exclude inherited secrets from parent environments").action(async (options) => {
|
|
2005
|
+
const validFormats = ["table", "json", "env"];
|
|
2006
|
+
if (!validFormats.includes(options.format)) {
|
|
2007
|
+
ui.error(`Invalid format "${options.format}". Valid formats: ${validFormats.join(", ")}`);
|
|
2008
|
+
process.exit(1);
|
|
2009
|
+
}
|
|
2010
|
+
if (!configManager.isAuthenticated()) {
|
|
2011
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2012
|
+
process.exit(1);
|
|
2013
|
+
}
|
|
2014
|
+
const ctx = requireContext();
|
|
2015
|
+
const { password } = await prompt5({
|
|
2016
|
+
type: "password",
|
|
2017
|
+
name: "password",
|
|
2018
|
+
message: "Enter your password to decrypt secrets:"
|
|
2019
|
+
});
|
|
2020
|
+
const spinner = ui.spinner("Fetching secrets...");
|
|
2021
|
+
const includeInherited = options.inherited !== false;
|
|
2022
|
+
const result = await apiClient.pullSecrets(ctx.teamSlug, ctx.projectSlug, ctx.environment, password, { includeInherited });
|
|
2023
|
+
if (result.error) {
|
|
2024
|
+
spinner.fail();
|
|
2025
|
+
ui.error(result.error.message);
|
|
2026
|
+
process.exit(1);
|
|
2027
|
+
}
|
|
2028
|
+
spinner.stop();
|
|
2029
|
+
if (!result.data?.secrets.length) {
|
|
2030
|
+
if (options.format === "json") {
|
|
2031
|
+
console.log("[]");
|
|
2032
|
+
} else if (options.format === "env") {
|
|
2033
|
+
} else {
|
|
2034
|
+
ui.warning("No secrets in this environment");
|
|
2035
|
+
ui.info(`Add secrets with ${ui.code("sstash secrets create")} or ${ui.code("sstash push")}`);
|
|
2036
|
+
}
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
const secretsList = result.data.secrets;
|
|
2040
|
+
if (options.format === "json") {
|
|
2041
|
+
const jsonOutput = secretsList.map((secret) => ({
|
|
2042
|
+
key: secret.key,
|
|
2043
|
+
value: options.reveal ? secret.value : "********",
|
|
2044
|
+
...secret.description && { description: secret.description },
|
|
2045
|
+
...secret.inherited !== void 0 && { inherited: secret.inherited }
|
|
2046
|
+
}));
|
|
2047
|
+
console.log(JSON.stringify(jsonOutput, null, 2));
|
|
2048
|
+
} else if (options.format === "env") {
|
|
2049
|
+
const sorted = [...secretsList].sort((a, b) => a.key.localeCompare(b.key));
|
|
2050
|
+
for (const secret of sorted) {
|
|
2051
|
+
const value = options.reveal ? secret.value : "********";
|
|
2052
|
+
const needsQuotes = options.reveal && /[\s#"'$\\]/.test(value);
|
|
2053
|
+
const formattedValue = needsQuotes ? `"${value.replace(/"/g, '\\"')}"` : value;
|
|
2054
|
+
const inheritedComment = secret.inherited ? " # [inherited]" : "";
|
|
2055
|
+
console.log(`${secret.key}=${formattedValue}${inheritedComment}`);
|
|
2056
|
+
}
|
|
2057
|
+
} else {
|
|
2058
|
+
ui.heading(`Secrets in ${ui.envBadge(ctx.environment)}`);
|
|
2059
|
+
const hasDescriptions = secretsList.some((s) => s.description);
|
|
2060
|
+
const hasInherited = secretsList.some((s) => s.inherited);
|
|
2061
|
+
const tableData = secretsList.map((secret) => {
|
|
2062
|
+
const inheritedMarker = secret.inherited ? ui.dim("[inherited]") : "";
|
|
2063
|
+
const keyDisplay = secret.inherited ? `${secret.key} ${inheritedMarker}` : secret.key;
|
|
2064
|
+
if (hasDescriptions) {
|
|
2065
|
+
return [
|
|
2066
|
+
keyDisplay,
|
|
2067
|
+
ui.secret(secret.value, options.reveal),
|
|
2068
|
+
secret.description || "-"
|
|
2069
|
+
];
|
|
2070
|
+
} else {
|
|
2071
|
+
return [
|
|
2072
|
+
keyDisplay,
|
|
2073
|
+
ui.secret(secret.value, options.reveal)
|
|
2074
|
+
];
|
|
2075
|
+
}
|
|
2076
|
+
});
|
|
2077
|
+
ui.table(tableData, {
|
|
2078
|
+
header: hasDescriptions ? ["Key", "Value", "Description"] : ["Key", "Value"]
|
|
2079
|
+
});
|
|
2080
|
+
ui.br();
|
|
2081
|
+
const inheritedCount = secretsList.filter((s) => s.inherited).length;
|
|
2082
|
+
const ownCount = secretsList.length - inheritedCount;
|
|
2083
|
+
if (inheritedCount > 0) {
|
|
2084
|
+
ui.info(`${ownCount} own secrets, ${inheritedCount} inherited (${secretsList.length} total)`);
|
|
2085
|
+
} else {
|
|
2086
|
+
ui.info(`${secretsList.length} secrets total`);
|
|
2087
|
+
}
|
|
2088
|
+
if (!options.reveal) {
|
|
2089
|
+
ui.info(`Use ${ui.code("--reveal")} flag to show values`);
|
|
2090
|
+
}
|
|
2091
|
+
if (hasInherited && includeInherited) {
|
|
2092
|
+
ui.info(`Use ${ui.code("--no-inherited")} to hide inherited secrets`);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
});
|
|
2096
|
+
secrets.command("set <key> [value]").description("Create or update a single secret").option("-e, --env <environment>", "Environment to use").option("-p, --project <project>", "Project slug to use").option("-d, --description <description>", "Secret description").option("--from-file <file>", "Read value from file").option("--expires <date>", "Set expiration date (YYYY-MM-DD format)").action(async (key, value, options) => {
|
|
2097
|
+
if (!configManager.isAuthenticated()) {
|
|
2098
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2099
|
+
process.exit(1);
|
|
2100
|
+
}
|
|
2101
|
+
const ctx = requireContext();
|
|
2102
|
+
const environment = options.env || ctx.environment;
|
|
2103
|
+
const projectSlug = options.project || ctx.projectSlug;
|
|
2104
|
+
let secretValue = value;
|
|
2105
|
+
if (options.fromFile) {
|
|
2106
|
+
if (!existsSync2(options.fromFile)) {
|
|
2107
|
+
ui.error(`File not found: ${options.fromFile}`);
|
|
2108
|
+
process.exit(1);
|
|
2109
|
+
}
|
|
2110
|
+
secretValue = readFileSync2(options.fromFile, "utf-8");
|
|
2111
|
+
}
|
|
2112
|
+
if (!secretValue) {
|
|
2113
|
+
ui.error("Value is required. Provide it as an argument or use --from-file");
|
|
2114
|
+
process.exit(1);
|
|
2115
|
+
}
|
|
2116
|
+
const { password } = await prompt5({
|
|
2117
|
+
type: "password",
|
|
2118
|
+
name: "password",
|
|
2119
|
+
message: "Enter your password to encrypt secret:"
|
|
2120
|
+
});
|
|
2121
|
+
const spinner = ui.spinner(`Checking if secret "${key}" exists...`);
|
|
2122
|
+
const pullResult = await apiClient.pullSecrets(ctx.teamSlug, projectSlug, environment, password);
|
|
2123
|
+
if (pullResult.error) {
|
|
2124
|
+
spinner.fail();
|
|
2125
|
+
ui.error(pullResult.error.message);
|
|
2126
|
+
process.exit(1);
|
|
2127
|
+
}
|
|
2128
|
+
const existingSecret = pullResult.data?.secrets.find((s) => s.key === key);
|
|
2129
|
+
let expiresAt;
|
|
2130
|
+
if (options.expires) {
|
|
2131
|
+
const expDate = new Date(options.expires);
|
|
2132
|
+
if (isNaN(expDate.getTime())) {
|
|
2133
|
+
spinner.fail();
|
|
2134
|
+
ui.error("Invalid expiration date. Use YYYY-MM-DD format.");
|
|
2135
|
+
process.exit(1);
|
|
2136
|
+
}
|
|
2137
|
+
if (expDate <= /* @__PURE__ */ new Date()) {
|
|
2138
|
+
spinner.fail();
|
|
2139
|
+
ui.error("Expiration date must be in the future.");
|
|
2140
|
+
process.exit(1);
|
|
2141
|
+
}
|
|
2142
|
+
expiresAt = expDate.toISOString();
|
|
2143
|
+
}
|
|
2144
|
+
spinner.text = existingSecret ? `Updating secret "${key}"...` : `Creating secret "${key}"...`;
|
|
2145
|
+
const secrets2 = pullResult.data?.secrets.map((s) => ({
|
|
2146
|
+
key: s.key,
|
|
2147
|
+
value: s.key === key ? secretValue : s.value,
|
|
2148
|
+
// Preserve existing description unless this is the secret being updated and a new description is provided
|
|
2149
|
+
description: s.key === key && options.description !== void 0 ? options.description : s.description,
|
|
2150
|
+
// Only set expiresAt for the secret being updated
|
|
2151
|
+
...s.key === key && expiresAt ? { expiresAt } : {}
|
|
2152
|
+
})) || [];
|
|
2153
|
+
if (!existingSecret) {
|
|
2154
|
+
secrets2.push({ key, value: secretValue, description: options.description, expiresAt });
|
|
2155
|
+
}
|
|
2156
|
+
const result = await apiClient.pushSecrets(ctx.teamSlug, projectSlug, environment, secrets2, password);
|
|
2157
|
+
if (result.error) {
|
|
2158
|
+
spinner.fail();
|
|
2159
|
+
ui.error(result.error.message);
|
|
2160
|
+
process.exit(1);
|
|
2161
|
+
}
|
|
2162
|
+
spinner.succeed(existingSecret ? `Updated secret "${key}"` : `Created secret "${key}"`);
|
|
2163
|
+
ui.br();
|
|
2164
|
+
ui.keyValue("Environment", ui.envBadge(environment));
|
|
2165
|
+
if (options.description) {
|
|
2166
|
+
ui.keyValue("Description", options.description);
|
|
2167
|
+
}
|
|
2168
|
+
if (expiresAt) {
|
|
2169
|
+
ui.keyValue("Expires", new Date(expiresAt).toLocaleDateString());
|
|
2170
|
+
}
|
|
2171
|
+
});
|
|
2172
|
+
secrets.command("get <key>").description("Get a single secret value").option("-e, --env <environment>", "Environment to use").option("-p, --project <project>", "Project slug to use").option("-r, --reveal", "Show unmasked value").action(async (key, options) => {
|
|
2173
|
+
if (!configManager.isAuthenticated()) {
|
|
2174
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2175
|
+
process.exit(1);
|
|
2176
|
+
}
|
|
2177
|
+
const ctx = requireContext();
|
|
2178
|
+
const environment = options.env || ctx.environment;
|
|
2179
|
+
const projectSlug = options.project || ctx.projectSlug;
|
|
2180
|
+
const { password } = await prompt5({
|
|
2181
|
+
type: "password",
|
|
2182
|
+
name: "password",
|
|
2183
|
+
message: "Enter your password to decrypt secret:"
|
|
2184
|
+
});
|
|
2185
|
+
const spinner = ui.spinner(`Fetching secret "${key}"...`);
|
|
2186
|
+
const result = await apiClient.pullSecrets(ctx.teamSlug, projectSlug, environment, password);
|
|
2187
|
+
if (result.error) {
|
|
2188
|
+
spinner.fail();
|
|
2189
|
+
ui.error(result.error.message);
|
|
2190
|
+
process.exit(1);
|
|
2191
|
+
}
|
|
2192
|
+
const secret = result.data?.secrets.find((s) => s.key === key);
|
|
2193
|
+
if (!secret) {
|
|
2194
|
+
spinner.fail();
|
|
2195
|
+
ui.error(`Secret "${key}" not found in environment ${environment}`);
|
|
2196
|
+
process.exit(1);
|
|
2197
|
+
}
|
|
2198
|
+
spinner.stop();
|
|
2199
|
+
ui.heading(`Secret: ${key}`);
|
|
2200
|
+
ui.keyValue("Environment", ui.envBadge(environment));
|
|
2201
|
+
ui.keyValue("Value", ui.secret(secret.value, options.reveal));
|
|
2202
|
+
if (secret.description) {
|
|
2203
|
+
ui.keyValue("Description", secret.description);
|
|
2204
|
+
}
|
|
2205
|
+
ui.br();
|
|
2206
|
+
if (!options.reveal) {
|
|
2207
|
+
ui.info(`Use ${ui.code("--reveal")} flag to show the actual value`);
|
|
2208
|
+
}
|
|
2209
|
+
});
|
|
2210
|
+
secrets.command("delete <key>").alias("rm").description("Delete a single secret").option("-e, --env <environment>", "Environment to use").option("-p, --project <project>", "Project slug to use").option("-f, --force", "Skip confirmation").action(async (key, options) => {
|
|
2211
|
+
if (!configManager.isAuthenticated()) {
|
|
2212
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2213
|
+
process.exit(1);
|
|
2214
|
+
}
|
|
2215
|
+
const ctx = requireContext();
|
|
2216
|
+
const environment = options.env || ctx.environment;
|
|
2217
|
+
const projectSlug = options.project || ctx.projectSlug;
|
|
2218
|
+
const { password } = await prompt5({
|
|
2219
|
+
type: "password",
|
|
2220
|
+
name: "password",
|
|
2221
|
+
message: "Enter your password:"
|
|
2222
|
+
});
|
|
2223
|
+
const spinner = ui.spinner(`Looking for secret "${key}"...`);
|
|
2224
|
+
const result = await apiClient.pullSecrets(ctx.teamSlug, projectSlug, environment, password);
|
|
2225
|
+
if (result.error) {
|
|
2226
|
+
spinner.fail();
|
|
2227
|
+
ui.error(result.error.message);
|
|
2228
|
+
process.exit(1);
|
|
2229
|
+
}
|
|
2230
|
+
const secretToDelete = result.data?.secrets.find((s) => s.key === key);
|
|
2231
|
+
if (!secretToDelete) {
|
|
2232
|
+
spinner.fail();
|
|
2233
|
+
ui.error(`Secret "${key}" not found in environment ${environment}`);
|
|
2234
|
+
process.exit(1);
|
|
2235
|
+
}
|
|
2236
|
+
spinner.stop();
|
|
2237
|
+
if (!options.force) {
|
|
2238
|
+
ui.warning(`You are about to delete secret "${key}" from ${ui.envBadge(environment)}`);
|
|
2239
|
+
const { confirm } = await prompt5({
|
|
2240
|
+
type: "confirm",
|
|
2241
|
+
name: "confirm",
|
|
2242
|
+
message: "Are you sure you want to delete this secret?",
|
|
2243
|
+
initial: false
|
|
2244
|
+
});
|
|
2245
|
+
if (!confirm) {
|
|
2246
|
+
ui.warning("Aborted");
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
const deleteSpinner = ui.spinner(`Deleting secret "${key}"...`);
|
|
2251
|
+
const remainingSecrets = result.data?.secrets.filter((s) => s.key !== key).map((s) => ({ key: s.key, value: s.value })) || [];
|
|
2252
|
+
const pushResult = await apiClient.pushSecrets(ctx.teamSlug, projectSlug, environment, remainingSecrets, password);
|
|
2253
|
+
if (pushResult.error) {
|
|
2254
|
+
deleteSpinner.fail();
|
|
2255
|
+
ui.error(pushResult.error.message);
|
|
2256
|
+
process.exit(1);
|
|
2257
|
+
}
|
|
2258
|
+
deleteSpinner.succeed(`Deleted secret "${key}"`);
|
|
2259
|
+
ui.keyValue("Environment", ui.envBadge(environment));
|
|
2260
|
+
});
|
|
2261
|
+
secrets.command("history <key>").description("Show version history for a secret").option("-e, --env <environment>", "Environment to use").option("-p, --project <project>", "Project slug to use").option("-l, --limit <n>", "Number of versions to show", "10").action(async (key, options) => {
|
|
2262
|
+
if (!configManager.isAuthenticated()) {
|
|
2263
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2264
|
+
process.exit(1);
|
|
2265
|
+
}
|
|
2266
|
+
const ctx = requireContext();
|
|
2267
|
+
const environment = options.env || ctx.environment;
|
|
2268
|
+
const projectSlug = options.project || ctx.projectSlug;
|
|
2269
|
+
const limit = parseInt(options.limit, 10) || 10;
|
|
2270
|
+
const { password } = await prompt5({
|
|
2271
|
+
type: "password",
|
|
2272
|
+
name: "password",
|
|
2273
|
+
message: "Enter your password to decrypt secrets:"
|
|
2274
|
+
});
|
|
2275
|
+
const spinner = ui.spinner(`Fetching version history for "${key}"...`);
|
|
2276
|
+
const teamsResult = await apiClient.getTeams();
|
|
2277
|
+
if (teamsResult.error) {
|
|
2278
|
+
spinner.fail();
|
|
2279
|
+
ui.error(teamsResult.error.message);
|
|
2280
|
+
process.exit(1);
|
|
2281
|
+
}
|
|
2282
|
+
const team = teamsResult.data?.teams.find((t) => t.slug === ctx.teamSlug);
|
|
2283
|
+
if (!team) {
|
|
2284
|
+
spinner.fail();
|
|
2285
|
+
ui.error(`Team "${ctx.teamSlug}" not found`);
|
|
2286
|
+
process.exit(1);
|
|
2287
|
+
}
|
|
2288
|
+
const projectsResult = await apiClient.getProjects(team.id);
|
|
2289
|
+
if (projectsResult.error) {
|
|
2290
|
+
spinner.fail();
|
|
2291
|
+
ui.error(projectsResult.error.message);
|
|
2292
|
+
process.exit(1);
|
|
2293
|
+
}
|
|
2294
|
+
const project = projectsResult.data?.projects.find((p) => p.slug === projectSlug);
|
|
2295
|
+
if (!project) {
|
|
2296
|
+
spinner.fail();
|
|
2297
|
+
ui.error(`Project "${projectSlug}" not found`);
|
|
2298
|
+
process.exit(1);
|
|
2299
|
+
}
|
|
2300
|
+
const envsResult = await apiClient.getEnvironments(project.id);
|
|
2301
|
+
if (envsResult.error) {
|
|
2302
|
+
spinner.fail();
|
|
2303
|
+
ui.error(envsResult.error.message);
|
|
2304
|
+
process.exit(1);
|
|
2305
|
+
}
|
|
2306
|
+
const env = envsResult.data?.environments.find((e) => e.slug === environment || e.name.toLowerCase() === environment.toLowerCase());
|
|
2307
|
+
if (!env) {
|
|
2308
|
+
spinner.fail();
|
|
2309
|
+
ui.error(`Environment "${environment}" not found`);
|
|
2310
|
+
process.exit(1);
|
|
2311
|
+
}
|
|
2312
|
+
const secretsResult = await apiClient.getSecrets(env.id);
|
|
2313
|
+
if (secretsResult.error) {
|
|
2314
|
+
spinner.fail();
|
|
2315
|
+
ui.error(secretsResult.error.message);
|
|
2316
|
+
process.exit(1);
|
|
2317
|
+
}
|
|
2318
|
+
const secret = secretsResult.data?.secrets.find((s) => s.key === key);
|
|
2319
|
+
if (!secret) {
|
|
2320
|
+
spinner.fail();
|
|
2321
|
+
ui.error(`Secret "${key}" not found in environment ${environment}`);
|
|
2322
|
+
process.exit(1);
|
|
2323
|
+
}
|
|
2324
|
+
const versionsResult = await apiClient.getSecretVersions(secret.id, password, limit);
|
|
2325
|
+
if (versionsResult.error) {
|
|
2326
|
+
spinner.fail();
|
|
2327
|
+
ui.error(versionsResult.error.message);
|
|
2328
|
+
process.exit(1);
|
|
2329
|
+
}
|
|
2330
|
+
spinner.stop();
|
|
2331
|
+
const versions = versionsResult.data?.versions || [];
|
|
2332
|
+
if (versions.length === 0) {
|
|
2333
|
+
ui.heading(`Version History: ${key}`);
|
|
2334
|
+
ui.warning("No version history found. History is created when a secret value is changed.");
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
ui.heading(`Version History: ${key}`);
|
|
2338
|
+
ui.keyValue("Environment", ui.envBadge(environment));
|
|
2339
|
+
ui.br();
|
|
2340
|
+
const tableData = versions.map((v) => [
|
|
2341
|
+
`v${v.version}`,
|
|
2342
|
+
ui.secret(v.value, false),
|
|
2343
|
+
v.createdByEmail || v.createdBy,
|
|
2344
|
+
new Date(v.createdAt).toLocaleString()
|
|
2345
|
+
]);
|
|
2346
|
+
ui.table(tableData, {
|
|
2347
|
+
header: ["Version", "Value", "Updated By", "Updated At"]
|
|
2348
|
+
});
|
|
2349
|
+
ui.br();
|
|
2350
|
+
ui.info(`Showing ${versions.length} version(s)`);
|
|
2351
|
+
ui.info(`Use ${ui.code(`sstash secrets rollback ${key} --version <n>`)} to restore a previous version`);
|
|
2352
|
+
});
|
|
2353
|
+
secrets.command("rollback <key>").description("Restore a secret to a previous version").option("-e, --env <environment>", "Environment to use").option("-p, --project <project>", "Project slug to use").option("-v, --version <n>", "Version number to restore (required)").action(async (key, options) => {
|
|
2354
|
+
if (!configManager.isAuthenticated()) {
|
|
2355
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2356
|
+
process.exit(1);
|
|
2357
|
+
}
|
|
2358
|
+
if (!options.version) {
|
|
2359
|
+
ui.error("Version number is required. Use --version <n>");
|
|
2360
|
+
ui.info(`Use ${ui.code(`sstash secrets history ${key}`)} to see available versions`);
|
|
2361
|
+
process.exit(1);
|
|
2362
|
+
}
|
|
2363
|
+
const version2 = parseInt(options.version, 10);
|
|
2364
|
+
if (isNaN(version2) || version2 < 1) {
|
|
2365
|
+
ui.error("Version must be a positive number");
|
|
2366
|
+
process.exit(1);
|
|
2367
|
+
}
|
|
2368
|
+
const ctx = requireContext();
|
|
2369
|
+
const environment = options.env || ctx.environment;
|
|
2370
|
+
const projectSlug = options.project || ctx.projectSlug;
|
|
2371
|
+
const { password } = await prompt5({
|
|
2372
|
+
type: "password",
|
|
2373
|
+
name: "password",
|
|
2374
|
+
message: "Enter your password to decrypt and restore the secret:"
|
|
2375
|
+
});
|
|
2376
|
+
const spinner = ui.spinner(`Fetching secret information...`);
|
|
2377
|
+
const teamsResult = await apiClient.getTeams();
|
|
2378
|
+
if (teamsResult.error) {
|
|
2379
|
+
spinner.fail();
|
|
2380
|
+
ui.error(teamsResult.error.message);
|
|
2381
|
+
process.exit(1);
|
|
2382
|
+
}
|
|
2383
|
+
const team = teamsResult.data?.teams.find((t) => t.slug === ctx.teamSlug);
|
|
2384
|
+
if (!team) {
|
|
2385
|
+
spinner.fail();
|
|
2386
|
+
ui.error(`Team "${ctx.teamSlug}" not found`);
|
|
2387
|
+
process.exit(1);
|
|
2388
|
+
}
|
|
2389
|
+
const projectsResult = await apiClient.getProjects(team.id);
|
|
2390
|
+
if (projectsResult.error) {
|
|
2391
|
+
spinner.fail();
|
|
2392
|
+
ui.error(projectsResult.error.message);
|
|
2393
|
+
process.exit(1);
|
|
2394
|
+
}
|
|
2395
|
+
const project = projectsResult.data?.projects.find((p) => p.slug === projectSlug);
|
|
2396
|
+
if (!project) {
|
|
2397
|
+
spinner.fail();
|
|
2398
|
+
ui.error(`Project "${projectSlug}" not found`);
|
|
2399
|
+
process.exit(1);
|
|
2400
|
+
}
|
|
2401
|
+
const envsResult = await apiClient.getEnvironments(project.id);
|
|
2402
|
+
if (envsResult.error) {
|
|
2403
|
+
spinner.fail();
|
|
2404
|
+
ui.error(envsResult.error.message);
|
|
2405
|
+
process.exit(1);
|
|
2406
|
+
}
|
|
2407
|
+
const env = envsResult.data?.environments.find((e) => e.slug === environment || e.name.toLowerCase() === environment.toLowerCase());
|
|
2408
|
+
if (!env) {
|
|
2409
|
+
spinner.fail();
|
|
2410
|
+
ui.error(`Environment "${environment}" not found`);
|
|
2411
|
+
process.exit(1);
|
|
2412
|
+
}
|
|
2413
|
+
const secretsResult = await apiClient.getSecrets(env.id);
|
|
2414
|
+
if (secretsResult.error) {
|
|
2415
|
+
spinner.fail();
|
|
2416
|
+
ui.error(secretsResult.error.message);
|
|
2417
|
+
process.exit(1);
|
|
2418
|
+
}
|
|
2419
|
+
const secret = secretsResult.data?.secrets.find((s) => s.key === key);
|
|
2420
|
+
if (!secret) {
|
|
2421
|
+
spinner.fail();
|
|
2422
|
+
ui.error(`Secret "${key}" not found in environment ${environment}`);
|
|
2423
|
+
process.exit(1);
|
|
2424
|
+
}
|
|
2425
|
+
spinner.text = "Fetching version history...";
|
|
2426
|
+
const versionsResult = await apiClient.getSecretVersions(secret.id, password);
|
|
2427
|
+
if (versionsResult.error) {
|
|
2428
|
+
spinner.fail();
|
|
2429
|
+
ui.error(versionsResult.error.message);
|
|
2430
|
+
process.exit(1);
|
|
2431
|
+
}
|
|
2432
|
+
const versions = versionsResult.data?.versions || [];
|
|
2433
|
+
const targetVersion = versions.find((v) => v.version === version2);
|
|
2434
|
+
if (!targetVersion) {
|
|
2435
|
+
spinner.fail();
|
|
2436
|
+
ui.error(`Version ${version2} not found for secret "${key}"`);
|
|
2437
|
+
if (versions.length > 0) {
|
|
2438
|
+
ui.info(`Available versions: ${versions.map((v) => `v${v.version}`).join(", ")}`);
|
|
2439
|
+
} else {
|
|
2440
|
+
ui.info("No version history available for this secret");
|
|
2441
|
+
}
|
|
2442
|
+
process.exit(1);
|
|
2443
|
+
}
|
|
2444
|
+
spinner.stop();
|
|
2445
|
+
ui.heading(`Rollback Secret: ${key}`);
|
|
2446
|
+
ui.keyValue("Environment", ui.envBadge(environment));
|
|
2447
|
+
ui.br();
|
|
2448
|
+
console.log(colors.warning(" Target version:"));
|
|
2449
|
+
ui.keyValue(" Version", `v${targetVersion.version}`);
|
|
2450
|
+
ui.keyValue(" Value", ui.secret(targetVersion.value, false));
|
|
2451
|
+
ui.keyValue(" Created by", targetVersion.createdByEmail || targetVersion.createdBy);
|
|
2452
|
+
ui.keyValue(" Created at", new Date(targetVersion.createdAt).toLocaleString());
|
|
2453
|
+
ui.br();
|
|
2454
|
+
const { confirm } = await prompt5({
|
|
2455
|
+
type: "confirm",
|
|
2456
|
+
name: "confirm",
|
|
2457
|
+
message: `Restore secret "${key}" to version ${version2}?`,
|
|
2458
|
+
initial: false
|
|
2459
|
+
});
|
|
2460
|
+
if (!confirm) {
|
|
2461
|
+
ui.warning("Aborted");
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
const rollbackSpinner = ui.spinner(`Rolling back "${key}" to v${version2}...`);
|
|
2465
|
+
const rollbackResult = await apiClient.rollbackSecret(secret.id, version2, password);
|
|
2466
|
+
if (rollbackResult.error) {
|
|
2467
|
+
rollbackSpinner.fail();
|
|
2468
|
+
ui.error(rollbackResult.error.message);
|
|
2469
|
+
process.exit(1);
|
|
2470
|
+
}
|
|
2471
|
+
rollbackSpinner.succeed(`Rolled back "${key}" to version ${version2}`);
|
|
2472
|
+
ui.br();
|
|
2473
|
+
ui.keyValue("Secret", key);
|
|
2474
|
+
ui.keyValue("Environment", ui.envBadge(environment));
|
|
2475
|
+
ui.keyValue("Restored to", `v${version2}`);
|
|
2476
|
+
});
|
|
2477
|
+
program2.command("pull").description("Pull secrets from SecretStash to a local .env file").option("-e, --env <environment>", "Environment to pull from").option("-o, --output <file>", "Output file path").option("-f, --force", "Overwrite existing file without confirmation").action(async (options) => {
|
|
2478
|
+
if (!configManager.isAuthenticated()) {
|
|
2479
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2480
|
+
process.exit(1);
|
|
2481
|
+
}
|
|
2482
|
+
const ctx = requireContext();
|
|
2483
|
+
const environment = options.env || ctx.environment;
|
|
2484
|
+
const outputPath = options.output || envPaths.getEnvPath(environment);
|
|
2485
|
+
if (existsSync2(outputPath) && !options.force) {
|
|
2486
|
+
const { overwrite } = await prompt5({
|
|
2487
|
+
type: "confirm",
|
|
2488
|
+
name: "overwrite",
|
|
2489
|
+
message: `File ${outputPath} already exists. Overwrite?`,
|
|
2490
|
+
initial: false
|
|
2491
|
+
});
|
|
2492
|
+
if (!overwrite) {
|
|
2493
|
+
ui.warning("Aborted");
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
const { password } = await prompt5({
|
|
2498
|
+
type: "password",
|
|
2499
|
+
name: "password",
|
|
2500
|
+
message: "Enter your password to decrypt secrets:"
|
|
2501
|
+
});
|
|
2502
|
+
const spinner = ui.spinner(`Pulling secrets from ${ui.envBadge(environment)}...`);
|
|
2503
|
+
const result = await apiClient.pullSecrets(ctx.teamSlug, ctx.projectSlug, environment, password);
|
|
2504
|
+
if (result.error) {
|
|
2505
|
+
spinner.fail();
|
|
2506
|
+
ui.error(result.error.message);
|
|
2507
|
+
process.exit(1);
|
|
2508
|
+
}
|
|
2509
|
+
if (!result.data?.secrets.length) {
|
|
2510
|
+
spinner.fail();
|
|
2511
|
+
ui.warning("No secrets to pull");
|
|
2512
|
+
return;
|
|
2513
|
+
}
|
|
2514
|
+
const content = formatEnvFile(result.data.secrets);
|
|
2515
|
+
writeFileSync2(outputPath, content, "utf-8");
|
|
2516
|
+
spinner.succeed(`Pulled ${result.data.secrets.length} secrets to ${outputPath}`);
|
|
2517
|
+
ui.br();
|
|
2518
|
+
ui.warning(`Remember to add ${outputPath} to .gitignore!`);
|
|
2519
|
+
});
|
|
2520
|
+
program2.command("export").description("Export secrets in various formats (env, json, yaml)").option("-e, --env <environment>", "Environment to export from").option("-f, --format <format>", "Output format (env, json, yaml)", "env").option("-o, --output <file>", "Output file path (default: stdout)").action(async (options) => {
|
|
2521
|
+
if (!configManager.isAuthenticated()) {
|
|
2522
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2523
|
+
process.exit(1);
|
|
2524
|
+
}
|
|
2525
|
+
const ctx = requireContext();
|
|
2526
|
+
const environment = options.env || ctx.environment;
|
|
2527
|
+
const format = options.format.toLowerCase();
|
|
2528
|
+
if (!["env", "json", "yaml"].includes(format)) {
|
|
2529
|
+
ui.error(`Invalid format: ${format}. Valid formats are: env, json, yaml`);
|
|
2530
|
+
process.exit(1);
|
|
2531
|
+
}
|
|
2532
|
+
if (options.output && existsSync2(options.output)) {
|
|
2533
|
+
const { overwrite } = await prompt5({
|
|
2534
|
+
type: "confirm",
|
|
2535
|
+
name: "overwrite",
|
|
2536
|
+
message: `File ${options.output} already exists. Overwrite?`,
|
|
2537
|
+
initial: false
|
|
2538
|
+
});
|
|
2539
|
+
if (!overwrite) {
|
|
2540
|
+
ui.warning("Aborted");
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
const { password } = await prompt5({
|
|
2545
|
+
type: "password",
|
|
2546
|
+
name: "password",
|
|
2547
|
+
message: "Enter your password to decrypt secrets:"
|
|
2548
|
+
});
|
|
2549
|
+
const spinner = ui.spinner(`Fetching secrets from ${ui.envBadge(environment)}...`);
|
|
2550
|
+
const result = await apiClient.pullSecrets(ctx.teamSlug, ctx.projectSlug, environment, password);
|
|
2551
|
+
if (result.error) {
|
|
2552
|
+
spinner.fail();
|
|
2553
|
+
ui.error(result.error.message);
|
|
2554
|
+
process.exit(1);
|
|
2555
|
+
}
|
|
2556
|
+
if (!result.data?.secrets.length) {
|
|
2557
|
+
spinner.fail();
|
|
2558
|
+
ui.warning("No secrets to export");
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
spinner.stop();
|
|
2562
|
+
const sorted = [...result.data.secrets].sort((a, b) => a.key.localeCompare(b.key));
|
|
2563
|
+
let output;
|
|
2564
|
+
switch (format) {
|
|
2565
|
+
case "json": {
|
|
2566
|
+
const jsonObj = {};
|
|
2567
|
+
for (const secret of sorted) {
|
|
2568
|
+
jsonObj[secret.key] = secret.value;
|
|
2569
|
+
}
|
|
2570
|
+
output = JSON.stringify(jsonObj, null, 2) + "\n";
|
|
2571
|
+
break;
|
|
2572
|
+
}
|
|
2573
|
+
case "yaml": {
|
|
2574
|
+
const yamlObj = {};
|
|
2575
|
+
for (const secret of sorted) {
|
|
2576
|
+
yamlObj[secret.key] = secret.value;
|
|
2577
|
+
}
|
|
2578
|
+
output = yaml.dump(yamlObj, { lineWidth: -1, quotingType: '"', forceQuotes: true });
|
|
2579
|
+
break;
|
|
2580
|
+
}
|
|
2581
|
+
case "env":
|
|
2582
|
+
default: {
|
|
2583
|
+
output = formatEnvFile(sorted);
|
|
2584
|
+
break;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
if (options.output) {
|
|
2588
|
+
writeFileSync2(options.output, output, "utf-8");
|
|
2589
|
+
ui.success(`Exported ${result.data.secrets.length} secrets to ${options.output}`);
|
|
2590
|
+
ui.keyValue("Format", format);
|
|
2591
|
+
ui.keyValue("Environment", ui.envBadge(environment));
|
|
2592
|
+
ui.br();
|
|
2593
|
+
ui.warning(`Remember to add ${options.output} to .gitignore!`);
|
|
2594
|
+
} else {
|
|
2595
|
+
process.stdout.write(output);
|
|
2596
|
+
}
|
|
2597
|
+
});
|
|
2598
|
+
program2.command("push").description("Push secrets from a local .env file to SecretStash").option("-e, --env <environment>", "Environment to push to").option("-i, --input <file>", "Input file path").option("--dry-run", "Show what would be pushed without making changes").action(async (options) => {
|
|
2599
|
+
if (!configManager.isAuthenticated()) {
|
|
2600
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2601
|
+
process.exit(1);
|
|
2602
|
+
}
|
|
2603
|
+
const ctx = requireContext();
|
|
2604
|
+
const environment = options.env || ctx.environment;
|
|
2605
|
+
const inputPath = options.input || envPaths.getEnvPath(environment);
|
|
2606
|
+
if (!existsSync2(inputPath)) {
|
|
2607
|
+
ui.error(`File ${inputPath} not found`);
|
|
2608
|
+
ui.info(`Create one or specify a different file with ${ui.code("--input")}`);
|
|
2609
|
+
process.exit(1);
|
|
2610
|
+
}
|
|
2611
|
+
const spinner = ui.spinner(`Reading ${inputPath}...`);
|
|
2612
|
+
const content = readFileSync2(inputPath, "utf-8");
|
|
2613
|
+
const localSecrets = parseEnvFile(content);
|
|
2614
|
+
if (localSecrets.size === 0) {
|
|
2615
|
+
spinner.fail();
|
|
2616
|
+
ui.warning("No secrets found in file");
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
const { password } = await prompt5({
|
|
2620
|
+
type: "password",
|
|
2621
|
+
name: "password",
|
|
2622
|
+
message: "Enter your password to encrypt secrets:"
|
|
2623
|
+
});
|
|
2624
|
+
spinner.text = "Comparing with remote...";
|
|
2625
|
+
const remoteResult = await apiClient.pullSecrets(ctx.teamSlug, ctx.projectSlug, environment, password);
|
|
2626
|
+
const remoteSecrets = new Map(
|
|
2627
|
+
remoteResult.data?.secrets.map((s) => [s.key, s.value]) || []
|
|
2628
|
+
);
|
|
2629
|
+
const toCreate = [];
|
|
2630
|
+
const toUpdate = [];
|
|
2631
|
+
for (const [key, value] of localSecrets) {
|
|
2632
|
+
if (!remoteSecrets.has(key)) {
|
|
2633
|
+
toCreate.push({ key, value });
|
|
2634
|
+
} else if (remoteSecrets.get(key) !== value) {
|
|
2635
|
+
toUpdate.push({ key, value });
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
spinner.stop();
|
|
2639
|
+
if (toCreate.length === 0 && toUpdate.length === 0) {
|
|
2640
|
+
ui.success("All secrets are up to date");
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
ui.heading(`Changes for ${ui.envBadge(environment)}`);
|
|
2644
|
+
if (toCreate.length > 0) {
|
|
2645
|
+
console.log(colors.success.bold(` + ${toCreate.length} secrets to create`));
|
|
2646
|
+
toCreate.forEach((s) => console.log(` ${colors.success("+")} ${s.key}`));
|
|
2647
|
+
}
|
|
2648
|
+
if (toUpdate.length > 0) {
|
|
2649
|
+
console.log(colors.warning.bold(` ~ ${toUpdate.length} secrets to update`));
|
|
2650
|
+
toUpdate.forEach((s) => console.log(` ${colors.warning("~")} ${s.key}`));
|
|
2651
|
+
}
|
|
2652
|
+
ui.br();
|
|
2653
|
+
if (options.dryRun) {
|
|
2654
|
+
ui.info("Dry run - no changes made");
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
const { confirm } = await prompt5({
|
|
2658
|
+
type: "confirm",
|
|
2659
|
+
name: "confirm",
|
|
2660
|
+
message: "Push these changes?",
|
|
2661
|
+
initial: true
|
|
2662
|
+
});
|
|
2663
|
+
if (!confirm) {
|
|
2664
|
+
ui.warning("Aborted");
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
const pushSpinner = ui.spinner("Pushing secrets...");
|
|
2668
|
+
const secrets2 = [...localSecrets].map(([key, value]) => ({ key, value }));
|
|
2669
|
+
const result = await apiClient.pushSecrets(ctx.teamSlug, ctx.projectSlug, environment, secrets2, password);
|
|
2670
|
+
if (result.error) {
|
|
2671
|
+
pushSpinner.fail();
|
|
2672
|
+
ui.error(result.error.message);
|
|
2673
|
+
process.exit(1);
|
|
2674
|
+
}
|
|
2675
|
+
pushSpinner.succeed("Secrets pushed successfully");
|
|
2676
|
+
ui.br();
|
|
2677
|
+
ui.keyValue("Created", String(result.data?.created || 0));
|
|
2678
|
+
ui.keyValue("Updated", String(result.data?.updated || 0));
|
|
2679
|
+
});
|
|
2680
|
+
program2.command("run").description("Run a command with secrets injected as environment variables").option("-e, --env <environment>", "Environment to use").argument("<command...>", "Command to run").action(async (commandArgs, options) => {
|
|
2681
|
+
if (!configManager.isAuthenticated()) {
|
|
2682
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2683
|
+
process.exit(1);
|
|
2684
|
+
}
|
|
2685
|
+
const ctx = requireContext();
|
|
2686
|
+
const environment = options.env || ctx.environment;
|
|
2687
|
+
const { password } = await prompt5({
|
|
2688
|
+
type: "password",
|
|
2689
|
+
name: "password",
|
|
2690
|
+
message: "Enter your password to decrypt secrets:"
|
|
2691
|
+
});
|
|
2692
|
+
ui.warning("Note: Secrets will be injected as environment variables.");
|
|
2693
|
+
ui.warning("These may be visible via /proc/<pid>/environ on Linux.");
|
|
2694
|
+
ui.br();
|
|
2695
|
+
const spinner = ui.spinner(`Loading secrets from ${ui.envBadge(environment)}...`);
|
|
2696
|
+
const result = await apiClient.pullSecrets(ctx.teamSlug, ctx.projectSlug, environment, password);
|
|
2697
|
+
if (result.error) {
|
|
2698
|
+
spinner.fail();
|
|
2699
|
+
ui.error(result.error.message);
|
|
2700
|
+
process.exit(1);
|
|
2701
|
+
}
|
|
2702
|
+
spinner.stop();
|
|
2703
|
+
const secretEnv = {};
|
|
2704
|
+
for (const secret of result.data?.secrets || []) {
|
|
2705
|
+
secretEnv[secret.key] = secret.value;
|
|
2706
|
+
}
|
|
2707
|
+
ui.info(`Loaded ${Object.keys(secretEnv).length} secrets`);
|
|
2708
|
+
ui.br();
|
|
2709
|
+
const [cmd, ...args] = commandArgs;
|
|
2710
|
+
const child = spawn(cmd, args, {
|
|
2711
|
+
env: { ...process.env, ...secretEnv },
|
|
2712
|
+
stdio: "inherit",
|
|
2713
|
+
shell: true
|
|
2714
|
+
});
|
|
2715
|
+
child.on("close", (code) => {
|
|
2716
|
+
process.exit(code || 0);
|
|
2717
|
+
});
|
|
2718
|
+
child.on("error", (err) => {
|
|
2719
|
+
ui.error(`Failed to start command: ${err.message}`);
|
|
2720
|
+
process.exit(1);
|
|
2721
|
+
});
|
|
2722
|
+
});
|
|
2723
|
+
program2.command("init").description("Initialize SecretStash in the current directory").action(async () => {
|
|
2724
|
+
if (!configManager.isAuthenticated()) {
|
|
2725
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2726
|
+
process.exit(1);
|
|
2727
|
+
}
|
|
2728
|
+
ui.heading("Initialize SecretStash");
|
|
2729
|
+
const teamsSpinner = ui.spinner("Fetching teams...");
|
|
2730
|
+
const teamsResult = await apiClient.getTeams();
|
|
2731
|
+
if (teamsResult.error) {
|
|
2732
|
+
teamsSpinner.fail();
|
|
2733
|
+
ui.error(teamsResult.error.message);
|
|
2734
|
+
process.exit(1);
|
|
2735
|
+
}
|
|
2736
|
+
teamsSpinner.stop();
|
|
2737
|
+
if (!teamsResult.data?.teams.length) {
|
|
2738
|
+
ui.error("No teams found. Create one first with `sstash teams create`");
|
|
2739
|
+
process.exit(1);
|
|
2740
|
+
}
|
|
2741
|
+
const { teamSlug } = await prompt5({
|
|
2742
|
+
type: "select",
|
|
2743
|
+
name: "teamSlug",
|
|
2744
|
+
message: "Select team:",
|
|
2745
|
+
choices: teamsResult.data.teams.map((t) => ({
|
|
2746
|
+
name: t.slug,
|
|
2747
|
+
message: `${t.name} (${t.slug})`
|
|
2748
|
+
}))
|
|
2749
|
+
});
|
|
2750
|
+
const selectedTeam = teamsResult.data.teams.find((t) => t.slug === teamSlug);
|
|
2751
|
+
const projectsSpinner = ui.spinner("Fetching projects...");
|
|
2752
|
+
const projectsResult = await apiClient.getProjects(selectedTeam.id);
|
|
2753
|
+
projectsSpinner.stop();
|
|
2754
|
+
let projectSlug;
|
|
2755
|
+
if (!projectsResult.data?.projects.length) {
|
|
2756
|
+
ui.info("No projects found. Creating a new one...");
|
|
2757
|
+
const { name } = await prompt5({
|
|
2758
|
+
type: "input",
|
|
2759
|
+
name: "name",
|
|
2760
|
+
message: "Project name:",
|
|
2761
|
+
validate: (value) => value ? true : "Name is required"
|
|
2762
|
+
});
|
|
2763
|
+
const createResult = await apiClient.createProject(selectedTeam.id, name);
|
|
2764
|
+
if (createResult.error) {
|
|
2765
|
+
ui.error(createResult.error.message);
|
|
2766
|
+
process.exit(1);
|
|
2767
|
+
}
|
|
2768
|
+
projectSlug = createResult.data.slug;
|
|
2769
|
+
} else {
|
|
2770
|
+
const response = await prompt5({
|
|
2771
|
+
type: "select",
|
|
2772
|
+
name: "projectSlug",
|
|
2773
|
+
message: "Select project:",
|
|
2774
|
+
choices: [
|
|
2775
|
+
...projectsResult.data.projects.map((p) => ({
|
|
2776
|
+
name: p.slug,
|
|
2777
|
+
message: `${p.name} (${p.slug})`
|
|
2778
|
+
})),
|
|
2779
|
+
{ name: "__new__", message: "+ Create new project" }
|
|
2780
|
+
]
|
|
2781
|
+
});
|
|
2782
|
+
if (response.projectSlug === "__new__") {
|
|
2783
|
+
const { name } = await prompt5({
|
|
2784
|
+
type: "input",
|
|
2785
|
+
name: "name",
|
|
2786
|
+
message: "Project name:",
|
|
2787
|
+
validate: (value) => value ? true : "Name is required"
|
|
2788
|
+
});
|
|
2789
|
+
const createResult = await apiClient.createProject(selectedTeam.id, name);
|
|
2790
|
+
if (createResult.error) {
|
|
2791
|
+
ui.error(createResult.error.message);
|
|
2792
|
+
process.exit(1);
|
|
2793
|
+
}
|
|
2794
|
+
projectSlug = createResult.data.slug;
|
|
2795
|
+
} else {
|
|
2796
|
+
projectSlug = response.projectSlug;
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
const { environment } = await prompt5({
|
|
2800
|
+
type: "select",
|
|
2801
|
+
name: "environment",
|
|
2802
|
+
message: "Default environment:",
|
|
2803
|
+
choices: ["development", "staging", "production"],
|
|
2804
|
+
initial: 0
|
|
2805
|
+
});
|
|
2806
|
+
const config2 = {
|
|
2807
|
+
teamSlug,
|
|
2808
|
+
project: projectSlug,
|
|
2809
|
+
environment
|
|
2810
|
+
};
|
|
2811
|
+
const configPath = projectConfig.getLocalConfigPath();
|
|
2812
|
+
writeFileSync2(configPath, JSON.stringify(config2, null, 2) + "\n", "utf-8");
|
|
2813
|
+
ui.br();
|
|
2814
|
+
ui.success(`Created ${configPath}`);
|
|
2815
|
+
ui.br();
|
|
2816
|
+
ui.box("Next Steps", [
|
|
2817
|
+
`1. Add secrets: ${ui.code("sstash push .env")}`,
|
|
2818
|
+
`2. Pull secrets: ${ui.code("sstash pull")}`,
|
|
2819
|
+
`3. Run with secrets: ${ui.code("sstash run npm start")}`,
|
|
2820
|
+
"",
|
|
2821
|
+
`Add ${ui.code(".secretstash.json")} to version control`,
|
|
2822
|
+
`Add ${ui.code(".env*")} to .gitignore`
|
|
2823
|
+
]);
|
|
2824
|
+
});
|
|
2825
|
+
secrets.command("tag <key> <tagname>").description("Add a tag to a secret").option("-e, --env <environment>", "Environment to use").option("-p, --project <project>", "Project slug to use").action(async (key, tagname, options) => {
|
|
2826
|
+
if (!configManager.isAuthenticated()) {
|
|
2827
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2828
|
+
process.exit(1);
|
|
2829
|
+
}
|
|
2830
|
+
const ctx = requireContext();
|
|
2831
|
+
const environment = options.env || ctx.environment;
|
|
2832
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
2833
|
+
if (!currentTeam) {
|
|
2834
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
2835
|
+
process.exit(1);
|
|
2836
|
+
}
|
|
2837
|
+
const spinner = ui.spinner("Looking up secret and tag...");
|
|
2838
|
+
const tagsResult = await apiClient.getTags(currentTeam.id);
|
|
2839
|
+
if (tagsResult.error) {
|
|
2840
|
+
spinner.fail();
|
|
2841
|
+
ui.error(tagsResult.error.message);
|
|
2842
|
+
process.exit(1);
|
|
2843
|
+
}
|
|
2844
|
+
const tag = tagsResult.data?.tags.find(
|
|
2845
|
+
(t) => t.name.toLowerCase() === tagname.toLowerCase()
|
|
2846
|
+
);
|
|
2847
|
+
if (!tag) {
|
|
2848
|
+
spinner.fail();
|
|
2849
|
+
ui.error(`Tag "${tagname}" not found`);
|
|
2850
|
+
ui.info(`Create it with ${ui.code(`sstash tags create ${tagname}`)}`);
|
|
2851
|
+
process.exit(1);
|
|
2852
|
+
}
|
|
2853
|
+
const secretsResult = await apiClient.getSecrets(environment);
|
|
2854
|
+
if (secretsResult.error) {
|
|
2855
|
+
spinner.fail();
|
|
2856
|
+
ui.error(secretsResult.error.message);
|
|
2857
|
+
process.exit(1);
|
|
2858
|
+
}
|
|
2859
|
+
const secret = secretsResult.data?.secrets.find(
|
|
2860
|
+
(s) => s.key.toUpperCase() === key.toUpperCase()
|
|
2861
|
+
);
|
|
2862
|
+
if (!secret) {
|
|
2863
|
+
spinner.fail();
|
|
2864
|
+
ui.error(`Secret "${key}" not found in environment ${environment}`);
|
|
2865
|
+
process.exit(1);
|
|
2866
|
+
}
|
|
2867
|
+
spinner.text = `Tagging secret "${key}" with "${tag.name}"...`;
|
|
2868
|
+
const result = await apiClient.addTagToSecret(secret.id, tag.id);
|
|
2869
|
+
if (result.error) {
|
|
2870
|
+
spinner.fail();
|
|
2871
|
+
ui.error(result.error.message);
|
|
2872
|
+
process.exit(1);
|
|
2873
|
+
}
|
|
2874
|
+
spinner.succeed(`Tagged "${key}" with "${tag.name}"`);
|
|
2875
|
+
ui.br();
|
|
2876
|
+
ui.keyValue("Secret", key);
|
|
2877
|
+
ui.keyValue("Tag", tag.name);
|
|
2878
|
+
ui.keyValue("Environment", ui.envBadge(environment));
|
|
2879
|
+
});
|
|
2880
|
+
secrets.command("untag <key> <tagname>").description("Remove a tag from a secret").option("-e, --env <environment>", "Environment to use").option("-p, --project <project>", "Project slug to use").action(async (key, tagname, options) => {
|
|
2881
|
+
if (!configManager.isAuthenticated()) {
|
|
2882
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2883
|
+
process.exit(1);
|
|
2884
|
+
}
|
|
2885
|
+
const ctx = requireContext();
|
|
2886
|
+
const environment = options.env || ctx.environment;
|
|
2887
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
2888
|
+
if (!currentTeam) {
|
|
2889
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
2890
|
+
process.exit(1);
|
|
2891
|
+
}
|
|
2892
|
+
const spinner = ui.spinner("Looking up secret and tag...");
|
|
2893
|
+
const tagsResult = await apiClient.getTags(currentTeam.id);
|
|
2894
|
+
if (tagsResult.error) {
|
|
2895
|
+
spinner.fail();
|
|
2896
|
+
ui.error(tagsResult.error.message);
|
|
2897
|
+
process.exit(1);
|
|
2898
|
+
}
|
|
2899
|
+
const tag = tagsResult.data?.tags.find(
|
|
2900
|
+
(t) => t.name.toLowerCase() === tagname.toLowerCase()
|
|
2901
|
+
);
|
|
2902
|
+
if (!tag) {
|
|
2903
|
+
spinner.fail();
|
|
2904
|
+
ui.error(`Tag "${tagname}" not found`);
|
|
2905
|
+
process.exit(1);
|
|
2906
|
+
}
|
|
2907
|
+
const secretsResult = await apiClient.getSecrets(environment);
|
|
2908
|
+
if (secretsResult.error) {
|
|
2909
|
+
spinner.fail();
|
|
2910
|
+
ui.error(secretsResult.error.message);
|
|
2911
|
+
process.exit(1);
|
|
2912
|
+
}
|
|
2913
|
+
const secret = secretsResult.data?.secrets.find(
|
|
2914
|
+
(s) => s.key.toUpperCase() === key.toUpperCase()
|
|
2915
|
+
);
|
|
2916
|
+
if (!secret) {
|
|
2917
|
+
spinner.fail();
|
|
2918
|
+
ui.error(`Secret "${key}" not found in environment ${environment}`);
|
|
2919
|
+
process.exit(1);
|
|
2920
|
+
}
|
|
2921
|
+
spinner.text = `Removing tag "${tag.name}" from "${key}"...`;
|
|
2922
|
+
const result = await apiClient.removeTagFromSecret(secret.id, tag.id);
|
|
2923
|
+
if (result.error) {
|
|
2924
|
+
spinner.fail();
|
|
2925
|
+
ui.error(result.error.message);
|
|
2926
|
+
process.exit(1);
|
|
2927
|
+
}
|
|
2928
|
+
spinner.succeed(`Removed tag "${tag.name}" from "${key}"`);
|
|
2929
|
+
});
|
|
2930
|
+
secrets.command("expiring").description("List secrets that are expiring soon").option("--days <days>", "Number of days to look ahead", "7").action(async (options) => {
|
|
2931
|
+
if (!configManager.isAuthenticated()) {
|
|
2932
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
2933
|
+
process.exit(1);
|
|
2934
|
+
}
|
|
2935
|
+
const teamSlug = projectConfig.getEffectiveTeam();
|
|
2936
|
+
if (!teamSlug) {
|
|
2937
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
2938
|
+
process.exit(1);
|
|
2939
|
+
}
|
|
2940
|
+
const days = parseInt(options.days) || 7;
|
|
2941
|
+
const spinner = ui.spinner(`Fetching secrets expiring in the next ${days} days...`);
|
|
2942
|
+
const teamsResult = await apiClient.getTeams();
|
|
2943
|
+
if (teamsResult.error) {
|
|
2944
|
+
spinner.fail();
|
|
2945
|
+
ui.error(teamsResult.error.message);
|
|
2946
|
+
process.exit(1);
|
|
2947
|
+
}
|
|
2948
|
+
const team = teamsResult.data?.teams.find((t) => t.slug === teamSlug);
|
|
2949
|
+
if (!team) {
|
|
2950
|
+
spinner.fail();
|
|
2951
|
+
ui.error(`Team "${teamSlug}" not found`);
|
|
2952
|
+
process.exit(1);
|
|
2953
|
+
}
|
|
2954
|
+
const result = await apiClient.getExpiringSecrets(team.id, days);
|
|
2955
|
+
if (result.error) {
|
|
2956
|
+
spinner.fail();
|
|
2957
|
+
ui.error(result.error.message);
|
|
2958
|
+
process.exit(1);
|
|
2959
|
+
}
|
|
2960
|
+
spinner.stop();
|
|
2961
|
+
if (!result.data?.secrets.length) {
|
|
2962
|
+
ui.success(`No secrets expiring in the next ${days} days`);
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
ui.heading(`${colors.warning("\u26A0")} Secrets Expiring Soon (${days} days)`);
|
|
2966
|
+
const tableData = result.data.secrets.map((secret) => {
|
|
2967
|
+
const expiresDate = new Date(secret.expiresAt);
|
|
2968
|
+
const daysUntil = Math.ceil((expiresDate.getTime() - Date.now()) / (1e3 * 60 * 60 * 24));
|
|
2969
|
+
const urgency = daysUntil <= 1 ? colors.error(`${daysUntil}d`) : daysUntil <= 3 ? colors.warning(`${daysUntil}d`) : colors.muted(`${daysUntil}d`);
|
|
2970
|
+
return [
|
|
2971
|
+
secret.key,
|
|
2972
|
+
`${secret.projectName} / ${secret.environmentName}`,
|
|
2973
|
+
urgency,
|
|
2974
|
+
expiresDate.toLocaleDateString()
|
|
2975
|
+
];
|
|
2976
|
+
});
|
|
2977
|
+
ui.table(tableData, {
|
|
2978
|
+
header: ["Key", "Project / Environment", "Days Left", "Expires"]
|
|
2979
|
+
});
|
|
2980
|
+
ui.br();
|
|
2981
|
+
ui.warning(`${result.data.secrets.length} secret(s) expiring soon`);
|
|
2982
|
+
ui.info("Consider rotating these secrets before they expire.");
|
|
2983
|
+
});
|
|
2984
|
+
secrets.action(async () => {
|
|
2985
|
+
await secrets.commands.find((c) => c.name() === "list")?.parseAsync([], { from: "user" });
|
|
2986
|
+
});
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
// src/commands/diff.ts
|
|
2990
|
+
import { prompt as prompt6 } from "enquirer";
|
|
2991
|
+
function computeDiff(secrets1, secrets2) {
|
|
2992
|
+
const map1 = new Map(secrets1.map((s) => [s.key, s.value]));
|
|
2993
|
+
const map2 = new Map(secrets2.map((s) => [s.key, s.value]));
|
|
2994
|
+
const added = [];
|
|
2995
|
+
const removed = [];
|
|
2996
|
+
const changed = [];
|
|
2997
|
+
const unchanged = [];
|
|
2998
|
+
for (const [key, value1] of map1) {
|
|
2999
|
+
if (!map2.has(key)) {
|
|
3000
|
+
removed.push({ key, value: value1 });
|
|
3001
|
+
} else {
|
|
3002
|
+
const value2 = map2.get(key);
|
|
3003
|
+
if (value1 !== value2) {
|
|
3004
|
+
changed.push({ key, value1, value2 });
|
|
3005
|
+
} else {
|
|
3006
|
+
unchanged.push({ key, value: value1 });
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
for (const [key, value] of map2) {
|
|
3011
|
+
if (!map1.has(key)) {
|
|
3012
|
+
added.push({ key, value });
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
added.sort((a, b) => a.key.localeCompare(b.key));
|
|
3016
|
+
removed.sort((a, b) => a.key.localeCompare(b.key));
|
|
3017
|
+
changed.sort((a, b) => a.key.localeCompare(b.key));
|
|
3018
|
+
unchanged.sort((a, b) => a.key.localeCompare(b.key));
|
|
3019
|
+
return { added, removed, changed, unchanged };
|
|
3020
|
+
}
|
|
3021
|
+
function formatTableOutput(diff, showUnchanged) {
|
|
3022
|
+
const hasChanges = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0;
|
|
3023
|
+
if (!hasChanges && !showUnchanged) {
|
|
3024
|
+
ui.success("Environments are identical");
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
if (!hasChanges && showUnchanged && diff.unchanged.length === 0) {
|
|
3028
|
+
ui.warning("Both environments have no secrets");
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
ui.br();
|
|
3032
|
+
if (diff.added.length > 0) {
|
|
3033
|
+
for (const secret of diff.added) {
|
|
3034
|
+
console.log(`${colors.success("+")} ${secret.key} ${colors.muted("(only in target)")}`);
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
if (diff.removed.length > 0) {
|
|
3038
|
+
for (const secret of diff.removed) {
|
|
3039
|
+
console.log(`${colors.error("-")} ${secret.key} ${colors.muted("(only in source)")}`);
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
if (diff.changed.length > 0) {
|
|
3043
|
+
for (const item of diff.changed) {
|
|
3044
|
+
console.log(`${colors.warning("~")} ${item.key} ${colors.muted("(value differs)")}`);
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
if (showUnchanged && diff.unchanged.length > 0) {
|
|
3048
|
+
for (const secret of diff.unchanged) {
|
|
3049
|
+
console.log(`${colors.muted("=")} ${secret.key} ${colors.muted("(same in both)")}`);
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
ui.br();
|
|
3053
|
+
const parts = [];
|
|
3054
|
+
if (diff.added.length > 0) {
|
|
3055
|
+
parts.push(colors.success(`+${diff.added.length} added`));
|
|
3056
|
+
}
|
|
3057
|
+
if (diff.removed.length > 0) {
|
|
3058
|
+
parts.push(colors.error(`-${diff.removed.length} removed`));
|
|
3059
|
+
}
|
|
3060
|
+
if (diff.changed.length > 0) {
|
|
3061
|
+
parts.push(colors.warning(`~${diff.changed.length} changed`));
|
|
3062
|
+
}
|
|
3063
|
+
if (showUnchanged && diff.unchanged.length > 0) {
|
|
3064
|
+
parts.push(colors.muted(`=${diff.unchanged.length} unchanged`));
|
|
3065
|
+
}
|
|
3066
|
+
if (parts.length > 0) {
|
|
3067
|
+
ui.info(`Summary: ${parts.join(", ")}`);
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
function formatJsonOutput(diff, showUnchanged) {
|
|
3071
|
+
const output = {
|
|
3072
|
+
added: diff.added.map((s) => s.key),
|
|
3073
|
+
removed: diff.removed.map((s) => s.key),
|
|
3074
|
+
changed: diff.changed.map((s) => s.key)
|
|
3075
|
+
};
|
|
3076
|
+
if (showUnchanged) {
|
|
3077
|
+
output.unchanged = diff.unchanged.map((s) => s.key);
|
|
3078
|
+
}
|
|
3079
|
+
console.log(JSON.stringify(output, null, 2));
|
|
3080
|
+
}
|
|
3081
|
+
function requirePartialContext() {
|
|
3082
|
+
const teamSlug = projectConfig.getEffectiveTeam();
|
|
3083
|
+
const projectSlug = projectConfig.getEffectiveProject();
|
|
3084
|
+
if (!teamSlug) {
|
|
3085
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
3086
|
+
process.exit(1);
|
|
3087
|
+
}
|
|
3088
|
+
if (!projectSlug) {
|
|
3089
|
+
ui.error("No project selected. Run `sstash projects use <slug>` first.");
|
|
3090
|
+
process.exit(1);
|
|
3091
|
+
}
|
|
3092
|
+
return { teamSlug, projectSlug };
|
|
3093
|
+
}
|
|
3094
|
+
function registerDiffCommand(program2) {
|
|
3095
|
+
program2.command("diff <env1> <env2>").description("Compare secrets between two environments").option("-p, --project <slug>", "Use specific project").option("--show-unchanged", "Include secrets that are the same in both environments").option("-f, --format <format>", "Output format: table or json", "table").action(async (env1, env2, options) => {
|
|
3096
|
+
if (!configManager.isAuthenticated()) {
|
|
3097
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
3098
|
+
process.exit(1);
|
|
3099
|
+
}
|
|
3100
|
+
const ctx = requirePartialContext();
|
|
3101
|
+
const projectSlug = options.project || ctx.projectSlug;
|
|
3102
|
+
if (options.format !== "table" && options.format !== "json") {
|
|
3103
|
+
ui.error(`Invalid format: ${options.format}. Use 'table' or 'json'.`);
|
|
3104
|
+
process.exit(1);
|
|
3105
|
+
}
|
|
3106
|
+
if (env1 === env2) {
|
|
3107
|
+
ui.error("Cannot compare an environment with itself.");
|
|
3108
|
+
process.exit(1);
|
|
3109
|
+
}
|
|
3110
|
+
const { password } = await prompt6({
|
|
3111
|
+
type: "password",
|
|
3112
|
+
name: "password",
|
|
3113
|
+
message: "Enter your password to decrypt secrets:"
|
|
3114
|
+
});
|
|
3115
|
+
const spinner = ui.spinner(`Comparing ${ui.envBadge(env1)} and ${ui.envBadge(env2)}...`);
|
|
3116
|
+
const [result1, result2] = await Promise.all([
|
|
3117
|
+
apiClient.pullSecrets(ctx.teamSlug, projectSlug, env1, password),
|
|
3118
|
+
apiClient.pullSecrets(ctx.teamSlug, projectSlug, env2, password)
|
|
3119
|
+
]);
|
|
3120
|
+
if (result1.error) {
|
|
3121
|
+
spinner.fail();
|
|
3122
|
+
ui.error(`Failed to fetch secrets from ${env1}: ${result1.error.message}`);
|
|
3123
|
+
process.exit(1);
|
|
3124
|
+
}
|
|
3125
|
+
if (result2.error) {
|
|
3126
|
+
spinner.fail();
|
|
3127
|
+
ui.error(`Failed to fetch secrets from ${env2}: ${result2.error.message}`);
|
|
3128
|
+
process.exit(1);
|
|
3129
|
+
}
|
|
3130
|
+
spinner.stop();
|
|
3131
|
+
const secrets1 = result1.data?.secrets || [];
|
|
3132
|
+
const secrets2 = result2.data?.secrets || [];
|
|
3133
|
+
const diff = computeDiff(secrets1, secrets2);
|
|
3134
|
+
if (options.format === "json") {
|
|
3135
|
+
formatJsonOutput(diff, options.showUnchanged);
|
|
3136
|
+
} else {
|
|
3137
|
+
ui.heading(`Comparing ${ui.envBadge(env1)} \u2192 ${ui.envBadge(env2)}`);
|
|
3138
|
+
formatTableOutput(diff, options.showUnchanged);
|
|
3139
|
+
}
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
// src/commands/share.ts
|
|
3144
|
+
import { prompt as prompt7 } from "enquirer";
|
|
3145
|
+
function requireContext2() {
|
|
3146
|
+
const teamSlug = projectConfig.getEffectiveTeam();
|
|
3147
|
+
const projectSlug = projectConfig.getEffectiveProject();
|
|
3148
|
+
const environment = projectConfig.getEffectiveEnvironment();
|
|
3149
|
+
if (!teamSlug) {
|
|
3150
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
3151
|
+
process.exit(1);
|
|
3152
|
+
}
|
|
3153
|
+
if (!projectSlug) {
|
|
3154
|
+
ui.error("No project selected. Run `sstash projects use <slug>` first.");
|
|
3155
|
+
process.exit(1);
|
|
3156
|
+
}
|
|
3157
|
+
if (!environment) {
|
|
3158
|
+
ui.error("No environment selected. Run `sstash environments use <slug>` first.");
|
|
3159
|
+
process.exit(1);
|
|
3160
|
+
}
|
|
3161
|
+
return { teamSlug, projectSlug, environment };
|
|
3162
|
+
}
|
|
3163
|
+
function formatExpiration(expiresAt) {
|
|
3164
|
+
const now = /* @__PURE__ */ new Date();
|
|
3165
|
+
const expiry = new Date(expiresAt);
|
|
3166
|
+
const diffMs = expiry.getTime() - now.getTime();
|
|
3167
|
+
if (diffMs <= 0) {
|
|
3168
|
+
return colors.error("Expired");
|
|
3169
|
+
}
|
|
3170
|
+
const diffMins = Math.floor(diffMs / (1e3 * 60));
|
|
3171
|
+
const diffHours = Math.floor(diffMs / (1e3 * 60 * 60));
|
|
3172
|
+
const diffDays = Math.floor(diffMs / (1e3 * 60 * 60 * 24));
|
|
3173
|
+
if (diffDays > 0) {
|
|
3174
|
+
return `${diffDays}d ${diffHours % 24}h`;
|
|
3175
|
+
} else if (diffHours > 0) {
|
|
3176
|
+
return `${diffHours}h ${diffMins % 60}m`;
|
|
3177
|
+
} else {
|
|
3178
|
+
return `${diffMins}m`;
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
function formatDate(dateStr) {
|
|
3182
|
+
const date = new Date(dateStr);
|
|
3183
|
+
return date.toLocaleDateString("en-US", {
|
|
3184
|
+
month: "short",
|
|
3185
|
+
day: "numeric",
|
|
3186
|
+
hour: "2-digit",
|
|
3187
|
+
minute: "2-digit"
|
|
3188
|
+
});
|
|
3189
|
+
}
|
|
3190
|
+
function registerShareCommands(program2) {
|
|
3191
|
+
const share = program2.command("share").description("Share secrets via temporary links");
|
|
3192
|
+
share.command("create <key>").alias("new").description("Create a share link for a secret").option("-e, --expires <time>", "Expiration time (e.g., 1h, 24h, 7d, 30m)", "24h").option("--one-time", "Link can only be viewed once").option("--env <environment>", "Environment (overrides current)").action(async (key, options) => {
|
|
3193
|
+
if (!configManager.isAuthenticated()) {
|
|
3194
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
3195
|
+
process.exit(1);
|
|
3196
|
+
}
|
|
3197
|
+
const ctx = requireContext2();
|
|
3198
|
+
const environment = options.env || ctx.environment;
|
|
3199
|
+
const expiresRegex = /^(\d+)(m|h|d)$/;
|
|
3200
|
+
if (!expiresRegex.test(options.expires)) {
|
|
3201
|
+
ui.error("Invalid expiration format. Use formats like: 30m, 1h, 24h, 7d");
|
|
3202
|
+
process.exit(1);
|
|
3203
|
+
}
|
|
3204
|
+
const spinner = ui.spinner("Creating share link...");
|
|
3205
|
+
const result = await apiClient.createShare(
|
|
3206
|
+
ctx.teamSlug,
|
|
3207
|
+
ctx.projectSlug,
|
|
3208
|
+
environment,
|
|
3209
|
+
key,
|
|
3210
|
+
{
|
|
3211
|
+
expiresIn: options.expires,
|
|
3212
|
+
oneTime: options.oneTime || false
|
|
3213
|
+
}
|
|
3214
|
+
);
|
|
3215
|
+
if (result.error) {
|
|
3216
|
+
spinner.fail();
|
|
3217
|
+
ui.error(result.error.message);
|
|
3218
|
+
process.exit(1);
|
|
3219
|
+
}
|
|
3220
|
+
spinner.succeed("Share link created");
|
|
3221
|
+
ui.br();
|
|
3222
|
+
ui.heading("Share Link Details");
|
|
3223
|
+
ui.keyValue("Secret", key);
|
|
3224
|
+
ui.keyValue("Environment", ui.envBadge(environment));
|
|
3225
|
+
ui.keyValue("Expires", formatExpiration(result.data.expiresAt));
|
|
3226
|
+
ui.keyValue("One-time", result.data.oneTime ? colors.warning("Yes") : "No");
|
|
3227
|
+
ui.br();
|
|
3228
|
+
ui.box("Share URL", [
|
|
3229
|
+
colors.highlight(result.data.url)
|
|
3230
|
+
]);
|
|
3231
|
+
ui.warning("This link provides access to the secret value. Share securely!");
|
|
3232
|
+
if (result.data.oneTime) {
|
|
3233
|
+
ui.info("This link will be invalidated after a single view.");
|
|
3234
|
+
}
|
|
3235
|
+
});
|
|
3236
|
+
share.command("list").alias("ls").description("List active share links").option("-a, --all", "Include expired and revoked links").action(async (options) => {
|
|
3237
|
+
if (!configManager.isAuthenticated()) {
|
|
3238
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
3239
|
+
process.exit(1);
|
|
3240
|
+
}
|
|
3241
|
+
const spinner = ui.spinner("Fetching share links...");
|
|
3242
|
+
const result = await apiClient.listShares({
|
|
3243
|
+
includeExpired: options.all || false
|
|
3244
|
+
});
|
|
3245
|
+
if (result.error) {
|
|
3246
|
+
spinner.fail();
|
|
3247
|
+
ui.error(result.error.message);
|
|
3248
|
+
process.exit(1);
|
|
3249
|
+
}
|
|
3250
|
+
spinner.stop();
|
|
3251
|
+
if (!result.data?.shares.length) {
|
|
3252
|
+
ui.warning("No share links found");
|
|
3253
|
+
ui.info(`Create one with ${ui.code("sstash share create <KEY>")}`);
|
|
3254
|
+
return;
|
|
3255
|
+
}
|
|
3256
|
+
ui.heading("Share Links");
|
|
3257
|
+
const tableData = result.data.shares.map((share2) => {
|
|
3258
|
+
let status = colors.success("Active");
|
|
3259
|
+
if (share2.isRevoked) {
|
|
3260
|
+
status = colors.error("Revoked");
|
|
3261
|
+
} else if (share2.isExpired) {
|
|
3262
|
+
status = colors.muted("Expired");
|
|
3263
|
+
}
|
|
3264
|
+
const viewInfo = share2.oneTime ? share2.viewCount > 0 ? colors.muted("Used") : colors.warning("1 view") : String(share2.viewCount);
|
|
3265
|
+
return [
|
|
3266
|
+
share2.id.substring(0, 8),
|
|
3267
|
+
share2.secretKey,
|
|
3268
|
+
ui.envBadge(share2.environment),
|
|
3269
|
+
formatDate(share2.createdAt),
|
|
3270
|
+
share2.isExpired || share2.isRevoked ? "-" : formatExpiration(share2.expiresAt),
|
|
3271
|
+
viewInfo,
|
|
3272
|
+
status
|
|
3273
|
+
];
|
|
3274
|
+
});
|
|
3275
|
+
ui.table(tableData, {
|
|
3276
|
+
header: ["ID", "Key", "Environment", "Created", "Expires", "Views", "Status"]
|
|
3277
|
+
});
|
|
3278
|
+
ui.br();
|
|
3279
|
+
ui.info(`${result.data.shares.length} share link(s) total`);
|
|
3280
|
+
ui.info(`Revoke with ${ui.code("sstash share revoke <id>")}`);
|
|
3281
|
+
});
|
|
3282
|
+
share.command("revoke <id>").description("Revoke a share link").option("-f, --force", "Skip confirmation prompt").action(async (id, options) => {
|
|
3283
|
+
if (!configManager.isAuthenticated()) {
|
|
3284
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
3285
|
+
process.exit(1);
|
|
3286
|
+
}
|
|
3287
|
+
if (!options.force) {
|
|
3288
|
+
const { confirm } = await prompt7({
|
|
3289
|
+
type: "confirm",
|
|
3290
|
+
name: "confirm",
|
|
3291
|
+
message: `Are you sure you want to revoke share link "${id}"?`,
|
|
3292
|
+
initial: false
|
|
3293
|
+
});
|
|
3294
|
+
if (!confirm) {
|
|
3295
|
+
ui.warning("Aborted");
|
|
3296
|
+
return;
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
const spinner = ui.spinner("Revoking share link...");
|
|
3300
|
+
const result = await apiClient.revokeShare(id);
|
|
3301
|
+
if (result.error) {
|
|
3302
|
+
spinner.fail();
|
|
3303
|
+
ui.error(result.error.message);
|
|
3304
|
+
process.exit(1);
|
|
3305
|
+
}
|
|
3306
|
+
spinner.succeed("Share link revoked");
|
|
3307
|
+
ui.info("The link can no longer be used to access the secret.");
|
|
3308
|
+
});
|
|
3309
|
+
share.action(async () => {
|
|
3310
|
+
await share.commands.find((c) => c.name() === "list")?.parseAsync([], { from: "user" });
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
// src/commands/doctor.ts
|
|
3315
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
3316
|
+
async function checkApiConnectivity() {
|
|
3317
|
+
const apiUrl = configManager.getApiUrl();
|
|
3318
|
+
try {
|
|
3319
|
+
const controller = new AbortController();
|
|
3320
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
3321
|
+
const response = await fetch(`${apiUrl}/api/health`, {
|
|
3322
|
+
method: "GET",
|
|
3323
|
+
signal: controller.signal
|
|
3324
|
+
});
|
|
3325
|
+
clearTimeout(timeout);
|
|
3326
|
+
if (response.ok) {
|
|
3327
|
+
return {
|
|
3328
|
+
name: "API reachable",
|
|
3329
|
+
status: "pass",
|
|
3330
|
+
message: `API reachable (${apiUrl})`
|
|
3331
|
+
};
|
|
3332
|
+
}
|
|
3333
|
+
return {
|
|
3334
|
+
name: "API reachable",
|
|
3335
|
+
status: "fail",
|
|
3336
|
+
message: `API returned ${response.status}`,
|
|
3337
|
+
hint: "Check if the API server is running"
|
|
3338
|
+
};
|
|
3339
|
+
} catch (error) {
|
|
3340
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
3341
|
+
if (errorMessage.includes("abort")) {
|
|
3342
|
+
return {
|
|
3343
|
+
name: "API reachable",
|
|
3344
|
+
status: "fail",
|
|
3345
|
+
message: "Connection timed out",
|
|
3346
|
+
hint: `Check if the API server is running at ${apiUrl}`
|
|
3347
|
+
};
|
|
3348
|
+
}
|
|
3349
|
+
return {
|
|
3350
|
+
name: "API reachable",
|
|
3351
|
+
status: "fail",
|
|
3352
|
+
message: `Cannot reach API: ${errorMessage}`,
|
|
3353
|
+
hint: `Check if the API server is running at ${apiUrl}`
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
async function checkNetworkLatency() {
|
|
3358
|
+
const apiUrl = configManager.getApiUrl();
|
|
3359
|
+
const start = Date.now();
|
|
3360
|
+
try {
|
|
3361
|
+
const controller = new AbortController();
|
|
3362
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
3363
|
+
await fetch(`${apiUrl}/api/health`, {
|
|
3364
|
+
method: "GET",
|
|
3365
|
+
signal: controller.signal
|
|
3366
|
+
});
|
|
3367
|
+
clearTimeout(timeout);
|
|
3368
|
+
const latency = Date.now() - start;
|
|
3369
|
+
if (latency < 200) {
|
|
3370
|
+
return {
|
|
3371
|
+
name: "Network latency",
|
|
3372
|
+
status: "pass",
|
|
3373
|
+
message: `Network latency OK (${latency}ms)`
|
|
3374
|
+
};
|
|
3375
|
+
} else if (latency < 1e3) {
|
|
3376
|
+
return {
|
|
3377
|
+
name: "Network latency",
|
|
3378
|
+
status: "warn",
|
|
3379
|
+
message: `Network latency high (${latency}ms)`,
|
|
3380
|
+
hint: "Operations may be slow"
|
|
3381
|
+
};
|
|
3382
|
+
} else {
|
|
3383
|
+
return {
|
|
3384
|
+
name: "Network latency",
|
|
3385
|
+
status: "warn",
|
|
3386
|
+
message: `Network latency very high (${latency}ms)`,
|
|
3387
|
+
hint: "Consider using a closer API endpoint or checking network"
|
|
3388
|
+
};
|
|
3389
|
+
}
|
|
3390
|
+
} catch {
|
|
3391
|
+
return {
|
|
3392
|
+
name: "Network latency",
|
|
3393
|
+
status: "fail",
|
|
3394
|
+
message: "Could not measure latency",
|
|
3395
|
+
hint: "API is not reachable"
|
|
3396
|
+
};
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
async function checkAuthentication() {
|
|
3400
|
+
if (configManager.isServiceTokenAuth()) {
|
|
3401
|
+
const serviceToken = configManager.getServiceToken();
|
|
3402
|
+
if (serviceToken) {
|
|
3403
|
+
const source = process.env.SECRETSTASH_TOKEN ? "env var" : "config";
|
|
3404
|
+
return {
|
|
3405
|
+
name: "Authentication",
|
|
3406
|
+
status: "pass",
|
|
3407
|
+
message: `Authenticated via service token (${source})`
|
|
3408
|
+
};
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
const token = configManager.getAccessToken();
|
|
3412
|
+
if (!token) {
|
|
3413
|
+
return {
|
|
3414
|
+
name: "Authentication",
|
|
3415
|
+
status: "fail",
|
|
3416
|
+
message: "Not authenticated",
|
|
3417
|
+
hint: "Run: sstash login"
|
|
3418
|
+
};
|
|
3419
|
+
}
|
|
3420
|
+
const user = configManager.getUser();
|
|
3421
|
+
if (user.email) {
|
|
3422
|
+
return {
|
|
3423
|
+
name: "Authentication",
|
|
3424
|
+
status: "pass",
|
|
3425
|
+
message: `Authenticated as ${user.email}`
|
|
3426
|
+
};
|
|
3427
|
+
}
|
|
3428
|
+
return {
|
|
3429
|
+
name: "Authentication",
|
|
3430
|
+
status: "pass",
|
|
3431
|
+
message: "Authenticated"
|
|
3432
|
+
};
|
|
3433
|
+
}
|
|
3434
|
+
function checkTokenExpiration() {
|
|
3435
|
+
if (configManager.isServiceTokenAuth()) {
|
|
3436
|
+
return {
|
|
3437
|
+
name: "Token expiration",
|
|
3438
|
+
status: "pass",
|
|
3439
|
+
message: "Service token (expiration managed by server)"
|
|
3440
|
+
};
|
|
3441
|
+
}
|
|
3442
|
+
const token = configManager.getAccessToken();
|
|
3443
|
+
if (!token) {
|
|
3444
|
+
return {
|
|
3445
|
+
name: "Token expiration",
|
|
3446
|
+
status: "fail",
|
|
3447
|
+
message: "No token found",
|
|
3448
|
+
hint: "Run: sstash login"
|
|
3449
|
+
};
|
|
3450
|
+
}
|
|
3451
|
+
if (configManager.isTokenExpired()) {
|
|
3452
|
+
return {
|
|
3453
|
+
name: "Token expiration",
|
|
3454
|
+
status: "warn",
|
|
3455
|
+
message: "Token expired or expiring soon",
|
|
3456
|
+
hint: "Run: sstash login"
|
|
3457
|
+
};
|
|
3458
|
+
}
|
|
3459
|
+
return {
|
|
3460
|
+
name: "Token expiration",
|
|
3461
|
+
status: "pass",
|
|
3462
|
+
message: "Token valid"
|
|
3463
|
+
};
|
|
3464
|
+
}
|
|
3465
|
+
function checkTeamSelected() {
|
|
3466
|
+
const effectiveTeam = projectConfig.getEffectiveTeam();
|
|
3467
|
+
if (effectiveTeam) {
|
|
3468
|
+
const team = configManager.getCurrentTeam();
|
|
3469
|
+
const source = projectConfig.getLocalConfig()?.teamSlug ? "local config" : "global";
|
|
3470
|
+
return {
|
|
3471
|
+
name: "Team selected",
|
|
3472
|
+
status: "pass",
|
|
3473
|
+
message: `Team: ${team?.name || effectiveTeam} (${source})`
|
|
3474
|
+
};
|
|
3475
|
+
}
|
|
3476
|
+
return {
|
|
3477
|
+
name: "Team selected",
|
|
3478
|
+
status: "fail",
|
|
3479
|
+
message: "No team selected",
|
|
3480
|
+
hint: "Run: sstash teams use <slug>"
|
|
3481
|
+
};
|
|
3482
|
+
}
|
|
3483
|
+
function checkProjectSelected() {
|
|
3484
|
+
const effectiveProject = projectConfig.getEffectiveProject();
|
|
3485
|
+
if (effectiveProject) {
|
|
3486
|
+
const project = configManager.getCurrentProject();
|
|
3487
|
+
const source = projectConfig.getLocalConfig()?.project ? "local config" : "global";
|
|
3488
|
+
return {
|
|
3489
|
+
name: "Project selected",
|
|
3490
|
+
status: "pass",
|
|
3491
|
+
message: `Project: ${project?.name || effectiveProject} (${source})`
|
|
3492
|
+
};
|
|
3493
|
+
}
|
|
3494
|
+
return {
|
|
3495
|
+
name: "Project selected",
|
|
3496
|
+
status: "fail",
|
|
3497
|
+
message: "No project selected",
|
|
3498
|
+
hint: "Run: sstash projects use <slug>"
|
|
3499
|
+
};
|
|
3500
|
+
}
|
|
3501
|
+
function checkEnvironmentSelected() {
|
|
3502
|
+
const effectiveEnv = projectConfig.getEffectiveEnvironment();
|
|
3503
|
+
if (effectiveEnv) {
|
|
3504
|
+
const source = projectConfig.getLocalConfig()?.environment ? "local config" : "global";
|
|
3505
|
+
return {
|
|
3506
|
+
name: "Environment selected",
|
|
3507
|
+
status: "pass",
|
|
3508
|
+
message: `Environment: ${effectiveEnv} (${source})`
|
|
3509
|
+
};
|
|
3510
|
+
}
|
|
3511
|
+
return {
|
|
3512
|
+
name: "Environment selected",
|
|
3513
|
+
status: "fail",
|
|
3514
|
+
message: "No environment selected",
|
|
3515
|
+
hint: "Run: sstash environments use <name>"
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
function checkLocalConfigExists() {
|
|
3519
|
+
if (projectConfig.hasLocalConfig()) {
|
|
3520
|
+
return {
|
|
3521
|
+
name: "Local config file",
|
|
3522
|
+
status: "pass",
|
|
3523
|
+
message: ".secretstash.json exists"
|
|
3524
|
+
};
|
|
3525
|
+
}
|
|
3526
|
+
return {
|
|
3527
|
+
name: "Local config file",
|
|
3528
|
+
status: "warn",
|
|
3529
|
+
message: ".secretstash.json not found",
|
|
3530
|
+
hint: "Run: sstash init (optional, for project-specific config)"
|
|
3531
|
+
};
|
|
3532
|
+
}
|
|
3533
|
+
function checkLocalConfigValid() {
|
|
3534
|
+
if (!projectConfig.hasLocalConfig()) {
|
|
3535
|
+
return {
|
|
3536
|
+
name: "Config file valid",
|
|
3537
|
+
status: "pass",
|
|
3538
|
+
message: "No local config to validate"
|
|
3539
|
+
};
|
|
3540
|
+
}
|
|
3541
|
+
const configPath = projectConfig.getLocalConfigPath();
|
|
3542
|
+
try {
|
|
3543
|
+
const content = readFileSync3(configPath, "utf-8");
|
|
3544
|
+
JSON.parse(content);
|
|
3545
|
+
return {
|
|
3546
|
+
name: "Config file valid",
|
|
3547
|
+
status: "pass",
|
|
3548
|
+
message: ".secretstash.json is valid JSON"
|
|
3549
|
+
};
|
|
3550
|
+
} catch (error) {
|
|
3551
|
+
const message = error instanceof Error ? error.message : "Invalid JSON";
|
|
3552
|
+
return {
|
|
3553
|
+
name: "Config file valid",
|
|
3554
|
+
status: "fail",
|
|
3555
|
+
message: ".secretstash.json has invalid JSON",
|
|
3556
|
+
hint: `Fix the JSON syntax error: ${message}`
|
|
3557
|
+
};
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
async function checkCliVersion() {
|
|
3561
|
+
let currentVersion = "0.1.0";
|
|
3562
|
+
try {
|
|
3563
|
+
const pkgPath = new URL("../../package.json", import.meta.url);
|
|
3564
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
|
|
3565
|
+
currentVersion = pkg.version;
|
|
3566
|
+
} catch {
|
|
3567
|
+
}
|
|
3568
|
+
try {
|
|
3569
|
+
const controller = new AbortController();
|
|
3570
|
+
const timeout = setTimeout(() => controller.abort(), 3e3);
|
|
3571
|
+
const response = await fetch("https://registry.npmjs.org/@secretstash/cli/latest", {
|
|
3572
|
+
signal: controller.signal
|
|
3573
|
+
});
|
|
3574
|
+
clearTimeout(timeout);
|
|
3575
|
+
if (!response.ok) {
|
|
3576
|
+
return {
|
|
3577
|
+
name: "CLI version",
|
|
3578
|
+
status: "pass",
|
|
3579
|
+
message: `v${currentVersion} (could not check for updates)`
|
|
3580
|
+
};
|
|
3581
|
+
}
|
|
3582
|
+
const data = await response.json();
|
|
3583
|
+
const latestVersion = data.version;
|
|
3584
|
+
if (!latestVersion) {
|
|
3585
|
+
return {
|
|
3586
|
+
name: "CLI version",
|
|
3587
|
+
status: "pass",
|
|
3588
|
+
message: `v${currentVersion}`
|
|
3589
|
+
};
|
|
3590
|
+
}
|
|
3591
|
+
if (currentVersion === latestVersion) {
|
|
3592
|
+
return {
|
|
3593
|
+
name: "CLI version",
|
|
3594
|
+
status: "pass",
|
|
3595
|
+
message: `v${currentVersion} (latest)`
|
|
3596
|
+
};
|
|
3597
|
+
}
|
|
3598
|
+
const currentParts = currentVersion.split(".").map(Number);
|
|
3599
|
+
const latestParts = latestVersion.split(".").map(Number);
|
|
3600
|
+
let isOutdated = false;
|
|
3601
|
+
for (let i = 0; i < 3; i++) {
|
|
3602
|
+
if ((latestParts[i] || 0) > (currentParts[i] || 0)) {
|
|
3603
|
+
isOutdated = true;
|
|
3604
|
+
break;
|
|
3605
|
+
} else if ((latestParts[i] || 0) < (currentParts[i] || 0)) {
|
|
3606
|
+
break;
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
if (isOutdated) {
|
|
3610
|
+
return {
|
|
3611
|
+
name: "CLI version",
|
|
3612
|
+
status: "warn",
|
|
3613
|
+
message: `v${currentVersion} (v${latestVersion} available)`,
|
|
3614
|
+
hint: "Run: npm update -g @secretstash/cli"
|
|
3615
|
+
};
|
|
3616
|
+
}
|
|
3617
|
+
return {
|
|
3618
|
+
name: "CLI version",
|
|
3619
|
+
status: "pass",
|
|
3620
|
+
message: `v${currentVersion}`
|
|
3621
|
+
};
|
|
3622
|
+
} catch {
|
|
3623
|
+
return {
|
|
3624
|
+
name: "CLI version",
|
|
3625
|
+
status: "pass",
|
|
3626
|
+
message: `v${currentVersion} (could not check for updates)`
|
|
3627
|
+
};
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
function printResult(result) {
|
|
3631
|
+
const icon = result.status === "pass" ? colors.success("\u2713") : result.status === "warn" ? colors.warning("\u26A0") : colors.error("\u2717");
|
|
3632
|
+
console.log(`${icon} ${result.message}`);
|
|
3633
|
+
if (result.hint) {
|
|
3634
|
+
console.log(` ${colors.muted("\u2192")} ${colors.muted(result.hint)}`);
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
function registerDoctorCommand(program2) {
|
|
3638
|
+
program2.command("doctor").description("Diagnose common setup issues").action(async () => {
|
|
3639
|
+
console.log();
|
|
3640
|
+
console.log(colors.primary.bold("SecretStash Doctor"));
|
|
3641
|
+
console.log(colors.muted("=================="));
|
|
3642
|
+
console.log();
|
|
3643
|
+
const results = [];
|
|
3644
|
+
const apiResult = await checkApiConnectivity();
|
|
3645
|
+
results.push(apiResult);
|
|
3646
|
+
printResult(apiResult);
|
|
3647
|
+
const authResult = await checkAuthentication();
|
|
3648
|
+
results.push(authResult);
|
|
3649
|
+
printResult(authResult);
|
|
3650
|
+
const tokenResult = checkTokenExpiration();
|
|
3651
|
+
results.push(tokenResult);
|
|
3652
|
+
printResult(tokenResult);
|
|
3653
|
+
const teamResult = checkTeamSelected();
|
|
3654
|
+
results.push(teamResult);
|
|
3655
|
+
printResult(teamResult);
|
|
3656
|
+
const projectResult = checkProjectSelected();
|
|
3657
|
+
results.push(projectResult);
|
|
3658
|
+
printResult(projectResult);
|
|
3659
|
+
const envResult = checkEnvironmentSelected();
|
|
3660
|
+
results.push(envResult);
|
|
3661
|
+
printResult(envResult);
|
|
3662
|
+
const localConfigResult = checkLocalConfigExists();
|
|
3663
|
+
results.push(localConfigResult);
|
|
3664
|
+
printResult(localConfigResult);
|
|
3665
|
+
const configValidResult = checkLocalConfigValid();
|
|
3666
|
+
results.push(configValidResult);
|
|
3667
|
+
printResult(configValidResult);
|
|
3668
|
+
const versionResult = await checkCliVersion();
|
|
3669
|
+
results.push(versionResult);
|
|
3670
|
+
printResult(versionResult);
|
|
3671
|
+
if (apiResult.status === "pass") {
|
|
3672
|
+
const latencyResult = await checkNetworkLatency();
|
|
3673
|
+
results.push(latencyResult);
|
|
3674
|
+
printResult(latencyResult);
|
|
3675
|
+
}
|
|
3676
|
+
console.log();
|
|
3677
|
+
const issues = results.filter((r) => r.status === "fail");
|
|
3678
|
+
const warnings = results.filter((r) => r.status === "warn");
|
|
3679
|
+
if (issues.length === 0 && warnings.length === 0) {
|
|
3680
|
+
console.log(colors.success("All checks passed!"));
|
|
3681
|
+
} else {
|
|
3682
|
+
const parts = [];
|
|
3683
|
+
if (issues.length > 0) {
|
|
3684
|
+
parts.push(`${issues.length} issue${issues.length !== 1 ? "s" : ""}`);
|
|
3685
|
+
}
|
|
3686
|
+
if (warnings.length > 0) {
|
|
3687
|
+
parts.push(`${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`);
|
|
3688
|
+
}
|
|
3689
|
+
console.log(colors.muted(parts.join(", ") + " found"));
|
|
3690
|
+
}
|
|
3691
|
+
console.log();
|
|
3692
|
+
});
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
// src/commands/tags.ts
|
|
3696
|
+
import { prompt as prompt8 } from "enquirer";
|
|
3697
|
+
import chalk2 from "chalk";
|
|
3698
|
+
function registerTagCommands(program2) {
|
|
3699
|
+
const tags = program2.command("tags").description("Manage tags for organizing secrets");
|
|
3700
|
+
tags.command("list").alias("ls").description("List all tags in the current team").action(async () => {
|
|
3701
|
+
if (!configManager.isAuthenticated()) {
|
|
3702
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
3703
|
+
process.exit(1);
|
|
3704
|
+
}
|
|
3705
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
3706
|
+
if (!currentTeam) {
|
|
3707
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
3708
|
+
process.exit(1);
|
|
3709
|
+
}
|
|
3710
|
+
const spinner = ui.spinner("Fetching tags...");
|
|
3711
|
+
const result = await apiClient.getTags(currentTeam.id);
|
|
3712
|
+
if (result.error) {
|
|
3713
|
+
spinner.fail();
|
|
3714
|
+
ui.error(result.error.message);
|
|
3715
|
+
process.exit(1);
|
|
3716
|
+
}
|
|
3717
|
+
spinner.stop();
|
|
3718
|
+
if (!result.data?.tags.length) {
|
|
3719
|
+
ui.warning("No tags found");
|
|
3720
|
+
ui.info(`Create one with ${ui.code("sstash tags create <name>")}`);
|
|
3721
|
+
return;
|
|
3722
|
+
}
|
|
3723
|
+
ui.heading(`Tags in ${currentTeam.name}`);
|
|
3724
|
+
const tableData = result.data.tags.map((tag) => [
|
|
3725
|
+
tag.name,
|
|
3726
|
+
tag.color ? chalk2.hex(tag.color)(tag.color) : ui.dim("none"),
|
|
3727
|
+
new Date(tag.createdAt).toLocaleDateString()
|
|
3728
|
+
]);
|
|
3729
|
+
ui.table(tableData, {
|
|
3730
|
+
header: ["Name", "Color", "Created"]
|
|
3731
|
+
});
|
|
3732
|
+
ui.br();
|
|
3733
|
+
ui.info(`${result.data.tags.length} tags total`);
|
|
3734
|
+
});
|
|
3735
|
+
tags.command("create <name>").description("Create a new tag").option("-c, --color <hex>", "Hex color for the tag (e.g., #FF5733)").action(async (name, options) => {
|
|
3736
|
+
if (!configManager.isAuthenticated()) {
|
|
3737
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
3738
|
+
process.exit(1);
|
|
3739
|
+
}
|
|
3740
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
3741
|
+
if (!currentTeam) {
|
|
3742
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
3743
|
+
process.exit(1);
|
|
3744
|
+
}
|
|
3745
|
+
if (options.color && !/^#[0-9A-Fa-f]{6}$/.test(options.color)) {
|
|
3746
|
+
ui.error("Color must be a valid hex color (e.g., #FF5733)");
|
|
3747
|
+
process.exit(1);
|
|
3748
|
+
}
|
|
3749
|
+
const spinner = ui.spinner(`Creating tag "${name}"...`);
|
|
3750
|
+
const result = await apiClient.createTag(currentTeam.id, name, options.color);
|
|
3751
|
+
if (result.error) {
|
|
3752
|
+
spinner.fail();
|
|
3753
|
+
ui.error(result.error.message);
|
|
3754
|
+
process.exit(1);
|
|
3755
|
+
}
|
|
3756
|
+
spinner.succeed(`Tag "${result.data.tag.name}" created`);
|
|
3757
|
+
ui.br();
|
|
3758
|
+
ui.keyValue("ID", result.data.tag.id);
|
|
3759
|
+
if (result.data.tag.color) {
|
|
3760
|
+
ui.keyValue("Color", chalk2.hex(result.data.tag.color)(result.data.tag.color));
|
|
3761
|
+
}
|
|
3762
|
+
});
|
|
3763
|
+
tags.command("delete <name>").alias("rm").description("Delete a tag").option("-f, --force", "Skip confirmation prompt").action(async (name, options) => {
|
|
3764
|
+
if (!configManager.isAuthenticated()) {
|
|
3765
|
+
ui.error("Not authenticated. Run `sstash login` first.");
|
|
3766
|
+
process.exit(1);
|
|
3767
|
+
}
|
|
3768
|
+
const currentTeam = configManager.getCurrentTeam();
|
|
3769
|
+
if (!currentTeam) {
|
|
3770
|
+
ui.error("No team selected. Run `sstash teams use <slug>` first.");
|
|
3771
|
+
process.exit(1);
|
|
3772
|
+
}
|
|
3773
|
+
const spinner = ui.spinner("Looking up tag...");
|
|
3774
|
+
const listResult = await apiClient.getTags(currentTeam.id);
|
|
3775
|
+
if (listResult.error) {
|
|
3776
|
+
spinner.fail();
|
|
3777
|
+
ui.error(listResult.error.message);
|
|
3778
|
+
process.exit(1);
|
|
3779
|
+
}
|
|
3780
|
+
const tag = listResult.data?.tags.find(
|
|
3781
|
+
(t) => t.name.toLowerCase() === name.toLowerCase()
|
|
3782
|
+
);
|
|
3783
|
+
if (!tag) {
|
|
3784
|
+
spinner.fail();
|
|
3785
|
+
ui.error(`Tag "${name}" not found`);
|
|
3786
|
+
process.exit(1);
|
|
3787
|
+
}
|
|
3788
|
+
spinner.stop();
|
|
3789
|
+
if (!options.force) {
|
|
3790
|
+
ui.warning(`You are about to delete tag "${tag.name}"`);
|
|
3791
|
+
ui.warning("This will remove the tag from all secrets.");
|
|
3792
|
+
const response = await prompt8({
|
|
3793
|
+
type: "confirm",
|
|
3794
|
+
name: "confirm",
|
|
3795
|
+
message: "Are you sure you want to delete this tag?",
|
|
3796
|
+
initial: false
|
|
3797
|
+
});
|
|
3798
|
+
if (!response.confirm) {
|
|
3799
|
+
ui.warning("Operation cancelled");
|
|
3800
|
+
return;
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
const deleteSpinner = ui.spinner(`Deleting tag "${tag.name}"...`);
|
|
3804
|
+
const deleteResult = await apiClient.deleteTag(tag.id);
|
|
3805
|
+
if (deleteResult.error) {
|
|
3806
|
+
deleteSpinner.fail();
|
|
3807
|
+
ui.error(deleteResult.error.message);
|
|
3808
|
+
process.exit(1);
|
|
3809
|
+
}
|
|
3810
|
+
deleteSpinner.succeed(`Tag "${tag.name}" deleted`);
|
|
3811
|
+
});
|
|
3812
|
+
tags.action(async () => {
|
|
3813
|
+
await tags.commands.find((c) => c.name() === "list")?.parseAsync([], { from: "user" });
|
|
3814
|
+
});
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
// src/commands/pull.ts
|
|
3818
|
+
import { writeFileSync as writeFileSync3 } from "fs";
|
|
3819
|
+
function formatSecrets(secrets, format) {
|
|
3820
|
+
const sorted = [...secrets].sort((a, b) => a.key.localeCompare(b.key));
|
|
3821
|
+
switch (format) {
|
|
3822
|
+
case "json": {
|
|
3823
|
+
const obj = {};
|
|
3824
|
+
for (const secret of sorted) {
|
|
3825
|
+
obj[secret.key] = secret.value;
|
|
3826
|
+
}
|
|
3827
|
+
return JSON.stringify(obj, null, 2);
|
|
3828
|
+
}
|
|
3829
|
+
case "shell": {
|
|
3830
|
+
return sorted.map(({ key, value }) => {
|
|
3831
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
3832
|
+
return `export ${key}="${escaped}"`;
|
|
3833
|
+
}).join("\n");
|
|
3834
|
+
}
|
|
3835
|
+
case "env":
|
|
3836
|
+
default: {
|
|
3837
|
+
return sorted.map(({ key, value }) => {
|
|
3838
|
+
const needsQuotes = /[\s#"'$\\]/.test(value);
|
|
3839
|
+
const formattedValue = needsQuotes ? `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : value;
|
|
3840
|
+
return `${key}=${formattedValue}`;
|
|
3841
|
+
}).join("\n");
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
async function fetchCISecrets(project, environment, token) {
|
|
3846
|
+
const apiUrl = configManager.getApiUrl();
|
|
3847
|
+
const url = `${apiUrl}/api/v1/ci/secrets`;
|
|
3848
|
+
try {
|
|
3849
|
+
const response = await fetch(url, {
|
|
3850
|
+
method: "POST",
|
|
3851
|
+
headers: {
|
|
3852
|
+
"Content-Type": "application/json",
|
|
3853
|
+
"Authorization": `Bearer ${token}`
|
|
3854
|
+
},
|
|
3855
|
+
body: JSON.stringify({ project, environment })
|
|
3856
|
+
});
|
|
3857
|
+
const data = await response.json();
|
|
3858
|
+
if (!response.ok) {
|
|
3859
|
+
if (response.status === 401) {
|
|
3860
|
+
return {
|
|
3861
|
+
success: false,
|
|
3862
|
+
error: {
|
|
3863
|
+
code: "UNAUTHORIZED",
|
|
3864
|
+
message: "Invalid or expired token"
|
|
3865
|
+
}
|
|
3866
|
+
};
|
|
3867
|
+
}
|
|
3868
|
+
if (response.status === 403) {
|
|
3869
|
+
return {
|
|
3870
|
+
success: false,
|
|
3871
|
+
error: {
|
|
3872
|
+
code: "FORBIDDEN",
|
|
3873
|
+
message: "Token does not have access to this project/environment"
|
|
3874
|
+
}
|
|
3875
|
+
};
|
|
3876
|
+
}
|
|
3877
|
+
return {
|
|
3878
|
+
success: false,
|
|
3879
|
+
error: data.error || {
|
|
3880
|
+
code: "API_ERROR",
|
|
3881
|
+
message: `Request failed with status ${response.status}`
|
|
3882
|
+
}
|
|
3883
|
+
};
|
|
3884
|
+
}
|
|
3885
|
+
return data;
|
|
3886
|
+
} catch (error) {
|
|
3887
|
+
return {
|
|
3888
|
+
success: false,
|
|
3889
|
+
error: {
|
|
3890
|
+
code: "NETWORK_ERROR",
|
|
3891
|
+
message: error instanceof Error ? error.message : "Network request failed"
|
|
3892
|
+
}
|
|
3893
|
+
};
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
function registerPullCICommand(program2) {
|
|
3897
|
+
program2.command("ci-pull").description("Pull secrets for CI/CD pipelines (uses SECRETS_TOKEN env var or --token flag)").requiredOption("-p, --project <slug>", "Project slug").requiredOption("-e, --env <environment>", "Environment name (e.g., production, staging)").option("-t, --token <token>", "Service token (defaults to SECRETS_TOKEN env var)").option("-f, --format <format>", "Output format: env (default), shell, json", "env").option("-o, --output <file>", "Write to file instead of stdout").action(async (options) => {
|
|
3898
|
+
const { project, env: environment, format, output } = options;
|
|
3899
|
+
const token = options.token || process.env.SECRETS_TOKEN;
|
|
3900
|
+
if (!token) {
|
|
3901
|
+
ui.error("Set SECRETS_TOKEN environment variable or use --token flag");
|
|
3902
|
+
process.exit(1);
|
|
3903
|
+
}
|
|
3904
|
+
const validFormats = ["env", "shell", "json"];
|
|
3905
|
+
if (!validFormats.includes(format)) {
|
|
3906
|
+
ui.error(`Invalid format "${format}". Valid formats: ${validFormats.join(", ")}`);
|
|
3907
|
+
process.exit(1);
|
|
3908
|
+
}
|
|
3909
|
+
const result = await fetchCISecrets(project, environment, token);
|
|
3910
|
+
if (!result.success || result.error) {
|
|
3911
|
+
const errorMessage = result.error?.message || "Failed to fetch secrets";
|
|
3912
|
+
ui.error(errorMessage);
|
|
3913
|
+
if (result.error?.code === "UNAUTHORIZED") {
|
|
3914
|
+
ui.info("Check that your token is valid and has not expired");
|
|
3915
|
+
} else if (result.error?.code === "FORBIDDEN") {
|
|
3916
|
+
ui.info("Verify the token has access to the specified project and environment");
|
|
3917
|
+
}
|
|
3918
|
+
process.exit(1);
|
|
3919
|
+
}
|
|
3920
|
+
const secrets = result.data?.secrets || [];
|
|
3921
|
+
if (secrets.length === 0) {
|
|
3922
|
+
const emptyOutput = format === "json" ? "{}" : "";
|
|
3923
|
+
if (output) {
|
|
3924
|
+
writeFileSync3(output, emptyOutput + "\n", "utf-8");
|
|
3925
|
+
} else {
|
|
3926
|
+
if (emptyOutput) {
|
|
3927
|
+
process.stdout.write(emptyOutput + "\n");
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
return;
|
|
3931
|
+
}
|
|
3932
|
+
const formatted = formatSecrets(secrets, format);
|
|
3933
|
+
if (output) {
|
|
3934
|
+
writeFileSync3(output, formatted + "\n", "utf-8");
|
|
3935
|
+
if (!configManager.isQuiet()) {
|
|
3936
|
+
ui.success(`Wrote ${secrets.length} secrets to ${output}`);
|
|
3937
|
+
}
|
|
3938
|
+
} else {
|
|
3939
|
+
process.stdout.write(formatted + "\n");
|
|
3940
|
+
}
|
|
3941
|
+
});
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
// src/commands/exec.ts
|
|
3945
|
+
import { spawn as spawn2 } from "child_process";
|
|
3946
|
+
async function fetchCISecrets2(apiUrl, token, project, environment) {
|
|
3947
|
+
const url = `${apiUrl}/api/v1/ci/secrets`;
|
|
3948
|
+
const response = await fetch(url, {
|
|
3949
|
+
method: "POST",
|
|
3950
|
+
headers: {
|
|
3951
|
+
"Authorization": `Bearer ${token}`,
|
|
3952
|
+
"Content-Type": "application/json",
|
|
3953
|
+
"User-Agent": "SecretStash-CLI/1.0"
|
|
3954
|
+
},
|
|
3955
|
+
body: JSON.stringify({ project, environment })
|
|
3956
|
+
});
|
|
3957
|
+
if (!response.ok) {
|
|
3958
|
+
const errorText = await response.text();
|
|
3959
|
+
let errorData = {};
|
|
3960
|
+
try {
|
|
3961
|
+
errorData = JSON.parse(errorText);
|
|
3962
|
+
} catch {
|
|
3963
|
+
}
|
|
3964
|
+
return {
|
|
3965
|
+
success: false,
|
|
3966
|
+
error: {
|
|
3967
|
+
code: errorData.error?.code || "API_ERROR",
|
|
3968
|
+
message: errorData.error?.message || `API request failed: ${response.status} ${response.statusText}`
|
|
3969
|
+
}
|
|
3970
|
+
};
|
|
3971
|
+
}
|
|
3972
|
+
return response.json();
|
|
3973
|
+
}
|
|
3974
|
+
function registerExecCommand(program2) {
|
|
3975
|
+
program2.command("exec").description("Run a command with secrets injected as environment variables (CI/CD friendly)").requiredOption("--project <slug>", "Project slug").requiredOption("--env <environment>", "Environment name (e.g., production, staging)").option("--token <token>", "Service token (defaults to SECRETS_TOKEN env var)").option("--api-url <url>", "API URL (defaults to VAULT_API_URL env var or configured URL)").argument("<command...>", "Command to run").allowExcessArguments(true).action(async (commandArgs, options) => {
|
|
3976
|
+
if (!commandArgs || commandArgs.length === 0) {
|
|
3977
|
+
ui.error("No command provided");
|
|
3978
|
+
ui.info("Usage: sstash exec --project=<slug> --env=<environment> -- <command> [args...]");
|
|
3979
|
+
ui.info("");
|
|
3980
|
+
ui.info("Example:");
|
|
3981
|
+
ui.info(" sstash exec --project=myapp --env=production -- npm run build");
|
|
3982
|
+
process.exit(1);
|
|
3983
|
+
}
|
|
3984
|
+
const token = options.token || process.env.SECRETS_TOKEN;
|
|
3985
|
+
if (!token) {
|
|
3986
|
+
ui.error("No service token provided");
|
|
3987
|
+
ui.info("Provide a token via --token flag or SECRETS_TOKEN environment variable");
|
|
3988
|
+
ui.info("");
|
|
3989
|
+
ui.info("Generate a service token from the SecretStash dashboard or CLI:");
|
|
3990
|
+
ui.info(' sstash tokens create --name "CI Token" --env production');
|
|
3991
|
+
process.exit(1);
|
|
3992
|
+
}
|
|
3993
|
+
if (!token.startsWith("stk_")) {
|
|
3994
|
+
ui.error("Invalid service token format");
|
|
3995
|
+
ui.info('Service tokens should start with "stk_" prefix');
|
|
3996
|
+
ui.info("User access tokens are not supported for the exec command");
|
|
3997
|
+
process.exit(1);
|
|
3998
|
+
}
|
|
3999
|
+
const apiUrl = options.apiUrl || configManager.getApiUrl();
|
|
4000
|
+
const { project, env: environment } = options;
|
|
4001
|
+
const spinner = ui.spinner(`Fetching secrets for ${project}/${environment}...`);
|
|
4002
|
+
try {
|
|
4003
|
+
const result = await fetchCISecrets2(apiUrl, token, project, environment);
|
|
4004
|
+
if (!result.success || !result.data?.secrets) {
|
|
4005
|
+
spinner.fail();
|
|
4006
|
+
ui.error(result.error?.message || "Failed to fetch secrets");
|
|
4007
|
+
if (result.error?.code === "FORBIDDEN") {
|
|
4008
|
+
ui.info("The service token may not have access to this project/environment");
|
|
4009
|
+
} else if (result.error?.code === "UNAUTHORIZED") {
|
|
4010
|
+
ui.info("The service token may be expired or invalid");
|
|
4011
|
+
}
|
|
4012
|
+
process.exit(1);
|
|
4013
|
+
}
|
|
4014
|
+
const secrets = result.data.secrets;
|
|
4015
|
+
spinner.succeed(`Loaded ${secrets.length} secret(s)`);
|
|
4016
|
+
const secretEnv = {};
|
|
4017
|
+
for (const secret of secrets) {
|
|
4018
|
+
secretEnv[secret.key] = secret.value;
|
|
4019
|
+
}
|
|
4020
|
+
let cmd;
|
|
4021
|
+
let args;
|
|
4022
|
+
if (commandArgs.length === 1 && commandArgs[0].includes(" ")) {
|
|
4023
|
+
const parts = commandArgs[0].split(/\s+/);
|
|
4024
|
+
cmd = parts[0];
|
|
4025
|
+
args = parts.slice(1);
|
|
4026
|
+
} else {
|
|
4027
|
+
cmd = commandArgs[0];
|
|
4028
|
+
args = commandArgs.slice(1);
|
|
4029
|
+
}
|
|
4030
|
+
const child = spawn2(cmd, args, {
|
|
4031
|
+
env: { ...process.env, ...secretEnv },
|
|
4032
|
+
stdio: "inherit",
|
|
4033
|
+
// Forward stdin/stdout/stderr
|
|
4034
|
+
shell: true
|
|
4035
|
+
});
|
|
4036
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
4037
|
+
signals.forEach((signal) => {
|
|
4038
|
+
process.on(signal, () => {
|
|
4039
|
+
child.kill(signal);
|
|
4040
|
+
});
|
|
4041
|
+
});
|
|
4042
|
+
child.on("close", (code, signal) => {
|
|
4043
|
+
if (signal) {
|
|
4044
|
+
process.exit(128 + (signal === "SIGINT" ? 2 : signal === "SIGTERM" ? 15 : 1));
|
|
4045
|
+
}
|
|
4046
|
+
process.exit(code ?? 0);
|
|
4047
|
+
});
|
|
4048
|
+
child.on("error", (err) => {
|
|
4049
|
+
ui.error(`Failed to start command: ${err.message}`);
|
|
4050
|
+
if (err.code === "ENOENT") {
|
|
4051
|
+
ui.info(`Command not found: ${cmd}`);
|
|
4052
|
+
}
|
|
4053
|
+
process.exit(127);
|
|
4054
|
+
});
|
|
4055
|
+
} catch (error) {
|
|
4056
|
+
spinner.fail();
|
|
4057
|
+
if (error instanceof Error) {
|
|
4058
|
+
ui.error(error.message);
|
|
4059
|
+
if (error.message.includes("fetch")) {
|
|
4060
|
+
ui.info("Could not connect to the API. Check your network and API URL.");
|
|
4061
|
+
}
|
|
4062
|
+
} else {
|
|
4063
|
+
ui.error("An unexpected error occurred");
|
|
4064
|
+
}
|
|
4065
|
+
process.exit(1);
|
|
4066
|
+
}
|
|
4067
|
+
});
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
// src/index.ts
|
|
4071
|
+
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
4072
|
+
var version = "0.1.0";
|
|
4073
|
+
try {
|
|
4074
|
+
const pkg = JSON.parse(readFileSync4(join2(__dirname, "..", "package.json"), "utf-8"));
|
|
4075
|
+
version = pkg.version;
|
|
4076
|
+
} catch {
|
|
4077
|
+
}
|
|
4078
|
+
var banner = chalk3.hex("#6366f1")(`
|
|
4079
|
+
\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
|
|
4080
|
+
\u2551 \u2551
|
|
4081
|
+
\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551
|
|
4082
|
+
\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u2551
|
|
4083
|
+
\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2551
|
|
4084
|
+
\u2551 \u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551 \u2551
|
|
4085
|
+
\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2551
|
|
4086
|
+
\u2551 \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u2551
|
|
4087
|
+
\u2551 \u2551
|
|
4088
|
+
\u2551 ${chalk3.white("SecretStash")} ${chalk3.dim(`v${version}`)} \u2551
|
|
4089
|
+
\u2551 ${chalk3.dim("Ultra-secure team secrets management")} \u2551
|
|
4090
|
+
\u2551 \u2551
|
|
4091
|
+
\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
|
|
4092
|
+
`);
|
|
4093
|
+
var program = new Command();
|
|
4094
|
+
program.name("sstash").description("SecretStash CLI - Ultra-secure team secrets management").version(version, "-v, --version", "Output the current version").option("--no-banner", "Disable the banner").option("-q, --quiet", "Suppress non-essential output (useful for CI/CD)").hook("preAction", (thisCommand) => {
|
|
4095
|
+
const opts = thisCommand.opts();
|
|
4096
|
+
if (opts.quiet) {
|
|
4097
|
+
configManager.setQuiet(true);
|
|
4098
|
+
}
|
|
4099
|
+
if (opts.banner !== false && !opts.quiet && process.argv.length <= 2) {
|
|
4100
|
+
console.log(banner);
|
|
4101
|
+
}
|
|
4102
|
+
});
|
|
4103
|
+
registerAuthCommands(program);
|
|
4104
|
+
registerTeamCommands(program);
|
|
4105
|
+
registerProjectCommands(program);
|
|
4106
|
+
registerEnvironmentCommands(program);
|
|
4107
|
+
registerSecretCommands(program);
|
|
4108
|
+
registerDiffCommand(program);
|
|
4109
|
+
registerShareCommands(program);
|
|
4110
|
+
registerDoctorCommand(program);
|
|
4111
|
+
registerTagCommands(program);
|
|
4112
|
+
registerPullCICommand(program);
|
|
4113
|
+
registerExecCommand(program);
|
|
4114
|
+
program.addHelpText("after", `
|
|
4115
|
+
${chalk3.bold("Examples:")}
|
|
4116
|
+
${chalk3.dim("# Authenticate")}
|
|
4117
|
+
$ sstash login
|
|
4118
|
+
$ sstash whoami
|
|
4119
|
+
|
|
4120
|
+
${chalk3.dim("# Set up context")}
|
|
4121
|
+
$ sstash teams use my-team
|
|
4122
|
+
$ sstash projects use my-project
|
|
4123
|
+
$ sstash environments use development
|
|
4124
|
+
|
|
4125
|
+
${chalk3.dim("# Or initialize in current directory")}
|
|
4126
|
+
$ sstash init
|
|
4127
|
+
|
|
4128
|
+
${chalk3.dim("# Manage secrets")}
|
|
4129
|
+
$ sstash pull # Pull secrets to .env
|
|
4130
|
+
$ sstash push # Push secrets from .env
|
|
4131
|
+
$ sstash run npm start # Run with secrets injected
|
|
4132
|
+
|
|
4133
|
+
${chalk3.dim("# View secrets")}
|
|
4134
|
+
$ sstash secrets list
|
|
4135
|
+
$ sstash secrets list --reveal
|
|
4136
|
+
|
|
4137
|
+
${chalk3.dim("# Compare environments")}
|
|
4138
|
+
$ sstash diff development production
|
|
4139
|
+
|
|
4140
|
+
${chalk3.dim("# Share secrets")}
|
|
4141
|
+
$ sstash share create DATABASE_URL --expires 24h --one-time
|
|
4142
|
+
$ sstash share list
|
|
4143
|
+
$ sstash share revoke <id>
|
|
4144
|
+
|
|
4145
|
+
${chalk3.dim("# Organize with tags")}
|
|
4146
|
+
$ sstash tags create database --color #FF5733
|
|
4147
|
+
$ sstash tags list
|
|
4148
|
+
$ sstash secrets tag DATABASE_URL database
|
|
4149
|
+
|
|
4150
|
+
${chalk3.dim("# CI/CD - pull secrets or run commands with secrets injected")}
|
|
4151
|
+
$ SECRETS_TOKEN=stk_xxx sstash ci-pull -p myapp -e production
|
|
4152
|
+
$ SECRETS_TOKEN=stk_xxx sstash ci-pull -p myapp -e production --format json
|
|
4153
|
+
$ eval "$(SECRETS_TOKEN=stk_xxx sstash ci-pull -p myapp -e production --format shell)"
|
|
4154
|
+
$ SECRETS_TOKEN=stk_... sstash exec --project=myapp --env=production -- npm run build
|
|
4155
|
+
|
|
4156
|
+
${chalk3.bold("Documentation:")}
|
|
4157
|
+
https://secretstash.dev/docs
|
|
4158
|
+
`);
|
|
4159
|
+
program.parse();
|