@nightlybuildgroup/vault 1.2.0 → 1.4.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 CHANGED
@@ -32,6 +32,12 @@ nbg-pw get "GitHub" --field username # a built-in field
32
32
  nbg-pw get "GitHub" --custom apiToken # a custom field, looked up by name
33
33
  nbg-pw get "My Visa" --field number # a credit-card field (see below)
34
34
  nbg-pw list "My Visa" # show an item's field names + types, no values
35
+ printf '%s' "$pw" | nbg-pw add "GitHub" --username octocat --password-stdin
36
+ printf '%s' "$pw" | nbg-pw edit "GitHub" --password-stdin # update an existing item
37
+ nbg-pw attach "GitHub" --file ./key.pem # add an attachment; --list/--get/--delete too
38
+ nbg-pw delete "GitHub" # soft-delete to trash (alias: rm; --permanent skips it)
39
+ nbg-pw items --search git # list item names; also: folders, collections
40
+ nbg-pw generate --length 24 --symbols # generate a password (no vault needed)
35
41
  nbg-pw serve --port 8087 # local bw REST API for fast repeated reads
36
42
  nbg-pw status # presence + auth state (no secrets)
37
43
  nbg-pw doctor # diagnose bw / Keychain / connectivity
@@ -39,6 +45,125 @@ nbg-pw logout # lock the session
39
45
  nbg-pw reset # log out and delete stored credentials
