@envseal/cli 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.
@@ -0,0 +1,94 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { hasInteractiveSurface } from './cli-utils.js';
3
+ /**
4
+ * A fixed answer for the approval question, for tests only.
5
+ *
6
+ * Double-gated exactly like the stub prompter in test-prompter.ts: the caller
7
+ * must set BOTH `ENVSEAL_TEST_MODE=1` and `ENVSEAL_TEST_APPROVAL` to `yes` or
8
+ * `no`. Neither is ever set by the shipped CLI. This is a deliberate hole in
9
+ * "a probe to an unknown host is only ever approved by a human", which is why
10
+ * it costs two variables — and why the notice is still printed when it fires,
11
+ * so the display path stays under test rather than being skipped.
12
+ */
13
+ function forcedAnswer() {
14
+ if (process.env.ENVSEAL_TEST_MODE !== '1')
15
+ return null;
16
+ const answer = process.env.ENVSEAL_TEST_APPROVAL;
17
+ if (answer === 'yes')
18
+ return true;
19
+ if (answer === 'no')
20
+ return false;
21
+ return null;
22
+ }
23
+ function describeProbe(key, probe, hostname) {
24
+ const lines = [
25
+ '',
26
+ `envseal needs your approval before verifying ${key}.`,
27
+ `${hostname} is not on the built-in registry allowlist, so this probe would send`,
28
+ 'the credential to a host envseal cannot vouch for.',
29
+ '',
30
+ ` key: ${key}`,
31
+ ` host: ${hostname}`,
32
+ ` method: ${probe.method}`,
33
+ ` url: ${probe.url}`,
34
+ ' headers:',
35
+ ];
36
+ for (const [name, template] of Object.entries(probe.headerTemplate)) {
37
+ lines.push(` ${name}: ${template}`);
38
+ }
39
+ lines.push('', '{{value}} is where your credential is substituted at request time. The value', 'itself is never printed here, and never written to the approval record.', '');
40
+ return lines;
41
+ }
42
+ function noSurfaceNotice(key, approvalsPath) {
43
+ const reason = process.env.CI !== undefined
44
+ ? 'CI is set in the environment'
45
+ : 'stdin is not a terminal';
46
+ return [
47
+ `Cannot ask: ${reason}, so there is nobody to answer.`,
48
+ `The probe was NOT sent and ${key} is reported as probe_not_approved.`,
49
+ 'To approve it, run this once in an interactive terminal on a machine with',
50
+ `access to this project: envseal verify ${key}`,
51
+ `The decision is recorded in ${approvalsPath} and replayed without asking again,`,
52
+ 'until the key, method, URL or header template changes.',
53
+ '',
54
+ ];
55
+ }
56
+ /**
57
+ * Build the `onApprovalNeeded` callback for the broker.
58
+ *
59
+ * Fails closed: with no interactive surface it returns false rather than
60
+ * hanging on a read nobody will answer, and rather than approving silently.
61
+ * Core turns that false into `probe_not_approved`, so `verify` still exits 6.
62
+ */
63
+ export function makeProbeApprover(approvalsPath) {
64
+ return async (entry) => {
65
+ const probe = entry.verify;
66
+ if (probe === undefined) {
67
+ // Core only reaches this callback for entries that declare a probe.
68
+ // Refusing keeps a broken invariant a refusal rather than a crash.
69
+ return false;
70
+ }
71
+ const hostname = new URL(probe.url).hostname;
72
+ process.stderr.write(`${describeProbe(entry.key, probe, hostname).join('\n')}\n`);
73
+ const forced = forcedAnswer();
74
+ if (forced !== null) {
75
+ process.stderr.write(`ENVSEAL_TEST_MODE: approval answered '${forced ? 'yes' : 'no'}' from ENVSEAL_TEST_APPROVAL.\n`);
76
+ return forced;
77
+ }
78
+ if (!hasInteractiveSurface()) {
79
+ process.stderr.write(`${noSurfaceNotice(entry.key, approvalsPath).join('\n')}\n`);
80
+ return false;
81
+ }
82
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
83
+ try {
84
+ const answer = await new Promise((resolve) => {
85
+ rl.question(`Send ${entry.key} to ${hostname}? [y/N] `, resolve);
86
+ });
87
+ return /^y(es)?$/i.test(answer.trim());
88
+ }
89
+ finally {
90
+ rl.close();
91
+ }
92
+ };
93
+ }
94
+ //# sourceMappingURL=probe-approval.js.map
package/dist/scan.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export interface DiscoveredKey {
2
+ key: string;
3
+ secret: boolean;
4
+ files: string[];
5
+ }
6
+ export declare function scanForEnvKeys(root: string): DiscoveredKey[];
7
+ /** Build a manifest entry, filling provider metadata from the registry when the name is known. */
8
+ export declare function entryForKey(d: DiscoveredKey): Record<string, unknown>;
9
+ //# sourceMappingURL=scan.d.ts.map
package/dist/scan.js ADDED
@@ -0,0 +1,138 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join, extname } from 'node:path';
3
+ import { findKey } from '@envseal/registry';
4
+ /**
5
+ * Discover environment variables a project actually reads.
6
+ *
7
+ * Deliberately syntactic rather than semantic: a regex sweep over source text
8
+ * finds the overwhelming majority of real references at a fraction of the cost
9
+ * of parsing every dialect a polyglot repo might contain, and a missed variable
10
+ * is a prompt the user answers once, not a failure.
11
+ */
12
+ const SOURCE_EXT = new Set([
13
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts',
14
+ '.py', '.rb', '.go', '.rs', '.java', '.kt', '.php', '.cs',
15
+ '.sh', '.bash', '.zsh', '.yml', '.yaml', '.toml',
16
+ ]);
17
+ const SKIP_DIRS = new Set([
18
+ 'node_modules', '.git', 'dist', 'build', 'out', 'target', 'vendor',
19
+ '.next', '.nuxt', '.svelte-kit', 'coverage', '.venv', 'venv',
20
+ '__pycache__', '.envseal', '.turbo', '.cache',
21
+ ]);
22
+ /**
23
+ * Names that are configuration rather than credentials. Declaring these as
24
+ * secrets would pop a password prompt for `NODE_ENV`, which teaches users to
25
+ * dismiss the prompt without reading it — the exact habit this tool exists to
26
+ * break.
27
+ */
28
+ const NON_SECRET = new Set([
29
+ 'NODE_ENV', 'PORT', 'HOST', 'CI', 'HOME', 'PATH', 'PWD', 'USER', 'SHELL',
30
+ 'LANG', 'TZ', 'TERM', 'TMPDIR', 'LOG_LEVEL', 'DEBUG', 'VERBOSE',
31
+ 'npm_lifecycle_event', 'npm_package_version',
32
+ ]);
33
+ /** Prefixes that a bundler inlines into client bundles — public by construction. */
34
+ const PUBLIC_PREFIXES = ['NEXT_PUBLIC_', 'VITE_', 'REACT_APP_', 'PUBLIC_', 'EXPO_PUBLIC_', 'NUXT_PUBLIC_'];
35
+ const PATTERNS = [
36
+ /process\.env\.([A-Z][A-Z0-9_]*)/g,
37
+ /process\.env\[['"`]([A-Z][A-Z0-9_]*)['"`]\]/g,
38
+ /import\.meta\.env\.([A-Z][A-Z0-9_]*)/g,
39
+ /os\.environ(?:\.get)?[[(]['"]([A-Z][A-Z0-9_]*)['"]/g,
40
+ /Deno\.env\.get\(['"]([A-Z][A-Z0-9_]*)['"]\)/g,
41
+ /ENV\[['"]([A-Z][A-Z0-9_]*)['"]\]/g,
42
+ /os\.Getenv\(['"`]([A-Z][A-Z0-9_]*)['"`]\)/g,
43
+ /std::env::var\(['"]([A-Z][A-Z0-9_]*)['"]\)/g,
44
+ /getenv\(['"]([A-Z][A-Z0-9_]*)['"]\)/g,
45
+ /\$\{?([A-Z][A-Z0-9_]{2,})\}?/g,
46
+ ];
47
+ function walk(dir, out, depth = 0) {
48
+ if (depth > 12)
49
+ return;
50
+ let entries;
51
+ try {
52
+ entries = readdirSync(dir);
53
+ }
54
+ catch {
55
+ return;
56
+ }
57
+ for (const entry of entries) {
58
+ if (SKIP_DIRS.has(entry))
59
+ continue;
60
+ const full = join(dir, entry);
61
+ let st;
62
+ try {
63
+ st = statSync(full);
64
+ }
65
+ catch {
66
+ continue;
67
+ }
68
+ if (st.isDirectory()) {
69
+ walk(full, out, depth + 1);
70
+ }
71
+ else if (SOURCE_EXT.has(extname(entry)) && st.size < 2_000_000) {
72
+ out.push(full);
73
+ }
74
+ }
75
+ }
76
+ export function scanForEnvKeys(root) {
77
+ const files = [];
78
+ walk(root, files);
79
+ const found = new Map();
80
+ for (const file of files) {
81
+ let text;
82
+ try {
83
+ text = readFileSync(file, 'utf8');
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ for (const source of PATTERNS) {
89
+ const re = new RegExp(source.source, 'g');
90
+ let m;
91
+ while ((m = re.exec(text)) !== null) {
92
+ const name = m[1];
93
+ if (name === undefined || name.length < 3)
94
+ continue;
95
+ const rel = file.slice(root.length + 1).replace(/\\/g, '/');
96
+ const set = found.get(name) ?? new Set();
97
+ set.add(rel);
98
+ found.set(name, set);
99
+ }
100
+ }
101
+ }
102
+ return [...found.entries()]
103
+ .map(([key, fileSet]) => ({
104
+ key,
105
+ secret: !NON_SECRET.has(key) && !PUBLIC_PREFIXES.some((p) => key.startsWith(p)),
106
+ files: [...fileSet].sort().slice(0, 5),
107
+ }))
108
+ .filter((d) => !NON_SECRET.has(d.key) || d.secret)
109
+ .sort((a, b) => a.key.localeCompare(b.key));
110
+ }
111
+ /** Build a manifest entry, filling provider metadata from the registry when the name is known. */
112
+ export function entryForKey(d) {
113
+ const where = d.files.length > 0 ? ` Referenced in ${d.files.join(', ')}.` : '';
114
+ const known = findKey(d.key);
115
+ const entry = {
116
+ key: d.key,
117
+ description: known ? `${known.key.description}${where}` : `Environment variable ${d.key}.${where}`,
118
+ required: true,
119
+ secret: d.secret,
120
+ sink: 'dotenv',
121
+ };
122
+ if (known) {
123
+ if (known.key.format)
124
+ entry.format = known.key.format;
125
+ const p = { id: known.provider.id, name: known.provider.name };
126
+ if (known.key.signupUrl)
127
+ p.signupUrl = known.key.signupUrl;
128
+ if (known.key.docsUrl)
129
+ p.docsUrl = known.key.docsUrl;
130
+ if (known.key.rotateUrl)
131
+ p.rotateUrl = known.key.rotateUrl;
132
+ entry.provider = p;
133
+ if (known.key.verify)
134
+ entry.verify = known.key.verify;
135
+ }
136
+ return entry;
137
+ }
138
+ //# sourceMappingURL=scan.js.map
@@ -0,0 +1,33 @@
1
+ import type { Prompter } from '@envseal/prompters';
2
+ /**
3
+ * A prompter that returns a fixed value without any UI.
4
+ *
5
+ * This exists so the zero-leak test can drive the real server, over real stdio,
6
+ * without a human at a browser. It is a deliberate hole in the "a value only ever
7
+ * comes from the user" guarantee, so it is gated twice in `bin.ts`: the caller must
8
+ * set BOTH `ENVSEAL_TEST_MODE=1` and `ENVSEAL_TEST_PROMPTER_VALUE`. Neither is ever
9
+ * set by the shipped CLI, and nothing in the published package sets them for you.
10
+ *
11
+ * If you are reading this because you want to inject a value programmatically in
12
+ * production: don't. Use a sink the value already lives in (keychain, vault) and let
13
+ * `presence` resolve it. Injecting through the prompter path would put the value in
14
+ * an environment variable, which is exactly what threat T6 is about.
15
+ */
16
+ export declare function createStubPrompter(value: string): Prompter;
17
+ /** Non-`entered` outcomes a stub prompter can be told to report. */
18
+ export type StubOutcome = 'skipped' | 'cancelled' | 'timeout';
19
+ export declare function isStubOutcome(value: string | undefined): value is StubOutcome;
20
+ /**
21
+ * A prompter that reports a refusal without any UI.
22
+ *
23
+ * The documented exit codes for `set` and `ensure` fork on WHY a key was not
24
+ * stored, and until this existed there was no way to drive `cancelled` or
25
+ * `timeout` through the real binary — so those rows of docs/cli-contract.md
26
+ * were asserted by nothing.
27
+ *
28
+ * Gated the same way as createStubPrompter (`ENVSEAL_TEST_MODE=1` plus a second
29
+ * variable), but note it is strictly the safer of the two: it can only ever
30
+ * make the CLI report that nothing was stored. It cannot introduce a value.
31
+ */
32
+ export declare function createRefusingPrompter(outcome: StubOutcome): Prompter;
33
+ //# sourceMappingURL=test-prompter.d.ts.map
@@ -0,0 +1,61 @@
1
+ import { secretFromUtf8 } from '@envseal/protocol';
2
+ /**
3
+ * A prompter that returns a fixed value without any UI.
4
+ *
5
+ * This exists so the zero-leak test can drive the real server, over real stdio,
6
+ * without a human at a browser. It is a deliberate hole in the "a value only ever
7
+ * comes from the user" guarantee, so it is gated twice in `bin.ts`: the caller must
8
+ * set BOTH `ENVSEAL_TEST_MODE=1` and `ENVSEAL_TEST_PROMPTER_VALUE`. Neither is ever
9
+ * set by the shipped CLI, and nothing in the published package sets them for you.
10
+ *
11
+ * If you are reading this because you want to inject a value programmatically in
12
+ * production: don't. Use a sink the value already lives in (keychain, vault) and let
13
+ * `presence` resolve it. Injecting through the prompter path would put the value in
14
+ * an environment variable, which is exactly what threat T6 is about.
15
+ */
16
+ export function createStubPrompter(value) {
17
+ return {
18
+ id: 'ide',
19
+ available: async () => true,
20
+ prompt: async (req) => ({
21
+ ticket: req.ticket,
22
+ results: req.keys.map((k) => ({
23
+ key: k.key,
24
+ outcome: 'entered',
25
+ value: secretFromUtf8(value),
26
+ })),
27
+ }),
28
+ cancel: async () => {
29
+ /* nothing to tear down */
30
+ },
31
+ };
32
+ }
33
+ export function isStubOutcome(value) {
34
+ return value === 'skipped' || value === 'cancelled' || value === 'timeout';
35
+ }
36
+ /**
37
+ * A prompter that reports a refusal without any UI.
38
+ *
39
+ * The documented exit codes for `set` and `ensure` fork on WHY a key was not
40
+ * stored, and until this existed there was no way to drive `cancelled` or
41
+ * `timeout` through the real binary — so those rows of docs/cli-contract.md
42
+ * were asserted by nothing.
43
+ *
44
+ * Gated the same way as createStubPrompter (`ENVSEAL_TEST_MODE=1` plus a second
45
+ * variable), but note it is strictly the safer of the two: it can only ever
46
+ * make the CLI report that nothing was stored. It cannot introduce a value.
47
+ */
48
+ export function createRefusingPrompter(outcome) {
49
+ return {
50
+ id: 'ide',
51
+ available: async () => true,
52
+ prompt: async (req) => ({
53
+ ticket: req.ticket,
54
+ results: req.keys.map((k) => ({ key: k.key, outcome })),
55
+ }),
56
+ cancel: async () => {
57
+ /* nothing to tear down */
58
+ },
59
+ };
60
+ }
61
+ //# sourceMappingURL=test-prompter.js.map
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@envseal/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "envseal": "./dist/bin.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "!dist/**/*.map"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "provenance": true
24
+ },
25
+ "dependencies": {
26
+ "@envseal/core": "0.1.0",
27
+ "@envseal/protocol": "0.1.0",
28
+ "@envseal/registry": "0.1.0",
29
+ "@envseal/mcp-server": "0.1.0",
30
+ "@envseal/http-server": "0.1.0",
31
+ "@envseal/detector": "0.1.0",
32
+ "@envseal/prompters": "0.1.0"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "typecheck": "tsc -p tsconfig.json --noEmit",
37
+ "test": "vitest run"
38
+ }
39
+ }