@centient/secrets 0.4.0 → 0.6.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.
Files changed (64) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/hidden-input.d.ts +52 -0
  3. package/dist/cli/hidden-input.d.ts.map +1 -0
  4. package/dist/cli/hidden-input.js +109 -0
  5. package/dist/cli/hidden-input.js.map +1 -0
  6. package/dist/cli/secrets-cli.d.ts +9 -0
  7. package/dist/cli/secrets-cli.d.ts.map +1 -1
  8. package/dist/cli/secrets-cli.js +251 -169
  9. package/dist/cli/secrets-cli.js.map +1 -1
  10. package/dist/crypto/vault-common.d.ts +23 -4
  11. package/dist/crypto/vault-common.d.ts.map +1 -1
  12. package/dist/crypto/vault-common.js +47 -6
  13. package/dist/crypto/vault-common.js.map +1 -1
  14. package/dist/index.d.ts +5 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/vault/file-lock.d.ts +33 -0
  19. package/dist/vault/file-lock.d.ts.map +1 -0
  20. package/dist/vault/file-lock.js +143 -0
  21. package/dist/vault/file-lock.js.map +1 -0
  22. package/dist/vault/policy.d.ts +50 -0
  23. package/dist/vault/policy.d.ts.map +1 -0
  24. package/dist/vault/policy.js +68 -0
  25. package/dist/vault/policy.js.map +1 -0
  26. package/dist/vault/session-vault-errors.d.ts +38 -0
  27. package/dist/vault/session-vault-errors.d.ts.map +1 -0
  28. package/dist/vault/session-vault-errors.js +67 -0
  29. package/dist/vault/session-vault-errors.js.map +1 -0
  30. package/dist/vault/session-vault.d.ts +147 -0
  31. package/dist/vault/session-vault.d.ts.map +1 -0
  32. package/dist/vault/session-vault.js +667 -0
  33. package/dist/vault/session-vault.js.map +1 -0
  34. package/dist/vault/sidecar.d.ts +37 -0
  35. package/dist/vault/sidecar.d.ts.map +1 -0
  36. package/dist/vault/sidecar.js +84 -0
  37. package/dist/vault/sidecar.js.map +1 -0
  38. package/dist/vault/types.d.ts +7 -4
  39. package/dist/vault/types.d.ts.map +1 -1
  40. package/dist/vault/vault-env.d.ts +1 -1
  41. package/dist/vault/vault-env.d.ts.map +1 -1
  42. package/dist/vault/vault-env.js +1 -1
  43. package/dist/vault/vault-env.js.map +1 -1
  44. package/dist/vault/vault-gpg.d.ts +1 -1
  45. package/dist/vault/vault-gpg.d.ts.map +1 -1
  46. package/dist/vault/vault-gpg.js +1 -1
  47. package/dist/vault/vault-gpg.js.map +1 -1
  48. package/dist/vault/vault-libsecret.d.ts +28 -14
  49. package/dist/vault/vault-libsecret.d.ts.map +1 -1
  50. package/dist/vault/vault-libsecret.js +76 -15
  51. package/dist/vault/vault-libsecret.js.map +1 -1
  52. package/dist/vault/vault-utils.d.ts +12 -2
  53. package/dist/vault/vault-utils.d.ts.map +1 -1
  54. package/dist/vault/vault-utils.js +21 -4
  55. package/dist/vault/vault-utils.js.map +1 -1
  56. package/dist/vault/vault-windows.d.ts +1 -1
  57. package/dist/vault/vault-windows.d.ts.map +1 -1
  58. package/dist/vault/vault-windows.js +1 -1
  59. package/dist/vault/vault-windows.js.map +1 -1
  60. package/dist/vault/vault.d.ts +5 -4
  61. package/dist/vault/vault.d.ts.map +1 -1
  62. package/dist/vault/vault.js +83 -8
  63. package/dist/vault/vault.js.map +1 -1
  64. package/package.json +4 -1
