@centient/secrets 0.5.0 → 0.7.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 (55) hide show
  1. package/README.md +39 -1
  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/hidden-prompt.d.ts +48 -0
  7. package/dist/cli/hidden-prompt.d.ts.map +1 -0
  8. package/dist/cli/hidden-prompt.js +127 -0
  9. package/dist/cli/hidden-prompt.js.map +1 -0
  10. package/dist/cli/secrets-cli.d.ts +9 -1
  11. package/dist/cli/secrets-cli.d.ts.map +1 -1
  12. package/dist/cli/secrets-cli.js +272 -175
  13. package/dist/cli/secrets-cli.js.map +1 -1
  14. package/dist/crypto/vault-common.d.ts +17 -4
  15. package/dist/crypto/vault-common.d.ts.map +1 -1
  16. package/dist/crypto/vault-common.js +23 -6
  17. package/dist/crypto/vault-common.js.map +1 -1
  18. package/dist/index.d.ts +7 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +7 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/key-providers/index.d.ts +4 -1
  23. package/dist/key-providers/index.d.ts.map +1 -1
  24. package/dist/key-providers/index.js +1 -0
  25. package/dist/key-providers/index.js.map +1 -1
  26. package/dist/key-providers/passphrase-provider.d.ts +79 -0
  27. package/dist/key-providers/passphrase-provider.d.ts.map +1 -0
  28. package/dist/key-providers/passphrase-provider.js +298 -0
  29. package/dist/key-providers/passphrase-provider.js.map +1 -0
  30. package/dist/key-providers/resolve.d.ts +8 -3
  31. package/dist/key-providers/resolve.d.ts.map +1 -1
  32. package/dist/key-providers/resolve.js +34 -10
  33. package/dist/key-providers/resolve.js.map +1 -1
  34. package/dist/key-providers/types.d.ts +20 -2
  35. package/dist/key-providers/types.d.ts.map +1 -1
  36. package/dist/key-providers/types.js +1 -1
  37. package/dist/vault/file-lock.d.ts +33 -0
  38. package/dist/vault/file-lock.d.ts.map +1 -0
  39. package/dist/vault/file-lock.js +143 -0
  40. package/dist/vault/file-lock.js.map +1 -0
  41. package/dist/vault/session-vault-errors.d.ts +38 -0
  42. package/dist/vault/session-vault-errors.d.ts.map +1 -0
  43. package/dist/vault/session-vault-errors.js +67 -0
  44. package/dist/vault/session-vault-errors.js.map +1 -0
  45. package/dist/vault/session-vault.d.ts +147 -0
  46. package/dist/vault/session-vault.d.ts.map +1 -0
  47. package/dist/vault/session-vault.js +669 -0
  48. package/dist/vault/session-vault.js.map +1 -0
  49. package/dist/vault/sidecar.d.ts +37 -0
  50. package/dist/vault/sidecar.d.ts.map +1 -0
  51. package/dist/vault/sidecar.js +84 -0
  52. package/dist/vault/sidecar.js.map +1 -0
  53. package/dist/vault/types.d.ts +1 -1
  54. package/dist/vault/types.d.ts.map +1 -1
  55. package/package.json +1 -1
@@ -25,13 +25,23 @@
25
25
  * Security:
26
26
  * - All commands check for AI agent environment and refuse to run
27
27
  * - Vault is encrypted with AES-256-GCM
28
- * - Key stored via pluggable provider (macOS Keychain or 1Password)
28
+ * - Key stored or derived via pluggable provider (macOS Keychain, 1Password,
29
+ * or passphrase)
29
30
  * - 4-hour session timeout
31
+ *
32
+ * Internals:
33
+ * Vault mutations flow through `openVault` (session-vault.ts) so the CLI
34
+ * and the library-facing API share one on-disk format (AAD-bound, schema 1).
35
+ * Pre-`openVault` CLI-written vaults (AAD-less, legacy flat format) are
36
+ * handled transparently by session-vault's legacy-read path and upgraded to
37
+ * schema 1 on the next successful write.
30
38
  */
31
39
  import { createInterface } from "readline";