40
46
  ```
41
47
 
48
+ ### Adding items (`add`)
49
+
50
+ Creates a **login** item. The password never goes on the command line (it would
51
+ be visible in `ps`) — it's read from stdin, or supplied via `--json`.
52
+
53
+ ```sh
54
+ # Flag mode — password piped on stdin:
55
+ printf '%s' "$pw" | nbg-pw add "GitHub" \
56
+ --username octocat \
57
+ --url https://github.com \
58
+ --notes "work account" \
59
+ --folder "Agents" \
60
+ --field plan=pro \
61
+ --field-hidden token="$tok" \
62
+ --password-stdin
63
+ ```
64
+
65
+ Options: `--username`, `--url` (repeatable), `--notes`, `--folder` (resolved by
66
+ name, **created if missing**), `--collection` / `--organization` (shares the item
67
+ into an org collection), `--field name=value` (text, repeatable),
68
+ `--field-hidden name=value` (hidden, repeatable), `--totp`. On success it prints
69
+ `Created "<name>" (<id>)` — never any secret value.
70
+
71
+ > Note: in flag mode, `--field-hidden` values still pass through argv. For
72
+ > secret custom fields use `--json` (below), which keeps **everything** off the
73
+ > command line.
74
+
75
+ #### JSON mode — secret-safe bulk automation
76
+
77
+ `nbg-pw add --json` reads one entry as a JSON object on stdin, so every value
78
+ (password, hidden fields, notes) stays off argv:
79
+
80
+ ```sh
81
+ jq -n --arg name GitHub --arg u octocat --arg p "$pw" --arg t "$tok" \
82
+ '{name:$name, username:$u, password:$p, uris:["https://github.com"],
83
+ folder:"Agents", fields:[{name:"token", value:$t, type:1}]}' \
84
+ | nbg-pw add --json
85
+ ```
86
+
87
+ Accepted keys: `name` (required), `username`, `password`, `uris` (array),
88
+ `notes`, `totp`, `folder`, `collection`, `organization`, and `fields`
89
+ (`[{name, value, type}]`; type `0` = text, `1` = hidden).
90
+
91
+ #### Migrating a KeePass `.kdbx` to Bitwarden
92
+
93
+ Loop your KeePass entries and pipe each into `add --json` — secrets flow through
94
+ stdin only:
95
+
96
+ ```sh
97
+ DB=~/Documents/Agents.kdbx
98
+ for path in $(keepassxc-cli ls -R -f "$DB"); do
99
+ [ "${path%/}" != "$path" ] && continue # skip groups
100
+ user=$(keepassxc-cli show -q -a UserName "$DB" "$path")
101
+ pass=$(keepassxc-cli show -q -a Password "$DB" "$path")
102
+ url=$( keepassxc-cli show -q -a URL "$DB" "$path")
103
+ group=$(dirname "$path")
104
+ jq -n --arg n "$(basename "$path")" --arg u "$user" --arg p "$pass" \
105
+ --arg url "$url" --arg f "$group" \
106
+ '{name:$n, username:$u, password:$p,
107
+ uris:(if $url=="" then [] else [$url] end),
108
+ folder:(if $f=="." then null else $f end)}' \
109
+ | nbg-pw add --json
110
+ done
111
+ ```
112
+
113
+ ### Editing items (`edit`)
114
+
115
+ Fetch-merge-write: only the flags you pass are changed; everything else stays.
116
+ Same secret discipline as `add` — the password comes from stdin.
117
+
118
+ ```sh
119
+ printf '%s' "$newpw" | nbg-pw edit "GitHub" --password-stdin
120
+ nbg-pw edit "GitHub" --notes "rotated 2026-06" --field plan=enterprise
121
+ nbg-pw edit "GitHub" --folder "Agents" # move into a folder (created if missing)
122
+ ```
123
+
124
+ Flags mirror `add`: `--username`, `--url` (repeatable, **replaces** the URI list),
125
+ `--notes`, `--totp`, `--field`/`--field-hidden` (upsert by name), `--folder`,
126
+ `--password-stdin`.
127
+
128
+ ### Deleting (`delete` / `rm`)
129
+
130
+ ```sh
131
+ nbg-pw delete "GitHub" # soft-delete to trash
132
+ nbg-pw delete "GitHub" --permanent # skip the trash
133
+ nbg-pw delete --folder "Agents" # delete a folder
134
+ ```
135
+
136
+ ### Attachments (`attach`)
137
+
138
+ ```sh
139
+ nbg-pw attach "GitHub" --file ./deploy-key.pem # upload
140
+ nbg-pw attach "GitHub" --list # id, filename, size (no contents)
141
+ nbg-pw attach "GitHub" --get deploy-key.pem --output ./key.pem # download
142
+ nbg-pw attach "GitHub" --delete <attachment-id> # remove
143
+ ```
144
+
145
+ ### Browsing the vault (`items`, `folders`, `collections`)
146
+
147
+ Names only, never values — handy for scripting and for finding the exact item
148
+ name to pass to `get`/`edit`/`attach`.
149
+
150
+ ```sh
151
+ nbg-pw items # every item name
152
+ nbg-pw items --search git # filtered by search
153
+ nbg-pw items --folder "Agents" # filtered by folder
154
+ nbg-pw folders # folder names
155
+ nbg-pw collections # collection names
156
+ ```
157
+
158
+ ### Generating secrets (`generate`)
159
+
160
+ Pure generator — needs no vault session.
161
+
162
+ ```sh
163
+ nbg-pw generate --length 24 --uppercase --number --symbols
164
+ nbg-pw generate --passphrase --words 5 --separator - --capitalize
165
+ ```
166
+
42
167
  ### Credit-card items
43
168
 
44
169
  Credit cards have no username or password — just card data. `bw` has no native
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightlybuildgroup/vault",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Store Vaultwarden/Bitwarden credentials in the macOS Keychain and read secrets back for local agents.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/bw.js CHANGED
@@ -102,6 +102,130 @@ export async function getCardField({ item, field, session }, deps = {}) {
102
102
  return String(value);
103
103
  }
104
104
 
105
+ // `bw create`/`bw move` read the object as base64 JSON. We pass it on stdin
106
+ // (never argv), so secret values in the payload stay off the process list.
107
+ function encode(obj) {
108
+ return Buffer.from(JSON.stringify(obj)).toString('base64');
109
+ }
110
+
111
+ export async function createItem({ item, session }, deps = {}) {
112
+ const run = deps.run ?? realRun;
113
+ const { stdout } = await run('bw', ['create', 'item'], {
114
+ env: { BW_SESSION: session },
115
+ input: encode(item),
116
+ });
117
+ return JSON.parse(stdout);
118
+ }
119
+
120
+ export async function listFolders({ session }, deps = {}) {
121
+ const run = deps.run ?? realRun;
122
+ const { stdout } = await run('bw', ['list', 'folders'], { env: { BW_SESSION: session } });
123
+ return JSON.parse(stdout);
124
+ }
125
+
126
+ export async function createFolder({ name, session }, deps = {}) {
127
+ const run = deps.run ?? realRun;
128
+ const { stdout } = await run('bw', ['create', 'folder'], {
129
+ env: { BW_SESSION: session },
130
+ input: encode({ name }),
131
+ });
132
+ return JSON.parse(stdout);
133
+ }
134
+
135
+ export async function listCollections({ session }, deps = {}) {
136
+ const run = deps.run ?? realRun;
137
+ const { stdout } = await run('bw', ['list', 'collections'], { env: { BW_SESSION: session } });
138
+ return JSON.parse(stdout);
139
+ }
140
+
141
+ export async function listOrganizations({ session }, deps = {}) {
142
+ const run = deps.run ?? realRun;
143
+ const { stdout } = await run('bw', ['list', 'organizations'], { env: { BW_SESSION: session } });
144
+ return JSON.parse(stdout);
145
+ }
146
+
147
+ // Share an existing item into an organization's collection(s). This is bw's only
148
+ // path to put an item in a collection; the item must already exist.
149
+ export async function moveItemToCollection({ itemId, organizationId, collectionIds, session }, deps = {}) {
150
+ const run = deps.run ?? realRun;
151
+ await run('bw', ['move', itemId, organizationId], {
152
+ env: { BW_SESSION: session },
153
+ input: encode(collectionIds),
154
+ });
155
+ }
156
+
157
+ export async function editItem({ id, item, session }, deps = {}) {
158
+ const run = deps.run ?? realRun;
159
+ const { stdout } = await run('bw', ['edit', 'item', id], {
160
+ env: { BW_SESSION: session },
161
+ input: encode(item),
162
+ });
163
+ return JSON.parse(stdout);
164
+ }
165
+
166
+ export async function deleteItem({ id, permanent, session }, deps = {}) {
167
+ const run = deps.run ?? realRun;
168
+ const args = ['delete', 'item', id];
169
+ if (permanent) args.push('--permanent');
170
+ await run('bw', args, { env: { BW_SESSION: session } });
171
+ }
172
+
173
+ export async function deleteFolder({ id, session }, deps = {}) {
174
+ const run = deps.run ?? realRun;
175
+ await run('bw', ['delete', 'folder', id], { env: { BW_SESSION: session } });
176
+ }
177
+
178
+ export async function deleteAttachment({ id, itemId, session }, deps = {}) {
179
+ const run = deps.run ?? realRun;
180
+ await run('bw', ['delete', 'attachment', id, '--itemid', itemId], { env: { BW_SESSION: session } });
181
+ }
182
+
183
+ export async function listItems({ search, folderId, collectionId, session }, deps = {}) {
184
+ const run = deps.run ?? realRun;
185
+ const args = ['list', 'items'];
186
+ if (search) args.push('--search', search);
187
+ if (folderId) args.push('--folderid', folderId);
188
+ if (collectionId) args.push('--collectionid', collectionId);
189
+ const { stdout } = await run('bw', args, { env: { BW_SESSION: session } });
190
+ return JSON.parse(stdout);
191
+ }
192
+
193
+ // `bw generate` needs no vault session — it's a pure generator.
194
+ export async function generate(opts = {}, deps = {}) {
195
+ const run = deps.run ?? realRun;
196
+ const args = ['generate'];
197
+ if (opts.passphrase) args.push('--passphrase');
198
+ if (opts.length != null) args.push('--length', String(opts.length));
199
+ if (opts.uppercase) args.push('--uppercase');
200
+ if (opts.lowercase) args.push('--lowercase');
201
+ if (opts.number) args.push('--number');
202
+ if (opts.special) args.push('--special');
203
+ if (opts.words != null) args.push('--words', String(opts.words));
204
+ if (opts.separator != null) args.push('--separator', opts.separator);
205
+ if (opts.capitalize) args.push('--capitalize');
206
+ const { stdout } = await run('bw', args);
207
+ return stdout;
208
+ }
209
+
210
+ export async function createAttachment({ itemId, file, session }, deps = {}) {
211
+ const run = deps.run ?? realRun;
212
+ const { stdout } = await run('bw', ['create', 'attachment', '--file', file, '--itemid', itemId], {
213
+ env: { BW_SESSION: session },
214
+ });
215
+ return JSON.parse(stdout);
216
+ }
217
+
218
+ // Downloads to `output` (a file path) or, with `raw`, returns the content. bw
219
+ // writes the file itself; we return whatever it prints (e.g. "Saved <path>").
220
+ export async function getAttachment({ itemId, attachment, output, raw, session }, deps = {}) {
221
+ const run = deps.run ?? realRun;
222
+ const args = ['get', 'attachment', attachment, '--itemid', itemId];
223
+ if (output) args.push('--output', output);
224
+ if (raw) args.push('--raw');
225
+ const { stdout } = await run('bw', args, { env: { BW_SESSION: session }, trim: false });
226
+ return stdout;
227
+ }
228
+
105
229
  export async function status(deps = {}) {
106
230
  const run = deps.run ?? realRun;
107
231
  const { stdout } = await run('bw', ['status']);
package/src/cli.js CHANGED
@@ -3,14 +3,22 @@ const HELP = `nbg-pw — Vaultwarden credential helper for macOS
3
3
  Usage: nbg-pw <command> [options]
