@nightlybuildgroup/vault 1.2.0 → 1.3.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,7 @@ 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
35
36
  nbg-pw serve --port 8087 # local bw REST API for fast repeated reads
36
37
  nbg-pw status # presence + auth state (no secrets)
37
38
  nbg-pw doctor # diagnose bw / Keychain / connectivity
@@ -39,6 +40,71 @@ nbg-pw logout # lock the session
39
40
  nbg-pw reset # log out and delete stored credentials
40
41
  ```
41
42
 
43
+ ### Adding items (`add`)
44
+
45
+ Creates a **login** item. The password never goes on the command line (it would
46
+ be visible in `ps`) — it's read from stdin, or supplied via `--json`.
47
+
48
+ ```sh
49
+ # Flag mode — password piped on stdin:
50
+ printf '%s' "$pw" | nbg-pw add "GitHub" \
51
+ --username octocat \
52
+ --url https://github.com \
53
+ --notes "work account" \
54
+ --folder "Agents" \
55
+ --field plan=pro \
56
+ --field-hidden token="$tok" \
57
+ --password-stdin
58
+ ```
59
+
60
+ Options: `--username`, `--url` (repeatable), `--notes`, `--folder` (resolved by
61
+ name, **created if missing**), `--collection` / `--organization` (shares the item
62
+ into an org collection), `--field name=value` (text, repeatable),
63
+ `--field-hidden name=value` (hidden, repeatable), `--totp`. On success it prints
64
+ `Created "<name>" (<id>)` — never any secret value.
65
+
66
+ > Note: in flag mode, `--field-hidden` values still pass through argv. For
67
+ > secret custom fields use `--json` (below), which keeps **everything** off the
68
+ > command line.
69
+
70
+ #### JSON mode — secret-safe bulk automation
71
+
72
+ `nbg-pw add --json` reads one entry as a JSON object on stdin, so every value
73
+ (password, hidden fields, notes) stays off argv:
74
+
75
+ ```sh
76
+ jq -n --arg name GitHub --arg u octocat --arg p "$pw" --arg t "$tok" \
77
+ '{name:$name, username:$u, password:$p, uris:["https://github.com"],
78
+ folder:"Agents", fields:[{name:"token", value:$t, type:1}]}' \
79
+ | nbg-pw add --json
80
+ ```
81
+
82
+ Accepted keys: `name` (required), `username`, `password`, `uris` (array),
83
+ `notes`, `totp`, `folder`, `collection`, `organization`, and `fields`
84
+ (`[{name, value, type}]`; type `0` = text, `1` = hidden).
85
+
86
+ #### Migrating a KeePass `.kdbx` to Bitwarden
87
+
88
+ Loop your KeePass entries and pipe each into `add --json` — secrets flow through
89
+ stdin only:
90
+
91
+ ```sh
92
+ DB=~/Documents/Agents.kdbx
93
+ for path in $(keepassxc-cli ls -R -f "$DB"); do
94
+ [ "${path%/}" != "$path" ] && continue # skip groups
95
+ user=$(keepassxc-cli show -q -a UserName "$DB" "$path")
96
+ pass=$(keepassxc-cli show -q -a Password "$DB" "$path")
97
+ url=$( keepassxc-cli show -q -a URL "$DB" "$path")
98
+ group=$(dirname "$path")
99
+ jq -n --arg n "$(basename "$path")" --arg u "$user" --arg p "$pass" \
100
+ --arg url "$url" --arg f "$group" \
101
+ '{name:$n, username:$u, password:$p,
102
+ uris:(if $url=="" then [] else [$url] end),
103
+ folder:(if $f=="." then null else $f end)}' \
104
+ | nbg-pw add --json
105
+ done
106
+ ```
107
+
42
108
  ### Credit-card items
43
109
 
44
110
  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.3.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,58 @@ 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
+
105
157
  export async function status(deps = {}) {
106
158
  const run = deps.run ?? realRun;
107
159
  const { stdout } = await run('bw', ['status']);
package/src/cli.js CHANGED
@@ -5,6 +5,7 @@ Usage: nbg-pw <command> [options]
5
5
  Commands:
6
6
  setup Interactive wizard: store your Vaultwarden credentials in the Keychain
7
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)
8
9
  list Show an item's field names + types, no values (nbg-pw list "My Visa")
9
10
  serve Start a local bw API daemon (unlocked) for fast repeated reads
10
11
  status Show config + auth state (no secret values)
@@ -46,9 +47,10 @@ export async function runCli(argv, deps = {}) {
46
47
  }
47
48
 
48
49
  async function loadCommands() {
49
- const [setup, get, list, status, doctor, serve, reset] = await Promise.all([
50
+ const [setup, get, add, list, status, doctor, serve, reset] = await Promise.all([
50
51
  import('./commands/setup.js'),
51
52
  import('./commands/get.js'),
53
+ import('./commands/add.js'),
52
54
  import('./commands/list.js'),
53
55
  import('./commands/status.js'),
54
56
  import('./commands/doctor.js'),
@@ -58,6 +60,7 @@ async function loadCommands() {
58
60
  return {
59
61
  setup: setup.default,
60
62
  get: get.default,
63
+ add: add.default,
61
64
  list: list.default,
62
65
  status: status.default,
63
66
  doctor: doctor.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
+ }
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; });