32
- import { join } from "path";
33
- import { homedir } from "os";
34
- import { existsSync } from "fs";
40
+ import { dirname, join } from "node:path";
41
+ import { homedir } from "node:os";
42
+ import { existsSync, mkdirSync, realpathSync, writeFileSync, } from "node:fs";
43
+ import { basename } from "node:path";
44
+ import { createHash, randomBytes } from "node:crypto";
35
45
  // =============================================================================
36
46
  // Agent Detection (Local Implementation)
37
47
  // =============================================================================
@@ -46,48 +56,31 @@ function isAgentEnvironment() {
46
56
  process.env.CLAUDE_CODE_ENTRY_POINT);
47
57
  }
48
58
  // =============================================================================
49
- // Simple Encrypted Vault
59
+ // Session vault integration
50
60
  // =============================================================================
51
- import { readFileSync, writeFileSync, mkdirSync } from "fs";
52
- import { randomBytes } from "crypto";
53
- import { encryptObject, decryptObject, } from "../crypto/vault-common.js";
61
+ import { encryptObject } from "../crypto/vault-common.js";
54
62
  import { resolveKeyProvider, getProviderByType, loadConfig, saveSecretsConfig, } from "../key-providers/index.js";
55
63
  import { listCredentials, getActiveVaultType } from "../vault/vault.js";
64
+ import { openVault, VAULT_SCHEMA_VERSION, VAULT_AAD_PREFIX, DEFAULT_SIDECAR_PATH, } from "../vault/session-vault.js";
56
65
  const VAULT_PATH = join(homedir(), ".centient", "secrets", "vault.enc");
57
66
  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
67
+ /** Preserve CLI's historical 4-hour auto-lock semantics. */
68
+ const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
69
+ /**
70
+ * Single process-wide vault handle. Non-null => "session is unlocked".
71
+ * Replaces the previous `sessionKey` + `sessionUnlockedAt` pair. Auto-close
72
+ * on TTL is delegated to `openVault({ ttlMs })`, which clears this handle
73
+ * from inside itself — we observe the closed state via `isSessionValid`.
74
+ */
75
+ let vault = null;
62
76
  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;
77
+ return vault !== null;
85
78
  }
86
79
  /**
87
80
  * Resolve the active key provider, printing an error if unavailable.
88
81
  */
