@nightlybuildgroup/vault 1.3.0 → 1.5.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
@@ -33,6 +33,12 @@ 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
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)
41
+ nbg-pw config set collection "Shared" # new items share into this org collection
36
42
  nbg-pw serve --port 8087 # local bw REST API for fast repeated reads
37
43
  nbg-pw status # presence + auth state (no secrets)
38
44
  nbg-pw doctor # diagnose bw / Keychain / connectivity
@@ -105,6 +111,87 @@ for path in $(keepassxc-cli ls -R -f "$DB"); do
105
111
  done
106
112
  ```
107
113
 
114
+ ### Sharing new items with an organization (`config`)
115
+
116
+ By default `add` creates items in the API account's **personal** vault, which
117
+ other org members (and your own interactive login) can't see. To make every new
118
+ item land in a shared **organization collection** instead, set a default — it's
119
+ stored in your Keychain, never in this repo, so nothing org-specific is baked
120
+ into the tool:
121
+
122
+ ```sh
123
+ nbg-pw config set organization "Acme Inc" # by name or id
124
+ nbg-pw config set collection "Shared" # by name or id
125
+ nbg-pw config show # current defaults
126
+ nbg-pw config unset collection # back to personal-vault default
127
+ ```
128
+
129
+ Once a default collection is set, every `add` shares the new item into it:
130
+
131
+ ```sh
132
+ printf '%s' "$pw" | nbg-pw add "GitHub" --username octocat --password-stdin
133
+ # → Created "GitHub" (…) → shared to "Shared"
134
+
135
+ nbg-pw add "Personal Note" --personal # opt a single item out (personal vault)
136
+ ```
137
+
138
+ An explicit `--collection`/`--organization` on `add` overrides the default for
139
+ that call. `status` shows the active default share.
140
+
141
+ ### Editing items (`edit`)
142
+
143
+ Fetch-merge-write: only the flags you pass are changed; everything else stays.
144
+ Same secret discipline as `add` — the password comes from stdin.
145
+
146
+ ```sh
147
+ printf '%s' "$newpw" | nbg-pw edit "GitHub" --password-stdin
148
+ nbg-pw edit "GitHub" --notes "rotated 2026-06" --field plan=enterprise
149
+ nbg-pw edit "GitHub" --folder "Agents" # move into a folder (created if missing)
150
+ ```
151
+
152
+ Flags mirror `add`: `--username`, `--url` (repeatable, **replaces** the URI list),
153
+ `--notes`, `--totp`, `--field`/`--field-hidden` (upsert by name), `--folder`,
154
+ `--password-stdin`.
155
+
156
+ ### Deleting (`delete` / `rm`)
157
+
158
+ ```sh
159
+ nbg-pw delete "GitHub" # soft-delete to trash
160
+ nbg-pw delete "GitHub" --permanent # skip the trash
161
+ nbg-pw delete --folder "Agents" # delete a folder
162
+ ```
163
+
164
+ ### Attachments (`attach`)
165
+
166
+ ```sh
167
+ nbg-pw attach "GitHub" --file ./deploy-key.pem # upload
168
+ nbg-pw attach "GitHub" --list # id, filename, size (no contents)
169
+ nbg-pw attach "GitHub" --get deploy-key.pem --output ./key.pem # download
170
+ nbg-pw attach "GitHub" --delete <attachment-id> # remove
171
+ ```
172
+
173
+ ### Browsing the vault (`items`, `folders`, `collections`)
174
+
175
+ Names only, never values — handy for scripting and for finding the exact item
176
+ name to pass to `get`/`edit`/`attach`.
177
+
178
+ ```sh
179
+ nbg-pw items # every item name
180
+ nbg-pw items --search git # filtered by search
181
+ nbg-pw items --folder "Agents" # filtered by folder
182
+ nbg-pw folders # folder names
183
+ nbg-pw collections # collection names
184
+ ```
185
+
186
+ ### Generating secrets (`generate`)
187
+
188
+ Pure generator — needs no vault session.
189
+
190
+ ```sh
191
+ nbg-pw generate --length 24 --uppercase --number --symbols
192
+ nbg-pw generate --passphrase --words 5 --separator - --capitalize
193
+ ```
194
+
108
195
  ### Credit-card items
109
196
 
110
197
  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.3.0",
3
+ "version": "1.5.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
@@ -144,14 +144,101 @@ export async function listOrganizations({ session }, deps = {}) {
144
144
  return JSON.parse(stdout);
145
145
  }
146
146
 
147
+ export async function sync({ session }, deps = {}) {
148
+ const run = deps.run ?? realRun;
149
+ await run('bw', ['sync'], { env: { BW_SESSION: session } });
150
+ }
151
+
147
152
  // 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.
153
+ // path to put an item in a collection; the item must already exist. bw's local
154
+ // cache can lag a just-created item ("client copy ... out of date"); a sync +
155
+ // single retry clears that.
149
156
  export async function moveItemToCollection({ itemId, organizationId, collectionIds, session }, deps = {}) {
150
157
  const run = deps.run ?? realRun;
151
- await run('bw', ['move', itemId, organizationId], {
158
+ const move = () =>
159
+ run('bw', ['move', itemId, organizationId], { env: { BW_SESSION: session }, input: encode(collectionIds) });
160
+ try {
161
+ await move();
162
+ } catch (e) {
163
+ if (/out of date/i.test(e.message || '')) {
164
+ await run('bw', ['sync'], { env: { BW_SESSION: session } });
165
+ await move();
166
+ } else {
167
+ throw e;
168
+ }
169
+ }
170
+ }
171
+
172
+ export async function editItem({ id, item, session }, deps = {}) {
173
+ const run = deps.run ?? realRun;
174
+ const { stdout } = await run('bw', ['edit', 'item', id], {
175
+ env: { BW_SESSION: session },
176
+ input: encode(item),
177
+ });
178
+ return JSON.parse(stdout);
179
+ }
180
+
181
+ export async function deleteItem({ id, permanent, session }, deps = {}) {
182
+ const run = deps.run ?? realRun;
183
+ const args = ['delete', 'item', id];
184
+ if (permanent) args.push('--permanent');
185
+ await run('bw', args, { env: { BW_SESSION: session } });
186
+ }
187
+
188
+ export async function deleteFolder({ id, session }, deps = {}) {
189
+ const run = deps.run ?? realRun;
190
+ await run('bw', ['delete', 'folder', id], { env: { BW_SESSION: session } });
191
+ }
192
+
193
+ export async function deleteAttachment({ id, itemId, session }, deps = {}) {
194
+ const run = deps.run ?? realRun;
195
+ await run('bw', ['delete', 'attachment', id, '--itemid', itemId], { env: { BW_SESSION: session } });
196
+ }
197
+
198
+ export async function listItems({ search, folderId, collectionId, session }, deps = {}) {
199
+ const run = deps.run ?? realRun;
200
+ const args = ['list', 'items'];
201
+ if (search) args.push('--search', search);
202
+ if (folderId) args.push('--folderid', folderId);
203
+ if (collectionId) args.push('--collectionid', collectionId);
204
+ const { stdout } = await run('bw', args, { env: { BW_SESSION: session } });
205
+ return JSON.parse(stdout);
206
+ }
207
+
208
+ // `bw generate` needs no vault session — it's a pure generator.
209
+ export async function generate(opts = {}, deps = {}) {
210
+ const run = deps.run ?? realRun;
211
+ const args = ['generate'];
212
+ if (opts.passphrase) args.push('--passphrase');
213
+ if (opts.length != null) args.push('--length', String(opts.length));
214
+ if (opts.uppercase) args.push('--uppercase');
215
+ if (opts.lowercase) args.push('--lowercase');
216
+ if (opts.number) args.push('--number');
217
+ if (opts.special) args.push('--special');
218
+ if (opts.words != null) args.push('--words', String(opts.words));
219
+ if (opts.separator != null) args.push('--separator', opts.separator);
220
+ if (opts.capitalize) args.push('--capitalize');
221
+ const { stdout } = await run('bw', args);
222
+ return stdout;
223
+ }
224
+
225
+ export async function createAttachment({ itemId, file, session }, deps = {}) {
226
+ const run = deps.run ?? realRun;
227
+ const { stdout } = await run('bw', ['create', 'attachment', '--file', file, '--itemid', itemId], {
152
228
  env: { BW_SESSION: session },
153
- input: encode(collectionIds),
154
229
  });
230
+ return JSON.parse(stdout);
231
+ }
232
+
233
+ // Downloads to `output` (a file path) or, with `raw`, returns the content. bw
234
+ // writes the file itself; we return whatever it prints (e.g. "Saved <path>").
235
+ export async function getAttachment({ itemId, attachment, output, raw, session }, deps = {}) {
236
+ const run = deps.run ?? realRun;
237
+ const args = ['get', 'attachment', attachment, '--itemid', itemId];
238
+ if (output) args.push('--output', output);
239
+ if (raw) args.push('--raw');
240
+ const { stdout } = await run('bw', args, { env: { BW_SESSION: session }, trim: false });
241
+ return stdout;
155
242
  }
156
243
 
157
244
  export async function status(deps = {}) {
package/src/cli.js CHANGED
@@ -3,15 +3,23 @@ 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
- add Create a login item (flags or --json on stdin; for kdbx → bw imports)
9
- list Show an item's field names + types, no values (nbg-pw list "My Visa")
10
- serve Start a local bw API daemon (unlocked) for fast repeated reads
11
- status Show config + auth state (no secret values)
12
- doctor Diagnose bw / Keychain / server connectivity (--fix resets a wedged bw session)
13
- logout Lock the session (bw logout)
14
- 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
+ config Show/set defaults, e.g. the org collection new items share into
18
+ serve Start a local bw API daemon (unlocked) for fast repeated reads
19
+ status Show config + auth state (no secret values)
20
+ doctor Diagnose bw / Keychain / server connectivity (--fix resets a wedged bw session)
21
+ logout Lock the session (bw logout)
22
+ reset Log out and delete the stored Keychain item
15
23
  `;
