bulgur 0.0.0-stage → 1.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 CHANGED
@@ -1,3 +1,77 @@
1
- # Temporary Holding Version
1
+ # bulgur
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Ultra-simple secrets manager CLI. This npm package is a thin, self-contained
4
+ distribution of the `bulgur` binary — no postinstall downloads, no network
5
+ access required. The correct binary for your platform is bundled inside the
6
+ package and launched by a small Node shim.
7
+
8
+ ## Add it to a project
9
+
10
+ ```sh
11
+ npx bulgur init
12
+ ```
13
+
14
+ `init` links the directory to a bulgur project (writes `bulgur.json`), adds
15
+ `bulgur` as a devDependency and wraps your `dev` script:
16
+
17
+ ```json
18
+ "scripts": { "dev": "bulgur run -- next dev" }
19
+ ```
20
+
21
+ Commit `bulgur.json` and `package.json`. From then on a teammate only runs:
22
+
23
+ ```sh
24
+ git clone … && cd app
25
+ pnpm install
26
+ pnpm dev # asks for their bulgur email/username + password once, then starts with secrets
27
+ ```
28
+
29
+ No `.env` files to pass around. In CI, set `BULGUR_TOKEN`, or
30
+ `BULGUR_SKIP=1` to run a wrapped script without secrets.
31
+
32
+ ## Install globally
33
+
34
+ ```sh
35
+ npm install -g bulgur
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```sh
41
+ bulgur login # email/username + password, or --token blg_...
42
+ bulgur init
43
+ bulgur run -- <command> # inject secrets into one command
44
+ bulgur pull # write a .env
45
+ bulgur push
46
+ ```
47
+
48
+ ## Supported platforms
49
+
50
+ - macOS (Intel `x64`, Apple Silicon `arm64`)
51
+ - Linux (`x64`, `arm64`)
52
+
53
+ Windows is not supported yet.
54
+
55
+ ## How it works
56
+
57
+ `bin/bulgur.js` selects `binaries/<platform>-<arch>/bulgur` based on
58
+ `process.platform` + `process.arch`, spawns it with inherited stdio, and
59
+ forwards the exit code and signals. Binaries are stamped into the package at
60
+ publish time from the goreleaser build.
61
+
62
+ ## Runtime SDK
63
+
64
+ This package also exports a tiny, dependency-free runtime SDK for loading
65
+ secrets into `process.env` at process startup — no CLI binary involved:
66
+
67
+ ```js
68
+ const { load } = require("bulgur");
69
+ // or: import { load } from "bulgur";
70
+
71
+ await load(); // reads BULGUR_DSN, assigns onto process.env
72
+ ```
73
+
74
+ Set `BULGUR_DSN` to a **runtime-scoped** token DSN:
75
+ `bulgur://TOKEN@HOST/ORG/PROJECT/ENV`. See the repo root `README.md`
76
+ ("Runtime (deploy platforms)") for platform examples. `load()` accepts
77
+ `{ dsn, override, optional }` — see `index.d.ts` for details.
package/bin/bulgur.js ADDED
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Thin launcher: pick the bundled bulgur binary that matches the host
5
+ // platform/arch and exec it, forwarding stdio, the exit code, and signals.
6
+ // The binaries are fat-bundled into this package at pack time (see the repo
7
+ // Makefile `npm-pack` target), so there is NO network/postinstall step —
8
+ // this works even while the GitHub repo is private.
9
+
10
+ const { spawnSync } = require("child_process");
11
+ const path = require("path");
12
+ const fs = require("fs");
13
+
14
+ // Node reports arch as x64/arm64; we bundle binaries under matching dir names.
15
+ const platform = process.platform; // 'darwin' | 'linux'
16
+ const arch = process.arch; // 'x64' | 'arm64'
17
+
18
+ const supported = new Set([
19
+ "darwin-x64",
20
+ "darwin-arm64",
21
+ "linux-x64",
22
+ "linux-arm64",
23
+ ]);
24
+
25
+ const key = `${platform}-${arch}`;
26
+ if (!supported.has(key)) {
27
+ console.error(
28
+ `bulgur: unsupported platform/arch "${key}". ` +
29
+ `Supported: ${[...supported].join(", ")}.`
30
+ );
31
+ process.exit(1);
32
+ }
33
+
34
+ const binPath = path.join(__dirname, "..", "binaries", key, "bulgur");
35
+ if (!fs.existsSync(binPath)) {
36
+ console.error(
37
+ `bulgur: bundled binary not found at ${binPath}. ` +
38
+ `This package may have been built without the ${key} binary.`
39
+ );
40
+ process.exit(1);
41
+ }
42
+
43
+ // spawnSync with stdio: inherit forwards stdin/stdout/stderr transparently and
44
+ // relays terminating signals to the child on POSIX, so the run command's signal
45
+ // handling keeps working.
46
+ const result = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" });
47
+
48
+ if (result.error) {
49
+ console.error(`bulgur: failed to launch binary: ${result.error.message}`);
50
+ process.exit(1);
51
+ }
52
+
53
+ if (result.signal) {
54
+ // Re-raise the signal so the parent shell sees the correct termination cause.
55
+ process.kill(process.pid, result.signal);
56
+ return;
57
+ }
58
+
59
+ process.exit(result.status === null ? 1 : result.status);
Binary file
Binary file
Binary file
Binary file
package/index.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ // Type definitions for the `bulgur` runtime SDK.
2
+ // Hand-written; no build step. Kept in sync with index.js by hand.
3
+
4
+ export interface LoadOptions {
5
+ /**
6
+ * The DSN to load, of the form `bulgur://TOKEN@HOST/ORG/PROJECT/ENV`.
7
+ * Defaults to `process.env.BULGUR_DSN`.
8
+ */
9
+ dsn?: string;
10
+
11
+ /**
12
+ * When true, values fetched from bulgur overwrite any existing
13
+ * `process.env` entry with the same name.
14
+ *
15
+ * Default: false — existing `process.env` values win, and the fetched
16
+ * value is skipped (not counted in the returned `loaded` count).
17
+ */
18
+ override?: boolean;
19
+
20
+ /**
21
+ * When true:
22
+ * - a missing/empty DSN resolves `{ loaded: 0 }` instead of throwing.
23
+ * - a network failure or non-2xx HTTP response resolves `{ loaded: 0 }`
24
+ * instead of throwing.
25
+ * A DSN that IS present but malformed (bad scheme, missing token, wrong
26
+ * number of path segments, etc.) always throws regardless of this option
27
+ * — a configuration typo should be loud.
28
+ *
29
+ * Default: false — all of the above throw.
30
+ */
31
+ optional?: boolean;
32
+ }
33
+
34
+ export interface LoadResult {
35
+ /**
36
+ * The number of secrets actually written to `process.env`. Secrets that
37
+ * were fetched but skipped because an existing `process.env` value won
38
+ * (see `override`) are NOT counted.
39
+ */
40
+ loaded: number;
41
+ }
42
+
43
+ export interface ParsedDsn {
44
+ /** The bearer token (userinfo component of the DSN). */
45
+ token: string;
46
+ /** Lowercased `host[:port]`. */
47
+ host: string;
48
+ /** Org slug (first path segment). */
49
+ org: string;
50
+ /** Project slug (second path segment). */
51
+ project: string;
52
+ /** Environment slug (third path segment). */
53
+ env: string;
54
+ /**
55
+ * The resolved API base URL, e.g. `https://api.bulgur.dev` or
56
+ * `http://localhost:5432`. `http` is used only for `localhost` /
57
+ * `127.0.0.1` hosts; every other host uses `https`.
58
+ */
59
+ baseUrl: string;
60
+ }
61
+
62
+ /**
63
+ * Load secret values for the environment identified by `options.dsn` (or
64
+ * `process.env.BULGUR_DSN`) and assign them onto `process.env`.
65
+ *
66
+ * This is a one-shot call: after it resolves, this module has no further
67
+ * runtime effect and holds no open connections.
68
+ */
69
+ export function load(options?: LoadOptions): Promise<LoadResult>;
70
+
71
+ /**
72
+ * Parse and validate a `bulgur://TOKEN@HOST/ORG/PROJECT/ENV` DSN without
73
+ * making a network call. Thrown error messages never include the token.
74
+ */
75
+ export function parseDsn(dsn: string): ParsedDsn;
package/index.js ADDED
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+
3
+ // bulgur runtime SDK — Node >= 18 (relies on global fetch, no deps).
4
+ //
5
+ // Reads a `bulgur://TOKEN@HOST/ORG/PROJECT/ENV` DSN, fetches the merged
6
+ // secret values for that org/project/environment from the bulgur API, and
7
+ // assigns them onto process.env. This module has zero runtime dependencies
8
+ // and is hand-written CJS (no bundler/build step) so it can ship inside the
9
+ // npm fat-bundle package alongside the CLI binary wrapper.
10
+ //
11
+ // Error hygiene: no error thrown by this module ever includes the TOKEN.
12
+ // Messages may include host/org/project/env and HTTP status for debugging.
13
+
14
+ /**
15
+ * Parse a bulgur DSN of the form:
16
+ * bulgur://TOKEN@HOST[:PORT]/ORG/PROJECT/ENV
17
+ *
18
+ * Parsed manually (not via WHATWG URL) because the URL parser does not
19
+ * lowercase the host for non-special schemes like "bulgur:", which would
20
+ * silently produce a different (case-sensitive) host than intended.
21
+ *
22
+ * @param {string} dsn
23
+ * @returns {{token: string, host: string, org: string, project: string, env: string, baseUrl: string}}
24
+ */
25
+ function parseDsn(dsn) {
26
+ if (typeof dsn !== "string" || dsn.length === 0) {
27
+ throw new Error("bulgur: invalid DSN: expected a non-empty string");
28
+ }
29
+
30
+ const SCHEME_SEP = "://";
31
+ const schemeIdx = dsn.indexOf(SCHEME_SEP);
32
+ if (schemeIdx === -1) {
33
+ throw new Error(
34
+ 'bulgur: invalid DSN: expected "bulgur://TOKEN@HOST/ORG/PROJECT/ENV"'
35
+ );
36
+ }
37
+
38
+ const scheme = dsn.slice(0, schemeIdx);
39
+ if (scheme !== "bulgur") {
40
+ throw new Error(
41
+ `bulgur: invalid DSN: scheme must be "bulgur", got "${scheme}"`
42
+ );
43
+ }
44
+
45
+ // rest = TOKEN@HOST[:PORT]/ORG/PROJECT/ENV[/]
46
+ const rest = dsn.slice(schemeIdx + SCHEME_SEP.length);
47
+
48
+ const atIdx = rest.indexOf("@");
49
+ if (atIdx === -1) {
50
+ throw new Error(
51
+ "bulgur: invalid DSN: missing token (expected TOKEN@HOST/...)"
52
+ );
53
+ }
54
+
55
+ const token = rest.slice(0, atIdx);
56
+ if (token.length === 0) {
57
+ throw new Error("bulgur: invalid DSN: token must not be empty");
58
+ }
59
+
60
+ const afterAt = rest.slice(atIdx + 1); // HOST[:PORT]/ORG/PROJECT/ENV[/]
61
+ const slashIdx = afterAt.indexOf("/");
62
+ if (slashIdx === -1) {
63
+ throw new Error(
64
+ "bulgur: invalid DSN: missing path (expected /ORG/PROJECT/ENV)"
65
+ );
66
+ }
67
+
68
+ const rawHost = afterAt.slice(0, slashIdx);
69
+ if (rawHost.length === 0) {
70
+ throw new Error("bulgur: invalid DSN: host must not be empty");
71
+ }
72
+
73
+ let pathPart = afterAt.slice(slashIdx + 1); // ORG/PROJECT/ENV possibly with a trailing slash
74
+ if (pathPart.endsWith("/")) {
75
+ pathPart = pathPart.slice(0, -1);
76
+ }
77
+
78
+ const segments = pathPart.length === 0 ? [] : pathPart.split("/");
79
+ if (segments.length !== 3 || segments.some((s) => s.length === 0)) {
80
+ throw new Error(
81
+ "bulgur: invalid DSN: path must have exactly 3 non-empty segments " +
82
+ `(/ORG/PROJECT/ENV), got ${segments.length} segment(s)`
83
+ );
84
+ }
85
+ const [org, project, envSlug] = segments;
86
+
87
+ // Split optional :PORT off the host, then lowercase (hosts are
88
+ // case-insensitive; the WHATWG URL parser does NOT do this for us on a
89
+ // non-special scheme like "bulgur:", so we do it ourselves).
90
+ let hostname = rawHost;
91
+ let port = "";
92
+ const colonIdx = rawHost.lastIndexOf(":");
93
+ if (colonIdx !== -1) {
94
+ hostname = rawHost.slice(0, colonIdx);
95
+ port = rawHost.slice(colonIdx + 1);
96
+ if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
97
+ throw new Error("bulgur: invalid DSN: invalid port in host");
98
+ }
99
+ }
100
+ if (hostname.length === 0) {
101
+ throw new Error("bulgur: invalid DSN: host must not be empty");
102
+ }
103
+ hostname = hostname.toLowerCase();
104
+ const host = port ? `${hostname}:${port}` : hostname;
105
+
106
+ const isLocal = hostname === "localhost" || hostname === "127.0.0.1";
107
+ const scheme2 = isLocal ? "http" : "https";
108
+ const baseUrl = `${scheme2}://${host}`;
109
+
110
+ return { token, host, org, project, env: envSlug, baseUrl };
111
+ }
112
+
113
+ /**
114
+ * @param {{dsn?: string, override?: boolean, optional?: boolean}} [options]
115
+ * @returns {Promise<{loaded: number}>}
116
+ */
117
+ async function load(options) {
118
+ const opts = options || {};
119
+ const dsn = opts.dsn !== undefined ? opts.dsn : process.env.BULGUR_DSN;
120
+ const override = opts.override === true;
121
+ const optional = opts.optional === true;
122
+
123
+ if (!dsn) {
124
+ if (optional) return { loaded: 0 };
125
+ throw new Error(
126
+ "bulgur: missing DSN (set BULGUR_DSN or pass { dsn } to load())"
127
+ );
128
+ }
129
+
130
+ // Malformed DSN is always loud, even with optional:true.
131
+ const parsed = parseDsn(dsn);
132
+ const { token, host, org, project, env, baseUrl } = parsed;
133
+
134
+ const url =
135
+ `${baseUrl}/v1/orgs/${encodeURIComponent(org)}` +
136
+ `/projects/${encodeURIComponent(project)}` +
137
+ `/environments/${encodeURIComponent(env)}/values`;
138
+
139
+ let res;
140
+ try {
141
+ res = await fetch(url, {
142
+ headers: {
143
+ Authorization: `Bearer ${token}`,
144
+ Accept: "application/json",
145
+ },
146
+ });
147
+ } catch (err) {
148
+ if (optional) return { loaded: 0 };
149
+ throw new Error(
150
+ `bulgur: failed to reach ${host} (org=${org}, project=${project}, ` +
151
+ `env=${env}): ${err && err.message ? err.message : err}`
152
+ );
153
+ }
154
+
155
+ if (!res.ok) {
156
+ if (optional) return { loaded: 0 };
157
+ throw new Error(
158
+ `bulgur: request to ${host} failed with HTTP ${res.status} ` +
159
+ `(org=${org}, project=${project}, env=${env})`
160
+ );
161
+ }
162
+
163
+ let body;
164
+ try {
165
+ body = await res.json();
166
+ } catch (err) {
167
+ if (optional) return { loaded: 0 };
168
+ throw new Error(
169
+ `bulgur: failed to parse response from ${host} as JSON ` +
170
+ `(org=${org}, project=${project}, env=${env})`
171
+ );
172
+ }
173
+
174
+ const secrets = body && Array.isArray(body.secrets) ? body.secrets : [];
175
+ let loaded = 0;
176
+ for (const secret of secrets) {
177
+ if (!secret || typeof secret.name !== "string") continue;
178
+ const key = secret.name;
179
+ const hasExisting = Object.prototype.hasOwnProperty.call(
180
+ process.env,
181
+ key
182
+ );
183
+ if (hasExisting && !override) continue; // existing env vars win by default
184
+ process.env[key] = secret.value == null ? "" : String(secret.value);
185
+ loaded++;
186
+ }
187
+
188
+ return { loaded };
189
+ }
190
+
191
+ module.exports = { load, parseDsn };
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "bulgur",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "Ultra-simple secrets manager CLI (bulgur), plus a runtime SDK: `import { load } from \"bulgur\"`",
5
+ "bin": {
6
+ "bulgur": "bin/bulgur.js"
7
+ },
8
+ "main": "index.js",
9
+ "types": "index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./index.d.ts",
13
+ "import": "./index.js",
14
+ "require": "./index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "bin/",
20
+ "binaries/",
21
+ "index.js",
22
+ "index.d.ts",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test"
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "os": [
32
+ "darwin",
33
+ "linux"
34
+ ],
35
+ "cpu": [
36
+ "x64",
37
+ "arm64"
38
+ ],
39
+ "homepage": "https://bulgur.dev",
40
+ "license": "UNLICENSED",
41
+ "keywords": [
42
+ "bulgur",
43
+ "secrets",
44
+ "cli",
45
+ "env",
46
+ "dotenv",
47
+ "environment-variables",
48
+ "secrets-manager"
49
+ ]
50
+ }