89
82
  function getProvider() {
90
- const result = resolveKeyProvider();
83
+ const result = resolveKeyProvider({ vaultPath: VAULT_PATH });
91
84
  if (!result.ok) {
92
85
  console.error(`❌ ${result.error.message}`);
93
86
  return null;
@@ -97,46 +90,73 @@ function getProvider() {
97
90
  // =============================================================================
98
91
  // CLI Handlers
99
92
  // =============================================================================
93
+ import { advanceHiddenInput, createHiddenInputState, ENABLE_BRACKETED_PASTE, DISABLE_BRACKETED_PASTE, } from "./hidden-input.js";
100
94
  /**
101
- * Prompt for input (with optional hidden mode for passwords)
95
+ * Prompt for input (with optional hidden mode for passwords/secrets).
96
+ *
97
+ * Handles three input shapes correctly:
98
+ * 1. **Piped stdin** (`echo "value" | centient secrets set ...`): reads the
99
+ * full stream to EOF, trims a single trailing newline (pipe artifact).
100
+ * Multi-line values pass through unchanged.
101
+ * 2. **Interactive TTY with bracketed paste**: content wrapped in
102
+ * `\x1b[200~ ... \x1b[201~` is treated atomically; newlines inside a
103
+ * paste are literal content, not submit signals.
104
+ * 3. **Interactive TTY without bracketed paste**: a single newline still
105
+ * submits (preserves single-line UX), and Ctrl-D is an escape hatch for
106
+ * submitting multi-line content on terminals that don't emit paste
107
+ * brackets.
108
+ *
109
+ * Regression hook for issue #37: the previous implementation resolved on the
110
+ * first `\n` from a terminal paste and silently truncated PEM keys / other
111
+ * multi-line secrets to their first line. The parsing state machine lives in
112
+ * `./hidden-input.ts` so it can be unit-tested without stubbing process.stdin.
102
113
  */
103
114
  async function prompt(message, hidden = false) {
115
+ // Non-TTY stdin: don't touch raw mode, don't use readline. Read the whole
116
+ // stream to EOF. This is the path for `cat key.pem | centient secrets set`
117
+ // and the pattern most CLIs use for piped-value workflows.
118
+ if (!process.stdin.isTTY) {
119
+ process.stdout.write(message);
120
+ const chunks = [];
121
+ for await (const chunk of process.stdin) {
122
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
123
+ }
124
+ // Strip a single trailing newline — pipes and `<<<` heredocs typically
125
+ // append one. Preserve any other trailing whitespace (matters for PEM).
126
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
127
+ }
104
128
  const rl = createInterface({
105
129
  input: process.stdin,
106
130
  output: process.stdout,
107
131
  });
108
132
  return new Promise((resolve) => {
109
133
  if (hidden) {
110
- // For hidden input, we need to handle it differently
111
134
  process.stdout.write(message);
112
- let input = "";
135
+ const state = createHiddenInputState();
113
136
  const stdin = process.stdin;
114
137
  const wasRaw = stdin.isRaw;
115
138
  stdin.setRawMode?.(true);
139
+ process.stdout.write(ENABLE_BRACKETED_PASTE);
116
140
  stdin.resume();
117
141
  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
142
+ const finish = () => {
143
+ process.stdout.write(DISABLE_BRACKETED_PASTE);
144
+ stdin.setRawMode?.(wasRaw || false);
145
+ stdin.pause();
146
+ stdin.removeListener("data", onData);
147
+ process.stdout.write("\n");
148
+ rl.close();
149
+ resolve(state.input);
150
+ };
151
+ // Terminals can deliver a paste as one large chunk OR as many small
152
+ // chunks; the state machine processes character-by-character so
153
+ // escape-sequence state survives across chunk boundaries.
154
+ const onData = (chunk) => {
155
+ const signal = advanceHiddenInput(state, chunk);
156
+ if (signal === "ctrl-c")
129
157
  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
- }
158
+ if (signal === "submit")
159
+ finish();
140
160
  };
141
161
  stdin.on("data", onData);
142
162
  }
@@ -148,6 +168,31 @@ async function prompt(message, hidden = false) {
148
168
  }
149
169
  });
150
170
  }
171
+ /**
172
+ * Derive the AAD for a freshly-initialised vault so the bootstrap blob is
173
+ * openable by `openVault()` on the very next invocation. Mirrors the
174
+ * derivation in session-vault.ts (same path-resolution + hash construction)
175
+ * because we can't call the private helper from here.
176
+ *
177
+ * Note on symlink handling: session-vault's `resolveVaultPath` realpaths the
178
+ * vault file. Because the vault file doesn't exist yet at bootstrap time, we
179
+ * realpath the PARENT directory (already created) and append the basename —
180
+ * which reproduces the same resolved-path bytes that realpath would produce
181
+ * on the file itself after the first openVault call. Absent this, a setup
182
+ * where any parent component is a symlink (e.g. `~/.centient` pointing into
183
+ * iCloud Drive on macOS) would produce a different AAD on init vs on unlock,
184
+ * and the first unlock-after-init would fail with VaultDecryptError.
185
+ *
186
+ * @param vaultPathAbs - Absolute vault file path whose PARENT directory must
187
+ * already exist on disk so `realpathSync` can resolve it.
188
+ */
189
+ function deriveBootstrapAad(vaultPathAbs) {
190
+ const parentReal = realpathSync(dirname(vaultPathAbs));
191
+ const resolved = join(parentReal, basename(vaultPathAbs));
192
+ return createHash("sha256")
193
+ .update(`${VAULT_AAD_PREFIX}:v${VAULT_SCHEMA_VERSION}:${resolved}`)
194
+ .digest();
195
+ }
151
196
  /**
152
197
  * Initialize a new vault
153
198
  */
@@ -160,28 +205,78 @@ async function initVault() {
160
205
  return;
161
206
  }
162
207
  }
163
- // Generate key
164
- const key = randomBytes(KEY_LENGTH);
165
208
  // Store via key provider
166
209
  const provider = getProvider();
167
210
  if (!provider)
168
211
  return;
169
- process.stdout.write(`Storing encryption key via ${provider.name}...\n`);
170
- if (!provider.storeKey(key)) {
171
- console.error(`❌ Failed to store key via ${provider.name}`);
172
- return;
173
- }
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();
212
+ // Ensure directory exists BEFORE computing AAD so realpath inside
213
+ // openVault can resolve parent components cleanly on the next open.
214
+ const dir = dirname(VAULT_PATH);
215
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
216
+ let key;
217
+ if (provider.setupKey) {
218
+ process.stdout.write(`Configuring vault key via ${provider.name}...\n`);
219
+ const setupKey = provider.setupKey();
220
+ if (!setupKey) {
221
+ const providerError = provider.getLastError?.();
222
+ console.error(`❌ Failed to configure key via ${provider.name}`);
223
+ if (providerError)
224
+ console.error(` ${providerError.message}`);
225
+ return;
226
+ }
227
+ key = setupKey;
228
+ }
229
+ else {
230
+ key = randomBytes(KEY_LENGTH);
231
+ process.stdout.write(`Storing encryption key via ${provider.name}...\n`);
232
+ if (!provider.storeKey(key)) {
233
+ const providerError = provider.getLastError?.();
234
+ console.error(`❌ Failed to store key via ${provider.name}`);
235
+ if (providerError)
236
+ console.error(` ${providerError.message}`);
237
+ key.fill(0);
238
+ return;
239
+ }
240
+ }
241
+ // Bootstrap the vault file in the session-vault v1 format (AAD-bound,
242
+ // `{schema, vaultVersion, secrets}` payload) so the next `openVault()` call
243
+ // succeeds without hitting the legacy-upgrade path. The alternative —
244
+ // writing an AAD-less blob — would force a write on first unlock just to
245
+ // upgrade schemas, which is both weirder and slower.
246
+ try {
247
+ const aad = deriveBootstrapAad(VAULT_PATH);
248
+ const bootstrapPayload = {
249
+ schema: VAULT_SCHEMA_VERSION,
250
+ vaultVersion: 1,
251
+ secrets: {},
252
+ };
253
+ const encrypted = encryptObject(bootstrapPayload, key, aad);
254
+ if (!encrypted) {
255
+ console.error("❌ Failed to encrypt empty vault");
256
+ return;
257
+ }
258
+ writeFileSync(VAULT_PATH, encrypted, { mode: 0o600 });
259
+ // Write the sidecar at the default location so openVault doesn't warn on
260
+ // missing-sidecar the first time we open. Matches `DEFAULT_SIDECAR_PATH`.
261
+ writeFileSync(DEFAULT_SIDECAR_PATH, JSON.stringify({ highestSeenVersion: 1 }), { mode: 0o600 });
262
+ // Open the vault immediately so the CLI session is already unlocked —
263
+ // same UX as before, but via the shared code path.
264
+ vault = await openVault({
265
+ path: VAULT_PATH,
266
+ ttlMs: SESSION_TTL_MS,
267
+ });
268
+ }
269
+ catch (err) {
270
+ const message = err instanceof Error ? err.message : String(err);
271
+ console.error(`⚠️ Vault written but failed to auto-unlock: ${message}`);
272
+ console.error(" Run 'centient secrets unlock' to unlock manually.");
273
+ return;
274
+ }
275
+ finally {
276
+ // Zero the local key copy — `openVault` fetched its own copy from the
277
+ // provider; we never need the original again.
278
+ key.fill(0);
279
+ }
185
280
  process.stdout.write("\n✅ Vault initialized successfully!\n");
186
281
  process.stdout.write(` Location: ${VAULT_PATH}\n`);
187
282
  process.stdout.write(" The vault is now unlocked. Use 'centient secrets set' to add secrets.\n\n");
@@ -195,64 +290,66 @@ async function unlockVault() {
195
290
  console.error("❌ Vault not found. Run 'centient secrets init' first.");
196
291
  return false;
197
292
  }
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;
293
+ try {
294
+ vault = await openVault({
295
+ path: VAULT_PATH,
296
+ // Existing CLI-written vaults predate the sidecar; session-vault's
297
+ // legacy-upgrade path treats them as a first-use context, but explicit
298
+ // opt-in is required for non-legacy fresh installs. We set this to
299
+ // `true` to keep the first-unlock-after-init path from failing on
300
+ // sidecar-missing (init writes the sidecar, but a manual
301
+ // backup-without-sidecar restore shouldn't brick the CLI either).
302
+ acceptMissingSidecar: true,
303
+ // Preserve the CLI's 4-hour auto-lock behaviour.
304
+ ttlMs: SESSION_TTL_MS,
305
+ });
207
306
  }
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");
307
+ catch (err) {
308
+ const message = err instanceof Error ? err.message : String(err);
309
+ console.error(`❌ Failed to unlock vault: ${message}`);
213
310
  return false;
214
311
  }
215
- // Set session
216
- sessionKey = key;
217
- sessionUnlockedAt = Date.now();
218
312
  process.stdout.write("✅ Vault unlocked successfully!\n");
219
- process.stdout.write(` Session valid for 4 hours.\n\n`);
313
+ process.stdout.write(` Session valid for 4 hours (provider: ${vault.provider}).\n\n`);
220
314
  return true;
221
315
  }