16
24
 
17
25
  export async function runCli(argv, deps = {}) {
@@ -47,21 +55,39 @@ export async function runCli(argv, deps = {}) {
47
55
  }
48
56
 
49
57
  async function loadCommands() {
50
- const [setup, get, add, list, status, doctor, serve, reset] = await Promise.all([
51
- import('./commands/setup.js'),
52
- import('./commands/get.js'),
53
- import('./commands/add.js'),
54
- import('./commands/list.js'),
55
- import('./commands/status.js'),
56
- import('./commands/doctor.js'),
57
- import('./commands/serve.js'),
58
- import('./commands/reset.js'),
59
- ]);
58
+ const [setup, get, add, edit, del, attach, list, items, folders, collections, generate, config, status, doctor, serve, reset] =
59
+ await Promise.all([
60
+ import('./commands/setup.js'),
61
+ import('./commands/get.js'),
62
+ import('./commands/add.js'),
63
+ import('./commands/edit.js'),
64
+ import('./commands/delete.js'),
65
+ import('./commands/attach.js'),
66
+ import('./commands/list.js'),
67
+ import('./commands/items.js'),
68
+ import('./commands/folders.js'),
69
+ import('./commands/collections.js'),
70
+ import('./commands/generate.js'),
71
+ import('./commands/config.js'),
72
+ import('./commands/status.js'),
73
+ import('./commands/doctor.js'),
74
+ import('./commands/serve.js'),
75
+ import('./commands/reset.js'),
76
+ ]);
60
77
  return {
61
78
  setup: setup.default,
62
79
  get: get.default,
63
80
  add: add.default,
81
+ edit: edit.default,
82
+ delete: del.default,
83
+ rm: del.default,
84
+ attach: attach.default,
64
85
  list: list.default,
86
+ items: items.default,
87
+ folders: folders.default,
88
+ collections: collections.default,
89
+ generate: generate.default,
90
+ config: config.default,
65
91
  status: status.default,
66
92
  doctor: doctor.default,
67
93
  serve: serve.default,
@@ -5,7 +5,7 @@ const USAGE =
5
5
  'usage: nbg-pw add <name> [--username <u>] [--url <uri>]... [--notes <n>] ' +
6
6
  '[--folder <name>] [--collection <name>] [--organization <name|id>] ' +
7
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)';
8
+ '[--password-stdin] [--personal]\n or: nbg-pw add --json (reads one entry as a JSON object on stdin)';
9
9
 
10
10
  // `--field name=value` → { name, value, type }. Splits on the FIRST `=` so the
11
11
  // value may itself contain `=`.
@@ -16,7 +16,7 @@ function parseField(raw, type) {
16
16
  }
17
17
 
18
18
  export function parseAddArgs(args) {
19
- const opts = { uris: [], fields: [], passwordStdin: false, json: false };
19
+ const opts = { uris: [], fields: [], passwordStdin: false, json: false, personal: false };
20
20
  let name = null;
21
21
  for (let i = 0; i < args.length; i++) {
22
22
  const a = args[i];
@@ -27,6 +27,7 @@ export function parseAddArgs(args) {
27
27
  };
28
28
  switch (a) {
29
29
  case '--json': opts.json = true; break;
30
+ case '--personal': opts.personal = true; break;
30
31
  case '--password-stdin': opts.passwordStdin = true; break;
31
32
  case '--username': opts.username = need(); break;
32
33
  case '--url': opts.uris.push(need()); break;
@@ -122,23 +123,36 @@ export async function runAdd(args, deps) {
122
123
  if (opts.passwordStdin) entry.password = (await readStdin()).replace(/\r?\n$/, '');
123
124
  }
124
125
 
126
+ // Where the item lands: an explicit --collection wins; otherwise the
127
+ // configured default shares every new item into the org collection, unless
128
+ // --personal (or a json `personal:true`) opts out into the personal vault.
129
+ const personal = opts.personal || entry.personal;
130
+ let collectionName = entry.collection;
131
+ let organizationName = entry.organization;
132
+ if (!personal && !collectionName && config.defaultCollection) {
133
+ collectionName = config.defaultCollection;
134
+ organizationName = organizationName ?? config.defaultOrganization;
135
+ }
136
+
125
137
  const session = await bw.ensureSession(config);
126
138
 
127
139
  if (entry.folder) entry.folderId = await resolveFolderId(bw, session, entry.folder);
128
140
 
129
141
  const created = await bw.createItem({ item: buildLoginItem(entry), session });
130
142
 
131
- if (entry.collection) {
132
- const col = await resolveCollection(bw, session, entry.collection, entry.organization);
143
+ let shared = '';
144
+ if (collectionName) {
145
+ const col = await resolveCollection(bw, session, collectionName, organizationName);
133
146
  await bw.moveItemToCollection({
134
147
  itemId: created.id,
135
148
  organizationId: col.organizationId,
136
149
  collectionIds: [col.id],
137
150
  session,
138
151
  });
152
+ shared = ` → shared to "${col.name}"`;
139
153
  }
140
154
 
141
- out(`Created "${created.name}" (${created.id})\n`);
155
+ out(`Created "${created.name}" (${created.id})${shared}\n`);
142
156
  return 0;
143
157
  }
144
158
 
@@ -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,60 @@
1
+ import * as keychainModule from '../keychain.js';
2
+
3
+ // User-facing key → stored config field.
4
+ const KEYS = { organization: 'defaultOrganization', collection: 'defaultCollection' };
5
+
6
+ const USAGE = 'usage: nbg-pw config [show | set <organization|collection> <value> | unset <organization|collection>]';
7
+
8
+ export function parseConfigArgs(args) {
9
+ const [sub, ...rest] = args;
10
+ if (!sub || sub === 'show') return { action: 'show' };
11
+ if (sub === 'set') {
12
+ const [key, value] = rest;
13
+ if (!key || !(key in KEYS)) throw new Error(USAGE);
14
+ if (value === undefined) throw new Error('config set requires a value');
15
+ return { action: 'set', key, value };
16
+ }
17
+ if (sub === 'unset') {
18
+ const [key] = rest;
19
+ if (!key || !(key in KEYS)) throw new Error(USAGE);
20
+ return { action: 'unset', key };
21
+ }
22
+ throw new Error(`unknown config subcommand "${sub}"\n${USAGE}`);
23
+ }
24
+
25
+ export async function runConfig(args, deps) {
26
+ const { keychain, out, err } = deps;
27
+ const opts = parseConfigArgs(args);
28
+
29
+ const config = await keychain.readConfig();
30
+ if (!config) {
31
+ err('No stored credentials. Run "nbg-pw setup" first.\n');
32
+ return 1;
33
+ }
34
+
35
+ if (opts.action === 'show') {
36
+ out(`default organization: ${config.defaultOrganization ?? '(not set)'}\n`);
37
+ out(`default collection: ${config.defaultCollection ?? '(not set)'}\n`);
38
+ return 0;
39
+ }
40
+
41
+ const field = KEYS[opts.key];
42
+ if (opts.action === 'set') {
43
+ config[field] = opts.value;
44
+ await keychain.writeConfig(config);
45
+ out(`Set default ${opts.key} = ${opts.value}\n`);
46
+ } else {
47
+ delete config[field];
48
+ await keychain.writeConfig(config);
49
+ out(`Unset default ${opts.key}\n`);
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ export default async function config(args) {
55
+ return runConfig(args, {
56
+ keychain: keychainModule,
57
+ out: (s) => process.stdout.write(s),
58
+ err: (s) => process.stderr.write(s),
59
+ });
60
+ }
@@ -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
+ }
@@ -12,6 +12,10 @@ export async function runStatus(args, deps) {
12
12
  out(` stored creds: yes\n`);
13
13
  out(` server URL: ${config.serverUrl}\n`);
14
14
  out(` email: ${config.email}\n`);
15
+ if (config.defaultCollection) {
16
+ const org = config.defaultOrganization ? `${config.defaultOrganization} / ` : '';
17
+ out(` default share: ${org}${config.defaultCollection}\n`);
18
+ }
15
19
  } else {
16
20
  out(' stored creds: No credentials stored (run "nbg-pw setup")\n');
17
21
  }
package/src/config.js CHANGED
@@ -2,6 +2,8 @@ export const SERVICE = 'com.nightlybuild.vault';
2
2
  export const ACCOUNT = 'default';
3
3
  export const FIELDS = ['serverUrl', 'email', 'clientId', 'clientSecret', 'masterPassword'];
4
4
  export const READ_FIELDS = [...FIELDS, 'savedAt'];
5
+ // Optional: when set, `add` shares new items into this org collection by default.
6
+ export const OPTIONAL_FIELDS = ['defaultOrganization', 'defaultCollection'];
5
7
 
6
8
  export function isValidUrl(s) {
7
9
  if (typeof s !== 'string' || s.length === 0) return false;
@@ -26,6 +28,11 @@ export function validateConfig(obj) {
26
28
  }
27
29
  if (!isValidUrl(obj.serverUrl)) throw new Error('config serverUrl is not a valid URL');
28
30
  if (!isValidEmail(obj.email)) throw new Error('config email is not a valid email');
31
+ for (const f of OPTIONAL_FIELDS) {
32
+ if (obj[f] !== undefined && (typeof obj[f] !== 'string' || obj[f].length === 0)) {
33
+ throw new Error(`config field ${f} must be a non-empty string when set`);
34
+ }
35
+ }
29
36
  return obj;
30
37
  }
31
38