@clef-sh/ui 0.1.14 → 0.1.15-beta.108
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/client/assets/index-C4tsbWst.js +26 -0
- package/dist/client/index.html +1 -1
- package/dist/client-lib/components/MatrixGrid.d.ts +3 -1
- package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -1
- package/dist/client-lib/components/Sidebar.d.ts +1 -1
- package/dist/client-lib/components/Sidebar.d.ts.map +1 -1
- package/dist/client-lib/components/SyncPanel.d.ts +8 -0
- package/dist/client-lib/components/SyncPanel.d.ts.map +1 -0
- package/dist/server/api.d.ts.map +1 -1
- package/dist/server/api.js +356 -89
- package/dist/server/api.js.map +1 -1
- package/package.json +1 -1
- package/src/client/App.tsx +10 -16
- package/src/client/components/MatrixGrid.tsx +179 -145
- package/src/client/components/Sidebar.tsx +15 -1
- package/src/client/components/SyncPanel.test.tsx +138 -0
- package/src/client/components/SyncPanel.tsx +217 -0
- package/src/client/screens/BackendScreen.tsx +0 -1
- package/src/client/screens/ManifestScreen.test.tsx +394 -0
- package/src/client/screens/ManifestScreen.tsx +977 -0
- package/src/client/screens/MatrixView.tsx +34 -3
- package/src/client/screens/NamespaceEditor.test.tsx +24 -24
- package/src/client/screens/NamespaceEditor.tsx +27 -51
- package/src/client/screens/ResetScreen.test.tsx +397 -0
- package/src/client/screens/ResetScreen.tsx +614 -0
- package/dist/client/assets/index-CVpAmirt.js +0 -26
package/dist/client/index.html
CHANGED
|
@@ -8,6 +8,8 @@ export interface MatrixGridProps {
|
|
|
8
8
|
}>;
|
|
9
9
|
matrixStatuses: MatrixStatus[];
|
|
10
10
|
onNamespaceClick?: (ns: string) => void;
|
|
11
|
+
onSyncClick?: (ns: string) => void;
|
|
12
|
+
syncingNs?: string | null;
|
|
11
13
|
}
|
|
12
|
-
export declare function MatrixGrid({ namespaces, environments, matrixStatuses, onNamespaceClick, }: MatrixGridProps): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
export declare function MatrixGrid({ namespaces, environments, matrixStatuses, onNamespaceClick, onSyncClick, syncingNs, }: MatrixGridProps): import("react/jsx-runtime").JSX.Element;
|
|
13
15
|
//# sourceMappingURL=MatrixGrid.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MatrixGrid.d.ts","sourceRoot":"","sources":["../../../src/client/components/MatrixGrid.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpC,YAAY,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtC,cAAc,EAAE,YAAY,EAAE,CAAC;IAC/B,gBAAgB,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"MatrixGrid.d.ts","sourceRoot":"","sources":["../../../src/client/components/MatrixGrid.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpC,YAAY,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACtC,cAAc,EAAE,YAAY,EAAE,CAAC;IAC/B,gBAAgB,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAwBD,wBAAgB,UAAU,CAAC,EACzB,UAAU,EACV,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,WAAW,EACX,SAAS,GACV,EAAE,eAAe,2CA8PjB"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ClefManifest, MatrixStatus, GitStatus as GitStatusType } from "@clef-sh/core";
|
|
2
|
-
export type ViewName = "matrix" | "editor" | "diff" | "lint" | "scan" | "import" | "recipients" | "identities" | "backend" | "history";
|
|
2
|
+
export type ViewName = "matrix" | "editor" | "diff" | "lint" | "scan" | "import" | "recipients" | "identities" | "backend" | "reset" | "history" | "manifest";
|
|
3
3
|
interface SidebarProps {
|
|
4
4
|
activeView: ViewName;
|
|
5
5
|
setView: (view: ViewName) => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Sidebar.d.ts","sourceRoot":"","sources":["../../../src/client/components/Sidebar.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,SAAS,IAAI,aAAa,EAAE,MAAM,eAAe,CAAC;AAE5F,MAAM,MAAM,QAAQ,GAChB,QAAQ,GACR,QAAQ,GACR,MAAM,GACN,MAAM,GACN,MAAM,GACN,QAAQ,GACR,YAAY,GACZ,YAAY,GACZ,SAAS,GACT,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"Sidebar.d.ts","sourceRoot":"","sources":["../../../src/client/components/Sidebar.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,SAAS,IAAI,aAAa,EAAE,MAAM,eAAe,CAAC;AAE5F,MAAM,MAAM,QAAQ,GAChB,QAAQ,GACR,QAAQ,GACR,MAAM,GACN,MAAM,GACN,MAAM,GACN,QAAQ,GACR,YAAY,GACZ,YAAY,GACZ,SAAS,GACT,OAAO,GACP,SAAS,GACT,UAAU,CAAC;AAEf,UAAU,YAAY;IACpB,UAAU,EAAE,QAAQ,CAAC;IACrB,OAAO,EAAE,CAAC,IAAI,EAAE,QAAQ,KAAK,IAAI,CAAC;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,QAAQ,EAAE,YAAY,GAAG,IAAI,CAAC;IAC9B,cAAc,EAAE,YAAY,EAAE,CAAC;IAC/B,SAAS,EAAE,aAAa,GAAG,IAAI,CAAC;IAChC,cAAc,EAAE,MAAM,CAAC;IACvB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,wBAAgB,OAAO,CAAC,EACtB,UAAU,EACV,OAAO,EACP,QAAQ,EACR,KAAK,EACL,QAAQ,EACR,cAAc,EACd,SAAS,EACT,cAAc,EACd,cAAc,GACf,EAAE,YAAY,2CA4Od"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface SyncPanelProps {
|
|
2
|
+
namespace: string;
|
|
3
|
+
onComplete: () => void;
|
|
4
|
+
onCancel: () => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function SyncPanel({ namespace, onComplete, onCancel }: SyncPanelProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export {};
|
|
8
|
+
//# sourceMappingURL=SyncPanel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SyncPanel.d.ts","sourceRoot":"","sources":["../../../src/client/components/SyncPanel.tsx"],"names":[],"mappings":"AAyBA,UAAU,cAAc;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAED,wBAAgB,SAAS,CAAC,EAAE,SAAS,EAAE,UAAU,EAAE,QAAQ,EAAE,EAAE,cAAc,2CAyL5E"}
|
package/dist/server/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/server/api.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAqB,MAAM,SAAS,CAAC;AAOpD,OAAO,
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/server/api.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAqB,MAAM,SAAS,CAAC;AAOpD,OAAO,EASL,gBAAgB,EAoBjB,MAAM,eAAe,CAAC;AAGvB,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,gBAAgB,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAg7CrD"}
|
package/dist/server/api.js
CHANGED
|
@@ -115,11 +115,14 @@ function createApiRouter(deps) {
|
|
|
115
115
|
const schemaValidator = new core_1.SchemaValidator();
|
|
116
116
|
const lintRunner = new core_1.LintRunner(matrix, schemaValidator, sops);
|
|
117
117
|
const git = new core_1.GitIntegration(deps.runner);
|
|
118
|
+
const tx = new core_1.TransactionManager(git);
|
|
118
119
|
const scanRunner = new core_1.ScanRunner(deps.runner);
|
|
119
|
-
const recipientManager = new core_1.RecipientManager(sops, matrix);
|
|
120
|
-
const serviceIdManager = new core_1.ServiceIdentityManager(sops, matrix);
|
|
121
|
-
const backendMigrator = new core_1.BackendMigrator(sops, matrix);
|
|
122
|
-
const
|
|
120
|
+
const recipientManager = new core_1.RecipientManager(sops, matrix, tx);
|
|
121
|
+
const serviceIdManager = new core_1.ServiceIdentityManager(sops, matrix, tx);
|
|
122
|
+
const backendMigrator = new core_1.BackendMigrator(sops, matrix, tx);
|
|
123
|
+
const resetManager = new core_1.ResetManager(matrix, sops, schemaValidator, tx);
|
|
124
|
+
const syncManager = new core_1.SyncManager(matrix, sops, tx);
|
|
125
|
+
const structureManager = new core_1.StructureManager(matrix, sops, tx);
|
|
123
126
|
// In-session scan cache
|
|
124
127
|
let lastScanResult = null;
|
|
125
128
|
let lastScanAt = null;
|
|
@@ -206,7 +209,10 @@ function createApiRouter(deps) {
|
|
|
206
209
|
try {
|
|
207
210
|
const manifest = loadManifest();
|
|
208
211
|
const { ns, env, key } = req.params;
|
|
209
|
-
const { value, random, confirmed } = req.body;
|
|
212
|
+
const { value, random, confirmed, commit: commitFlag, } = req.body;
|
|
213
|
+
// Auto-commit by default. The edit-multiple-rows-then-batch-commit
|
|
214
|
+
// flow in NamespaceEditor.handleSave passes commit:false to defer.
|
|
215
|
+
const shouldCommit = commitFlag !== false;
|
|
210
216
|
if (!random && (value === undefined || value === null)) {
|
|
211
217
|
res.status(400).json({
|
|
212
218
|
error: "Request body must include 'value' or 'random: true'.",
|
|
@@ -231,87 +237,71 @@ function createApiRouter(deps) {
|
|
|
231
237
|
});
|
|
232
238
|
return;
|
|
233
239
|
}
|
|
234
|
-
const
|
|
240
|
+
const relCellPath = manifest.file_pattern
|
|
241
|
+
.replace("{namespace}", ns)
|
|
242
|
+
.replace("{environment}", env);
|
|
243
|
+
const filePath = `${deps.repoRoot}/${relCellPath}`;
|
|
235
244
|
const decrypted = await sops.decrypt(filePath);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
245
|
+
// Inside the mutate callback we do the actual encrypt + metadata
|
|
246
|
+
// update. When auto-commit is enabled this runs inside tx.run; when
|
|
247
|
+
// disabled (batched edit flow) it runs directly.
|
|
248
|
+
let response = { success: true, key };
|
|
249
|
+
const doWork = async () => {
|
|
250
|
+
if (random) {
|
|
251
|
+
decrypted.values[key] = (0, core_1.generateRandomValue)();
|
|
252
|
+
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
253
|
+
// Metadata update failure used to trigger an in-method rollback
|
|
254
|
+
// here. Inside tx.run, that rollback comes for free via git
|
|
255
|
+
// reset, so we just let the throw propagate.
|
|
243
256
|
await (0, core_1.markPendingWithRetry)(filePath, [key], "clef ui");
|
|
257
|
+
response = { success: true, key, pending: true };
|
|
244
258
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
259
|
+
else {
|
|
260
|
+
decrypted.values[key] = String(value);
|
|
261
|
+
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
262
|
+
// Validate against schema if defined
|
|
263
|
+
const nsDef = manifest.namespaces.find((n) => n.name === ns);
|
|
264
|
+
if (nsDef?.schema) {
|
|
265
|
+
try {
|
|
266
|
+
const schema = schemaValidator.loadSchema(path.join(deps.repoRoot, nsDef.schema));
|
|
267
|
+
const result = schemaValidator.validate({ [key]: String(value) }, schema);
|
|
268
|
+
const violations = [...result.errors, ...result.warnings];
|
|
269
|
+
if (violations.length > 0) {
|
|
270
|
+
response = {
|
|
271
|
+
success: true,
|
|
272
|
+
key,
|
|
273
|
+
warnings: violations.map((v) => v.message),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
250
276
|
}
|
|
251
|
-
|
|
252
|
-
|
|
277
|
+
catch {
|
|
278
|
+
// Schema load failed — skip validation, not fatal
|
|
253
279
|
}
|
|
254
|
-
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
255
|
-
}
|
|
256
|
-
catch {
|
|
257
|
-
// Rollback also failed — return 500 with context
|
|
258
|
-
return res.status(500).json({
|
|
259
|
-
error: "Partial failure",
|
|
260
|
-
message: "Value was encrypted but pending state could not be recorded. " +
|
|
261
|
-
"Rollback also failed. The key may have a random placeholder value. " +
|
|
262
|
-
"Check the file manually.",
|
|
263
|
-
code: "PARTIAL_FAILURE",
|
|
264
|
-
});
|
|
265
280
|
}
|
|
266
|
-
|
|
267
|
-
error: "Pending state could not be recorded",
|
|
268
|
-
message: "The operation was rolled back. No changes were made.",
|
|
269
|
-
code: "PENDING_FAILURE",
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
res.json({ success: true, key, pending: true });
|
|
273
|
-
}
|
|
274
|
-
else {
|
|
275
|
-
decrypted.values[key] = String(value);
|
|
276
|
-
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
277
|
-
// Validate against schema if defined (B1)
|
|
278
|
-
const nsDef = manifest.namespaces.find((n) => n.name === ns);
|
|
279
|
-
if (nsDef?.schema) {
|
|
281
|
+
// Resolve pending state if the key was pending
|
|
280
282
|
try {
|
|
281
|
-
|
|
282
|
-
const result = schemaValidator.validate({ [key]: String(value) }, schema);
|
|
283
|
-
const violations = [...result.errors, ...result.warnings];
|
|
284
|
-
if (violations.length > 0) {
|
|
285
|
-
// Resolve pending state if the key was pending
|
|
286
|
-
try {
|
|
287
|
-
await (0, core_1.markResolved)(filePath, [key]);
|
|
288
|
-
}
|
|
289
|
-
catch {
|
|
290
|
-
// Metadata update failed — non-fatal
|
|
291
|
-
}
|
|
292
|
-
return res.json({
|
|
293
|
-
success: true,
|
|
294
|
-
key,
|
|
295
|
-
warnings: violations.map((v) => v.message),
|
|
296
|
-
});
|
|
297
|
-
}
|
|
283
|
+
await (0, core_1.markResolved)(filePath, [key]);
|
|
298
284
|
}
|
|
299
285
|
catch {
|
|
300
|
-
//
|
|
286
|
+
// Metadata update failed — non-fatal
|
|
301
287
|
}
|
|
302
288
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
res.json({ success: true, key });
|
|
289
|
+
};
|
|
290
|
+
if (shouldCommit) {
|
|
291
|
+
await tx.run(deps.repoRoot, {
|
|
292
|
+
description: `clef ui: set ${ns}/${env}/${key}`,
|
|
293
|
+
paths: [relCellPath, relCellPath.replace(/\.enc\.(yaml|json)$/, ".clef-meta.yaml")],
|
|
294
|
+
mutate: doWork,
|
|
295
|
+
});
|
|
311
296
|
}
|
|
297
|
+
else {
|
|
298
|
+
await doWork();
|
|
299
|
+
}
|
|
300
|
+
res.json(response);
|
|
312
301
|
}
|
|
313
|
-
catch {
|
|
314
|
-
|
|
302
|
+
catch (err) {
|
|
303
|
+
const message = err instanceof Error ? err.message : "Failed to set value";
|
|
304
|
+
res.status(500).json({ error: message, code: "SET_ERROR" });
|
|
315
305
|
}
|
|
316
306
|
});
|
|
317
307
|
// DELETE /api/namespace/:ns/:env/:key
|
|
@@ -320,7 +310,8 @@ function createApiRouter(deps) {
|
|
|
320
310
|
try {
|
|
321
311
|
const manifest = loadManifest();
|
|
322
312
|
const { ns, env, key } = req.params;
|
|
323
|
-
const { confirmed } = (req.body ?? {});
|
|
313
|
+
const { confirmed, commit: commitFlag } = (req.body ?? {});
|
|
314
|
+
const shouldCommit = commitFlag !== false;
|
|
324
315
|
const nsExists = manifest.namespaces.some((n) => n.name === ns);
|
|
325
316
|
const envExists = manifest.environments.some((e) => e.name === env);
|
|
326
317
|
if (!nsExists || !envExists) {
|
|
@@ -338,7 +329,10 @@ function createApiRouter(deps) {
|
|
|
338
329
|
});
|
|
339
330
|
return;
|
|
340
331
|
}
|
|
341
|
-
const
|
|
332
|
+
const relCellPath = manifest.file_pattern
|
|
333
|
+
.replace("{namespace}", ns)
|
|
334
|
+
.replace("{environment}", env);
|
|
335
|
+
const filePath = `${deps.repoRoot}/${relCellPath}`;
|
|
342
336
|
const decrypted = await sops.decrypt(filePath);
|
|
343
337
|
if (!(key in decrypted.values)) {
|
|
344
338
|
res.status(404).json({
|
|
@@ -347,19 +341,31 @@ function createApiRouter(deps) {
|
|
|
347
341
|
});
|
|
348
342
|
return;
|
|
349
343
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
344
|
+
const doWork = async () => {
|
|
345
|
+
delete decrypted.values[key];
|
|
346
|
+
await sops.encrypt(filePath, decrypted.values, manifest, env);
|
|
347
|
+
try {
|
|
348
|
+
await (0, core_1.markResolved)(filePath, [key]);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Best effort — orphaned metadata is annoying but not dangerous
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
if (shouldCommit) {
|
|
355
|
+
await tx.run(deps.repoRoot, {
|
|
356
|
+
description: `clef ui: delete ${ns}/${env}/${key}`,
|
|
357
|
+
paths: [relCellPath, relCellPath.replace(/\.enc\.(yaml|json)$/, ".clef-meta.yaml")],
|
|
358
|
+
mutate: doWork,
|
|
359
|
+
});
|
|
355
360
|
}
|
|
356
|
-
|
|
357
|
-
|
|
361
|
+
else {
|
|
362
|
+
await doWork();
|
|
358
363
|
}
|
|
359
364
|
res.json({ success: true, key });
|
|
360
365
|
}
|
|
361
|
-
catch {
|
|
362
|
-
|
|
366
|
+
catch (err) {
|
|
367
|
+
const message = err instanceof Error ? err.message : "Failed to delete key";
|
|
368
|
+
res.status(500).json({ error: message, code: "DELETE_ERROR" });
|
|
363
369
|
}
|
|
364
370
|
});
|
|
365
371
|
// POST /api/namespace/:ns/:env/:key/accept — resolve pending state without changing the value
|
|
@@ -377,10 +383,19 @@ function createApiRouter(deps) {
|
|
|
377
383
|
});
|
|
378
384
|
return;
|
|
379
385
|
}
|
|
380
|
-
const
|
|
386
|
+
const relCellPath = manifest.file_pattern
|
|
387
|
+
.replace("{namespace}", ns)
|
|
388
|
+
.replace("{environment}", env);
|
|
389
|
+
const filePath = `${deps.repoRoot}/${relCellPath}`;
|
|
381
390
|
const decrypted = await sops.decrypt(filePath);
|
|
382
391
|
const value = key in decrypted.values ? String(decrypted.values[key]) : undefined;
|
|
383
|
-
await (
|
|
392
|
+
await tx.run(deps.repoRoot, {
|
|
393
|
+
description: `clef ui: accept ${ns}/${env}/${key}`,
|
|
394
|
+
paths: [relCellPath.replace(/\.enc\.(yaml|json)$/, ".clef-meta.yaml")],
|
|
395
|
+
mutate: async () => {
|
|
396
|
+
await (0, core_1.markResolved)(filePath, [key]);
|
|
397
|
+
},
|
|
398
|
+
});
|
|
384
399
|
res.json({ success: true, key, value });
|
|
385
400
|
}
|
|
386
401
|
catch {
|
|
@@ -418,7 +433,30 @@ function createApiRouter(deps) {
|
|
|
418
433
|
});
|
|
419
434
|
return;
|
|
420
435
|
}
|
|
421
|
-
await
|
|
436
|
+
const source = await sops.decrypt(fromCell.filePath);
|
|
437
|
+
if (!(key in source.values)) {
|
|
438
|
+
res.status(404).json({
|
|
439
|
+
error: `Key '${key}' not found in ${fromNs}/${fromEnv}.`,
|
|
440
|
+
code: "KEY_NOT_FOUND",
|
|
441
|
+
});
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const relToPath = path.relative(deps.repoRoot, toCell.filePath);
|
|
445
|
+
await tx.run(deps.repoRoot, {
|
|
446
|
+
description: `clef ui: copy ${key} from ${fromNs}/${fromEnv} to ${toNs}/${toEnv}`,
|
|
447
|
+
paths: [relToPath, relToPath.replace(/\.enc\.(yaml|json)$/, ".clef-meta.yaml")],
|
|
448
|
+
mutate: async () => {
|
|
449
|
+
const dest = await sops.decrypt(toCell.filePath);
|
|
450
|
+
dest.values[key] = source.values[key];
|
|
451
|
+
await sops.encrypt(toCell.filePath, dest.values, manifest, toCell.environment);
|
|
452
|
+
try {
|
|
453
|
+
await (0, core_1.markResolved)(toCell.filePath, [key]);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// Non-fatal — destination may not have had pending state
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
});
|
|
422
460
|
res.json({ success: true, key, from: `${fromNs}/${fromEnv}`, to: `${toNs}/${toEnv}` });
|
|
423
461
|
}
|
|
424
462
|
catch (err) {
|
|
@@ -661,7 +699,7 @@ function createApiRouter(deps) {
|
|
|
661
699
|
});
|
|
662
700
|
return;
|
|
663
701
|
}
|
|
664
|
-
const importRunner = new core_1.ImportRunner(sops);
|
|
702
|
+
const importRunner = new core_1.ImportRunner(sops, tx);
|
|
665
703
|
const result = await importRunner.import(target, null, content, manifest, deps.repoRoot, {
|
|
666
704
|
format,
|
|
667
705
|
dryRun: true,
|
|
@@ -714,7 +752,7 @@ function createApiRouter(deps) {
|
|
|
714
752
|
res.json({ imported: [], skipped: [], failed: [] });
|
|
715
753
|
return;
|
|
716
754
|
}
|
|
717
|
-
const importRunner = new core_1.ImportRunner(sops);
|
|
755
|
+
const importRunner = new core_1.ImportRunner(sops, tx);
|
|
718
756
|
const result = await importRunner.import(target, null, content, manifest, deps.repoRoot, {
|
|
719
757
|
format,
|
|
720
758
|
keys,
|
|
@@ -931,6 +969,143 @@ function createApiRouter(deps) {
|
|
|
931
969
|
res.status(500).json({ error: message, code: "SERVICE_IDENTITY_ERROR" });
|
|
932
970
|
}
|
|
933
971
|
});
|
|
972
|
+
// ── Manifest Structure (namespaces + environments) ─────────────────
|
|
973
|
+
//
|
|
974
|
+
// Each endpoint maps to a StructureManager method. Errors from the manager
|
|
975
|
+
// are mapped to HTTP status codes:
|
|
976
|
+
// 400 — invalid input (missing/wrong-type body field, invalid identifier)
|
|
977
|
+
// 404 — name not found in the manifest
|
|
978
|
+
// 409 — name collision (entity already exists, or rename target taken)
|
|
979
|
+
// 412 — refusal precondition (protected env, last namespace/env, orphaned SI)
|
|
980
|
+
// 500 — anything else (filesystem, sops, transaction failure)
|
|
981
|
+
/**
|
|
982
|
+
* Map a thrown error from StructureManager to an HTTP status. The manager
|
|
983
|
+
* throws plain `Error` instances with descriptive messages — we sniff the
|
|
984
|
+
* message text to pick the right status. Brittle but contained: every
|
|
985
|
+
* sniff matches a string the manager itself produces.
|
|
986
|
+
*/
|
|
987
|
+
function structureErrorStatus(err) {
|
|
988
|
+
const message = err instanceof Error ? err.message : "";
|
|
989
|
+
if (/not found/.test(message))
|
|
990
|
+
return { status: 404, code: "NOT_FOUND" };
|
|
991
|
+
if (/already exists/.test(message))
|
|
992
|
+
return { status: 409, code: "CONFLICT" };
|
|
993
|
+
if (/Invalid (namespace|environment) name/.test(message))
|
|
994
|
+
return { status: 400, code: "BAD_REQUEST" };
|
|
995
|
+
if (/is protected|last (namespace|environment)|only scope/.test(message))
|
|
996
|
+
return { status: 412, code: "PRECONDITION_FAILED" };
|
|
997
|
+
return { status: 500, code: "STRUCTURE_ERROR" };
|
|
998
|
+
}
|
|
999
|
+
// POST /api/namespaces — add a new namespace and scaffold cells
|
|
1000
|
+
router.post("/namespaces", async (req, res) => {
|
|
1001
|
+
try {
|
|
1002
|
+
const { name, description, schema } = req.body;
|
|
1003
|
+
if (!name || typeof name !== "string") {
|
|
1004
|
+
res.status(400).json({ error: "name is required.", code: "BAD_REQUEST" });
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const manifest = loadManifest();
|
|
1008
|
+
await structureManager.addNamespace(name, { description, schema }, manifest, deps.repoRoot);
|
|
1009
|
+
res.status(201).json({ name, description: description ?? "", schema });
|
|
1010
|
+
}
|
|
1011
|
+
catch (err) {
|
|
1012
|
+
const { status, code } = structureErrorStatus(err);
|
|
1013
|
+
const message = err instanceof Error ? err.message : "Failed to add namespace";
|
|
1014
|
+
res.status(status).json({ error: message, code });
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
// PATCH /api/namespaces/:name — edit description, schema, or rename
|
|
1018
|
+
router.patch("/namespaces/:name", async (req, res) => {
|
|
1019
|
+
try {
|
|
1020
|
+
const name = req.params.name;
|
|
1021
|
+
const { rename, description, schema } = req.body;
|
|
1022
|
+
if (rename === undefined && description === undefined && schema === undefined) {
|
|
1023
|
+
res.status(400).json({
|
|
1024
|
+
error: "At least one of rename, description, or schema is required.",
|
|
1025
|
+
code: "BAD_REQUEST",
|
|
1026
|
+
});
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const manifest = loadManifest();
|
|
1030
|
+
await structureManager.editNamespace(name, { rename, description, schema }, manifest, deps.repoRoot);
|
|
1031
|
+
res.json({ name: rename ?? name, previousName: rename ? name : undefined });
|
|
1032
|
+
}
|
|
1033
|
+
catch (err) {
|
|
1034
|
+
const { status, code } = structureErrorStatus(err);
|
|
1035
|
+
const message = err instanceof Error ? err.message : "Failed to edit namespace";
|
|
1036
|
+
res.status(status).json({ error: message, code });
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
// DELETE /api/namespaces/:name — remove namespace and cascade through SIs
|
|
1040
|
+
router.delete("/namespaces/:name", async (req, res) => {
|
|
1041
|
+
try {
|
|
1042
|
+
const name = req.params.name;
|
|
1043
|
+
const manifest = loadManifest();
|
|
1044
|
+
await structureManager.removeNamespace(name, manifest, deps.repoRoot);
|
|
1045
|
+
res.json({ ok: true });
|
|
1046
|
+
}
|
|
1047
|
+
catch (err) {
|
|
1048
|
+
const { status, code } = structureErrorStatus(err);
|
|
1049
|
+
const message = err instanceof Error ? err.message : "Failed to remove namespace";
|
|
1050
|
+
res.status(status).json({ error: message, code });
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
// POST /api/environments — add a new environment and scaffold cells
|
|
1054
|
+
router.post("/environments", async (req, res) => {
|
|
1055
|
+
try {
|
|
1056
|
+
const { name, description, protected: isProtected, } = req.body;
|
|
1057
|
+
if (!name || typeof name !== "string") {
|
|
1058
|
+
res.status(400).json({ error: "name is required.", code: "BAD_REQUEST" });
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const manifest = loadManifest();
|
|
1062
|
+
await structureManager.addEnvironment(name, { description, protected: isProtected }, manifest, deps.repoRoot);
|
|
1063
|
+
res
|
|
1064
|
+
.status(201)
|
|
1065
|
+
.json({ name, description: description ?? "", protected: isProtected ?? false });
|
|
1066
|
+
}
|
|
1067
|
+
catch (err) {
|
|
1068
|
+
const { status, code } = structureErrorStatus(err);
|
|
1069
|
+
const message = err instanceof Error ? err.message : "Failed to add environment";
|
|
1070
|
+
res.status(status).json({ error: message, code });
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
// PATCH /api/environments/:name — edit description, protected, or rename
|
|
1074
|
+
router.patch("/environments/:name", async (req, res) => {
|
|
1075
|
+
try {
|
|
1076
|
+
const name = req.params.name;
|
|
1077
|
+
const { rename, description, protected: isProtected, } = req.body;
|
|
1078
|
+
if (rename === undefined && description === undefined && isProtected === undefined) {
|
|
1079
|
+
res.status(400).json({
|
|
1080
|
+
error: "At least one of rename, description, or protected is required.",
|
|
1081
|
+
code: "BAD_REQUEST",
|
|
1082
|
+
});
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
const manifest = loadManifest();
|
|
1086
|
+
await structureManager.editEnvironment(name, { rename, description, protected: isProtected }, manifest, deps.repoRoot);
|
|
1087
|
+
res.json({ name: rename ?? name, previousName: rename ? name : undefined });
|
|
1088
|
+
}
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
const { status, code } = structureErrorStatus(err);
|
|
1091
|
+
const message = err instanceof Error ? err.message : "Failed to edit environment";
|
|
1092
|
+
res.status(status).json({ error: message, code });
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
// DELETE /api/environments/:name — remove env and cascade through SIs
|
|
1096
|
+
router.delete("/environments/:name", async (req, res) => {
|
|
1097
|
+
try {
|
|
1098
|
+
const name = req.params.name;
|
|
1099
|
+
const manifest = loadManifest();
|
|
1100
|
+
await structureManager.removeEnvironment(name, manifest, deps.repoRoot);
|
|
1101
|
+
res.json({ ok: true });
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
const { status, code } = structureErrorStatus(err);
|
|
1105
|
+
const message = err instanceof Error ? err.message : "Failed to remove environment";
|
|
1106
|
+
res.status(status).json({ error: message, code });
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
934
1109
|
// ── Backend Migration ──────────────────────────────────────────────
|
|
935
1110
|
router.get("/backend-config", (_req, res) => {
|
|
936
1111
|
try {
|
|
@@ -1009,6 +1184,98 @@ function createApiRouter(deps) {
|
|
|
1009
1184
|
res.status(500).json({ error: message, code: "MIGRATION_ERROR" });
|
|
1010
1185
|
}
|
|
1011
1186
|
});
|
|
1187
|
+
// ── Destructive Reset ───────────────────────────────────────────────
|
|
1188
|
+
//
|
|
1189
|
+
// Disaster-recovery endpoint. Abandons the current encrypted contents of
|
|
1190
|
+
// a scope (env / namespace / cell) and re-scaffolds fresh placeholders,
|
|
1191
|
+
// optionally switching to a new SOPS backend in the same transaction.
|
|
1192
|
+
// The UI gates this with a typed-confirmation modal — there is no
|
|
1193
|
+
// server-side `confirmed` field because every other destructive endpoint
|
|
1194
|
+
// (DELETE namespaces, DELETE environments) follows the same pattern of
|
|
1195
|
+
// letting the UI carry the confirmation responsibility.
|
|
1196
|
+
router.post("/reset", async (req, res) => {
|
|
1197
|
+
try {
|
|
1198
|
+
const { scope, backend, key, keys } = req.body;
|
|
1199
|
+
if (!scope || typeof scope !== "object" || !("kind" in scope)) {
|
|
1200
|
+
res.status(400).json({
|
|
1201
|
+
error: "Reset requires a scope. Provide { kind: 'env'|'namespace'|'cell', ... }.",
|
|
1202
|
+
code: "BAD_REQUEST",
|
|
1203
|
+
});
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
const manifest = loadManifest();
|
|
1207
|
+
// Surface a clean 4xx for unknown scope before any destructive work.
|
|
1208
|
+
// ResetManager re-validates internally as defence in depth.
|
|
1209
|
+
try {
|
|
1210
|
+
(0, core_1.validateResetScope)(scope, manifest);
|
|
1211
|
+
}
|
|
1212
|
+
catch (err) {
|
|
1213
|
+
const message = err instanceof Error ? err.message : "Invalid reset scope";
|
|
1214
|
+
res.status(404).json({ error: message, code: "NOT_FOUND" });
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const result = await resetManager.reset({ scope, backend, key, keys }, manifest, deps.repoRoot);
|
|
1218
|
+
res.json({ success: true, result });
|
|
1219
|
+
}
|
|
1220
|
+
catch (err) {
|
|
1221
|
+
const message = err instanceof Error ? err.message : "Reset failed";
|
|
1222
|
+
// Validation-style errors that ResetManager throws after the scope
|
|
1223
|
+
// check passes — bad backend/key combination, scope matches zero
|
|
1224
|
+
// cells. Map these to 400 so the UI can render them as user errors
|
|
1225
|
+
// rather than server errors.
|
|
1226
|
+
const isUserError = /requires a key|does not take a key|matches zero cells/.test(message);
|
|
1227
|
+
const status = isUserError ? 400 : 500;
|
|
1228
|
+
const code = isUserError ? "BAD_REQUEST" : "RESET_ERROR";
|
|
1229
|
+
res.status(status).json({ error: message, code });
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
// ── Sync ─────────────────────────────────────────────────────────────
|
|
1233
|
+
// POST /api/sync/preview — dry-run: compute what sync would do
|
|
1234
|
+
router.post("/sync/preview", async (req, res) => {
|
|
1235
|
+
try {
|
|
1236
|
+
const { namespace } = req.body;
|
|
1237
|
+
const manifest = loadManifest();
|
|
1238
|
+
if (namespace) {
|
|
1239
|
+
const nsExists = manifest.namespaces.some((n) => n.name === namespace);
|
|
1240
|
+
if (!nsExists) {
|
|
1241
|
+
res.status(404).json({
|
|
1242
|
+
error: `Namespace '${namespace}' not found in manifest.`,
|
|
1243
|
+
code: "NOT_FOUND",
|
|
1244
|
+
});
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
const plan = await syncManager.plan(manifest, deps.repoRoot, { namespace });
|
|
1249
|
+
res.json(plan);
|
|
1250
|
+
}
|
|
1251
|
+
catch (err) {
|
|
1252
|
+
const message = err instanceof Error ? err.message : "Sync preview failed";
|
|
1253
|
+
res.status(500).json({ error: message, code: "SYNC_ERROR" });
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
// POST /api/sync — execute sync: scaffold missing keys with random pending values
|
|
1257
|
+
router.post("/sync", async (req, res) => {
|
|
1258
|
+
try {
|
|
1259
|
+
const { namespace } = req.body;
|
|
1260
|
+
const manifest = loadManifest();
|
|
1261
|
+
if (namespace) {
|
|
1262
|
+
const nsExists = manifest.namespaces.some((n) => n.name === namespace);
|
|
1263
|
+
if (!nsExists) {
|
|
1264
|
+
res.status(404).json({
|
|
1265
|
+
error: `Namespace '${namespace}' not found in manifest.`,
|
|
1266
|
+
code: "NOT_FOUND",
|
|
1267
|
+
});
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const result = await syncManager.sync(manifest, deps.repoRoot, { namespace });
|
|
1272
|
+
res.json({ success: true, result });
|
|
1273
|
+
}
|
|
1274
|
+
catch (err) {
|
|
1275
|
+
const message = err instanceof Error ? err.message : "Sync failed";
|
|
1276
|
+
res.status(500).json({ error: message, code: "SYNC_ERROR" });
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1012
1279
|
function dispose() {
|
|
1013
1280
|
lastScanResult = null;
|
|
1014
1281
|
lastScanAt = null;
|