222
316
  /**
223
317
  * Lock the vault
224
318
  */
225
319
  function lockVault() {
226
- if (sessionKey) {
227
- sessionKey.fill(0);
228
- }
229
- sessionKey = null;
230
- sessionUnlockedAt = null;
320
+ vault?.close();
321
+ vault = null;
231
322
  process.stdout.write("\n🔒 Vault locked.\n\n");
232
323
  }
324
+ /**
325
+ * Ensure the session is unlocked; unlock on demand if not. Returns false when
326
+ * unlock fails so callers can abort cleanly. Factored out of every operation
327
+ * (list / set / get / delete / status) to eliminate boilerplate duplication.
328
+ */
329
+ async function ensureUnlocked() {
330
+ if (isSessionValid())
331
+ return true;
332
+ process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
333
+ return unlockVault();
334
+ }
233
335
  /**
234
336
  * List secrets
235
337
  */
236
338
  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");
339
+ if (!(await ensureUnlocked()))
246
340
  return;
247
- }
248
- const names = Object.keys(secrets).sort();
341
+ const names = await vault.list();
249
342
  process.stdout.write(`\n📋 Secrets in vault (${names.length}):\n\n`);
250
343
  if (names.length === 0) {
251
344
  process.stdout.write(" (empty - use 'centient secrets set <name>' to add secrets)\n");
252
345
  }