@@ -27,11 +27,20 @@
27
27
  * - Vault is encrypted with AES-256-GCM
28
28
  * - Key stored via pluggable provider (macOS Keychain or 1Password)
29
29
  * - 4-hour session timeout
30
+ *
31
+ * Internals:
32
+ * Vault mutations flow through `openVault` (session-vault.ts) so the CLI
33
+ * and the library-facing API share one on-disk format (AAD-bound, schema 1).
34
+ * Pre-`openVault` CLI-written vaults (AAD-less, legacy flat format) are
35
+ * handled transparently by session-vault's legacy-read path and upgraded to
36
+ * schema 1 on the next successful write.
30
37
  */
31
38
  import { createInterface } from "readline";
32
- import { join } from "path";
33
- import { homedir } from "os";
34
- import { existsSync } from "fs";
39
+ import { dirname, join } from "node:path";
40
+ import { homedir } from "node:os";
41
+ import { existsSync, mkdirSync, realpathSync, writeFileSync, } from "node:fs";
42
+ import { basename } from "node:path";
43
+ import { createHash, randomBytes } from "node:crypto";
35
44
  // =============================================================================
36
45
  // Agent Detection (Local Implementation)
37
46
  // =============================================================================
@@ -46,42 +55,25 @@ function isAgentEnvironment() {
46
55
  process.env.CLAUDE_CODE_ENTRY_POINT);
47
56
  }
48
57
  // =============================================================================
49
- // Simple Encrypted Vault
58
+ // Session vault integration
50
59
  // =============================================================================
51
- import { readFileSync, writeFileSync, mkdirSync } from "fs";
52
- import { randomBytes } from "crypto";
53
- import { encryptObject, decryptObject, } from "../crypto/vault-common.js";
60
+ import { encryptObject } from "../crypto/vault-common.js";
54
61
  import { resolveKeyProvider, getProviderByType, loadConfig, saveSecretsConfig, } from "../key-providers/index.js";
55
62
  import { listCredentials, getActiveVaultType } from "../vault/vault.js";
63
+ import { openVault, VAULT_SCHEMA_VERSION, VAULT_AAD_PREFIX, DEFAULT_SIDECAR_PATH, } from "../vault/session-vault.js";
56
64
  const VAULT_PATH = join(homedir(), ".centient", "secrets", "vault.enc");
57
65
  const KEY_LENGTH = 32;
58
- // Session state
59
- let sessionKey = null;
60
- let sessionUnlockedAt = null;
61
- const SESSION_TTL = 4 * 60 * 60 * 1000; // 4 hours
66
+ /** Preserve CLI's historical 4-hour auto-lock semantics. */
67
+ const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
68
+ /**
69
+ * Single process-wide vault handle. Non-null => "session is unlocked".
70
+ * Replaces the previous `sessionKey` + `sessionUnlockedAt` pair. Auto-close
71
+ * on TTL is delegated to `openVault({ ttlMs })`, which clears this handle
72
+ * from inside itself — we observe the closed state via `isSessionValid`.
73
+ */
74
+ let vault = null;
62
75
  function isSessionValid() {
63
- if (!sessionKey || !sessionUnlockedAt)
64
- return false;
65
- return Date.now() - sessionUnlockedAt < SESSION_TTL;
66
- }
67
- function encrypt(data, key) {
68
- const result = encryptObject(data, key);
69
- if (!result)
70
- throw new Error("Encryption failed");
71
- return result;
72
- }
73
- function decrypt(data, key) {
74
- const parsed = decryptObject(data, key);
75
- if (!parsed)
76
- return null;
77
- // Validate all values are strings
78
- const result = {};
79
- for (const [k, v] of Object.entries(parsed)) {
80
- if (typeof v !== "string")
81
- return null;
82
- result[k] = v;
83
- }
84
- return result;
76
+ return vault !== null;
85
77
  }
