@nightlybuildgroup/vault 0.1.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 ADDED
@@ -0,0 +1,69 @@
1
+ # @nightlybuildgroup/vault (`nbg-pw`)
2
+
3
+ Store your Vaultwarden/Bitwarden credentials in the **macOS Keychain** with an
4
+ interactive wizard, then let local agents and scripts read secrets back through
5
+ the Bitwarden CLI. macOS only. Nothing is hardcoded — you supply the server URL
6
+ and credentials at setup time.
7
+
8
+ ## Requirements
9
+
10
+ - macOS
11
+ - Node.js >= 18
12
+ - Bitwarden CLI: `brew install bitwarden-cli`
13
+
14
+ ## Setup
15
+
16
+ ```sh
17
+ npx @nightlybuildgroup/vault setup
18
+ ```
19
+
20
+ You'll be asked for your server URL, email, API-key client id/secret, and master
21
+ password. Credentials are verified against the server before anything is saved.
22
+
23
+ > Get your API key from your Vaultwarden web vault: **Settings → Security →
24
+ > Keys → API Key** (`client_id` / `client_secret`).
25
+
26
+ ## Usage
27
+
28
+ ```sh
29
+ nbg-pw get "GitHub" # prints the password
30
+ nbg-pw get "GitHub" --field username # a built-in field
31
+ nbg-pw get "GitHub" --custom apiToken # a custom field, looked up by name
32
+ nbg-pw serve --port 8087 # local bw REST API for fast repeated reads
33
+ nbg-pw status # presence + auth state (no secrets)
34
+ nbg-pw doctor # diagnose bw / Keychain / connectivity
35
+ nbg-pw logout # lock the session
36
+ nbg-pw reset # log out and delete stored credentials
37
+ ```
38
+
39
+ ### Reading custom fields over `serve`
40
+
41
+ `serve` is a thin wrapper around Bitwarden's own `bw serve` REST API, which has
42
+ no route for a single custom field. Built-in fields are addressable directly:
43
+
44
+ ```sh
45
+ curl -s localhost:8087/object/username/GitHub | jq -r .data.data
46
+ ```
47
+
48
+ For a **custom** field, fetch the whole item and pluck it from `data.fields[]`:
49
+
50
+ ```sh
51
+ curl -s localhost:8087/object/item/GitHub \
52
+ | jq -r '.data.fields[] | select(.name=="apiToken").value'
53
+ ```
54
+
55
+ (The CLI `nbg-pw get "GitHub" --custom apiToken` does this lookup for you.)
56
+
57
+ ## Security notes
58
+
59
+ - Credentials live in one macOS Keychain item (`service: com.nightlybuild.vault`),
60
+ encrypted at rest by the Keychain.
61
+ - Secrets are passed to `bw` via environment variables, never command-line
62
+ arguments. `nbg-pw` never prints secret values in `status`, `doctor`, logs, or
63
+ errors.
64
+ - Vaultwarden does not support Bitwarden Secrets Manager / machine accounts, so
65
+ this uses the password-manager API-key + master-password flow.
66
+
67
+ ## License
68
+
69
+ MIT
package/bin/nbg-pw.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { runCli } from '../src/cli.js';
4
+
5
+ const pkg = JSON.parse(
6
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8')
7
+ );
8
+
9
+ const code = await runCli(process.argv.slice(2), { version: pkg.version });
10
+ process.exit(code);
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@nightlybuildgroup/vault",
3
+ "version": "0.1.0",
4
+ "description": "Store Vaultwarden/Bitwarden credentials in the macOS Keychain and read secrets back for local agents.",
5
+ "type": "module",
6
+ "bin": { "nbg-pw": "bin/nbg-pw.js" },
7
+ "files": ["bin", "src", "README.md"],
8
+ "engines": { "node": ">=18" },
9
+ "os": ["darwin"],
10
+ "scripts": {
11
+ "test": "node --test \"test/**/*.test.js\"",
12
+ "prepublishOnly": "npm test",
13
+ "release": "semantic-release"
14
+ },
15
+ "publishConfig": { "access": "public" },
16
+ "dependencies": { "@clack/prompts": "^0.7.0" },
17
+ "devDependencies": {
18
+ "@semantic-release/changelog": "^6.0.3",
19
+ "@semantic-release/commit-analyzer": "^13.0.1",
20
+ "@semantic-release/exec": "^7.1.0",
21
+ "@semantic-release/git": "^10.0.1",
22
+ "@semantic-release/github": "^11.0.6",
23
+ "@semantic-release/release-notes-generator": "^14.1.1",
24
+ "conventional-changelog-conventionalcommits": "^8.0.0",
25
+ "semantic-release": "^24.2.9"
26
+ },
27
+ "license": "MIT"
28
+ }
package/src/bw.js ADDED
@@ -0,0 +1,85 @@
1
+ import { run as realRun } from './exec.js';
2
+
3
+ function isAlreadyLoggedIn(msg) {
4
+ return /already logged in/i.test(msg);
5
+ }
6
+ function isNotLoggedIn(msg) {
7
+ return /not logged in/i.test(msg);
8
+ }
9
+
10
+ export async function bwVersion(deps = {}) {
11
+ const run = deps.run ?? realRun;
12
+ try {
13
+ const { stdout } = await run('bw', ['--version']);
14
+ return stdout;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export async function configServer(url, deps = {}) {
21
+ const run = deps.run ?? realRun;
22
+ await run('bw', ['config', 'server', url]);
23
+ }
24
+
25
+ export async function loginApiKey({ clientId, clientSecret }, deps = {}) {
26
+ const run = deps.run ?? realRun;
27
+ try {
28
+ await run('bw', ['login', '--apikey'], {
29
+ env: { BW_CLIENTID: clientId, BW_CLIENTSECRET: clientSecret },
30
+ });
31
+ } catch (e) {
32
+ if (isAlreadyLoggedIn(e.message)) return;
33
+ throw e;
34
+ }
35
+ }
36
+
37
+ export async function unlock({ masterPassword }, deps = {}) {
38
+ const run = deps.run ?? realRun;
39
+ const { stdout } = await run('bw', ['unlock', '--passwordenv', 'BW_PASSWORD', '--raw'], {
40
+ env: { BW_PASSWORD: masterPassword },
41
+ });
42
+ return stdout;
43
+ }
44
+
45
+ export async function getItem({ item, field, session }, deps = {}) {
46
+ const run = deps.run ?? realRun;
47
+ const { stdout } = await run('bw', ['get', field, item], {
48
+ env: { BW_SESSION: session },
49
+ trim: false,
50
+ });
51
+ return stdout.replace(/\n$/, '');
52
+ }
53
+
54
+ export async function getCustomField({ item, name, session }, deps = {}) {
55
+ const run = deps.run ?? realRun;
56
+ const { stdout } = await run('bw', ['get', 'item', item], {
57
+ env: { BW_SESSION: session },
58
+ });
59
+ const parsed = JSON.parse(stdout);
60
+ const match = (parsed.fields ?? []).find((f) => f.name === name);
61
+ if (!match) throw new Error(`item "${item}" has no custom field "${name}"`);
62
+ return match.value;
63
+ }
64
+
65
+ export async function status(deps = {}) {
66
+ const run = deps.run ?? realRun;
67
+ const { stdout } = await run('bw', ['status']);
68
+ return JSON.parse(stdout);
69
+ }
70
+
71
+ export async function logout(deps = {}) {
72
+ const run = deps.run ?? realRun;
73
+ try {
74
+ await run('bw', ['logout']);
75
+ } catch (e) {
76
+ if (isNotLoggedIn(e.message)) return;
77
+ throw e;
78
+ }
79
+ }
80
+
81
+ export async function ensureSession(config, deps = {}) {
82
+ await configServer(config.serverUrl, deps);
83
+ await loginApiKey({ clientId: config.clientId, clientSecret: config.clientSecret }, deps);
84
+ return unlock({ masterPassword: config.masterPassword }, deps);
85
+ }
package/src/cli.js ADDED
@@ -0,0 +1,65 @@
1
+ const HELP = `nbg-pw — Vaultwarden credential helper for macOS
2
+
3
+ Usage: nbg-pw <command> [options]
4
+
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
+ serve Start a local bw API daemon (unlocked) for fast repeated reads
9
+ status Show config + auth state (no secret values)
10
+ doctor Diagnose bw / Keychain / server connectivity
11
+ logout Lock the session (bw logout)
12
+ reset Log out and delete the stored Keychain item
13
+ `;
14
+
15
+ export async function runCli(argv, deps = {}) {
16
+ const out = deps.out ?? ((s) => process.stdout.write(s));
17
+ const err = deps.err ?? ((s) => process.stderr.write(s));
18
+ const commands = deps.commands ?? (await loadCommands());
19
+
20
+ const [name, ...rest] = argv;
21
+
22
+ if (!name || name === 'help' || name === '--help' || name === '-h') {
23
+ out(HELP);
24
+ return 0;
25
+ }
26
+ if (name === '--version' || name === 'version') {
27
+ out('nbg-pw ' + (deps.version ?? 'dev') + '\n');
28
+ return 0;
29
+ }
30
+
31
+ const cmd = commands[name];
32
+ if (!cmd) {
33
+ err(`Unknown command: ${name}\nRun "nbg-pw help" for usage.\n`);
34
+ return 1;
35
+ }
36
+ try {
37
+ const code = await cmd(rest);
38
+ return typeof code === 'number' ? code : 0;
39
+ } catch (e) {
40
+ // e.message here is trusted child-process stderr (bw/security), surfaced
41
+ // verbatim by design. Do not interpolate secret-bearing context into it.
42
+ err(`Error: ${e.message}\n`);
43
+ return e.exitCode ?? 2;
44
+ }
45
+ }
46
+
47
+ async function loadCommands() {
48
+ const [setup, get, status, doctor, serve, reset] = await Promise.all([
49
+ import('./commands/setup.js'),
50
+ import('./commands/get.js'),
51
+ import('./commands/status.js'),
52
+ import('./commands/doctor.js'),
53
+ import('./commands/serve.js'),
54
+ import('./commands/reset.js'),
55
+ ]);
56
+ return {
57
+ setup: setup.default,
58
+ get: get.default,
59
+ status: status.default,
60
+ doctor: doctor.default,
61
+ serve: serve.default,
62
+ logout: reset.default,
63
+ reset: reset.reset,
64
+ };
65
+ }
@@ -0,0 +1,47 @@
1
+ import * as bwModule from '../bw.js';
2
+ import * as keychainModule from '../keychain.js';
3
+
4
+ async function defaultFetchUrl(url) {
5
+ try {
6
+ const res = await globalThis.fetch(url, { method: 'HEAD' });
7
+ return { ok: true, status: res.status };
8
+ } catch {
9
+ return { ok: false };
10
+ }
11
+ }
12
+
13
+ export async function runDoctor(args, deps) {
14
+ const { keychain, bw, out } = deps;
15
+ const fetchUrl = deps.fetchUrl ?? defaultFetchUrl;
16
+ let failed = false;
17
+ const line = (label, ok, hint) => {
18
+ out(` ${label} [${ok ? 'OK' : 'FAIL'}]${ok ? '' : ` -> ${hint}`}\n`);
19
+ if (!ok) failed = true;
20
+ };
21
+
22
+ out('nbg-pw doctor\n');
23
+
24
+ const version = await bw.bwVersion();
25
+ line('bw installed', !!version, 'brew install bitwarden-cli');
26
+
27
+ const config = await keychain.readConfig();
28
+ line('Keychain credentials present & readable', !!config, 'run "nbg-pw setup"');
29
+
30
+ if (config) {
31
+ const r = await fetchUrl(config.serverUrl);
32
+ line(`server reachable (${config.serverUrl})`, r.ok, 'check the URL / your network / VPN');
33
+ } else {
34
+ line('server reachable', false, 'no stored server URL — run "nbg-pw setup"');
35
+ }
36
+
37
+ out(failed ? '\nSome checks failed.\n' : '\nAll checks passed.\n');
38
+ return failed ? 1 : 0;
39
+ }
40
+
41
+ export default async function doctor(args) {
42
+ return runDoctor(args, {
43
+ keychain: keychainModule,
44
+ bw: bwModule,
45
+ out: (s) => process.stdout.write(s),
46
+ });
47
+ }
@@ -0,0 +1,55 @@
1
+ import * as bwModule from '../bw.js';
2
+ import * as keychainModule from '../keychain.js';
3
+
4
+ export function parseGetArgs(args) {
5
+ let item = null;
6
+ let field = 'password';
7
+ let custom = null;
8
+ let fieldGiven = false;
9
+ for (let i = 0; i < args.length; i++) {
10
+ if (args[i] === '--field') {
11
+ const v = args[++i];
12
+ if (v === undefined) throw new Error('--field requires a value');
13
+ field = v;
14
+ fieldGiven = true;
15
+ }
16
+ else if (args[i] === '--custom') {
17
+ const v = args[++i];
18
+ if (v === undefined) throw new Error('--custom requires a value');
19
+ custom = v;
20
+ }
21
+ else if (!item) { item = args[i]; }
22
+ }
23
+ if (!item) throw new Error('usage: nbg-pw get <item> [--field password|username|uri|notes] [--custom <name>]');
24
+ if (fieldGiven && custom !== null) throw new Error('--field and --custom are mutually exclusive');
25
+ const result = { item, field };
26
+ if (custom !== null) result.custom = custom;
27
+ return result;
28
+ }
29
+
30
+ export async function runGet(args, deps) {
31
+ const { keychain, bw, out, err } = deps;
32
+ const { item, field, custom } = parseGetArgs(args);
33
+
34
+ const config = await keychain.readConfig();
35
+ if (!config) {
36
+ err('No stored credentials. Run "nbg-pw setup" first.\n');
37
+ return 1;
38
+ }
39
+
40
+ const session = await bw.ensureSession(config);
41
+ const value = custom !== undefined
42
+ ? await bw.getCustomField({ item, name: custom, session })
43
+ : await bw.getItem({ item, field, session });
44
+ out(value + '\n');
45
+ return 0;
46
+ }
47
+
48
+ export default async function get(args) {
49
+ return runGet(args, {
50
+ keychain: keychainModule,
51
+ bw: bwModule,
52
+ out: (s) => process.stdout.write(s),
53
+ err: (s) => process.stderr.write(s),
54
+ });
55
+ }
@@ -0,0 +1,25 @@
1
+ import * as bwModule from '../bw.js';
2
+ import * as keychainModule from '../keychain.js';
3
+
4
+ export async function runLogout(args, deps) {
5
+ const { bw, out } = deps;
6
+ await bw.logout();
7
+ out('Logged out (session locked).\n');
8
+ return 0;
9
+ }
10
+
11
+ export async function runReset(args, deps) {
12
+ const { bw, keychain, out } = deps;
13
+ await bw.logout();
14
+ const removed = await keychain.deleteConfig();
15
+ out(removed ? 'Removed stored credentials from the Keychain.\n' : 'Nothing to remove (no stored credentials).\n');
16
+ return 0;
17
+ }
18
+
19
+ export default async function logout(args) {
20
+ return runLogout(args, { bw: bwModule, out: (s) => process.stdout.write(s) });
21
+ }
22
+
23
+ export async function reset(args) {
24
+ return runReset(args, { bw: bwModule, keychain: keychainModule, out: (s) => process.stdout.write(s) });
25
+ }
@@ -0,0 +1,56 @@
1
+ import { spawn } from 'node:child_process';
2
+ import * as bwModule from '../bw.js';
3
+ import * as keychainModule from '../keychain.js';
4
+
5
+ export function parseServeArgs(args) {
6
+ let port = 8087;
7
+ for (let i = 0; i < args.length; i++) {
8
+ if (args[i] === '--port') {
9
+ const v = args[++i];
10
+ const n = Number(v);
11
+ if (v === undefined || !Number.isInteger(n) || n < 1 || n > 65535) {
12
+ throw new Error('--port requires a valid port (1-65535)');
13
+ }
14
+ port = n;
15
+ }
16
+ }
17
+ return { port };
18
+ }
19
+
20
+ function defaultSpawnServe({ port, session }) {
21
+ return spawn('bw', ['serve', '--port', String(port)], {
22
+ env: { ...process.env, BW_SESSION: session },
23
+ stdio: 'inherit',
24
+ });
25
+ }
26
+
27
+ export async function runServe(args, deps) {
28
+ const { keychain, bw, out, err } = deps;
29
+ const spawnServe = deps.spawnServe ?? defaultSpawnServe;
30
+ const { port } = parseServeArgs(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
+ const child = spawnServe({ port, session });
40
+ out(`Starting bw serve on http://localhost:${port} (Ctrl-C to stop)\n`);
41
+ // Keep the event loop alive until the child exits when run for real.
42
+ let exitCode = 0;
43
+ if (child && typeof child.once === 'function') {
44
+ exitCode = await new Promise((resolve) => child.once('close', (code) => resolve(code ?? 0)));
45
+ }
46
+ return exitCode;
47
+ }
48
+
49
+ export default async function serve(args) {
50
+ return runServe(args, {
51
+ keychain: keychainModule,
52
+ bw: bwModule,
53
+ out: (s) => process.stdout.write(s),
54
+ err: (s) => process.stderr.write(s),
55
+ });
56
+ }
@@ -0,0 +1,93 @@
1
+ import * as clack from '@clack/prompts';
2
+ import * as bwModule from '../bw.js';
3
+ import * as keychainModule from '../keychain.js';
4
+ import { buildConfig as realBuildConfig, isValidUrl, isValidEmail } from '../config.js';
5
+
6
+ export async function validateCredentials(input, deps) {
7
+ const { bw } = deps;
8
+ await bw.configServer(input.serverUrl);
9
+ await bw.loginApiKey({ clientId: input.clientId, clientSecret: input.clientSecret });
10
+ try {
11
+ const session = await bw.unlock({ masterPassword: input.masterPassword });
12
+ if (!session) throw new Error('credential check did not return a session');
13
+ } finally {
14
+ await bw.logout();
15
+ }
16
+ }
17
+
18
+ export async function runSetup(deps) {
19
+ const { prompts, bw, keychain, buildConfig, nowIso, out } = deps;
20
+
21
+ prompts.intro('nbg-pw setup');
22
+
23
+ const version = await bw.bwVersion();
24
+ if (!version) {
25
+ out('The Bitwarden CLI (bw) is not installed.\n');
26
+ out('Install it with: brew install bitwarden-cli\n');
27
+ prompts.outro('Setup aborted.');
28
+ return 1;
29
+ }
30
+
31
+ const serverUrl = await prompts.text({
32
+ message: 'Vaultwarden server URL',
33
+ placeholder: 'https://vault.example.com/',
34
+ validate: (v) => (isValidUrl(v) ? undefined : 'Enter a valid http(s) URL'),
35
+ });
36
+ if (prompts.isCancel(serverUrl)) { prompts.cancel('Cancelled.'); return 1; }
37
+
38
+ const email = await prompts.text({
39
+ message: 'Account email',
40
+ validate: (v) => (isValidEmail(v) ? undefined : 'Enter a valid email'),
41
+ });
42
+ if (prompts.isCancel(email)) { prompts.cancel('Cancelled.'); return 1; }
43
+
44
+ const clientId = await prompts.text({
45
+ message: 'API key client id',
46
+ placeholder: 'user.xxxxxxxx-...',
47
+ validate: (v) => (v && v.length > 0 ? undefined : 'Required'),
48
+ });
49
+ if (prompts.isCancel(clientId)) { prompts.cancel('Cancelled.'); return 1; }
50
+
51
+ const clientSecret = await prompts.password({
52
+ message: 'API key client secret',
53
+ validate: (v) => (v && v.length > 0 ? undefined : 'Required'),
54
+ });
55
+ if (prompts.isCancel(clientSecret)) { prompts.cancel('Cancelled.'); return 1; }
56
+
57
+ const masterPassword = await prompts.password({
58
+ message: 'Master password',
59
+ validate: (v) => (v && v.length > 0 ? undefined : 'Required'),
60
+ });
61
+ if (prompts.isCancel(masterPassword)) { prompts.cancel('Cancelled.'); return 1; }
62
+
63
+ const input = { serverUrl, email, clientId, clientSecret, masterPassword };
64
+
65
+ const spin = prompts.spinner();
66
+ spin.start('Verifying credentials against the server');
67
+ try {
68
+ await validateCredentials(input, { bw });
69
+ } catch (e) {
70
+ spin.stop('Credential check failed.');
71
+ out(`Could not verify credentials: ${e.message}\n`);
72
+ prompts.outro('Nothing was saved.');
73
+ return 1;
74
+ }
75
+ spin.stop('Credentials verified.');
76
+
77
+ const config = buildConfig(input, nowIso);
78
+ await keychain.writeConfig(config);
79
+
80
+ prompts.outro('Saved to your macOS Keychain. Try: nbg-pw status');
81
+ return 0;
82
+ }
83
+
84
+ export default async function setup() {
85
+ return runSetup({
86
+ prompts: clack,
87
+ bw: bwModule,
88
+ keychain: keychainModule,
89
+ buildConfig: realBuildConfig,
90
+ nowIso: new Date().toISOString(),
91
+ out: (s) => process.stdout.write(s),
92
+ });
93
+ }
@@ -0,0 +1,33 @@
1
+ import * as bwModule from '../bw.js';
2
+ import * as keychainModule from '../keychain.js';
3
+
4
+ export async function runStatus(args, deps) {
5
+ const { keychain, bw, out } = deps;
6
+ const config = await keychain.readConfig();
7
+ const version = await bw.bwVersion();
8
+
9
+ out('nbg-pw status\n');
10
+ out(` bw CLI: ${version ?? 'NOT INSTALLED (brew install bitwarden-cli)'}\n`);
11
+ if (config) {
12
+ out(` stored creds: yes\n`);
13
+ out(` server URL: ${config.serverUrl}\n`);
14
+ out(` email: ${config.email}\n`);
15
+ } else {
16
+ out(' stored creds: No credentials stored (run "nbg-pw setup")\n');
17
+ }
18
+ try {
19
+ const s = await bw.status();
20
+ out(` bw session: ${s.status}\n`);
21
+ } catch {
22
+ out(' bw session: unknown\n');
23
+ }
24
+ return 0;
25
+ }
26
+
27
+ export default async function status(args) {
28
+ return runStatus(args, {
29
+ keychain: keychainModule,
30
+ bw: bwModule,
31
+ out: (s) => process.stdout.write(s),
32
+ });
33
+ }
package/src/config.js ADDED
@@ -0,0 +1,42 @@
1
+ export const SERVICE = 'com.nightlybuild.vault';
2
+ export const ACCOUNT = 'default';
3
+ export const FIELDS = ['serverUrl', 'email', 'clientId', 'clientSecret', 'masterPassword'];
4
+ export const READ_FIELDS = [...FIELDS, 'savedAt'];
5
+
6
+ export function isValidUrl(s) {
7
+ if (typeof s !== 'string' || s.length === 0) return false;
8
+ try {
9
+ const u = new URL(s);
10
+ return u.protocol === 'http:' || u.protocol === 'https:';
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ export function isValidEmail(s) {
17
+ return typeof s === 'string' && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
18
+ }
19
+
20
+ export function validateConfig(obj) {
21
+ if (!obj || typeof obj !== 'object') throw new Error('config is not an object');
22
+ for (const f of READ_FIELDS) {
23
+ if (typeof obj[f] !== 'string' || obj[f].length === 0) {
24
+ throw new Error(`config missing or empty field: ${f}`);
25
+ }
26
+ }
27
+ if (!isValidUrl(obj.serverUrl)) throw new Error('config serverUrl is not a valid URL');
28
+ if (!isValidEmail(obj.email)) throw new Error('config email is not a valid email');
29
+ return obj;
30
+ }
31
+
32
+ export function buildConfig(input, nowIso) {
33
+ const c = {
34
+ serverUrl: input.serverUrl,
35
+ email: input.email,
36
+ clientId: input.clientId,
37
+ clientSecret: input.clientSecret,
38
+ masterPassword: input.masterPassword,
39
+ savedAt: nowIso,
40
+ };
41
+ return c;
42
+ }
package/src/exec.js ADDED
@@ -0,0 +1,37 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export function run(cmd, args, opts = {}) {
4
+ const env = { ...process.env, ...(opts.env ?? {}) };
5
+ const doTrim = opts.trim !== false;
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(cmd, args, { env });
8
+ let stdout = '';
9
+ let stderr = '';
10
+ child.stdout.on('data', (d) => { stdout += d; });
11
+ child.stderr.on('data', (d) => { stderr += d; });
12
+ child.on('error', (e) => {
13
+ const err = new Error(`failed to run ${cmd}: ${e.message}`);
14
+ err.exitCode = 1;
15
+ err.stderr = '';
16
+ reject(err);
17
+ });
18
+ child.on('close', (code, signal) => {
19
+ // A signal-kill (code === null, signal set) is a failure, not success.
20
+ const failed = code !== 0 || signal != null;
21
+ const result = { code: failed ? (code ?? 1) : 0, stdout: doTrim ? stdout.trim() : stdout, stderr: stderr.trim() };
22
+ if (failed && !opts.allowFail) {
23
+ const msg = result.stderr || `${cmd} ${signal ? `killed by signal ${signal}` : `exited with code ${code}`}`;
24
+ const err = new Error(msg);
25
+ // Contract: failures throw with .exitCode === 1. The raw OS exit code
26
+ // is also forwarded as .code so callers can distinguish specific failures
27
+ // (e.g. macOS `security` exits 44 for item-not-found vs other errors).
28
+ err.exitCode = 1;
29
+ err.code = code;
30
+ err.stderr = result.stderr;
31
+ reject(err);
32
+ } else {
33
+ resolve(result);
34
+ }
35
+ });
36
+ });
37
+ }
@@ -0,0 +1,54 @@
1
+ import { run as realRun } from './exec.js';
2
+ import { SERVICE, ACCOUNT, validateConfig } from './config.js';
3
+
4
+ // NOTE: `security add-generic-password -w <value>` places the JSON (which
5
+ // contains the master password) in argv for the duration of one local exec.
6
+ // This is an accepted, local-only exposure: `security` provides no stdin path
7
+ // for the value. All *repeated* secret use (bw) goes through env vars instead.
8
+
9
+ const LABEL = 'nbg-pw';
10
+ const DESC = 'Vaultwarden credentials';
11
+
12
+ function isItemNotFound(e) {
13
+ // macOS `security` exits 44 when the item is not in the keychain.
14
+ return e.code === 44 || /could not be found/i.test(e.stderr || e.message || '');
15
+ }
16
+
17
+ export async function writeConfig(config, deps = {}) {
18
+ const run = deps.run ?? realRun;
19
+ validateConfig(config);
20
+ const json = JSON.stringify(config);
21
+ await run('security', [
22
+ 'add-generic-password',
23
+ '-U',
24
+ '-s', SERVICE,
25
+ '-a', ACCOUNT,
26
+ '-D', LABEL,
27
+ '-j', DESC,
28
+ '-w', json,
29
+ ]);
30
+ }
31
+
32
+ export async function readConfig(deps = {}) {
33
+ const run = deps.run ?? realRun;
34
+ try {
35
+ const { stdout } = await run('security', [
36
+ 'find-generic-password', '-s', SERVICE, '-a', ACCOUNT, '-w',
37
+ ]);
38
+ return validateConfig(JSON.parse(stdout));
39
+ } catch (e) {
40
+ if (isItemNotFound(e)) return null;
41
+ throw e;
42
+ }
43
+ }
44
+
45
+ export async function deleteConfig(deps = {}) {
46
+ const run = deps.run ?? realRun;
47
+ try {
48
+ await run('security', ['delete-generic-password', '-s', SERVICE, '-a', ACCOUNT]);
49
+ return true;
50
+ } catch (e) {
51
+ if (isItemNotFound(e)) return false;
52
+ throw e;
53
+ }
54
+ }