253
346
  else {
254
347
  for (const name of names) {
255
- const value = secrets[name] ?? "";
348
+ // Per-name `get` is the only way to obtain values via the session-vault
349
+ // API — there's no `getAll`. For a vault with hundreds of entries this
350
+ // is O(n) syscall-free reads (RAM hit), so we eat the minor overhead in
351
+ // exchange for keeping session-vault's surface small.
352
+ const value = (await vault.get(name)) ?? "";
256
353
  const preview = value.length > 0 ? "•".repeat(Math.min(value.length, 20)) : "(empty)";
257
354
  process.stdout.write(` ${name.padEnd(30)} ${preview}\n`);
258
355
  }
@@ -309,18 +406,13 @@ async function setSecret(name) {
309
406
  console.error("❌ Secret name required. Usage: centient secrets set <name>");
310
407
  return;
311
408
  }
312
- if (!isSessionValid()) {
313
- process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
314
- if (!(await unlockVault()))
315
- return;
316
- }
317
- const data = readFileSync(VAULT_PATH);
318
- const secrets = decrypt(data, sessionKey);
319
- if (!secrets) {
320
- console.error("❌ Failed to decrypt vault");
409
+ if (!(await ensureUnlocked()))
321
410
  return;
322
- }
323
- const exists = name in secrets;
411
+ // Cheap pre-check so we can show "updating" vs "adding" in the prompt.
412
+ // The write path below is authoritative; races with an external writer
413
+ // don't matter for this cosmetic distinction.
414
+ const existing = await vault.get(name);
415
+ const exists = existing !== null;
324
416
  const action = exists ? "update" : "add";
325
417
  process.stdout.write(`\n${exists ? "✏️ Updating" : "➕ Adding"} secret: ${name}\n\n`);
326
418
  const value = await prompt(`Enter value for ${name}: `, true);
@@ -328,10 +420,14 @@ async function setSecret(name) {
328
420
  process.stdout.write("Aborted - empty value.\n");
329
421
  return;
330
422
  }
331
- secrets[name] = value;
332
- // Re-encrypt and save
333
- const encrypted = encrypt(secrets, sessionKey);
334
- writeFileSync(VAULT_PATH, encrypted);
423
+ try {
424
+ await vault.set(name, value);
425
+ }
426
+ catch (err) {
427
+ const message = err instanceof Error ? err.message : String(err);
428
+ console.error(`❌ Failed to save secret: ${message}`);
429
+ return;
430
+ }
335
431
  process.stdout.write(`\n✅ Secret '${name}' ${action}d successfully!\n\n`);
336
432
  }