4
4
 
5
5
  Commands:
6
- setup Interactive wizard: store your Vaultwarden credentials in the Keychain
7
- get Fetch a secret value (e.g. nbg-pw get "GitHub" --field password)
8
- list Show an item's field names + types, no values (nbg-pw list "My Visa")
9
- serve Start a local bw API daemon (unlocked) for fast repeated reads
10
- status Show config + auth state (no secret values)
11
- doctor Diagnose bw / Keychain / server connectivity (--fix resets a wedged bw session)
12
- logout Lock the session (bw logout)
13
- reset Log out and delete the stored Keychain item
6
+ setup Interactive wizard: store your Vaultwarden credentials in the Keychain
7
+ get Fetch a secret value (e.g. nbg-pw get "GitHub" --field password)
8
+ add Create a login item (flags or --json on stdin; for kdbx bw imports)
9
+ edit Update fields on an existing item (nbg-pw edit "GitHub" --notes ...)
10
+ delete Delete an item or folder (alias: rm; --permanent skips trash)
11
+ attach Manage an item's attachments (--file/--list/--get/--delete)
12
+ list Show an item's field names + types, no values (nbg-pw list "My Visa")
13
+ items List item names (--search, --folder, --collection)
14
+ folders List folder names
15
+ collections List collection names
16
+ generate Generate a password or passphrase (bw generate)
17
+ serve Start a local bw API daemon (unlocked) for fast repeated reads
18
+ status Show config + auth state (no secret values)
19
+ doctor Diagnose bw / Keychain / server connectivity (--fix resets a wedged bw session)
20
+ logout Lock the session (bw logout)
21
+ reset Log out and delete the stored Keychain item
14
22
  `;
15
23
 
16
24
  export async function runCli(argv, deps = {}) {
@@ -46,19 +54,37 @@ export async function runCli(argv, deps = {}) {
46
54
  }
47
55
 
48
56
  async function loadCommands() {
49
- const [setup, get, list, status, doctor, serve, reset] = await Promise.all([
50
- import('./commands/setup.js'),
51
- import('./commands/get.js'),
52
- import('./commands/list.js'),
53
- import('./commands/status.js'),
54
- import('./commands/doctor.js'),
55
- import('./commands/serve.js'),
56
- import('./commands/reset.js'),
57
- ]);
57
+ const [setup, get, add, edit, del, attach, list, items, folders, collections, generate, status, doctor, serve, reset] =
58
+ await Promise.all([
59
+ import('./commands/setup.js'),
60
+ import('./commands/get.js'),
61
+ import('./commands/add.js'),
62
+ import('./commands/edit.js'),
63
+ import('./commands/delete.js'),
64
+ import('./commands/attach.js'),
65
+ import('./commands/list.js'),
66
+ import('./commands/items.js'),
67
+ import('./commands/folders.js'),
68
+ import('./commands/collections.js'),
69
+ import('./commands/generate.js'),
70
+ import('./commands/status.js'),
71
+ import('./commands/doctor.js'),
72
+ import('./commands/serve.js'),
73
+ import('./commands/reset.js'),
74
+ ]);
58
75
  return {
59
76
  setup: setup.default,
60
77
  get: get.default,
78
+ add: add.default,
79
+ edit: edit.default,
80
+ delete: del.default,
81
+ rm: del.default,
82
+ attach: attach.default,
61
83
  list: list.default,
84
+ items: items.default,
85
+ folders: folders.default,
86
+ collections: collections.default,
87
+ generate: generate.default,
62
88
  status: status.default,
63
89
  doctor: doctor.default,
64
90
  serve: serve.default,
@@ -0,0 +1,163 @@
1
+ import * as bwModule from '../bw.js';
2
+ import * as keychainModule from '../keychain.js';
3
+
4
+ const USAGE =
5
+ 'usage: nbg-pw add <name> [--username <u>] [--url <uri>]... [--notes <n>] ' +
6
+ '[--folder <name>] [--collection <name>] [--organization <name|id>] ' +
7
+ '[--field <name>=<value>]... [--field-hidden <name>=<value>]... [--totp <secret>] ' +
8
+ '[--password-stdin]\n or: nbg-pw add --json (reads one entry as a JSON object on stdin)';
9
+
10
+ // `--field name=value` → { name, value, type }. Splits on the FIRST `=` so the
11
+ // value may itself contain `=`.
12
+ function parseField(raw, type) {
13
+ const eq = raw.indexOf('=');
14
+ if (eq === -1) throw new Error(`--field expects name=value, got "${raw}"`);
15
+ return { name: raw.slice(0, eq), value: raw.slice(eq + 1), type };
16
+ }
17
+
18
+ export function parseAddArgs(args) {
19
+ const opts = { uris: [], fields: [], passwordStdin: false, json: false };
20
+ let name = null;
21
+ for (let i = 0; i < args.length; i++) {
22
+ const a = args[i];
23
+ const need = () => {
24
+ const v = args[++i];
25
+ if (v === undefined) throw new Error(`${a} requires a value`);
26
+ return v;
27
+ };
28
+ switch (a) {
29
+ case '--json': opts.json = true; break;
30
+ case '--password-stdin': opts.passwordStdin = true; break;
31
+ case '--username': opts.username = need(); break;
32
+ case '--url': opts.uris.push(need()); break;
33
+ case '--notes': opts.notes = need(); break;
34
+ case '--folder': opts.folder = need(); break;
35
+ case '--collection': opts.collection = need(); break;
36
+ case '--organization': opts.organization = need(); break;
37
+ case '--totp': opts.totp = need(); break;
38
+ case '--field': opts.fields.push(parseField(need(), 0)); break;
39
+ case '--field-hidden': opts.fields.push(parseField(need(), 1)); break;
40
+ default:
41
+ if (a.startsWith('--')) throw new Error(`unknown option "${a}"\n${USAGE}`);
42
+ if (name === null) name = a;
43
+ else throw new Error(`unexpected argument "${a}"\n${USAGE}`);
44
+ }
45
+ }
46
+ if (!opts.json && name === null) throw new Error(USAGE);
47
+ opts.name = name;
48
+ return opts;
49
+ }
50
+
51
+ // Turns a normalized entry into the Bitwarden login-item JSON shape.
52
+ export function buildLoginItem(entry) {
53
+ const item = {
54
+ type: 1,
55
+ name: entry.name,
56
+ notes: entry.notes ?? null,
57
+ login: {
58
+ username: entry.username ?? null,
59
+ password: entry.password ?? null,
60
+ uris: (entry.uris ?? []).filter(Boolean).map((uri) => ({ match: null, uri })),
61
+ totp: entry.totp ?? null,
62
+ },
63
+ fields: (entry.fields ?? []).map((f) => ({ name: f.name, value: f.value ?? '', type: f.type ?? 0 })),
64
+ };
65
+ if (entry.folderId) item.folderId = entry.folderId;
66
+ return item;
67
+ }
68
+
69
+ async function resolveFolderId(bw, session, folderName) {
70
+ const folders = await bw.listFolders({ session });
71
+ const found = folders.find((f) => f.name === folderName && f.id);
72
+ if (found) return found.id;
73
+ const created = await bw.createFolder({ name: folderName, session });
74
+ return created.id;
75
+ }
76
+
77
+ async function resolveCollection(bw, session, collectionName, org) {
78
+ const collections = await bw.listCollections({ session });
79
+ let matches = collections.filter((c) => c.name === collectionName);
80
+ if (org) {
81
+ const orgs = await bw.listOrganizations({ session });
82
+ const match = orgs.find((o) => o.id === org || o.name === org);
83
+ const orgId = match ? match.id : org;
84
+ matches = matches.filter((c) => c.organizationId === orgId);
85
+ }
86
+ if (matches.length === 0) {
87
+ throw new Error(`no collection named "${collectionName}"${org ? ` in organization "${org}"` : ''}`);
88
+ }
89
+ if (matches.length > 1) {
90
+ throw new Error(`multiple collections named "${collectionName}"; disambiguate with --organization <name|id>`);
91
+ }
92
+ return matches[0];
93
+ }
94
+
95
+ export async function runAdd(args, deps) {
96
+ const { keychain, bw, out, err, readStdin } = deps;
97
+ const opts = parseAddArgs(args);
98
+
99
+ const config = await keychain.readConfig();
100
+ if (!config) {
101
+ err('No stored credentials. Run "nbg-pw setup" first.\n');
102
+ return 1;
103
+ }
104
+
105
+ let entry;
106
+ if (opts.json) {
107
+ const parsed = JSON.parse(await readStdin());
108
+ if (!parsed.name) throw new Error('json entry requires a "name"');
109
+ entry = parsed;
110
+ } else {
111
+ entry = {
112
+ name: opts.name,
113
+ username: opts.username,
114
+ uris: opts.uris,
115
+ notes: opts.notes,
116
+ totp: opts.totp,
117
+ fields: opts.fields,
118
+ folder: opts.folder,
119
+ collection: opts.collection,
120
+ organization: opts.organization,
121
+ };
122
+ if (opts.passwordStdin) entry.password = (await readStdin()).replace(/\r?\n$/, '');
123
+ }
124
+
125
+ const session = await bw.ensureSession(config);
126
+
127
+ if (entry.folder) entry.folderId = await resolveFolderId(bw, session, entry.folder);
128
+
129
+ const created = await bw.createItem({ item: buildLoginItem(entry), session });
130
+
131
+ if (entry.collection) {
132
+ const col = await resolveCollection(bw, session, entry.collection, entry.organization);
133
+ await bw.moveItemToCollection({
134
+ itemId: created.id,
135
+ organizationId: col.organizationId,
136
+ collectionIds: [col.id],
137
+ session,
138
+ });
139
+ }
140
+
141
+ out(`Created "${created.name}" (${created.id})\n`);
142
+ return 0;
143
+ }
144
+
145
+ function readStdin() {
146
+ return new Promise((resolve, reject) => {
147
+ let data = '';
148
+ process.stdin.setEncoding('utf8');
149
+ process.stdin.on('data', (chunk) => { data += chunk; });
150
+ process.stdin.on('end', () => resolve(data));
151
+ process.stdin.on('error', reject);
152
+ });
153
+ }
154
+
155
+ export default async function add(args) {
156
+ return runAdd(args, {
157
+ keychain: keychainModule,
158
+ bw: bwModule,
159
+ readStdin,
160
+ out: (s) => process.stdout.write(s),
161
+ err: (s) => process.stderr.write(s),
162
+ });
163
+ }
@@ -0,0 +1,92 @@
1
+ import path from 'node:path';
2
+ import * as bwModule from '../bw.js';
3
+ import * as keychainModule from '../keychain.js';
4
+
5
+ const USAGE =
6
+ 'usage: nbg-pw attach <item> [--file <path> | --list | --get <id|name> [--output <path>] [--raw] | --delete <id>]';
7
+
8
+ export function parseAttachArgs(args) {
9
+ let item = null;
10
+ let file = null;
11
+ let list = false;
12
+ let get = null;
13
+ let output = null;
14
+ let raw = false;
15
+ let del = null;
16
+ for (let i = 0; i < args.length; i++) {
17
+ const a = args[i];
18
+ const need = () => {
19
+ const v = args[++i];
20
+ if (v === undefined) throw new Error(`${a} requires a value`);
21
+ return v;
22
+ };
23
+ switch (a) {
24
+ case '--file': file = need(); break;
25
+ case '--list': list = true; break;
26
+ case '--get': get = need(); break;
27
+ case '--output': output = need(); break;
28
+ case '--raw': raw = true; break;
29
+ case '--delete': del = need(); break;
30
+ default:
31
+ if (a.startsWith('--')) throw new Error(`unknown option "${a}"\n${USAGE}`);
32
+ if (item === null) item = a;
33
+ }
34
+ }
35
+ if (!item) throw new Error(USAGE);
36
+ const actions = [file !== null, get !== null, del !== null].filter(Boolean).length;
37
+ if (actions > 1) throw new Error('choose one of --file, --get, or --delete');
38
+ return { item, file, list, get, output, raw, delete: del };
39
+ }
40
+
41
+ export async function runAttach(args, deps) {
42
+ const { keychain, bw, out, err } = deps;
43
+ const { item, file, get, output, raw, delete: del } = parseAttachArgs(args);
44
+
45
+ const config = await keychain.readConfig();
46
+ if (!config) {
47
+ err('No stored credentials. Run "nbg-pw setup" first.\n');
48
+ return 1;
49
+ }
50
+
51
+ const session = await bw.ensureSession(config);
52
+ const resolved = await bw.getItemJson({ item, session });
53
+ const itemId = resolved.id;
54
+
55
+ if (file !== null) {
56
+ await bw.createAttachment({ itemId, file, session });
57
+ out(`Attached "${path.basename(file)}" to "${item}"\n`);
58
+ return 0;
59
+ }
60
+
61
+ if (get !== null) {
62
+ const result = await bw.getAttachment({ itemId, attachment: get, output, raw, session });
63
+ out(result.endsWith('\n') ? result : result + '\n');
64
+ return 0;
65
+ }
66
+
67
+ if (del !== null) {
68
+ await bw.deleteAttachment({ id: del, itemId, session });
69
+ out(`Deleted attachment ${del} from "${item}"\n`);
70
+ return 0;
71
+ }
72
+
73
+ // Default action: list the item's attachments.
74
+ const attachments = resolved.attachments ?? [];
75
+ if (attachments.length === 0) {
76
+ out('(no attachments)\n');
77
+ return 0;
78
+ }
79
+ for (const a of attachments) {
80
+ out(`${a.id} ${a.fileName} (${a.sizeName ?? a.size})\n`);
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ export default async function attach(args) {
86
+ return runAttach(args, {
87
+ keychain: keychainModule,
88
+ bw: bwModule,
89
+ out: (s) => process.stdout.write(s),
90
+ err: (s) => process.stderr.write(s),
91
+ });
92
+ }
@@ -0,0 +1,35 @@
1
+ import * as bwModule from '../bw.js';
2
+ import * as keychainModule from '../keychain.js';
3
+
4
+ export function parseCollectionsArgs(args) {
5
+ if (args.length > 0) throw new Error(`unexpected argument: ${args[0]}`);
6
+ return {};
7
+ }
8
+
9
+ export async function runCollections(args, deps) {
10
+ const { keychain, bw, out, err } = deps;
11
+ parseCollectionsArgs(args);
12
+
13
+ const config = await keychain.readConfig();
14
+ if (!config) {
15
+ err('No stored credentials. Run "nbg-pw setup" first.\n');
16
+ return 1;
17
+ }
18
+
19
+ const session = await bw.ensureSession(config);
20
+ const collections = await bw.listCollections({ session });
21
+ const names = collections
22
+ .map((c) => c.name)
23
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
24
+ for (const name of names) out(name + '\n');
25
+ return 0;
26
+ }
27
+
28
+ export default async function collections(args) {
29
+ return runCollections(args, {
30
+ keychain: keychainModule,
31
+ bw: bwModule,
32
+ out: (s) => process.stdout.write(s),
33
+ err: (s) => process.stderr.write(s),
34
+ });
35
+ }
@@ -0,0 +1,69 @@
1
+ import * as bwModule from '../bw.js';
2
+ import * as keychainModule from '../keychain.js';
3
+
4
+ const USAGE = 'usage: nbg-pw delete <item> [--permanent]\n or: nbg-pw delete --folder <name>';
5
+
6
+ export function parseDeleteArgs(args) {
7
+ let item = null;
8
+ let folder = null;
9
+ let permanent = false;
10
+ for (let i = 0; i < args.length; i++) {
11
+ const a = args[i];
12
+ const need = () => {
13
+ const v = args[++i];
14
+ if (v === undefined) throw new Error(`${a} requires a value`);
15
+ return v;
16
+ };
17
+ switch (a) {
18
+ case '--permanent': permanent = true; break;
19
+ case '--folder': folder = need(); break;
20
+ default:
21
+ if (a.startsWith('--')) throw new Error(`unknown option "${a}"\n${USAGE}`);
22
+ if (item === null) item = a;
23
+ else throw new Error(`unexpected argument "${a}"\n${USAGE}`);
24
+ }
25
+ }
26
+ if (item !== null && folder !== null) {
27
+ throw new Error(`pass either <item> or --folder, not both\n${USAGE}`);
28
+ }
29
+ if (item === null && folder === null) {
30
+ throw new Error(USAGE);
31
+ }
32
+ return { item, folder, permanent };
33
+ }
34
+
35
+ export async function runDelete(args, deps) {
36
+ const { keychain, bw, out, err } = deps;
37
+ const opts = parseDeleteArgs(args);
38
+
39
+ const config = await keychain.readConfig();
40
+ if (!config) {
41
+ err('No stored credentials. Run "nbg-pw setup" first.\n');
42
+ return 1;
43
+ }
44
+
45
+ const session = await bw.ensureSession(config);
46
+
47
+ if (opts.folder !== null) {
48
+ const folders = await bw.listFolders({ session });
49
+ const found = folders.find((f) => f.name === opts.folder && f.id);
50
+ if (!found) throw new Error(`no folder named "${opts.folder}"`);
51
+ await bw.deleteFolder({ id: found.id, session });
52
+ out(`Deleted folder "${opts.folder}"\n`);
53
+ return 0;
54
+ }
55
+
56
+ const resolved = await bw.getItemJson({ item: opts.item, session });
57
+ await bw.deleteItem({ id: resolved.id, permanent: opts.permanent, session });
58
+ out(`Deleted "${opts.item}"\n`);
59
+ return 0;
60
+ }
61
+
62
+ export default async function del(args) {
63
+ return runDelete(args, {
64
+ keychain: keychainModule,
65
+ bw: bwModule,
66
+ out: (s) => process.stdout.write(s),
67
+ err: (s) => process.stderr.write(s),
68
+ });
69
+ }
@@ -0,0 +1,111 @@
1
+ import * as bwModule from '../bw.js';
2
+ import * as keychainModule from '../keychain.js';
3
+
4
+ const USAGE =
5
+ 'usage: nbg-pw edit <item> [--username <u>] [--password-stdin] [--url <uri>]... ' +
6
+ '[--notes <n>] [--totp <secret>] [--field <name>=<value>]... ' +
7
+ '[--field-hidden <name>=<value>]... [--folder <name>]';
8
+
9
+ // `--field name=value` → { name, value, type }. Splits on the FIRST `=` so the
10
+ // value may itself contain `=`.
11
+ function parseField(raw, type) {
12
+ const eq = raw.indexOf('=');
13
+ if (eq === -1) throw new Error(`--field expects name=value, got "${raw}"`);
14
+ return { name: raw.slice(0, eq), value: raw.slice(eq + 1), type };
15
+ }
16
+
17
+ export function parseEditArgs(args) {
18
+ const opts = { uris: [], fields: [], passwordStdin: false };
19
+ let item = null;
20
+ for (let i = 0; i < args.length; i++) {
21
+ const a = args[i];
22
+ const need = () => {
23
+ const v = args[++i];
24
+ if (v === undefined) throw new Error(`${a} requires a value`);
25
+ return v;
26
+ };
27
+ switch (a) {
28
+ case '--password-stdin': opts.passwordStdin = true; break;
29
+ case '--username': opts.username = need(); break;
30
+ case '--url': opts.uris.push(need()); break;
31
+ case '--notes': opts.notes = need(); break;
32
+ case '--totp': opts.totp = need(); break;
33
+ case '--field': opts.fields.push(parseField(need(), 0)); break;
34
+ case '--field-hidden': opts.fields.push(parseField(need(), 1)); break;
35
+ case '--folder': opts.folder = need(); break;
36
+ default:
37
+ if (a.startsWith('--')) throw new Error(`unknown option "${a}"\n${USAGE}`);
38
+ if (item === null) item = a;
39
+ else throw new Error(`unexpected argument "${a}"\n${USAGE}`);
40
+ }
41
+ }
42
+ if (item === null) throw new Error(USAGE);
43
+ opts.item = item;
44
+ return opts;
45
+ }
46
+
47
+ // Upsert a custom field by name onto item.fields: update in place if present,
48
+ // otherwise append.
49
+ function upsertField(item, { name, value, type }) {
50
+ const existing = item.fields.find((f) => f.name === name);
51
+ if (existing) existing.value = value;
52
+ else item.fields.push({ name, value, type });
53
+ }
54
+
55
+ async function resolveFolderId(bw, session, folderName) {
56
+ const folders = await bw.listFolders({ session });
57
+ const found = folders.find((f) => f.name === folderName && f.id);
58
+ if (found) return found.id;
59
+ const created = await bw.createFolder({ name: folderName, session });
60
+ return created.id;
61
+ }
62
+
63
+ export async function runEdit(args, deps) {
64
+ const { keychain, bw, out, err, readStdin } = deps;
65
+ const opts = parseEditArgs(args);
66
+
67
+ const config = await keychain.readConfig();
68
+ if (!config) {
69
+ err('No stored credentials. Run "nbg-pw setup" first.\n');
70
+ return 1;
71
+ }
72
+
73
+ const session = await bw.ensureSession(config);
74
+
75
+ const item = await bw.getItemJson({ item: opts.item, session });
76
+ item.login ??= {};
77
+ item.fields ??= [];
78
+
79
+ if (opts.username !== undefined) item.login.username = opts.username;
80
+ if (opts.passwordStdin) item.login.password = (await readStdin()).replace(/\r?\n$/, '');
81
+ if (opts.uris.length > 0) item.login.uris = opts.uris.map((uri) => ({ match: null, uri }));
82
+ if (opts.notes !== undefined) item.notes = opts.notes;
83
+ if (opts.totp !== undefined) item.login.totp = opts.totp;
84
+ for (const field of opts.fields) upsertField(item, field);
85
+ if (opts.folder !== undefined) item.folderId = await resolveFolderId(bw, session, opts.folder);
86
+
87
+ const updated = await bw.editItem({ id: item.id, item, session });
88
+
89
+ out(`Updated "${updated.name}" (${updated.id})\n`);
90
+ return 0;
91
+ }
92
+
93
+ function readStdin() {
94
+ return new Promise((resolve, reject) => {
95
+ let data = '';
96
+ process.stdin.setEncoding('utf8');
97
+ process.stdin.on('data', (chunk) => { data += chunk; });
98
+ process.stdin.on('end', () => resolve(data));
99
+ process.stdin.on('error', reject);
100
+ });
101
+ }
102
+
103
+ export default async function edit(args) {
104
+ return runEdit(args, {
105
+ keychain: keychainModule,
106
+ bw: bwModule,
107
+ readStdin,
108
+ out: (s) => process.stdout.write(s),
109
+ err: (s) => process.stderr.write(s),
110
+ });
111
+ }
@@ -0,0 +1,35 @@
1
+ import * as bwModule from '../bw.js';
2
+ import * as keychainModule from '../keychain.js';
3
+
4
+ export function parseFoldersArgs(args) {
5
+ if (args.length > 0) throw new Error(`unexpected argument: ${args[0]}`);
6
+ return {};
7
+ }
8
+
9
+ export async function runFolders(args, deps) {
10
+ const { keychain, bw, out, err } = deps;
11
+ parseFoldersArgs(args);
12
+
13
+ const config = await keychain.readConfig();
14
+ if (!config) {
15
+ err('No stored credentials. Run "nbg-pw setup" first.\n');
16
+ return 1;
17
+ }
18
+
19
+ const session = await bw.ensureSession(config);
20
+ const folders = await bw.listFolders({ session });
21
+ const names = folders
22
+ .map((f) => f.name)
23
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
24
+ for (const name of names) out(name + '\n');
25
+ return 0;
26
+ }
27
+
28
+ export default async function folders(args) {
29
+ return runFolders(args, {
30
+ keychain: keychainModule,
31
+ bw: bwModule,
32
+ out: (s) => process.stdout.write(s),
33
+ err: (s) => process.stderr.write(s),
34
+ });
35
+ }
@@ -0,0 +1,51 @@
1
+ import * as bwModule from '../bw.js';
2
+
3
+ export function parseGenerateArgs(args) {
4
+ const opts = {};
5
+ for (let i = 0; i < args.length; i++) {
6
+ const arg = args[i];
7
+ if (arg === '--length') {
8
+ const v = args[++i];
9
+ if (v === undefined) throw new Error('--length requires a value');
10
+ const n = Number(v);
11
+ if (Number.isNaN(n)) throw new Error('--length must be a number');
12
+ opts.length = n;
13
+ }
14
+ else if (arg === '--uppercase' || arg === '-u') { opts.uppercase = true; }
15
+ else if (arg === '--lowercase') { opts.lowercase = true; }
16
+ else if (arg === '--number' || arg === '-n') { opts.number = true; }
17
+ else if (arg === '--symbols' || arg === '--special' || arg === '-s') { opts.special = true; }
18
+ else if (arg === '--passphrase' || arg === '-p') { opts.passphrase = true; }
19
+ else if (arg === '--words') {
20
+ const v = args[++i];
21
+ if (v === undefined) throw new Error('--words requires a value');
22
+ const n = Number(v);
23
+ if (Number.isNaN(n)) throw new Error('--words must be a number');
24
+ opts.words = n;
25
+ }
26
+ else if (arg === '--separator') {
27
+ const v = args[++i];
28
+ if (v === undefined) throw new Error('--separator requires a value');
29
+ opts.separator = v;
30
+ }
31
+ else if (arg === '--capitalize' || arg === '-c') { opts.capitalize = true; }
32
+ else if (arg.startsWith('-')) { throw new Error(`unknown option "${arg}"`); }
33
+ else { throw new Error(`unexpected argument "${arg}"`); }
34
+ }
35
+ return opts;
36
+ }
37
+
38
+ export async function runGenerate(args, deps) {
39
+ const { bw, out } = deps;
40
+ const opts = parseGenerateArgs(args);
41
+ const value = await bw.generate(opts);
42
+ out(value + '\n');
43
+ return 0;
44
+ }
45
+
46
+ export default async function generate(args) {
47
+ return runGenerate(args, {
48
+ bw: bwModule,
49
+ out: (s) => process.stdout.write(s),
50
+ });
51
+ }
@@ -0,0 +1,76 @@
1
+ import * as bwModule from '../bw.js';
2
+ import * as keychainModule from '../keychain.js';
3
+
4
+ export function parseItemsArgs(args) {
5
+ let search = null;
6
+ let folder = null;
7
+ let collection = null;
8
+ for (let i = 0; i < args.length; i++) {
9
+ if (args[i] === '--search') {
10
+ const v = args[++i];
11
+ if (v === undefined) throw new Error('--search requires a value');
12
+ search = v;
13
+ } else if (args[i] === '--folder') {
14
+ const v = args[++i];
15
+ if (v === undefined) throw new Error('--folder requires a value');
16
+ folder = v;
17
+ } else if (args[i] === '--collection') {
18
+ const v = args[++i];
19
+ if (v === undefined) throw new Error('--collection requires a value');
20
+ collection = v;
21
+ } else {
22
+ throw new Error(`unknown option: ${args[i]}`);
23
+ }
24
+ }
25
+ return { search, folder, collection };
26
+ }
27
+
28
+ export async function runItems(args, deps) {
29
+ const { keychain, bw, out, err } = deps;
30
+ const { search, folder, collection } = parseItemsArgs(args);
31
+
32
+ const config = await keychain.readConfig();
33
+ if (!config) {
34
+ err('No stored credentials. Run "nbg-pw setup" first.\n');
35
+ return 1;
36
+ }
37
+
38
+ const session = await bw.ensureSession(config);
39
+
40
+ let folderId;
41
+ if (folder !== null) {
42
+ const folders = await bw.listFolders({ session });
43
+ const match = folders.find((f) => f.name === folder && f.id);
44
+ if (!match) throw new Error(`no folder named "${folder}"`);
45
+ folderId = match.id;
46
+ }
47
+
48
+ let collectionId;
49
+ if (collection !== null) {
50
+ const collections = await bw.listCollections({ session });
51
+ const match = collections.find((c) => c.name === collection);
52
+ if (!match) throw new Error(`no collection named "${collection}"`);
53
+ collectionId = match.id;
54
+ }
55
+
56
+ const filters = { session };
57
+ if (search !== null) filters.search = search;
58
+ if (folderId !== undefined) filters.folderId = folderId;
59
+ if (collectionId !== undefined) filters.collectionId = collectionId;
60
+
61
+ const items = await bw.listItems(filters);
62
+ const names = items
63
+ .map((i) => i.name)
64
+ .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
65
+ for (const name of names) out(name + '\n');
66
+ return 0;
67
+ }
68
+
69
+ export default async function items(args) {
70
+ return runItems(args, {
71
+ keychain: keychainModule,
72
+ bw: bwModule,
73
+ out: (s) => process.stdout.write(s),
74
+ err: (s) => process.stderr.write(s),
75
+ });
76
+ }
package/src/exec.js CHANGED
@@ -5,6 +5,12 @@ export function run(cmd, args, opts = {}) {
5
5
  const doTrim = opts.trim !== false;
6
6
  return new Promise((resolve, reject) => {
7
7
  const child = spawn(cmd, args, { env });
8
+ // `opts.input` is written to the child's stdin (used to pass base64-encoded
9
+ // JSON to `bw create`/`bw move` without exposing secrets on the command line).
10
+ if (opts.input != null) {
11
+ child.stdin.on('error', () => {}); // ignore EPIPE if the child closes stdin early
12
+ child.stdin.end(opts.input);
13
+ }
8
14
  let stdout = '';
9
15
  let stderr = '';
10
16
  child.stdout.on('data', (d) => { stdout += d; });