@clef-sh/cli 0.1.1 → 0.1.2
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/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/node_modules/@clef-sh/ui/dist/client/assets/index-DkMz8iGa.js +0 -46
- package/node_modules/@clef-sh/ui/dist/client/index.html +0 -47
- package/node_modules/@clef-sh/ui/dist/server/api.d.ts +0 -8
- package/node_modules/@clef-sh/ui/dist/server/api.d.ts.map +0 -1
- package/node_modules/@clef-sh/ui/dist/server/api.js +0 -643
- package/node_modules/@clef-sh/ui/dist/server/api.js.map +0 -1
- package/node_modules/@clef-sh/ui/dist/server/index.d.ts +0 -12
- package/node_modules/@clef-sh/ui/dist/server/index.d.ts.map +0 -1
- package/node_modules/@clef-sh/ui/dist/server/index.js +0 -125
- package/node_modules/@clef-sh/ui/dist/server/index.js.map +0 -1
- package/node_modules/@clef-sh/ui/package.json +0 -49
|
@@ -1,643 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.createApiRouter = createApiRouter;
|
|
37
|
-
const path = __importStar(require("path"));
|
|
38
|
-
const express_1 = require("express");
|
|
39
|
-
const core_1 = require("@clef-sh/core");
|
|
40
|
-
function createApiRouter(deps) {
|
|
41
|
-
const router = (0, express_1.Router)();
|
|
42
|
-
const parser = new core_1.ManifestParser();
|
|
43
|
-
const matrix = new core_1.MatrixManager();
|
|
44
|
-
const sops = new core_1.SopsClient(deps.runner);
|
|
45
|
-
const diffEngine = new core_1.DiffEngine();
|
|
46
|
-
const schemaValidator = new core_1.SchemaValidator();
|
|
47
|
-
const lintRunner = new core_1.LintRunner(matrix, schemaValidator, sops);
|
|
48
|
-
const git = new core_1.GitIntegration(deps.runner);
|
|
49
|
-
const scanRunner = new core_1.ScanRunner(deps.runner);
|
|
50
|
-
const recipientManager = new core_1.RecipientManager(sops, matrix);
|
|
51
|
-
// In-session scan cache
|
|
52
|
-
let lastScanResult = null;
|
|
53
|
-
let lastScanAt = null;
|
|
54
|
-
function loadManifest() {
|
|
55
|
-
const manifestPath = `${deps.repoRoot}/clef.yaml`;
|
|
56
|
-
return parser.parse(manifestPath);
|
|
57
|
-
}
|
|
58
|
-
function setNoCacheHeaders(res) {
|
|
59
|
-
res.set({
|
|
60
|
-
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
61
|
-
Pragma: "no-cache",
|
|
62
|
-
Expires: "0",
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
// GET /api/manifest
|
|
66
|
-
router.get("/manifest", (_req, res) => {
|
|
67
|
-
try {
|
|
68
|
-
const manifest = loadManifest();
|
|
69
|
-
res.json(manifest);
|
|
70
|
-
}
|
|
71
|
-
catch (err) {
|
|
72
|
-
const message = err instanceof Error ? err.message : "Failed to load manifest";
|
|
73
|
-
res.status(500).json({ error: message, code: "MANIFEST_ERROR" });
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
// GET /api/matrix
|
|
77
|
-
router.get("/matrix", async (_req, res) => {
|
|
78
|
-
try {
|
|
79
|
-
const manifest = loadManifest();
|
|
80
|
-
const statuses = await matrix.getMatrixStatus(manifest, deps.repoRoot, sops);
|
|
81
|
-
res.json(statuses);
|
|
82
|
-
}
|
|
83
|
-
catch (err) {
|
|
84
|
-
const message = err instanceof Error ? err.message : "Failed to get matrix status";
|
|
85
|
-
res.status(500).json({ error: message, code: "MATRIX_ERROR" });
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
// GET /api/namespace/:ns/:env
|
|
89
|
-
router.get("/namespace/:ns/:env", async (req, res) => {
|
|
90
|
-
setNoCacheHeaders(res);
|
|
91
|
-
try {
|
|
92
|
-
const manifest = loadManifest();
|
|
93
|
-
const { ns, env } = req.params;
|
|
94
|
-
const nsExists = manifest.namespaces.some((n) => n.name === ns);
|
|
95
|
-
const envExists = manifest.environments.some((e) => e.name === env);
|
|
96
|
-
if (!nsExists || !envExists) {
|
|
97
|
-
res.status(404).json({
|
|
98
|
-
error: `Namespace '${ns}' or environment '${env}' not found in manifest.`,
|
|
99
|
-
code: "NOT_FOUND",
|
|
100
|
-
});
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
const filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
|
|
104
|
-
const decrypted = await sops.decrypt(filePath);
|
|
105
|
-
// Read pending keys from metadata (plaintext sidecar)
|
|
106
|
-
let pending = [];
|
|
107
|
-
try {
|
|
108
|
-
pending = await (0, core_1.getPendingKeys)(filePath);
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
// Metadata unreadable — no pending info
|
|
112
|
-
}
|
|
113
|
-
res.json({ ...decrypted, pending });
|
|
114
|
-
}
|
|
115
|
-
catch (err) {
|
|
116
|
-
const message = err instanceof Error ? err.message : "Failed to decrypt namespace";
|
|
117
|
-
res.status(500).json({ error: message, code: "DECRYPT_ERROR" });
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
// PUT /api/namespace/:ns/:env/:key
|
|
121
|
-
// body: { value: string } — set a specific value
|
|
122
|
-
// body: { random: true } — generate random value server-side and mark pending
|
|
123
|
-
router.put("/namespace/:ns/:env/:key", async (req, res) => {
|
|
124
|
-
setNoCacheHeaders(res);
|
|
125
|
-
try {
|
|
126
|
-
const manifest = loadManifest();
|
|
127
|
-
const { ns, env, key } = req.params;
|
|
128
|
-
const { value, random, confirmed } = req.body;
|
|
129
|
-
if (!random && (value === undefined || value === null)) {
|
|
130
|
-
res.status(400).json({
|
|
131
|
-
error: "Request body must include 'value' or 'random: true'.",
|
|
132
|
-
code: "BAD_REQUEST",
|
|
133
|
-
});
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
const nsExists = manifest.namespaces.some((n) => n.name === ns);
|
|
137
|
-
const envExists = manifest.environments.some((e) => e.name === env);
|
|
138
|
-
if (!nsExists || !envExists) {
|
|
139
|
-
res.status(404).json({
|
|
140
|
-
error: `Namespace '${ns}' or environment '${env}' not found in manifest.`,
|
|
141
|
-
code: "NOT_FOUND",
|
|
142
|
-
});
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
if (matrix.isProtectedEnvironment(manifest, env) && !confirmed) {
|
|
146
|
-
res.status(409).json({
|
|
147
|
-
error: "Protected environment requires confirmation",
|
|
148
|
-
code: "PROTECTED_ENV",
|
|
149
|
-
protected: true,
|
|
150
|
-
});
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
const filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
|
|
154
|
-
const decrypted = await sops.decrypt(filePath);
|
|
155
|
-
if (random) {
|
|
156
|
-
// Generate random value server-side and mark as pending
|
|
157
|
-
const randomValue = (0, core_1.generateRandomValue)();
|
|
158
|
-
const previousValue = decrypted.values[key];
|
|
159
|
-
decrypted.values[key] = randomValue;
|
|
160
|
-
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
161
|
-
try {
|
|
162
|
-
await (0, core_1.markPendingWithRetry)(filePath, [key], "clef ui");
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
// Both retry attempts failed — roll back the encrypt
|
|
166
|
-
try {
|
|
167
|
-
if (previousValue !== undefined) {
|
|
168
|
-
decrypted.values[key] = previousValue;
|
|
169
|
-
}
|
|
170
|
-
else {
|
|
171
|
-
delete decrypted.values[key];
|
|
172
|
-
}
|
|
173
|
-
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
174
|
-
}
|
|
175
|
-
catch {
|
|
176
|
-
// Rollback also failed — return 500 with context
|
|
177
|
-
return res.status(500).json({
|
|
178
|
-
error: "Partial failure",
|
|
179
|
-
message: "Value was encrypted but pending state could not be recorded. " +
|
|
180
|
-
"Rollback also failed. The key may have a random placeholder value. " +
|
|
181
|
-
"Check the file manually.",
|
|
182
|
-
code: "PARTIAL_FAILURE",
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
return res.status(500).json({
|
|
186
|
-
error: "Pending state could not be recorded",
|
|
187
|
-
message: "The operation was rolled back. No changes were made.",
|
|
188
|
-
code: "PENDING_FAILURE",
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
res.json({ success: true, key, pending: true });
|
|
192
|
-
}
|
|
193
|
-
else {
|
|
194
|
-
decrypted.values[key] = String(value);
|
|
195
|
-
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
196
|
-
// Validate against schema if defined (B1)
|
|
197
|
-
const nsDef = manifest.namespaces.find((n) => n.name === ns);
|
|
198
|
-
if (nsDef?.schema) {
|
|
199
|
-
try {
|
|
200
|
-
const schema = schemaValidator.loadSchema(path.join(deps.repoRoot, nsDef.schema));
|
|
201
|
-
const result = schemaValidator.validate({ [key]: String(value) }, schema);
|
|
202
|
-
const violations = [...result.errors, ...result.warnings];
|
|
203
|
-
if (violations.length > 0) {
|
|
204
|
-
// Resolve pending state if the key was pending
|
|
205
|
-
try {
|
|
206
|
-
await (0, core_1.markResolved)(filePath, [key]);
|
|
207
|
-
}
|
|
208
|
-
catch {
|
|
209
|
-
// Metadata update failed — non-fatal
|
|
210
|
-
}
|
|
211
|
-
return res.json({
|
|
212
|
-
success: true,
|
|
213
|
-
key,
|
|
214
|
-
warnings: violations.map((v) => v.message),
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
catch {
|
|
219
|
-
// Schema load failed — skip validation, not fatal
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
// Resolve pending state if the key was pending
|
|
223
|
-
try {
|
|
224
|
-
await (0, core_1.markResolved)(filePath, [key]);
|
|
225
|
-
}
|
|
226
|
-
catch {
|
|
227
|
-
// Metadata update failed — non-fatal
|
|
228
|
-
}
|
|
229
|
-
res.json({ success: true, key });
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
const message = err instanceof Error ? err.message : "Failed to set value";
|
|
234
|
-
res.status(500).json({ error: message, code: "SET_ERROR" });
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
// DELETE /api/namespace/:ns/:env/:key
|
|
238
|
-
router.delete("/namespace/:ns/:env/:key", async (req, res) => {
|
|
239
|
-
setNoCacheHeaders(res);
|
|
240
|
-
try {
|
|
241
|
-
const manifest = loadManifest();
|
|
242
|
-
const { ns, env, key } = req.params;
|
|
243
|
-
const { confirmed } = req.body;
|
|
244
|
-
const nsExists = manifest.namespaces.some((n) => n.name === ns);
|
|
245
|
-
const envExists = manifest.environments.some((e) => e.name === env);
|
|
246
|
-
if (!nsExists || !envExists) {
|
|
247
|
-
res.status(404).json({
|
|
248
|
-
error: `Namespace '${ns}' or environment '${env}' not found in manifest.`,
|
|
249
|
-
code: "NOT_FOUND",
|
|
250
|
-
});
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
if (matrix.isProtectedEnvironment(manifest, env) && !confirmed) {
|
|
254
|
-
res.status(409).json({
|
|
255
|
-
error: "Protected environment requires confirmation",
|
|
256
|
-
code: "PROTECTED_ENV",
|
|
257
|
-
protected: true,
|
|
258
|
-
});
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
const filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
|
|
262
|
-
const decrypted = await sops.decrypt(filePath);
|
|
263
|
-
if (!(key in decrypted.values)) {
|
|
264
|
-
res.status(404).json({
|
|
265
|
-
error: `Key '${key}' not found in ${ns}/${env}.`,
|
|
266
|
-
code: "KEY_NOT_FOUND",
|
|
267
|
-
});
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
delete decrypted.values[key];
|
|
271
|
-
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
272
|
-
// Clean up pending metadata if it exists
|
|
273
|
-
try {
|
|
274
|
-
await (0, core_1.markResolved)(filePath, [key]);
|
|
275
|
-
}
|
|
276
|
-
catch {
|
|
277
|
-
// Best effort — orphaned metadata is annoying but not dangerous
|
|
278
|
-
}
|
|
279
|
-
res.json({ success: true, key });
|
|
280
|
-
}
|
|
281
|
-
catch (err) {
|
|
282
|
-
const message = err instanceof Error ? err.message : "Failed to delete key";
|
|
283
|
-
res.status(500).json({ error: message, code: "DELETE_ERROR" });
|
|
284
|
-
}
|
|
285
|
-
});
|
|
286
|
-
// GET /api/diff/:ns/:envA/:envB
|
|
287
|
-
router.get("/diff/:ns/:envA/:envB", async (req, res) => {
|
|
288
|
-
setNoCacheHeaders(res);
|
|
289
|
-
try {
|
|
290
|
-
const manifest = loadManifest();
|
|
291
|
-
const { ns, envA, envB } = req.params;
|
|
292
|
-
const nsExists = manifest.namespaces.some((n) => n.name === ns);
|
|
293
|
-
const envAExists = manifest.environments.some((e) => e.name === envA);
|
|
294
|
-
const envBExists = manifest.environments.some((e) => e.name === envB);
|
|
295
|
-
if (!nsExists || !envAExists || !envBExists) {
|
|
296
|
-
res.status(404).json({
|
|
297
|
-
error: `Namespace '${ns}', environment '${envA}', or environment '${envB}' not found.`,
|
|
298
|
-
code: "NOT_FOUND",
|
|
299
|
-
});
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
const result = await diffEngine.diffFiles(ns, envA, envB, manifest, sops, deps.repoRoot);
|
|
303
|
-
res.json(result);
|
|
304
|
-
}
|
|
305
|
-
catch (err) {
|
|
306
|
-
const message = err instanceof Error ? err.message : "Failed to compute diff";
|
|
307
|
-
res.status(500).json({ error: message, code: "DIFF_ERROR" });
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
// GET /api/lint/:namespace
|
|
311
|
-
router.get("/lint/:namespace", async (req, res) => {
|
|
312
|
-
try {
|
|
313
|
-
const manifest = loadManifest();
|
|
314
|
-
const { namespace } = req.params;
|
|
315
|
-
const nsExists = manifest.namespaces.some((n) => n.name === namespace);
|
|
316
|
-
if (!nsExists) {
|
|
317
|
-
res.status(404).json({
|
|
318
|
-
error: `Namespace '${namespace}' not found in manifest.`,
|
|
319
|
-
code: "NOT_FOUND",
|
|
320
|
-
});
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
const result = await lintRunner.run(manifest, deps.repoRoot);
|
|
324
|
-
const filtered = result.issues.filter((issue) => {
|
|
325
|
-
const issueNs = issue.file.split("/")[0];
|
|
326
|
-
return issueNs === namespace;
|
|
327
|
-
});
|
|
328
|
-
res.json({ issues: filtered, fileCount: result.fileCount });
|
|
329
|
-
}
|
|
330
|
-
catch (err) {
|
|
331
|
-
const message = err instanceof Error ? err.message : "Failed to run lint";
|
|
332
|
-
res.status(500).json({ error: message, code: "LINT_ERROR" });
|
|
333
|
-
}
|
|
334
|
-
});
|
|
335
|
-
// GET /api/lint
|
|
336
|
-
router.get("/lint", async (_req, res) => {
|
|
337
|
-
try {
|
|
338
|
-
const manifest = loadManifest();
|
|
339
|
-
const result = await lintRunner.run(manifest, deps.repoRoot);
|
|
340
|
-
res.json(result);
|
|
341
|
-
}
|
|
342
|
-
catch (err) {
|
|
343
|
-
const message = err instanceof Error ? err.message : "Failed to run lint";
|
|
344
|
-
res.status(500).json({ error: message, code: "LINT_ERROR" });
|
|
345
|
-
}
|
|
346
|
-
});
|
|
347
|
-
// POST /api/lint/fix
|
|
348
|
-
router.post("/lint/fix", async (_req, res) => {
|
|
349
|
-
try {
|
|
350
|
-
const manifest = loadManifest();
|
|
351
|
-
const result = await lintRunner.fix(manifest, deps.repoRoot);
|
|
352
|
-
res.json(result);
|
|
353
|
-
}
|
|
354
|
-
catch (err) {
|
|
355
|
-
const message = err instanceof Error ? err.message : "Failed to run lint fix";
|
|
356
|
-
res.status(500).json({ error: message, code: "LINT_FIX_ERROR" });
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
// POST /api/git/commit
|
|
360
|
-
router.post("/git/commit", async (req, res) => {
|
|
361
|
-
try {
|
|
362
|
-
const { message } = req.body;
|
|
363
|
-
if (!message || typeof message !== "string") {
|
|
364
|
-
res.status(400).json({
|
|
365
|
-
error: "Request body must include a 'message' string.",
|
|
366
|
-
code: "BAD_REQUEST",
|
|
367
|
-
});
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
// Stage all modified encrypted files and metadata files
|
|
371
|
-
const status = await git.getStatus(deps.repoRoot);
|
|
372
|
-
const clefFiles = [...status.staged, ...status.unstaged, ...status.untracked].filter((f) => f.endsWith(".enc.yaml") || f.endsWith(".enc.json") || f.endsWith(".clef-meta.yaml"));
|
|
373
|
-
if (clefFiles.length === 0) {
|
|
374
|
-
res.status(400).json({
|
|
375
|
-
error: "No changes to commit",
|
|
376
|
-
code: "NOTHING_TO_COMMIT",
|
|
377
|
-
});
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
await git.stageFiles(clefFiles, deps.repoRoot);
|
|
381
|
-
const hash = await git.commit(message, deps.repoRoot);
|
|
382
|
-
res.json({ hash });
|
|
383
|
-
}
|
|
384
|
-
catch (err) {
|
|
385
|
-
const message = err instanceof Error ? err.message : "Failed to commit";
|
|
386
|
-
res.status(500).json({ error: message, code: "GIT_ERROR" });
|
|
387
|
-
}
|
|
388
|
-
});
|
|
389
|
-
// GET /api/git/status
|
|
390
|
-
router.get("/git/status", async (_req, res) => {
|
|
391
|
-
try {
|
|
392
|
-
const status = await git.getStatus(deps.repoRoot);
|
|
393
|
-
res.json(status);
|
|
394
|
-
}
|
|
395
|
-
catch (err) {
|
|
396
|
-
const message = err instanceof Error ? err.message : "Failed to get git status";
|
|
397
|
-
res.status(500).json({ error: message, code: "GIT_ERROR" });
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
|
-
// GET /api/git/diff
|
|
401
|
-
router.get("/git/diff", async (_req, res) => {
|
|
402
|
-
setNoCacheHeaders(res);
|
|
403
|
-
try {
|
|
404
|
-
const diff = await git.getDiff(deps.repoRoot);
|
|
405
|
-
res.json({ diff });
|
|
406
|
-
}
|
|
407
|
-
catch (err) {
|
|
408
|
-
const message = err instanceof Error ? err.message : "Could not get diff";
|
|
409
|
-
res.status(500).json({ error: message, code: "GIT_DIFF_ERROR" });
|
|
410
|
-
}
|
|
411
|
-
});
|
|
412
|
-
// GET /api/git/log/:ns/:env
|
|
413
|
-
router.get("/git/log/:ns/:env", async (req, res) => {
|
|
414
|
-
try {
|
|
415
|
-
const manifest = loadManifest();
|
|
416
|
-
const { ns, env } = req.params;
|
|
417
|
-
const nsExists = manifest.namespaces.some((n) => n.name === ns);
|
|
418
|
-
const envExists = manifest.environments.some((e) => e.name === env);
|
|
419
|
-
if (!nsExists || !envExists) {
|
|
420
|
-
res.status(404).json({
|
|
421
|
-
error: `Namespace '${ns}' or environment '${env}' not found in manifest.`,
|
|
422
|
-
code: "NOT_FOUND",
|
|
423
|
-
});
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
const filePath = manifest.file_pattern
|
|
427
|
-
.replace("{namespace}", ns)
|
|
428
|
-
.replace("{environment}", env);
|
|
429
|
-
const log = await git.getLog(filePath, deps.repoRoot);
|
|
430
|
-
res.json({ log });
|
|
431
|
-
}
|
|
432
|
-
catch (err) {
|
|
433
|
-
const message = err instanceof Error ? err.message : "Could not get log";
|
|
434
|
-
res.status(500).json({ error: message, code: "GIT_LOG_ERROR" });
|
|
435
|
-
}
|
|
436
|
-
});
|
|
437
|
-
// POST /api/scan
|
|
438
|
-
router.post("/scan", async (req, res) => {
|
|
439
|
-
try {
|
|
440
|
-
const manifest = loadManifest();
|
|
441
|
-
const { severity, paths } = req.body;
|
|
442
|
-
const result = await scanRunner.scan(deps.repoRoot, manifest, {
|
|
443
|
-
severity: severity === "high" ? "high" : "all",
|
|
444
|
-
paths: paths && paths.length > 0 ? paths : undefined,
|
|
445
|
-
});
|
|
446
|
-
lastScanResult = result;
|
|
447
|
-
lastScanAt = new Date().toISOString();
|
|
448
|
-
res.json(result);
|
|
449
|
-
}
|
|
450
|
-
catch (err) {
|
|
451
|
-
const message = err instanceof Error ? err.message : "Scan failed";
|
|
452
|
-
res.status(500).json({ error: message, code: "SCAN_ERROR" });
|
|
453
|
-
}
|
|
454
|
-
});
|
|
455
|
-
// GET /api/scan/status — last scan result for this session
|
|
456
|
-
router.get("/scan/status", (_req, res) => {
|
|
457
|
-
res.json({ lastRun: lastScanResult, lastRunAt: lastScanAt });
|
|
458
|
-
});
|
|
459
|
-
// POST /api/editor/open — open a file in the OS default editor
|
|
460
|
-
router.post("/editor/open", async (req, res) => {
|
|
461
|
-
try {
|
|
462
|
-
const { file } = req.body;
|
|
463
|
-
if (!file || typeof file !== "string") {
|
|
464
|
-
res
|
|
465
|
-
.status(400)
|
|
466
|
-
.json({ error: "Request body must include a 'file' string.", code: "BAD_REQUEST" });
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
const resolved = path.resolve(deps.repoRoot, file);
|
|
470
|
-
if (!resolved.startsWith(deps.repoRoot + path.sep) && resolved !== deps.repoRoot) {
|
|
471
|
-
res.status(400).json({
|
|
472
|
-
error: "File path must be within the repository.",
|
|
473
|
-
code: "BAD_REQUEST",
|
|
474
|
-
});
|
|
475
|
-
return;
|
|
476
|
-
}
|
|
477
|
-
const editor = process.env.EDITOR || (process.env.TERM_PROGRAM === "vscode" ? "code" : "");
|
|
478
|
-
if (!editor) {
|
|
479
|
-
res.status(500).json({
|
|
480
|
-
error: "No editor configured. Set the EDITOR environment variable.",
|
|
481
|
-
code: "NO_EDITOR",
|
|
482
|
-
});
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
await deps.runner.run(editor, [file], { cwd: deps.repoRoot });
|
|
486
|
-
res.json({ success: true });
|
|
487
|
-
}
|
|
488
|
-
catch (err) {
|
|
489
|
-
const message = err instanceof Error ? err.message : "Failed to open editor";
|
|
490
|
-
res.status(500).json({ error: message, code: "EDITOR_ERROR" });
|
|
491
|
-
}
|
|
492
|
-
});
|
|
493
|
-
// POST /api/import/preview — dry run import
|
|
494
|
-
router.post("/import/preview", async (req, res) => {
|
|
495
|
-
try {
|
|
496
|
-
const manifest = loadManifest();
|
|
497
|
-
const { target, content, format, overwriteKeys } = req.body;
|
|
498
|
-
if (!target || typeof content !== "string") {
|
|
499
|
-
res.status(400).json({
|
|
500
|
-
error: "Request body must include 'target' and 'content'.",
|
|
501
|
-
code: "BAD_REQUEST",
|
|
502
|
-
});
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
const parts = target.split("/");
|
|
506
|
-
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
507
|
-
res.status(400).json({
|
|
508
|
-
error: "Invalid target format. Use 'namespace/environment'.",
|
|
509
|
-
code: "BAD_REQUEST",
|
|
510
|
-
});
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
const importRunner = new core_1.ImportRunner(sops);
|
|
514
|
-
const result = await importRunner.import(target, null, content, manifest, deps.repoRoot, {
|
|
515
|
-
format,
|
|
516
|
-
dryRun: true,
|
|
517
|
-
});
|
|
518
|
-
// Classify keys using overwriteKeys from the request
|
|
519
|
-
const overwriteSet = new Set(overwriteKeys ?? []);
|
|
520
|
-
const wouldImport = result.imported.filter((k) => !overwriteSet.has(k));
|
|
521
|
-
const wouldOverwrite = result.imported.filter((k) => overwriteSet.has(k));
|
|
522
|
-
const wouldSkip = result.skipped.map((k) => ({ key: k, reason: "already exists" }));
|
|
523
|
-
res.json({
|
|
524
|
-
wouldImport,
|
|
525
|
-
wouldSkip,
|
|
526
|
-
wouldOverwrite,
|
|
527
|
-
warnings: result.warnings,
|
|
528
|
-
totalKeys: result.imported.length + result.skipped.length,
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
catch (err) {
|
|
532
|
-
const message = err instanceof Error ? err.message : "Preview failed";
|
|
533
|
-
res.status(500).json({ error: message, code: "IMPORT_PREVIEW_ERROR" });
|
|
534
|
-
}
|
|
535
|
-
});
|
|
536
|
-
// POST /api/import/apply — run actual import
|
|
537
|
-
router.post("/import/apply", async (req, res) => {
|
|
538
|
-
try {
|
|
539
|
-
const manifest = loadManifest();
|
|
540
|
-
const { target, content, format, keys, overwriteKeys } = req.body;
|
|
541
|
-
if (!target || typeof content !== "string") {
|
|
542
|
-
res.status(400).json({
|
|
543
|
-
error: "Request body must include 'target' and 'content'.",
|
|
544
|
-
code: "BAD_REQUEST",
|
|
545
|
-
});
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
if (!Array.isArray(keys)) {
|
|
549
|
-
res
|
|
550
|
-
.status(400)
|
|
551
|
-
.json({ error: "Request body must include 'keys' array.", code: "BAD_REQUEST" });
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
const parts = target.split("/");
|
|
555
|
-
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
556
|
-
res.status(400).json({
|
|
557
|
-
error: "Invalid target format. Use 'namespace/environment'.",
|
|
558
|
-
code: "BAD_REQUEST",
|
|
559
|
-
});
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
if (keys.length === 0) {
|
|
563
|
-
res.json({ imported: [], skipped: [], failed: [] });
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
const importRunner = new core_1.ImportRunner(sops);
|
|
567
|
-
const result = await importRunner.import(target, null, content, manifest, deps.repoRoot, {
|
|
568
|
-
format,
|
|
569
|
-
keys,
|
|
570
|
-
overwrite: (overwriteKeys ?? []).length > 0,
|
|
571
|
-
});
|
|
572
|
-
res.json({
|
|
573
|
-
imported: result.imported,
|
|
574
|
-
skipped: result.skipped,
|
|
575
|
-
failed: result.failed,
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
catch (err) {
|
|
579
|
-
const message = err instanceof Error ? err.message : "Import failed";
|
|
580
|
-
res.status(500).json({ error: message, code: "IMPORT_APPLY_ERROR" });
|
|
581
|
-
}
|
|
582
|
-
});
|
|
583
|
-
// GET /api/recipients
|
|
584
|
-
router.get("/recipients", async (_req, res) => {
|
|
585
|
-
try {
|
|
586
|
-
const manifest = loadManifest();
|
|
587
|
-
const recipients = await recipientManager.list(manifest, deps.repoRoot);
|
|
588
|
-
const cells = matrix.resolveMatrix(manifest, deps.repoRoot);
|
|
589
|
-
const totalFiles = cells.filter((c) => c.exists).length;
|
|
590
|
-
res.json({ recipients, totalFiles });
|
|
591
|
-
}
|
|
592
|
-
catch (err) {
|
|
593
|
-
const message = err instanceof Error ? err.message : "Failed to list recipients";
|
|
594
|
-
res.status(500).json({ error: message, code: "RECIPIENTS_ERROR" });
|
|
595
|
-
}
|
|
596
|
-
});
|
|
597
|
-
// GET /api/recipients/validate?key=age1...
|
|
598
|
-
router.get("/recipients/validate", (req, res) => {
|
|
599
|
-
const key = req.query.key;
|
|
600
|
-
if (!key) {
|
|
601
|
-
res.status(400).json({ valid: false, error: "Missing 'key' query parameter." });
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
const result = (0, core_1.validateAgePublicKey)(key);
|
|
605
|
-
res.json(result);
|
|
606
|
-
});
|
|
607
|
-
// POST /api/recipients/add
|
|
608
|
-
router.post("/recipients/add", async (req, res) => {
|
|
609
|
-
try {
|
|
610
|
-
const manifest = loadManifest();
|
|
611
|
-
const { key, label } = req.body;
|
|
612
|
-
const result = await recipientManager.add(key, label, manifest, deps.repoRoot);
|
|
613
|
-
res.json(result);
|
|
614
|
-
}
|
|
615
|
-
catch (err) {
|
|
616
|
-
const message = err instanceof Error ? err.message : "Failed to add recipient";
|
|
617
|
-
res.status(500).json({ error: message, code: "RECIPIENTS_ADD_ERROR" });
|
|
618
|
-
}
|
|
619
|
-
});
|
|
620
|
-
// POST /api/recipients/remove
|
|
621
|
-
router.post("/recipients/remove", async (req, res) => {
|
|
622
|
-
try {
|
|
623
|
-
const manifest = loadManifest();
|
|
624
|
-
const { key } = req.body;
|
|
625
|
-
const result = await recipientManager.remove(key, manifest, deps.repoRoot);
|
|
626
|
-
const cells = matrix.resolveMatrix(manifest, deps.repoRoot);
|
|
627
|
-
const targets = cells.filter((c) => c.exists).map((c) => `${c.namespace}/${c.environment}`);
|
|
628
|
-
res.json({ ...result, rotationReminder: targets });
|
|
629
|
-
}
|
|
630
|
-
catch (err) {
|
|
631
|
-
const message = err instanceof Error ? err.message : "Failed to remove recipient";
|
|
632
|
-
res.status(500).json({ error: message, code: "RECIPIENTS_REMOVE_ERROR" });
|
|
633
|
-
}
|
|
634
|
-
});
|
|
635
|
-
function dispose() {
|
|
636
|
-
lastScanResult = null;
|
|
637
|
-
lastScanAt = null;
|
|
638
|
-
}
|
|
639
|
-
// Attach dispose to the router for cleanup
|
|
640
|
-
router.dispose = dispose;
|
|
641
|
-
return router;
|
|
642
|
-
}
|
|
643
|
-
//# sourceMappingURL=api.js.map
|