337
433
  /**
@@ -342,26 +438,16 @@ async function getSecret(name) {
342
438
  console.error("❌ Secret name required. Usage: centient secrets get <name>");
343
439
  return;
344
440
  }
345
- if (!isSessionValid()) {
346
- process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
347
- if (!(await unlockVault()))
348
- return;
349
- }
350
- const data = readFileSync(VAULT_PATH);
351
- const secrets = decrypt(data, sessionKey);
352
- if (!secrets) {
353
- console.error("❌ Failed to decrypt vault");
441
+ if (!(await ensureUnlocked()))
354
442
  return;
355
- }
356
- if (!(name in secrets)) {
443
+ const value = await vault.get(name);
444
+ if (value === null) {
357
445
  console.error(`❌ Secret '${name}' not found`);
358
446
  return;
359
447
  }
360
- // Print without newline for piping
361
- const secretValue = secrets[name];
362
- if (secretValue !== undefined) {
363
- process.stdout.write(secretValue);
364
- }
448
+ // Print without newline for piping, then add trailing newline for TTY
449
+ // readability matches the previous behaviour exactly.
450
+ process.stdout.write(value);
365
451
  process.stdout.write("\n");
366
452
  }
367
453
  /**
@@ -372,18 +458,14 @@ async function deleteSecret(name) {
372
458
  console.error("❌ Secret name required. Usage: centient secrets delete <name>");
373
459
  return;
374
460
  }
375
- if (!isSessionValid()) {
376
- process.stdout.write("\n🔒 Vault is locked. Unlocking...\n");
377
- if (!(await unlockVault()))
378
- return;
379
- }
380
- const data = readFileSync(VAULT_PATH);
381
- const secrets = decrypt(data, sessionKey);
382
- if (!secrets) {
383
- console.error("❌ Failed to decrypt vault");
461
+ if (!(await ensureUnlocked()))
384
462
  return;
385
- }
386
- if (!(name in secrets)) {
463
+ // Pre-check so we can distinguish "not found" from "declined to confirm" in
464
+ // the error output. The subsequent `vault.delete()` is authoritative — a
465
+ // concurrent external delete between the get and the delete just means the
466
+ // final delete reports `false`, which we treat the same as not-found.
467
+ const current = await vault.get(name);
468
+ if (current === null) {
387
469
  console.error(`❌ Secret '${name}' not found`);
388
470
  return;
389
471
  }
@@ -392,10 +474,19 @@ async function deleteSecret(name) {
392
474
  process.stdout.write("Aborted.\n");
393
475
  return;
394
476
  }
395
- delete secrets[name];
396
- // Re-encrypt and save
397
- const encrypted = encrypt(secrets, sessionKey);
398
- writeFileSync(VAULT_PATH, encrypted);
477
+ try {
478
+ const existed = await vault.delete(name);
479
+ if (!existed) {
480
+ // Lost a race with a concurrent deleter — report the end state.
481
+ console.error(`❌ Secret '${name}' not found`);
482
+ return;
483
+ }
484
+ }
485
+ catch (err) {
486
+ const message = err instanceof Error ? err.message : String(err);
487
+ console.error(`❌ Failed to delete secret: ${message}`);
488
+ return;
489
+ }
399
490
  process.stdout.write(`\n✅ Secret '${name}' deleted.\n\n`);
400
491
  }
401
492
  /**
@@ -508,14 +599,8 @@ async function showStatus() {
508
599
  // Check session
509
600
  const sessionValid = isSessionValid();
510
601
  process.stdout.write(`Session: ${sessionValid ? "🔓 unlocked" : "🔒 locked"}\n`);
511
- if (sessionValid && sessionUnlockedAt) {
512
- const remaining = SESSION_TTL - (Date.now() - sessionUnlockedAt);
513
- const hours = Math.floor(remaining / (60 * 60 * 1000));
514
- const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000));
515
- process.stdout.write(`Expires in: ${hours}h ${minutes}m\n`);
516
- }
517
602
  // Check key provider
518
- const providerResult = resolveKeyProvider();
603
+ const providerResult = resolveKeyProvider({ vaultPath: VAULT_PATH });
519
604
  if (providerResult.ok) {
520
605
  const hasKey = providerResult.provider.getKey() !== null;
521
606
  process.stdout.write(`Key provider: ${providerResult.provider.name} (${providerResult.method})\n`);
@@ -524,12 +609,19 @@ async function showStatus() {
524
609
  else {
525
610
  process.stdout.write(`Key provider: ❌ unavailable\n`);
526
611
  }
527
- // Count secrets if unlocked
528
- if (sessionValid && vaultExists) {
529
- const data = readFileSync(VAULT_PATH);
530
- const secrets = decrypt(data, sessionKey);
531
- if (secrets) {
532
- process.stdout.write(`Secrets count: ${Object.keys(secrets).length}\n`);
612
+ // If unlocked, surface the session-vault diagnostic fields (provider that
613
+ // unlocked, resolved vault path, in-memory version) + secret count.
614
+ if (sessionValid && vault) {
615
+ process.stdout.write(`Open via: ${vault.provider}\n`);
616
+ process.stdout.write(`Resolved path: ${vault.path}\n`);
617
+ process.stdout.write(`Vault version: ${vault.vaultVersion}\n`);
618
+ try {
619
+ const names = await vault.list();
620
+ process.stdout.write(`Secrets count: ${names.length}\n`);
621
+ }
622
+ catch (err) {
623
+ const message = err instanceof Error ? err.message : String(err);
624
+ process.stdout.write(`Secrets count: (unavailable: ${message})\n`);
533
625
  }
534
626
  }
535
627
  process.stdout.write("\u2500".repeat(40) + "\n");
@@ -545,17 +637,22 @@ async function showStatus() {
545
637
  async function migrateProvider(targetType) {
546
638
  if (!targetType) {
547
639
  console.error("❌ Target provider required. Usage: centient secrets migrate <provider>");
548
- console.error(" Supported providers: keychain, 1password");
640
+ console.error(" Supported providers: keychain, 1password, passphrase");
549
641
  return;
550
642
  }
551
- const validTypes = ["keychain", "1password"];
643
+ const validTypes = ["keychain", "1password", "passphrase"];
552
644
  if (!validTypes.includes(targetType)) {
553
645
  console.error(`❌ Unknown provider "${targetType}". Supported: ${validTypes.join(", ")}`);
554
646
  return;
555
647
  }
556
648
  const target = targetType;
649
+ if (target === "passphrase") {
650
+ console.error("❌ Migrating to passphrase is not supported by this provider-only step.");
651
+ console.error(" It requires re-encrypting the vault under a derived key and will be handled in a follow-up.");
652
+ return;
653
+ }
557
654
  // Resolve current provider
558
- const currentResult = resolveKeyProvider();
655
+ const currentResult = resolveKeyProvider({ vaultPath: VAULT_PATH });
559
656
  if (!currentResult.ok) {
560
657
  console.error(`❌ ${currentResult.error.message}`);
561
658
  return;
@@ -575,7 +672,7 @@ async function migrateProvider(targetType) {
575
672
  }
576
673
  // Create target provider
577
674
  const config = loadConfig();
578
- const targetProvider = getProviderByType(target, config.secrets);
675
+ const targetProvider = getProviderByType(target, config.secrets, { vaultPath: VAULT_PATH });
579
676
  if (!targetProvider) {
580
677
  console.error(`❌ Provider "${target}" is not available on this system.`);
581
678
  if (target === "1password") {