86
78
  /**
87
79
  * Resolve the active key provider, printing an error if unavailable.
@@ -97,46 +89,73 @@ function getProvider() {
97
89
  // =============================================================================
98
90
  // CLI Handlers
99
91
  // =============================================================================
92
+ import { advanceHiddenInput, createHiddenInputState, ENABLE_BRACKETED_PASTE, DISABLE_BRACKETED_PASTE, } from "./hidden-input.js";
100
93
  /**
101
- * Prompt for input (with optional hidden mode for passwords)
94
+ * Prompt for input (with optional hidden mode for passwords/secrets).
95
+ *
96
+ * Handles three input shapes correctly:
97
+ * 1. **Piped stdin** (`echo "value" | centient secrets set ...`): reads the
98
+ * full stream to EOF, trims a single trailing newline (pipe artifact).
99
+ * Multi-line values pass through unchanged.
100
+ * 2. **Interactive TTY with bracketed paste**: content wrapped in
101
+ * `\x1b[200~ ... \x1b[201~` is treated atomically; newlines inside a
102
+ * paste are literal content, not submit signals.
103
+ * 3. **Interactive TTY without bracketed paste**: a single newline still
104
+ * submits (preserves single-line UX), and Ctrl-D is an escape hatch for
105
+ * submitting multi-line content on terminals that don't emit paste
106
+ * brackets.
107
+ *
108
+ * Regression hook for issue #37: the previous implementation resolved on the
109
+ * first `\n` from a terminal paste and silently truncated PEM keys / other
110
+ * multi-line secrets to their first line. The parsing state machine lives in
111
+ * `./hidden-input.ts` so it can be unit-tested without stubbing process.stdin.
102
112
  */
