@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.
- package/README.md +2 -0
- package/dist/cli/hidden-input.d.ts +52 -0
- package/dist/cli/hidden-input.d.ts.map +1 -0
- package/dist/cli/hidden-input.js +109 -0
- package/dist/cli/hidden-input.js.map +1 -0
- package/dist/cli/secrets-cli.d.ts +9 -0
- package/dist/cli/secrets-cli.d.ts.map +1 -1
- package/dist/cli/secrets-cli.js +251 -169
- package/dist/cli/secrets-cli.js.map +1 -1
- package/dist/crypto/vault-common.d.ts +23 -4
- package/dist/crypto/vault-common.d.ts.map +1 -1
- package/dist/crypto/vault-common.js +47 -6
- package/dist/crypto/vault-common.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/vault/file-lock.d.ts +33 -0
- package/dist/vault/file-lock.d.ts.map +1 -0
- package/dist/vault/file-lock.js +143 -0
- package/dist/vault/file-lock.js.map +1 -0
- package/dist/vault/policy.d.ts +50 -0
- package/dist/vault/policy.d.ts.map +1 -0
- package/dist/vault/policy.js +68 -0
- package/dist/vault/policy.js.map +1 -0
- package/dist/vault/session-vault-errors.d.ts +38 -0
- package/dist/vault/session-vault-errors.d.ts.map +1 -0
- package/dist/vault/session-vault-errors.js +67 -0
- package/dist/vault/session-vault-errors.js.map +1 -0
- package/dist/vault/session-vault.d.ts +147 -0
- package/dist/vault/session-vault.d.ts.map +1 -0
- package/dist/vault/session-vault.js +667 -0
- package/dist/vault/session-vault.js.map +1 -0
- package/dist/vault/sidecar.d.ts +37 -0
- package/dist/vault/sidecar.d.ts.map +1 -0
- package/dist/vault/sidecar.js +84 -0
- package/dist/vault/sidecar.js.map +1 -0
- package/dist/vault/types.d.ts +7 -4
- package/dist/vault/types.d.ts.map +1 -1
- package/dist/vault/vault-env.d.ts +1 -1
- package/dist/vault/vault-env.d.ts.map +1 -1
- package/dist/vault/vault-env.js +1 -1
- package/dist/vault/vault-env.js.map +1 -1
- package/dist/vault/vault-gpg.d.ts +1 -1
- package/dist/vault/vault-gpg.d.ts.map +1 -1
- package/dist/vault/vault-gpg.js +1 -1
- package/dist/vault/vault-gpg.js.map +1 -1
- package/dist/vault/vault-libsecret.d.ts +28 -14
- package/dist/vault/vault-libsecret.d.ts.map +1 -1
- package/dist/vault/vault-libsecret.js +76 -15
- package/dist/vault/vault-libsecret.js.map +1 -1
- package/dist/vault/vault-utils.d.ts +12 -2
- package/dist/vault/vault-utils.d.ts.map +1 -1
- package/dist/vault/vault-utils.js +21 -4
- package/dist/vault/vault-utils.js.map +1 -1
- package/dist/vault/vault-windows.d.ts +1 -1
- package/dist/vault/vault-windows.d.ts.map +1 -1
- package/dist/vault/vault-windows.js +1 -1
- package/dist/vault/vault-windows.js.map +1 -1
- package/dist/vault/vault.d.ts +5 -4
- package/dist/vault/vault.d.ts.map +1 -1
- package/dist/vault/vault.js +83 -8
- package/dist/vault/vault.js.map +1 -1
- package/package.json +4 -1
package/dist/cli/secrets-cli.js
CHANGED
|
@@ -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
|
-
//
|
|
58
|
+
// Session vault integration
|
|
50
59
|
// =============================================================================
|
|
51
|
-
import {
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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 (!
|
|
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 (
|
|
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
|
-
|
|
353
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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 || "");
|