dsh-vault 1.10.63 → 1.10.64-rc.2

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-zh.md CHANGED
@@ -83,7 +83,7 @@ dsh-vault 是一个面向 DeepSeek Harness 的安全加密插件:把你在使
83
83
  | `vault_vault_rename` / `vault_vault_delete` | 重命名命名库(文件移动,当前会话跟随)或永久删除(default 库受保护) |
84
84
  | `vault_match_url` | 按 URL 查找匹配的登录条目(Bitwarden/1Password 风格:精确主机、子域、父域、路径前缀;www./端口归一化),带 0–100 评分——绝不返回密码 |
85
85
  | `vault_fill` | 按 host/URL/用户名/标题匹配条目并返回其凭据 |
86
- | `vault_env` | 把标记 env 的条目(tags 含 `env`)渲染为 `KEY=VALUE` 行 |
86
+ | `vault_env` | 把标记 env 的条目(tags 含 `env`)渲染为 `KEY=VALUE` 行 —— 见[环境变量导出](#环境变量导出) |
87
87
  | `vault_export_bitwarden` / `vault_import_bitwarden` | Bitwarden/Vaultwarden JSON 互通(完整字段映射,支持覆盖) |
88
88
  | `vault_import_bitwarden_encrypted` | 解密 Bitwarden 口令保护 JSON 导出(PBKDF2/Argon2id + HKDF → AES-256-CBC + HMAC)并导入,需提供导出口令 |
89
89
  | `vault_import_manager_csv` | 密码管理器 CSV 自动识别表头:Bitwarden(login_uri/login_username/…)、1Password 8、Dashlane、NordPass、Keeper、LastPass(fav/grouping/extra);支持 dryRun 预览 |
@@ -179,6 +179,48 @@ tarball 自带预构建 `lib/` 产物,无需构建或 allowBuilds。
179
179
 
180
180
  `dsh plugin --profile web remove dsh-vault` 卸载(同时移除依赖与 layer)。
181
181
 
182
+ ## 命令行
183
+
184
+ 插件把保险库交给助手,而自带的 `dsh-vault` 命令把**同一个保险库**交给 shell 与脚本 —— 于是技能里的子进程可以自己取密钥,**明文永远不进模型上下文**:
185
+
186
+ ```sh
187
+ export $(dsh-vault env) # 带 env 标签的条目 → KEY=VALUE
188
+ dsh-vault get my-entry --field apiKey # 只取一个字段,仅 stdout
189
+ dsh-vault get my-entry | pbcopy # 该条目的主密钥
190
+ dsh-vault export-env .env # 生成 0600 权限的 .env
191
+ dsh-vault list # 标题/id/类型,绝不含密钥
192
+ dsh-vault show my-entry # 非敏感字段的 JSON
193
+ ```
194
+
195
+ 主密码来源依次为:`--password-stdin` → `$DSH_VAULT_MASTER_PASSWORD`(或 `$DSH_VAULT_PASSWORD`)→ 交互式提示;它**不会**去读插件配置文件里的密码。密钥只走 stdout,其余(进度、错误)走 stderr,所以管道与 `$(...)` 都能正常用。
196
+
197
+ **怎么调用。** `dsh` 本身没有"插件子命令"注册表:启动器只解析自己的参数(`--profile`、`--patch`、`--dump-config`),而 `dsh plugin …` 是把参数**原样转发给 profile 目录里的 pnpm**;`dsh-cmdline` 是给**应用型 bundle** 拥有某个 profile 的参数族的,不是让插件获得 `dsh <插件>` 命令。所以按你的场景选:
198
+
199
+ ```sh
200
+ npx dsh-vault env # 直接用已发布包,无需安装
201
+ pnpm --dir ~/.dsh/profiles/web exec dsh-vault env # 用 profile 里那份
202
+ node ~/.dsh/profiles/web/node_modules/dsh-vault/lib/cli.js env # 一定可用
203
+ ```
204
+
205
+ 中间那种需要 profile 里由 `pnpm install` 生成的 bin 垫片;最后一种即使 `node_modules/dsh-vault` 只是指向工作区的软链也能用(直接执行文件)。要拿到垫片:把插件作为 profile 的真实依赖安装(`pnpm dsh plugin add dsh-vault`)并在该目录跑一次 `pnpm install`。
206
+
207
+ ## 环境变量导出
208
+
209
+ 打上 `env` 标签的条目可以用 `vault_env` / `vault_export_env` 导出为 `KEY=VALUE` 行,名字按各家工具链预期的字段名生成:
210
+
211
+ | 条目 | 导出的键 |
212
+ |---|---|
213
+ | 标题 `DASHSCOPE` + `apiKey` | `DASHSCOPE_API_KEY` |
214
+ | 标题 `DASHSCOPE` + `prefix: APP_` | `APP_DASHSCOPE_API_KEY` |
215
+ | `envKey: "TAVILY_TOKEN"` + `accessToken` | `TAVILY_TOKEN` |
216
+ | 同一条目的 `refreshToken` / `fields.scope` | `TAVILY_TOKEN_REFRESH_TOKEN` / `TAVILY_TOKEN_SCOPE` |
217
+
218
+ - 字段后缀遵循惯例:`apiKey → API_KEY`、`accessToken → ACCESS_TOKEN`、`refreshToken → REFRESH_TOKEN`、`privateKey → PRIVATE_KEY`、`password → PASSWORD`、`cardNumber → CARD_NUMBER`。
219
+ - 派生名字对不上脚本时,给条目设置**环境变量名**(可选字段 `envKey`,也可用 `vault_add`/`vault_update` 写入):它会**原样**作为该条目主密钥的名字(不加前缀、不从标题推导),次级密钥与自定义字段则以 `<envKey>_<字段>` 挂在后面。必须是合法 POSIX 名(`[A-Za-z_][A-Za-z0-9_]*`),否则保存时直接拒绝。
220
+ - 自定义字段同样会导出,形如 `<BASE>_<字段>`。
221
+
222
+ > 升级提示:1.10.64 起键名从 `DASHSCOPE_APIKEY` 改为符合厂商惯例的 `DASHSCOPE_API_KEY`。若脚本依赖旧写法,给条目设置 `envKey` 固定名字即可。
223
+
182
224
  ## 工具分级(工具集)
183
225
 
184
226
  插件内置 110+ 个模型工具,但**默认只注册 11 个基础工具**(`vault_list/search/get/add/update/delete/fill/clipboard/totp/generate_password/strength`),让模型的工具目录保持精简、减少 token 开销。
package/README.md CHANGED
@@ -85,7 +85,7 @@ Each record has a `title`, an optional `kind`, and any combination of fields:
85
85
  | `vault_vault_rename` / `vault_vault_delete` | Rename a named vault (file moves, active session follows) or permanently delete one (default vault protected) |
86
86
  | `vault_match_url` | Find login entries matching a URL (Bitwarden/1Password-style: exact host, subdomain, parent domain, path-prefix; www./port normalization) with a 0–100 score — never returns the password |
87
87
  | `vault_fill` | Find the entry matching a host/URL/username/title and return its credentials |
88
- | `vault_env` | Render env-flagged entries (tags contain `env`) as `KEY=VALUE` lines |
88
+ | `vault_env` | Render env-flagged entries (tags contain `env`) as `KEY=VALUE` lines — see [Environment export](#environment-export) |
89
89
  | `vault_export_bitwarden` / `vault_import_bitwarden` | Bitwarden/Vaultwarden JSON interop (full field mapping, overwrite support) |
90
90
  | `vault_import_bitwarden_encrypted` | Decrypt a Bitwarden password-protected JSON export (PBKDF2/Argon2id + HKDF → AES-256-CBC + HMAC) and import it; pass the export passphrase |
91
91
  | `vault_import_manager_csv` | Password-manager CSV auto-detected by header: Bitwarden (`login_uri`/`login_username`/…), 1Password 8, Dashlane, NordPass, Keeper, LastPass (`fav`/`grouping`/`extra`); dryRun preview |
@@ -181,6 +181,72 @@ The tarball ships prebuilt `lib/` artifacts, so no build step or `allowBuilds` i
181
181
 
182
182
  `dsh plugin --profile web remove dsh-vault` uninstalls (removes both the dependency and the layer).
183
183
 
184
+ ## Command line
185
+
186
+ The plugin hands the vault to the assistant; the bundled `dsh-vault` command
187
+ hands the same vault to shells and scripts, so a skill's child process can read
188
+ a secret **without the plaintext ever entering the model's context**:
189
+
190
+ ```sh
191
+ export $(dsh-vault env) # env-tagged entries → KEY=VALUE
192
+ dsh-vault get my-entry --field apiKey # one field, stdout only
193
+ dsh-vault get my-entry | pbcopy # the entry's primary secret
194
+ dsh-vault export-env .env # 0600 file for docker/systemd
195
+ dsh-vault list # titles/ids/kinds — never secrets
196
+ dsh-vault show my-entry # non-secret fields as JSON
197
+ ```
198
+
199
+ The master password comes from `--password-stdin`, then
200
+ `$DSH_VAULT_MASTER_PASSWORD` (or `$DSH_VAULT_PASSWORD`), then an interactive
201
+ prompt; it is never read from the plugin's config file. Secrets go to stdout and
202
+ everything else to stderr, so pipelines behave.
203
+
204
+ **How to invoke it.** `dsh` itself has no plugin-subcommand registry: the
205
+ launcher parses only its own flags (`--profile`, `--patch`, `--dump-config`) and
206
+ `dsh plugin …` forwards raw arguments to `pnpm` inside the profile directory.
207
+ `dsh-cmdline` exists so an *app* bundle can own a flag family of a profile, not
208
+ so a plugin gains a `dsh <plugin>` command. So pick whichever fits:
209
+
210
+ ```sh
211
+ npx dsh-vault env # published package, no install
212
+ pnpm --dir ~/.dsh/profiles/web exec dsh-vault env # profile-installed copy
213
+ node ~/.dsh/profiles/web/node_modules/dsh-vault/lib/cli.js env # always works
214
+ ```
215
+
216
+ The middle form needs the bin shim that `pnpm install` creates inside the
217
+ profile; the last form works even for a `node_modules/dsh-vault` symlink to a
218
+ checkout, because it invokes the file directly. To get the shim, declare the
219
+ plugin as a real dependency of the profile (`pnpm dsh plugin add dsh-vault`) and
220
+ run `pnpm install` there once.
221
+
222
+ ## Environment export
223
+
224
+ Entries tagged `env` can be materialised as `KEY=VALUE` lines (`vault_env`,
225
+ `vault_export_env`), and their names follow the field names every toolchain
226
+ expects:
227
+
228
+ | Entry | Exported key |
229
+ |---|---|
230
+ | title `DASHSCOPE`, `apiKey` | `DASHSCOPE_API_KEY` |
231
+ | title `DASHSCOPE`, `prefix: APP_` | `APP_DASHSCOPE_API_KEY` |
232
+ | `envKey: "TAVILY_TOKEN"`, `accessToken` | `TAVILY_TOKEN` |
233
+ | …the same entry's `refreshToken` / `fields.scope` | `TAVILY_TOKEN_REFRESH_TOKEN` / `TAVILY_TOKEN_SCOPE` |
234
+
235
+ - The field suffix is vendor-standard: `apiKey → API_KEY`, `accessToken →
236
+ ACCESS_TOKEN`, `refreshToken → REFRESH_TOKEN`, `privateKey → PRIVATE_KEY`,
237
+ `password → PASSWORD`, `cardNumber → CARD_NUMBER`.
238
+ - Set an entry's **env-var name** (optional field `envKey`, also via
239
+ `vault_add`/`vault_update`) when the derived name is not what your scripts
240
+ expect: it is used **verbatim** — no prefix, no title — for the entry's
241
+ primary secret, and secondary secrets/custom fields hang off it as
242
+ `<envKey>_<FIELD>`. It must be a valid POSIX name
243
+ (`[A-Za-z_][A-Za-z0-9_]*`); anything else is refused when saving.
244
+ - Custom fields are exported too, as `<BASE>_<FIELD>`.
245
+
246
+ > Upgrading: keys changed from `DASHSCOPE_APIKEY` to `DASHSCOPE_API_KEY` in
247
+ > 1.10.64 to match vendor conventions. Set `envKey` on an entry to pin an exact
248
+ > name if a script depends on the old spelling.
249
+
184
250
  ## Tool profiles
185
251
 
186
252
  The plugin ships 110+ model tools, but **registers only 11 core tools by default** (`vault_list/search/get/add/update/delete/fill/clipboard/totp/generate_password/strength`) to keep the model's tool catalog small and cheap.
package/lib/cli.js ADDED
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh-vault` — script-facing access to a vault, without the model in the loop.
4
+ *
5
+ * The plugin hands the vault to the assistant; this CLI hands the same vault to
6
+ * shells and scripts, so a skill's Python/Node child process can read a secret
7
+ * without the plaintext ever entering the model's context:
8
+ *
9
+ * export $(dsh-vault env) # env-tagged entries as KEY=VALUE
10
+ * dsh-vault get my-entry --field apiKey # one field, stdout only
11
+ * dsh-vault export-env .env # 0600 file for docker/systemd
12
+ *
13
+ * Secrets go to stdout and everything else (progress, errors) to stderr, so
14
+ * `dsh-vault get … | pbcopy` and `$(dsh-vault get …)` behave.
15
+ *
16
+ * The core is `runCli(argv, io)`, which tests drive directly without spawning.
17
+ */
18
+ import { chmod, mkdir, writeFile } from 'node:fs/promises';
19
+ import { existsSync, realpathSync } from 'node:fs';
20
+ import { createRequire } from 'node:module';
21
+ import { dirname, resolve } from 'node:path';
22
+ import { fileURLToPath, pathToFileURL } from 'node:url';
23
+ import { createInterface } from 'node:readline';
24
+ import { openVault, defaultVaultPath } from "./store.js";
25
+ import { envLinesFor, maskSecret, primarySecret } from "./env-export.js";
26
+ function packageVersion() {
27
+ try {
28
+ const require = createRequire(import.meta.url);
29
+ const pkg = require('../package.json');
30
+ return pkg.version ?? '0.0.0';
31
+ }
32
+ catch {
33
+ return '0.0.0';
34
+ }
35
+ }
36
+ const USAGE = `dsh-vault — encrypted credential vault, from the command line
37
+
38
+ Usage:
39
+ dsh-vault <command> [options]
40
+
41
+ Commands:
42
+ list List entries (titles, ids, kinds — never secrets)
43
+ get <id|title> Print one field of an entry (default: its main secret)
44
+ show <id|title> Print an entry's non-secret fields as JSON
45
+ env Print env-tagged entries as KEY=VALUE lines
46
+ export-env <path> Write those lines to a file (mode 0600)
47
+ verify Check the master password (nothing on stdout)
48
+
49
+ Options:
50
+ --vault <name> Named vault (default: "default")
51
+ --path <file> Explicit vault file (overrides --vault)
52
+ --password-stdin Read the master password from the first stdin line
53
+ --json Machine-readable output
54
+ -h, --help This help
55
+ -V, --version Version
56
+
57
+ list options:
58
+ --kind <k> Only entries of this kind
59
+ --tag <t> Only entries carrying this tag
60
+
61
+ get options:
62
+ --field <name> apiKey | secret | accessToken | refreshToken | privateKey
63
+ | password | cardNumber | username | url | fields.<custom>
64
+ --mask Print a masked value (first 4 chars + ***)
65
+ --all Print the whole entry as JSON (every secret — opt-in)
66
+
67
+ env options:
68
+ --prefix <P> Prefix for derived key names (an explicit envKey is kept verbatim)
69
+ --kind <k> Only entries of this kind
70
+ --keys-only Print key names without values
71
+ --mask Print masked values
72
+ --file <path> Write to a file instead of stdout
73
+
74
+ The master password comes from --password-stdin, then $DSH_VAULT_MASTER_PASSWORD
75
+ (or $DSH_VAULT_PASSWORD), then an interactive prompt. It is never read from the
76
+ plugin's own config file.
77
+ `;
78
+ const BOOLEAN_FLAGS = new Set(['json', 'mask', 'keys-only', 'all', 'password-stdin', 'help', 'version']);
79
+ function parseArgs(argv) {
80
+ const flags = new Map();
81
+ const positional = [];
82
+ let command = '';
83
+ for (let i = 0; i < argv.length; i++) {
84
+ const arg = argv[i];
85
+ if (arg === '-h' || arg === '--help') {
86
+ flags.set('help', true);
87
+ continue;
88
+ }
89
+ if (arg === '-V' || arg === '--version') {
90
+ flags.set('version', true);
91
+ continue;
92
+ }
93
+ if (arg.startsWith('--')) {
94
+ const eq = arg.indexOf('=');
95
+ const name = eq >= 0 ? arg.slice(2, eq) : arg.slice(2);
96
+ if (eq >= 0) {
97
+ flags.set(name, arg.slice(eq + 1));
98
+ continue;
99
+ }
100
+ const next = argv[i + 1];
101
+ if (BOOLEAN_FLAGS.has(name) || next === undefined || next.startsWith('-')) {
102
+ flags.set(name, true);
103
+ continue;
104
+ }
105
+ flags.set(name, next);
106
+ i++;
107
+ continue;
108
+ }
109
+ if (command === '')
110
+ command = arg;
111
+ else
112
+ positional.push(arg);
113
+ }
114
+ return { command, positional, flags };
115
+ }
116
+ function stringFlag(parsed, name) {
117
+ const value = parsed.flags.get(name);
118
+ return typeof value === 'string' ? value : undefined;
119
+ }
120
+ /**
121
+ * Vault file selection, mirroring how the plugin resolves `$DSH_HOME/vault`.
122
+ *
123
+ * The injected environment wins over `process.env` so a caller (or a test) can
124
+ * point the CLI at another harness home without mutating the real one.
125
+ */
126
+ export function resolveCliVaultPath(parsed, env) {
127
+ const explicit = stringFlag(parsed, 'path');
128
+ if (explicit !== undefined)
129
+ return resolve(explicit);
130
+ const name = stringFlag(parsed, 'vault') ?? 'default';
131
+ const home = env.DSH_HOME;
132
+ if (home !== undefined && home.length > 0)
133
+ return resolve(home, 'vault', `${name}.json`);
134
+ return defaultVaultPath(name);
135
+ }
136
+ /** Interactive prompt with echo off, read from the terminal rather than stdin. */
137
+ async function promptHidden(prompt) {
138
+ const { open } = await import('node:fs/promises');
139
+ let handle;
140
+ try {
141
+ handle = await open('/dev/tty', 'r+');
142
+ }
143
+ catch {
144
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
145
+ const answer = await new Promise(res => rl.question(prompt, res));
146
+ rl.close();
147
+ return answer;
148
+ }
149
+ const reader = handle.createReadStream();
150
+ const writer = handle.createWriteStream();
151
+ writer.write(prompt);
152
+ reader.setEncoding('utf8');
153
+ return await new Promise((res, rej) => {
154
+ let value = '';
155
+ const finish = (result) => {
156
+ reader.destroy();
157
+ void handle?.close();
158
+ res(result);
159
+ };
160
+ reader.on('data', (chunk) => {
161
+ for (const ch of String(chunk)) {
162
+ if (ch === '\n' || ch === '\r') {
163
+ writer.write('\n');
164
+ finish(value);
165
+ return;
166
+ }
167
+ if (ch === '\u0003') {
168
+ reader.destroy();
169
+ void handle?.close();
170
+ rej(new Error('aborted'));
171
+ return;
172
+ }
173
+ if (ch === '\u007f' || ch === '\b') {
174
+ value = value.slice(0, -1);
175
+ continue;
176
+ }
177
+ value += ch;
178
+ }
179
+ });
180
+ reader.on('error', rej);
181
+ });
182
+ }
183
+ async function readStdinLine() {
184
+ const rl = createInterface({ input: process.stdin });
185
+ const line = await new Promise(res => rl.once('line', res));
186
+ rl.close();
187
+ return line;
188
+ }
189
+ /** Master password: --password-stdin, then the environment, then a prompt. */
190
+ async function resolvePassword(parsed, io) {
191
+ if (parsed.flags.get('password-stdin') === true) {
192
+ const read = io.readStdinLine ?? readStdinLine;
193
+ const line = (await read()).replace(/\r?\n$/, '');
194
+ if (line.length === 0)
195
+ throw new Error('no password on stdin');
196
+ return line;
197
+ }
198
+ for (const key of ['DSH_VAULT_MASTER_PASSWORD', 'DSH_VAULT_PASSWORD']) {
199
+ const value = io.env[key];
200
+ if (value !== undefined && value.length > 0)
201
+ return value;
202
+ }
203
+ const prompt = io.readPassword ?? (process.stdin.isTTY ? promptHidden : undefined);
204
+ if (prompt === undefined) {
205
+ throw new Error('no master password: pass --password-stdin or set DSH_VAULT_MASTER_PASSWORD');
206
+ }
207
+ const typed = await prompt('Vault master password: ');
208
+ if (typed.length === 0)
209
+ throw new Error('empty master password');
210
+ return typed;
211
+ }
212
+ function findEntry(store, needle) {
213
+ const entries = store.list();
214
+ const byId = entries.find(e => e.id === needle);
215
+ if (byId !== undefined)
216
+ return byId;
217
+ const exact = entries.filter(e => e.title === needle);
218
+ if (exact.length === 1)
219
+ return exact[0];
220
+ const partial = exact.length > 0 ? exact : entries.filter(e => e.title.toLowerCase().includes(needle.toLowerCase()));
221
+ if (partial.length === 0)
222
+ throw new Error(`no entry matches "${needle}"`);
223
+ if (partial.length > 1) {
224
+ throw new Error(`"${needle}" is ambiguous (${partial.map(e => e.title).join(', ')}) — use the id`);
225
+ }
226
+ return partial[0];
227
+ }
228
+ /** Look one field up, supporting `fields.<name>` for custom fields. */
229
+ function fieldValue(entry, name) {
230
+ if (!name.startsWith('fields.'))
231
+ return entry[name];
232
+ return (entry.fields ?? {})[name.slice('fields.'.length)];
233
+ }
234
+ /** Non-secret projection of an entry, for `list --json` / `show`. */
235
+ function publicFields(entry) {
236
+ const { id, title, kind, username, email, phone, host, port, url, tags, icon, color, sensitivity, favorite, createdAt, updatedAt, envKey, rotationDays, expiresAt, cardExpiry, cardHolder } = entry;
237
+ const optional = {
238
+ kind, username, email, phone, host, port, url, tags, icon, color, sensitivity,
239
+ favorite, envKey, rotationDays, expiresAt, cardExpiry, cardHolder, createdAt, updatedAt,
240
+ };
241
+ const out = { id, title };
242
+ for (const [key, value] of Object.entries(optional)) {
243
+ if (value !== undefined)
244
+ out[key] = value;
245
+ }
246
+ out.hasSecret = primarySecret(entry) !== undefined;
247
+ return out;
248
+ }
249
+ const COMMANDS = new Set(['list', 'get', 'show', 'env', 'export-env', 'verify']);
250
+ /** Run one CLI invocation; returns the process exit code. */
251
+ export async function runCli(argv, io) {
252
+ const parsed = parseArgs(argv);
253
+ if (parsed.flags.get('version') === true) {
254
+ io.out(`${packageVersion()}\n`);
255
+ return 0;
256
+ }
257
+ if (parsed.flags.get('help') === true || parsed.command === 'help') {
258
+ io.out(USAGE);
259
+ return 0;
260
+ }
261
+ if (parsed.command === '') {
262
+ io.err(USAGE);
263
+ return 2;
264
+ }
265
+ if (!COMMANDS.has(parsed.command)) {
266
+ io.err(`dsh-vault: unknown command "${parsed.command}"\n\n${USAGE}`);
267
+ return 2;
268
+ }
269
+ const json = parsed.flags.get('json') === true;
270
+ const mask = parsed.flags.get('mask') === true;
271
+ const keysOnly = parsed.flags.get('keys-only') === true;
272
+ const path = resolveCliVaultPath(parsed, io.env);
273
+ if (!existsSync(path)) {
274
+ io.err(`dsh-vault: vault not found at ${path}\n`);
275
+ io.err('hint: --vault <name> selects a named vault; set DSH_HOME if the vault lives elsewhere.\n');
276
+ return 1;
277
+ }
278
+ let store;
279
+ try {
280
+ store = await openVault({ path, masterPassword: await resolvePassword(parsed, io) });
281
+ }
282
+ catch (err) {
283
+ io.err(`dsh-vault: ${err instanceof Error ? err.message : String(err)}\n`);
284
+ return 1;
285
+ }
286
+ try {
287
+ if (parsed.command === 'verify') {
288
+ // Nothing on stdout, so `dsh-vault verify && …` is usable in scripts.
289
+ io.err(`dsh-vault: master password ok (${store.list().length} entries)\n`);
290
+ return 0;
291
+ }
292
+ if (parsed.command === 'list') {
293
+ const kind = stringFlag(parsed, 'kind');
294
+ const tag = stringFlag(parsed, 'tag');
295
+ const entries = store.list().filter(entry => (kind === undefined || (entry.kind ?? 'login') === kind)
296
+ && (tag === undefined || (entry.tags ?? []).includes(tag)));
297
+ if (json) {
298
+ io.out(`${JSON.stringify(entries.map(publicFields), null, 2)}\n`);
299
+ return 0;
300
+ }
301
+ for (const entry of entries) {
302
+ const identity = [entry.username, entry.host, entry.url]
303
+ .filter((v) => typeof v === 'string' && v.length > 0).join(' · ');
304
+ io.out(`${entry.id} ${(entry.kind ?? 'login').padEnd(8)} ${entry.title}${identity.length > 0 ? ` (${identity})` : ''}\n`);
305
+ }
306
+ return 0;
307
+ }
308
+ if (parsed.command === 'show') {
309
+ const needle = parsed.positional[0];
310
+ if (needle === undefined) {
311
+ io.err('dsh-vault: show needs an entry id or title\n');
312
+ return 2;
313
+ }
314
+ io.out(`${JSON.stringify(publicFields(findEntry(store, needle)), null, 2)}\n`);
315
+ return 0;
316
+ }
317
+ if (parsed.command === 'get') {
318
+ const needle = parsed.positional[0];
319
+ if (needle === undefined) {
320
+ io.err('dsh-vault: get needs an entry id or title\n');
321
+ return 2;
322
+ }
323
+ const entry = findEntry(store, needle);
324
+ if (parsed.flags.get('all') === true) {
325
+ io.out(`${JSON.stringify(entry, null, 2)}\n`);
326
+ return 0;
327
+ }
328
+ let name = stringFlag(parsed, 'field');
329
+ let value;
330
+ if (name === undefined) {
331
+ const primary = primarySecret(entry);
332
+ if (primary === undefined) {
333
+ io.err(`dsh-vault: "${entry.title}" holds no secret\n`);
334
+ return 1;
335
+ }
336
+ name = primary.field;
337
+ value = primary.value;
338
+ }
339
+ else {
340
+ value = fieldValue(entry, name);
341
+ }
342
+ if (typeof value !== 'string' || value.length === 0) {
343
+ io.err(`dsh-vault: "${entry.title}" has no field "${name}"\n`);
344
+ return 1;
345
+ }
346
+ const textual = name === 'username' || name === 'url' || name === 'notes' || name.startsWith('fields.');
347
+ const shown = mask && !textual ? maskSecret(value) : value;
348
+ io.out(json ? `${JSON.stringify({ id: entry.id, title: entry.title, field: name, value: shown })}\n` : `${shown}\n`);
349
+ return 0;
350
+ }
351
+ // env / export-env
352
+ const prefix = stringFlag(parsed, 'prefix') ?? '';
353
+ const kind = stringFlag(parsed, 'kind');
354
+ const lines = envLinesFor(store.list(), { prefix, ...(kind !== undefined ? { kind } : {}) });
355
+ const rendered = lines.map(line => {
356
+ if (keysOnly)
357
+ return line.split('=')[0] ?? line;
358
+ if (!mask)
359
+ return line;
360
+ const eq = line.indexOf('=');
361
+ return `${line.slice(0, eq)}=${maskSecret(line.slice(eq + 1).replace(/^'|'$/g, ''))}`;
362
+ });
363
+ const target = parsed.command === 'export-env' ? parsed.positional[0] : stringFlag(parsed, 'file');
364
+ if (parsed.command === 'export-env' && target === undefined) {
365
+ io.err('dsh-vault: export-env needs a file path\n');
366
+ return 2;
367
+ }
368
+ if (target !== undefined) {
369
+ const body = rendered.join('\n') + (rendered.length > 0 ? '\n' : '');
370
+ const file = resolve(target);
371
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
372
+ await writeFile(file, body, { mode: 0o600 });
373
+ await chmod(file, 0o600);
374
+ io.err(keysOnly
375
+ ? `dsh-vault: wrote ${rendered.length} key names to ${file} (no values — --keys-only)\n`
376
+ : `dsh-vault: wrote ${rendered.length} lines to ${file} (mode 0600)\n`);
377
+ return 0;
378
+ }
379
+ io.out(rendered.join('\n') + (rendered.length > 0 ? '\n' : ''));
380
+ if (rendered.length === 0)
381
+ io.err('dsh-vault: no env-tagged entries (add the tag "env" to an entry)\n');
382
+ return 0;
383
+ }
384
+ catch (err) {
385
+ io.err(`dsh-vault: ${err instanceof Error ? err.message : String(err)}\n`);
386
+ return 1;
387
+ }
388
+ finally {
389
+ await store.lock().catch(() => { });
390
+ }
391
+ }
392
+ /**
393
+ * Whether this module is the process entry point (the bin shim).
394
+ *
395
+ * `import.meta.url` is the REAL path, while argv[1] may be a symlink (a profile
396
+ * installs `node_modules/dsh-vault` as one) and may contain characters that need
397
+ * URL encoding (a space in the checkout path). Compare realpaths instead of
398
+ * string-building a file URL, which silently failed in both cases.
399
+ */
400
+ function isEntryPoint() {
401
+ const entry = process.argv[1];
402
+ if (entry === undefined)
403
+ return false;
404
+ try {
405
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
406
+ }
407
+ catch {
408
+ return import.meta.url === pathToFileURL(entry).href;
409
+ }
410
+ }
411
+ if (isEntryPoint()) {
412
+ void runCli(process.argv.slice(2), {
413
+ out: text => process.stdout.write(text),
414
+ err: text => process.stderr.write(text),
415
+ env: process.env,
416
+ }).then(code => { process.exitCode = code; });
417
+ }