103
113
  async function prompt(message, hidden = false) {
114
+ // Non-TTY stdin: don't touch raw mode, don't use readline. Read the whole
115
+ // stream to EOF. This is the path for `cat key.pem | centient secrets set`
116
+ // and the pattern most CLIs use for piped-value workflows.
117
+ if (!process.stdin.isTTY) {
118
+ process.stdout.write(message);
119
+ const chunks = [];
120
+ for await (const chunk of process.stdin) {
121
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
122
+ }
123
+ // Strip a single trailing newline — pipes and `<<<` heredocs typically
124
+ // append one. Preserve any other trailing whitespace (matters for PEM).
125
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
126
+ }
104
127
  const rl = createInterface({
105
128
  input: process.stdin,
106
129
  output: process.stdout,
107
130
  });
108
131
  return new Promise((resolve) => {
109
132
  if (hidden) {
110
- // For hidden input, we need to handle it differently
111
133
  process.stdout.write(message);
112
- let input = "";
134
+ const state = createHiddenInputState();
113
135
  const stdin = process.stdin;
114
136
  const wasRaw = stdin.isRaw;
115
137
  stdin.setRawMode?.(true);
138
+ process.stdout.write(ENABLE_BRACKETED_PASTE);
116
139
  stdin.resume();
117
140
  stdin.setEncoding("utf8");
118
- const onData = (char) => {
119
- if (char === "\n" || char === "\r") {
120
- stdin.setRawMode?.(wasRaw || false);
121
- stdin.pause();
122
- stdin.removeListener("data", onData);
123
- process.stdout.write("\n"); // New line after hidden input
124
- rl.close();
125
- resolve(input);
126
- }
127
- else if (char === "\u0003") {
128
- // Ctrl+C
141
+ const finish = () => {
142
+ process.stdout.write(DISABLE_BRACKETED_PASTE);
143
+ stdin.setRawMode?.(wasRaw || false);
144
+ stdin.pause();
145
+ stdin.removeListener("data", onData);
146
+ process.stdout.write("\n");
147
+ rl.close();
148
+ resolve(state.input);
149
+ };
150
+ // Terminals can deliver a paste as one large chunk OR as many small
151
+ // chunks; the state machine processes character-by-character so
152
+ // escape-sequence state survives across chunk boundaries.
153
+ const onData = (chunk) => {
154
+ const signal = advanceHiddenInput(state, chunk);
155
+ if (signal === "ctrl-c")
129
156
  process.exit(0);
130
- }
131
- else if (char === "\u007F" || char === "\b") {
132
- // Backspace
133
- if (input.length > 0) {
134
- input = input.slice(0, -1);
135
- }
136
- }
137
- else {
138
- input += char;
139
- }
157
+ if (signal === "submit")
158
+ finish();
140
159
  };
141
160
  stdin.on("data", onData);
142
161
  }
@@ -148,6 +167,31 @@ async function prompt(message, hidden = false) {
148
167
  }
149
168
  });
150
169
  }
170
+ /**
171
+ * Derive the AAD for a freshly-initialised vault so the bootstrap blob is
172
+ * openable by `openVault()` on the very next invocation. Mirrors the
173
+ * derivation in session-vault.ts (same path-resolution + hash construction)
174
+ * because we can't call the private helper from here.
175
+ *
176
+ * Note on symlink handling: session-vault's `resolveVaultPath` realpaths the
177
+ * vault file. Because the vault file doesn't exist yet at bootstrap time, we
178
+ * realpath the PARENT directory (already created) and append the basename —
179
+ * which reproduces the same resolved-path bytes that realpath would produce
180
+ * on the file itself after the first openVault call. Absent this, a setup
181
+ * where any parent component is a symlink (e.g. `~/.centient` pointing into
182
+ * iCloud Drive on macOS) would produce a different AAD on init vs on unlock,
183
+ * and the first unlock-after-init would fail with VaultDecryptError.
184
+ *
185
+ * @param vaultPathAbs - Absolute vault file path whose PARENT directory must
186
+ * already exist on disk so `realpathSync` can resolve it.
187
+ */
188
+ function deriveBootstrapAad(vaultPathAbs) {
189
+ const parentReal = realpathSync(dirname(vaultPathAbs));
190
+ const resolved = join(parentReal, basename(vaultPathAbs));
191
+ return createHash("sha256")
192
+ .update(`${VAULT_AAD_PREFIX}:v${VAULT_SCHEMA_VERSION}:${resolved}`)
193
+ .digest();
194
+ }
151
195
  /**
152
196
  * Initialize a new vault
153
197
  */
@@ -171,17 +215,49 @@ async function initVault() {
171
215
  console.error(`❌ Failed to store key via ${provider.name}`);
172
216
  return;
173
217
  }
174
- // Create empty vault
175
- const secrets = {};
176
- const encrypted = encrypt(secrets, key);
177
- // Ensure directory exists
178
- const dir = join(homedir(), ".centient", "secrets");
179
- mkdirSync(dir, { recursive: true });
180
- // Write vault
181
- writeFileSync(VAULT_PATH, encrypted);
182
- // Set session
183
- sessionKey = key;
184
- sessionUnlockedAt = Date.now();
218
+ // Ensure directory exists BEFORE computing AAD so realpath inside
219
+ // openVault can resolve parent components cleanly on the next open.
220
+ const dir = dirname(VAULT_PATH);
221
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
222
+ // Bootstrap the vault file in the session-vault v1 format (AAD-bound,
223
+ // `{schema, vaultVersion, secrets}` payload) so the next `openVault()` call
224
+ // succeeds without hitting the legacy-upgrade path. The alternative —
225
+ // writing an AAD-less blob — would force a write on first unlock just to
226
+ // upgrade schemas, which is both weirder and slower.
227
+ const aad = deriveBootstrapAad(VAULT_PATH);
228
+ const bootstrapPayload = {
229
+ schema: VAULT_SCHEMA_VERSION,
230
+ vaultVersion: 1,
231
+ secrets: {},
232
+ };
233
+ const encrypted = encryptObject(bootstrapPayload, key, aad);
234
+ if (!encrypted) {
235
+ console.error("❌ Failed to encrypt empty vault");
236
+ return;
237
+ }
238
+ writeFileSync(VAULT_PATH, encrypted, { mode: 0o600 });
239
+ // Write the sidecar at the default location so openVault doesn't warn on
240
+ // missing-sidecar the first time we open. Matches `DEFAULT_SIDECAR_PATH`.
241
+ writeFileSync(DEFAULT_SIDECAR_PATH, JSON.stringify({ highestSeenVersion: 1 }), { mode: 0o600 });
242
+ // Open the vault immediately so the CLI session is already unlocked — same
243
+ // UX as before, but via the shared code path (no parallel session state).
244
+ try {
245
+ vault = await openVault({
246
+ path: VAULT_PATH,
247
+ ttlMs: SESSION_TTL_MS,
248
+ });
249
+ }
250
+ catch (err) {
251
+ const message = err instanceof Error ? err.message : String(err);
252
+ console.error(`⚠️ Vault written but failed to auto-unlock: ${message}`);
253
+ console.error(" Run 'centient secrets unlock' to unlock manually.");
254
+ return;
255
+ }
256
+ finally {
257
+ // Zero the local key copy — `openVault` fetched its own copy from the
258
+ // provider; we never need the original again.
259
+ key.fill(0);
260
+ }
185
261
  process.stdout.write("\n✅ Vault initialized successfully!\n");
186
262
  process.stdout.write(` Location: ${VAULT_PATH}\n`);
187
263
  process.stdout.write(" The vault is now unlocked. Use 'centient secrets set' to add secrets.\n\n");
@@ -195,64 +271,66 @@ async function unlockVault() {
195
271
  console.error("❌ Vault not found. Run 'centient secrets init' first.");
196
272
  return false;
197
273
  }
198
- // Get key from provider (may prompt for biometric/PIN depending on provider)
199
- const provider = getProvider();
200
- if (!provider)
201
- return false;
202
- process.stdout.write(`Retrieving key via ${provider.name}...\n`);
203
- const key = provider.getKey();
204
- if (!key) {
205
- console.error(`❌ Failed to retrieve key from ${provider.name}`);
206
- return false;
274
+ try {
275
+ vault = await openVault({
276
+ path: VAULT_PATH,
277
+ // Existing CLI-written vaults predate the sidecar; session-vault's
278
+ // legacy-upgrade path treats them as a first-use context, but explicit
279
+ // opt-in is required for non-legacy fresh installs. We set this to
280
+ // `true` to keep the first-unlock-after-init path from failing on
281
+ // sidecar-missing (init writes the sidecar, but a manual
282
+ // backup-without-sidecar restore shouldn't brick the CLI either).
283
+ acceptMissingSidecar: true,
284
+ // Preserve the CLI's 4-hour auto-lock behaviour.
285
+ ttlMs: SESSION_TTL_MS,
286
+ });
207
287
  }
208
- // Verify key works
209
- const data = readFileSync(VAULT_PATH);
210
- const secrets = decrypt(data, key);
211
- if (!secrets) {
212
- console.error("❌ Failed to decrypt vault - key may be incorrect");
288
+ catch (err) {
289
+ const message = err instanceof Error ? err.message : String(err);
290
+ console.error(`❌ Failed to unlock vault: ${message}`);
213
291
  return false;
214
292
  }
215
- // Set session
216
- sessionKey = key;
217
- sessionUnlockedAt = Date.now();
218
293
  process.stdout.write("✅ Vault unlocked successfully!\n");
219
- process.stdout.write(` Session valid for 4 hours.\n\n`);
294
+ process.stdout.write(` Session valid for 4 hours (provider: ${vault.provider}).\n\n`);
220
295
  return true;
221
296
  }
222
297
  /**
223
298
  * Lock the vault
224
299
  */
225
300
  function lockVault() {
226
- if (sessionKey) {
227
- sessionKey.fill(0);
228
- }
229
- sessionKey = null;
230
- sessionUnlockedAt = null;
301
+ vault?.close();
302
+ vault = null;
231
303
  process.stdout.write("\n🔒 Vault locked.\n\n");
232
304
  }
305
+ /**
306
+ * Ensure the session is unlocked; unlock on demand if not. Returns false when
307
+ * unlock fails so callers can abort cleanly. Factored out of every operation
308
+ * (list / set / get / delete / status) to eliminate boilerplate duplication.
309
+ */
310
+ async function ensureUnlocked() {
311
+ if (isSessionValid())
312
+ return true;
313
+ process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
314
+ return unlockVault();
315
+ }
233
316
  /**
234
317
  * List secrets
235
318
  */
236
319
  async function listSecrets() {
237
- if (!isSessionValid()) {
238
- process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
239
- if (!(await unlockVault()))
240
- return;
241
- }
242
- const data = readFileSync(VAULT_PATH);
243
- const secrets = decrypt(data, sessionKey);
244
- if (!secrets) {
245
- console.error("❌ Failed to decrypt vault");
320
+ if (!(await ensureUnlocked()))
246
321
  return;
247
- }
248
- const names = Object.keys(secrets).sort();
322
+ const names = await vault.list();
249
323
  process.stdout.write(`\n📋 Secrets in vault (${names.length}):\n\n`);
250
324
  if (names.length === 0) {
251
325
  process.stdout.write(" (empty - use 'centient secrets set <name>' to add secrets)\n");
252
326
  }
253
327
  else {
254
328
  for (const name of names) {
255
- const value = secrets[name] ?? "";
329
+ // Per-name `get` is the only way to obtain values via the session-vault
330
+ // API — there's no `getAll`. For a vault with hundreds of entries this
331
+ // is O(n) syscall-free reads (RAM hit), so we eat the minor overhead in
332
+ // exchange for keeping session-vault's surface small.
333
+ const value = (await vault.get(name)) ?? "";
256
334
  const preview = value.length > 0 ? "•".repeat(Math.min(value.length, 20)) : "(empty)";
257
335
  process.stdout.write(` ${name.padEnd(30)} ${preview}\n`);
258
336
  }
@@ -267,22 +345,31 @@ async function listSecrets() {
267
345
  * vault at `~/.centient/secrets/vault.enc`. The two storage paths are
268
346
  * separate in this release.
269
347
  */
270
- async function listBackendKeys(prefix) {
271
- const backendType = getActiveVaultType();
272
- const header = prefix !== undefined
273
- ? `\n🔑 Backend keys (${backendType}, prefix "${prefix}"):\n\n`
274
- : `\n🔑 Backend keys (${backendType}):\n\n`;
275
- process.stdout.write(header);
348
+ async function listBackendKeys(prefix, json) {
276
349
  let keys;
277
350
  try {
278
351
  keys = await listCredentials(prefix);
279
352
  }
280
353
  catch (err) {
281
354
  const message = err instanceof Error ? err.message : String(err);
282
- console.error(`❌ Failed to enumerate backend keys: ${message}`);
355
+ if (json) {
356
+ process.stdout.write(JSON.stringify({ error: message }) + "\n");
357
+ }
358
+ else {
359
+ console.error(`❌ Failed to enumerate backend keys: ${message}`);
360
+ }
283
361
  process.exit(1);
284
362
  }
285
363
  const sorted = [...keys].sort();
364
+ if (json) {
365
+ process.stdout.write(JSON.stringify(sorted) + "\n");
366
+ return;
367
+ }
368
+ const backendType = getActiveVaultType();
369
+ const header = prefix !== undefined
370
+ ? `\n🔑 Backend keys (${backendType}, prefix "${prefix}"):\n\n`
371
+ : `\n🔑 Backend keys (${backendType}):\n\n`;
372
+ process.stdout.write(header);
286
373
  if (sorted.length === 0) {
287
374
  process.stdout.write(" (no keys)\n\n");
288
375
  return;
@@ -300,18 +387,13 @@ async function setSecret(name) {
300
387
  console.error("❌ Secret name required. Usage: centient secrets set <name>");
301
388
  return;
302
389
  }
303
- if (!isSessionValid()) {
304
- process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
305
- if (!(await unlockVault()))
306
- return;
307
- }
308
- const data = readFileSync(VAULT_PATH);
309
- const secrets = decrypt(data, sessionKey);
310
- if (!secrets) {
311
- console.error("❌ Failed to decrypt vault");
390
+ if (!(await ensureUnlocked()))
312
391
  return;
313
- }
314
- const exists = name in secrets;
392
+ // Cheap pre-check so we can show "updating" vs "adding" in the prompt.
393
+ // The write path below is authoritative; races with an external writer
394
+ // don't matter for this cosmetic distinction.
395
+ const existing = await vault.get(name);
396
+ const exists = existing !== null;
315
397
  const action = exists ? "update" : "add";
316
398
  process.stdout.write(`\n${exists ? "✏️ Updating" : "➕ Adding"} secret: ${name}\n\n`);
317
399
  const value = await prompt(`Enter value for ${name}: `, true);
@@ -319,10 +401,14 @@ async function setSecret(name) {
319
401
  process.stdout.write("Aborted - empty value.\n");
320
402
  return;
321
403
  }
322
- secrets[name] = value;
323
- // Re-encrypt and save
324
- const encrypted = encrypt(secrets, sessionKey);
325
- writeFileSync(VAULT_PATH, encrypted);
404
+ try {
405
+ await vault.set(name, value);
406
+ }
407
+ catch (err) {
408
+ const message = err instanceof Error ? err.message : String(err);
409
+ console.error(`❌ Failed to save secret: ${message}`);
410
+ return;
411
+ }
326
412
  process.stdout.write(`\n✅ Secret '${name}' ${action}d successfully!\n\n`);
327
413
  }
328
414
  /**
@@ -333,26 +419,16 @@ async function getSecret(name) {
333
419
  console.error("❌ Secret name required. Usage: centient secrets get <name>");
334
420
  return;
335
421
  }
336
- if (!isSessionValid()) {
337
- process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
338
- if (!(await unlockVault()))
339
- return;
340
- }
341
- const data = readFileSync(VAULT_PATH);
342
- const secrets = decrypt(data, sessionKey);
343
- if (!secrets) {
344
- console.error("❌ Failed to decrypt vault");
422
+ if (!(await ensureUnlocked()))
345
423
  return;
346
- }
347
- if (!(name in secrets)) {
424
+ const value = await vault.get(name);
425
+ if (value === null) {
348
426
  console.error(`❌ Secret '${name}' not found`);
349
427
  return;
350
428
  }
351
- // Print without newline for piping
352
- const secretValue = secrets[name];
353
- if (secretValue !== undefined) {
354
- process.stdout.write(secretValue);
355
- }
429
+ // Print without newline for piping, then add trailing newline for TTY
430
+ // readability matches the previous behaviour exactly.
431
+ process.stdout.write(value);
356
432
  process.stdout.write("\n");
357
433
  }
358
434
  /**
@@ -363,18 +439,14 @@ async function deleteSecret(name) {
363
439
  console.error("❌ Secret name required. Usage: centient secrets delete <name>");
364
440
  return;
365
441
  }
366
- if (!isSessionValid()) {
367
- process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
368
- if (!(await unlockVault()))
369
- return;
370
- }
371
- const data = readFileSync(VAULT_PATH);
372
- const secrets = decrypt(data, sessionKey);
373
- if (!secrets) {
374
- console.error("❌ Failed to decrypt vault");
442
+ if (!(await ensureUnlocked()))
375
443
  return;
376
- }
377
- if (!(name in secrets)) {
444
+ // Pre-check so we can distinguish "not found" from "declined to confirm" in
445
+ // the error output. The subsequent `vault.delete()` is authoritative — a
446
+ // concurrent external delete between the get and the delete just means the
447
+ // final delete reports `false`, which we treat the same as not-found.
448
+ const current = await vault.get(name);
449
+ if (current === null) {
378
450
  console.error(`❌ Secret '${name}' not found`);
379
451
  return;
380
452
  }
@@ -383,10 +455,19 @@ async function deleteSecret(name) {
383
455
  process.stdout.write("Aborted.\n");
384
456
  return;
385
457
  }
386
- delete secrets[name];
387
- // Re-encrypt and save
388
- const encrypted = encrypt(secrets, sessionKey);
389
- writeFileSync(VAULT_PATH, encrypted);
458
+ try {
459
+ const existed = await vault.delete(name);
460
+ if (!existed) {
461
+ // Lost a race with a concurrent deleter — report the end state.
462
+ console.error(`❌ Secret '${name}' not found`);
463
+ return;
464
+ }
465
+ }
466
+ catch (err) {
467
+ const message = err instanceof Error ? err.message : String(err);
468
+ console.error(`❌ Failed to delete secret: ${message}`);
469
+ return;
470
+ }
390
471
  process.stdout.write(`\n✅ Secret '${name}' deleted.\n\n`);
391
472
  }
392
473
  /**
@@ -499,12 +580,6 @@ async function showStatus() {
499
580
  // Check session
500
581
  const sessionValid = isSessionValid();
501
582
  process.stdout.write(`Session: ${sessionValid ? "🔓 unlocked" : "🔒 locked"}\n`);
502
- if (sessionValid && sessionUnlockedAt) {
503
- const remaining = SESSION_TTL - (Date.now() - sessionUnlockedAt);
504
- const hours = Math.floor(remaining / (60 * 60 * 1000));
505
- const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000));
506
- process.stdout.write(`Expires in: ${hours}h ${minutes}m\n`);
507
- }
508
583
  // Check key provider
509
584
  const providerResult = resolveKeyProvider();
510
585
  if (providerResult.ok) {
@@ -515,12 +590,19 @@ async function showStatus() {
515
590
  else {
516
591
  process.stdout.write(`Key provider: ❌ unavailable\n`);
517
592
  }
518
- // Count secrets if unlocked
519
- if (sessionValid && vaultExists) {
520
- const data = readFileSync(VAULT_PATH);
521
- const secrets = decrypt(data, sessionKey);
522
- if (secrets) {
523
- process.stdout.write(`Secrets count: ${Object.keys(secrets).length}\n`);
593
+ // If unlocked, surface the session-vault diagnostic fields (provider that
594
+ // unlocked, resolved vault path, in-memory version) + secret count.
595
+ if (sessionValid && vault) {
596
+ process.stdout.write(`Open via: ${vault.provider}\n`);
597
+ process.stdout.write(`Resolved path: ${vault.path}\n`);
598
+ process.stdout.write(`Vault version: ${vault.vaultVersion}\n`);
599
+ try {
600
+ const names = await vault.list();
601
+ process.stdout.write(`Secrets count: ${names.length}\n`);
602
+ }
603
+ catch (err) {
604
+ const message = err instanceof Error ? err.message : String(err);
605
+ process.stdout.write(`Secrets count: (unavailable: ${message})\n`);
524
606
  }
525
607
  }
526
608
  process.stdout.write("\u2500".repeat(40) + "\n");
@@ -624,7 +706,7 @@ export async function runSecrets(options) {
624
706
  await listSecrets();
625
707
  break;
626
708
  case "list-backend-keys":
627
- await listBackendKeys(options.prefix);
709
+ await listBackendKeys(options.prefix, options.json);
628
710
  break;
629
711
  case "set":
630
712
  await setSecret(options.secretName || "");