@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.
@@ -42,7 +42,7 @@
42
42
  color: #3d4455;
43
43
  }
44
44
  </style>
45
- <script type="module" crossorigin src="/assets/index-CVpAmirt.js"></script>
45
+ <script type="module" crossorigin src="/assets/index-C4tsbWst.js"></script>
46
46
  </head>
47
47
  <body>
48
48
  <div id="root"></div>
@@ -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;CACzC;AAwBD,wBAAgB,UAAU,CAAC,EACzB,UAAU,EACV,YAAY,EACZ,cAAc,EACd,gBAAgB,GACjB,EAAE,eAAe,2CAgOjB"}
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;AAEd,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,2CAgOd"}
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"}
@@ -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,EAUL,gBAAgB,EAejB,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,CAomCrD"}
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"}
@@ -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 bulkOps = new core_1.BulkOps();
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 filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
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
- if (random) {
237
- // Generate random value server-side and mark as pending
238
- const randomValue = (0, core_1.generateRandomValue)();
239
- const previousValue = decrypted.values[key];
240
- decrypted.values[key] = randomValue;
241
- await sops.encrypt(filePath, decrypted.values, manifest, env);
242
- try {
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
- catch {
246
- // Both retry attempts failed — roll back the encrypt
247
- try {
248
- if (previousValue !== undefined) {
249
- decrypted.values[key] = previousValue;
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
- else {
252
- delete decrypted.values[key];
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
- return res.status(500).json({
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
- const schema = schemaValidator.loadSchema(path.join(deps.repoRoot, nsDef.schema));
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
- // Schema load failed — skip validation, not fatal
286
+ // Metadata update failed — non-fatal
301
287
  }
302
288
  }
303
- // Resolve pending state if the key was pending
304
- try {
305
- await (0, core_1.markResolved)(filePath, [key]);
306
- }
307
- catch {
308
- // Metadata update failed — non-fatal
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
- res.status(500).json({ error: "Failed to set value", code: "SET_ERROR" });
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 filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
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
- delete decrypted.values[key];
351
- await sops.encrypt(filePath, decrypted.values, manifest, env);
352
- // Clean up pending metadata if it exists
353
- try {
354
- await (0, core_1.markResolved)(filePath, [key]);
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
- catch {
357
- // Best effort — orphaned metadata is annoying but not dangerous
361
+ else {
362
+ await doWork();
358
363
  }
359
364
  res.json({ success: true, key });
360
365
  }
361
- catch {
362
- res.status(500).json({ error: "Failed to delete key", code: "DELETE_ERROR" });
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 filePath = `${deps.repoRoot}/${manifest.file_pattern.replace("{namespace}", ns).replace("{environment}", env)}`;
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 (0, core_1.markResolved)(filePath, [key]);
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 bulkOps.copyValue(key, fromCell, toCell, sops, manifest);
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;