@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.
- package/LICENSE +201 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.js +161 -0
- package/dist/cli-utils.d.ts +50 -0
- package/dist/cli-utils.js +228 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +89 -0
- package/dist/commands/ensure.d.ts +2 -0
- package/dist/commands/ensure.js +126 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +101 -0
- package/dist/commands/mcp.d.ts +2 -0
- package/dist/commands/mcp.js +21 -0
- package/dist/commands/revoke.d.ts +2 -0
- package/dist/commands/revoke.js +48 -0
- package/dist/commands/run.d.ts +2 -0
- package/dist/commands/run.js +82 -0
- package/dist/commands/set.d.ts +2 -0
- package/dist/commands/set.js +122 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +49 -0
- package/dist/commands/verify.d.ts +2 -0
- package/dist/commands/verify.js +52 -0
- package/dist/exit-codes.d.ts +24 -0
- package/dist/exit-codes.js +73 -0
- package/dist/exit.d.ts +37 -0
- package/dist/exit.js +66 -0
- package/dist/host.d.ts +10 -0
- package/dist/host.js +286 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/output.d.ts +16 -0
- package/dist/output.js +57 -0
- package/dist/probe-approval.d.ts +10 -0
- package/dist/probe-approval.js +94 -0
- package/dist/scan.d.ts +9 -0
- package/dist/scan.js +138 -0
- package/dist/test-prompter.d.ts +33 -0
- package/dist/test-prompter.js +61 -0
- package/package.json +39 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { SepError } from '@envseal/protocol';
|
|
5
|
+
import { emit, fail } from '../output.js';
|
|
6
|
+
import { EXIT } from '../exit-codes.js';
|
|
7
|
+
import { detectHost } from '../host.js';
|
|
8
|
+
import { createBroker } from '../cli-utils.js';
|
|
9
|
+
import { finish } from '../exit.js';
|
|
10
|
+
export async function doctor(root, json) {
|
|
11
|
+
try {
|
|
12
|
+
// An audit of a project with no configuration would report an empty,
|
|
13
|
+
// healthy-looking bill of health: gitignore/no, missing keys/0, exit 0.
|
|
14
|
+
// Every other command treats that state as SEP_NOT_DECLARED; the audit
|
|
15
|
+
// command must not be the one place it reads as success.
|
|
16
|
+
const manifestPath = join(root, 'env.schema.jsonc');
|
|
17
|
+
if (!existsSync(manifestPath)) {
|
|
18
|
+
fail(json, new SepError({
|
|
19
|
+
code: 'SEP_NOT_DECLARED',
|
|
20
|
+
userMessage: 'No env.schema.jsonc in this project (or parents). Run `envseal init` to create one.',
|
|
21
|
+
}));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const broker = await createBroker(root);
|
|
25
|
+
const status = await broker.describe();
|
|
26
|
+
const gitignorePath = join(root, '.gitignore');
|
|
27
|
+
const envPath = join(root, '.env');
|
|
28
|
+
// Check gitignore
|
|
29
|
+
let gitignoreCovers = false;
|
|
30
|
+
if (existsSync(gitignorePath)) {
|
|
31
|
+
const gitignoreContent = readFileSync(gitignorePath, 'utf-8');
|
|
32
|
+
gitignoreCovers = gitignoreContent.includes('.env');
|
|
33
|
+
}
|
|
34
|
+
// Check .env permissions
|
|
35
|
+
let envFileOk = false;
|
|
36
|
+
if (existsSync(envPath)) {
|
|
37
|
+
const stats = statSync(envPath);
|
|
38
|
+
envFileOk = (stats.mode & 0o077) === 0;
|
|
39
|
+
}
|
|
40
|
+
const host = detectHost(root);
|
|
41
|
+
const output = {
|
|
42
|
+
projectRoot: root,
|
|
43
|
+
manifestPath,
|
|
44
|
+
host: {
|
|
45
|
+
id: host.id,
|
|
46
|
+
name: host.name,
|
|
47
|
+
tier: host.tier,
|
|
48
|
+
reason: host.reason,
|
|
49
|
+
recommendation: host.recommendation,
|
|
50
|
+
},
|
|
51
|
+
gitignore: {
|
|
52
|
+
exists: existsSync(gitignorePath),
|
|
53
|
+
covers: gitignoreCovers,
|
|
54
|
+
},
|
|
55
|
+
envFile: {
|
|
56
|
+
exists: existsSync(envPath),
|
|
57
|
+
isTracked: false,
|
|
58
|
+
permissionsOk: envFileOk,
|
|
59
|
+
},
|
|
60
|
+
missingRequiredCount: status.missingRequired.length,
|
|
61
|
+
missingRequired: status.missingRequired,
|
|
62
|
+
};
|
|
63
|
+
if (!json) {
|
|
64
|
+
console.log(`Project root: ${root}`);
|
|
65
|
+
console.log(`Host: ${host.name} (Tier ${host.tier})`);
|
|
66
|
+
console.log(` ${host.reason}`);
|
|
67
|
+
console.log(` ${host.recommendation}`);
|
|
68
|
+
console.log(`Gitignore covers .env: ${gitignoreCovers ? 'yes' : 'no'}`);
|
|
69
|
+
console.log(`Missing required keys: ${status.missingRequired.length}`);
|
|
70
|
+
if (status.missingRequired.length > 0) {
|
|
71
|
+
for (const key of status.missingRequired) {
|
|
72
|
+
console.log(` - ${key}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
emit(json, '', output);
|
|
78
|
+
}
|
|
79
|
+
// Exit with UNSATISFIED if required keys are missing
|
|
80
|
+
if (status.missingRequired.length > 0) {
|
|
81
|
+
finish(EXIT.UNSATISFIED);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
fail(json, error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=doctor.js.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { emit, fail } from '../output.js';
|
|
2
|
+
import { EXIT } from '../exit-codes.js';
|
|
3
|
+
import { loadManifest, projectPaths } from '@envseal/core';
|
|
4
|
+
import { SepError } from '@envseal/protocol';
|
|
5
|
+
import { createBroker, outcomeForKey } from '../cli-utils.js';
|
|
6
|
+
import { finish } from '../exit.js';
|
|
7
|
+
export async function ensure(root, json, check = false) {
|
|
8
|
+
try {
|
|
9
|
+
// A project without env.schema.jsonc declares NOTHING, but describe()
|
|
10
|
+
// reports that exactly like an empty manifest: zero missing keys, so this
|
|
11
|
+
// used to print "✓ All required keys are satisfied" and exit 0 — vacuous
|
|
12
|
+
// success from the command whose job is telling the truth, while doctor on
|
|
13
|
+
// the same project reported the missing declarations.
|
|
14
|
+
//
|
|
15
|
+
// Exit USAGE (2), not UNSATISFIED (1): 1 means "required keys are still
|
|
16
|
+
// missing after the operation", and there are no keys here to satisfy.
|
|
17
|
+
// This is the same class as SEP_NOT_DECLARED, which already maps to 2.
|
|
18
|
+
// (`status` keeps exiting 0 on an init-less project because it is a
|
|
19
|
+
// read-only report with genuinely nothing to show; `ensure` claims work
|
|
20
|
+
// done, so it may not.) The message below differs from the missing-keys
|
|
21
|
+
// failure ("✗ Only N/M keys set" / satisfied:false), so scripts can tell
|
|
22
|
+
// the two apart by text as well as code.
|
|
23
|
+
const manifest = loadManifest(projectPaths(root));
|
|
24
|
+
if (manifest === null) {
|
|
25
|
+
throw new SepError({
|
|
26
|
+
code: 'SEP_NOT_DECLARED',
|
|
27
|
+
userMessage: 'No env.schema.jsonc in this project (or parents). Run `envseal init` to create one.',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
const broker = await createBroker(root);
|
|
31
|
+
const status = await broker.describe();
|
|
32
|
+
// Use missingRequired array from ManifestStatus
|
|
33
|
+
const missingRequired = status.missingRequired;
|
|
34
|
+
// --check is the headless gate: it reports and exits, never requests.
|
|
35
|
+
// Skipping broker.request() is what makes it prompt-free — not just under
|
|
36
|
+
// CI (where a request would exit 4) but in an interactive terminal too,
|
|
37
|
+
// where a request would open a dialog the caller of a *check* never asked
|
|
38
|
+
// for. Total counts required entries only: an optional key being absent
|
|
39
|
+
// is not a failure.
|
|
40
|
+
if (check) {
|
|
41
|
+
const total = manifest.entries.filter((e) => e.required).length;
|
|
42
|
+
const missing = missingRequired.length;
|
|
43
|
+
if (!json) {
|
|
44
|
+
if (missing === 0) {
|
|
45
|
+
console.log('✓ All required keys are satisfied');
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
console.log(`✗ ${missing} of ${total} required key(s) missing:`);
|
|
49
|
+
for (const key of missingRequired) {
|
|
50
|
+
console.log(` ${key}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
emit(json, '', {
|
|
56
|
+
satisfied: missing === 0,
|
|
57
|
+
keysSet: total - missing,
|
|
58
|
+
total,
|
|
59
|
+
missing: missingRequired,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (missing > 0) {
|
|
63
|
+
finish(EXIT.UNSATISFIED);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (missingRequired.length === 0) {
|
|
68
|
+
if (!json) {
|
|
69
|
+
console.log('✓ All required keys are satisfied');
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// `total` is part of the documented shape and was omitted here, so a
|
|
73
|
+
// caller reading `total` got undefined on exactly the path it is most
|
|
74
|
+
// likely to take. Nothing was missing, so nothing was requested: 0.
|
|
75
|
+
emit(json, '', {
|
|
76
|
+
satisfied: true,
|
|
77
|
+
keysSet: 0,
|
|
78
|
+
total: 0,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Request all missing keys at once
|
|
84
|
+
const ticket = await broker.request({
|
|
85
|
+
keys: missingRequired,
|
|
86
|
+
reason: 'Ensure all required keys are present',
|
|
87
|
+
});
|
|
88
|
+
// Await the results (timeoutMs defaults to 90000)
|
|
89
|
+
const results = await broker.await({
|
|
90
|
+
ticket: ticket.ticket,
|
|
91
|
+
timeoutMs: 90000,
|
|
92
|
+
});
|
|
93
|
+
const outcomes = missingRequired.map((key) => outcomeForKey(results, key));
|
|
94
|
+
const keysSet = outcomes.filter((o) => o === 'stored').length;
|
|
95
|
+
const allSet = keysSet === missingRequired.length;
|
|
96
|
+
if (!json) {
|
|
97
|
+
if (allSet) {
|
|
98
|
+
console.log(`✓ Set ${keysSet} key(s)`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.log(`✗ Only ${keysSet}/${missingRequired.length} keys set`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
emit(json, '', {
|
|
106
|
+
satisfied: allSet,
|
|
107
|
+
keysSet,
|
|
108
|
+
total: missingRequired.length,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (!allSet) {
|
|
112
|
+
// Exit 1 says "still missing"; exit 3 says "a human declined or never
|
|
113
|
+
// answered". Both are retriable, but only 3 tells a caller that retrying
|
|
114
|
+
// unattended is pointless — so a stop of that kind wins over a plain
|
|
115
|
+
// shortfall. Everything else (skipped, invalid_format, verify_failed)
|
|
116
|
+
// stays 1.
|
|
117
|
+
const stopped = outcomes.some((o) => o === 'cancelled' || o === 'timeout');
|
|
118
|
+
finish(stopped ? EXIT.CANCELLED : EXIT.UNSATISFIED);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
fail(json, error);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=ensure.js.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { projectPaths, loadManifest, declareEntries } from '@envseal/core';
|
|
2
|
+
import { emit, fail } from '../output.js';
|
|
3
|
+
import { detectHost } from '../host.js';
|
|
4
|
+
import { scanForEnvKeys, entryForKey } from '../scan.js';
|
|
5
|
+
import { EXIT } from '../exit-codes.js';
|
|
6
|
+
import { finish } from '../exit.js';
|
|
7
|
+
// The ids detectHost can ever return. --host used to accept any string
|
|
8
|
+
// silently, recording a host detection would never report and printing a tier
|
|
9
|
+
// computed for a fiction.
|
|
10
|
+
const KNOWN_HOST_IDS = [
|
|
11
|
+
'claude-code',
|
|
12
|
+
'cursor',
|
|
13
|
+
'continue',
|
|
14
|
+
'aider',
|
|
15
|
+
'windsurf',
|
|
16
|
+
'cline',
|
|
17
|
+
'zed',
|
|
18
|
+
'codex',
|
|
19
|
+
'jetbrains',
|
|
20
|
+
'goose',
|
|
21
|
+
'copilot',
|
|
22
|
+
'generic',
|
|
23
|
+
'unknown',
|
|
24
|
+
];
|
|
25
|
+
export async function init(root, json, hostOverride) {
|
|
26
|
+
try {
|
|
27
|
+
if (hostOverride !== undefined && !KNOWN_HOST_IDS.includes(hostOverride)) {
|
|
28
|
+
console.error(`Error: unknown --host '${hostOverride}'. Valid values: ${KNOWN_HOST_IDS.join(', ')}.`);
|
|
29
|
+
finish(EXIT.USAGE);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const paths = projectPaths(root);
|
|
33
|
+
const discovered = scanForEnvKeys(root);
|
|
34
|
+
const entries = discovered.map(entryForKey);
|
|
35
|
+
// declareEntries creates the manifest when absent and edits it surgically
|
|
36
|
+
// when present, so re-running init on an existing project is safe and
|
|
37
|
+
// preserves any descriptions the user has written by hand. It runs even
|
|
38
|
+
// when the scan finds nothing: the "created an empty manifest" message
|
|
39
|
+
// below must be true, not aspirational.
|
|
40
|
+
const result = declareEntries(paths, entries);
|
|
41
|
+
const manifest = loadManifest(paths);
|
|
42
|
+
const host = hostOverride
|
|
43
|
+
? { id: hostOverride, name: hostOverride, tier: 'C', reason: 'specified with --host', recommendation: '' }
|
|
44
|
+
: detectHost(root);
|
|
45
|
+
const output = {
|
|
46
|
+
manifestPath: paths.manifest,
|
|
47
|
+
host: host.id,
|
|
48
|
+
protectionTier: host.tier,
|
|
49
|
+
scanned: discovered.length,
|
|
50
|
+
added: result.added,
|
|
51
|
+
updated: result.updated,
|
|
52
|
+
unchanged: result.unchanged,
|
|
53
|
+
secretKeys: discovered.filter((d) => d.secret).map((d) => d.key),
|
|
54
|
+
configKeys: discovered.filter((d) => !d.secret).map((d) => d.key),
|
|
55
|
+
entries: manifest?.entries.length ?? 0,
|
|
56
|
+
};
|
|
57
|
+
if (json) {
|
|
58
|
+
emit(json, '', output);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (discovered.length === 0) {
|
|
62
|
+
console.log('No environment variables found in this project.');
|
|
63
|
+
console.log(`Created an empty manifest at ${paths.manifest}.`);
|
|
64
|
+
console.log('Add entries with `envseal set <KEY>` or let your agent call env_declare.');
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
console.log(`✓ Manifest written to ${paths.manifest}`);
|
|
68
|
+
console.log(` Found ${discovered.length} variable(s): ${result.added.length} added, ${result.unchanged.length} unchanged`);
|
|
69
|
+
const secrets = discovered.filter((d) => d.secret);
|
|
70
|
+
if (secrets.length > 0) {
|
|
71
|
+
console.log(` Secrets: ${secrets.map((s) => s.key).join(', ')}`);
|
|
72
|
+
}
|
|
73
|
+
const config = discovered.filter((d) => !d.secret);
|
|
74
|
+
if (config.length > 0) {
|
|
75
|
+
console.log(` Config (not prompted): ${config.map((s) => s.key).join(', ')}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
console.log(` Host: ${host.name} (protection tier ${host.tier})`);
|
|
79
|
+
if (host.recommendation)
|
|
80
|
+
console.log(` ${host.recommendation}`);
|
|
81
|
+
if (hostOverride) {
|
|
82
|
+
// The override line above is what was ASKED for, not what is here. An
|
|
83
|
+
// auto-detected init on the same project can print a different tier, and
|
|
84
|
+
// doctor is the one that reports evidence.
|
|
85
|
+
console.log(' Override recorded; envseal doctor reports what is actually detected.');
|
|
86
|
+
}
|
|
87
|
+
if (host.id === 'claude-code') {
|
|
88
|
+
// Without this the first run ends at a manifest and no connection: init
|
|
89
|
+
// writes env.schema.jsonc but nothing tells the user the agent still has
|
|
90
|
+
// to be pointed at the broker.
|
|
91
|
+
console.log('');
|
|
92
|
+
console.log('Connect your agent: create .mcp.json in the project root containing');
|
|
93
|
+
console.log(' {"mcpServers":{"envseal-mcp":{"command":"envseal-mcp","args":[]}}}');
|
|
94
|
+
console.log('then restart Claude Code — or install plugins/claude-code for Tier A hooks.');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
fail(json, error);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=init.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { EXIT } from '../exit-codes.js';
|
|
3
|
+
import { finish } from '../exit.js';
|
|
4
|
+
export async function mcp(root) {
|
|
5
|
+
// Delegate to the mcp-server binary
|
|
6
|
+
const result = spawnSync('envseal-mcp', ['--project', root], {
|
|
7
|
+
stdio: 'inherit',
|
|
8
|
+
});
|
|
9
|
+
// A spawn failure leaves `status` null AND populates `error`. The old
|
|
10
|
+
// `result.status ?? 0` turned "the server binary does not exist / cannot be
|
|
11
|
+
// executed" into exit 0 — a host wiring this command saw silent success while
|
|
12
|
+
// nothing was serving. Report it honestly instead.
|
|
13
|
+
if (result.error || result.status === null) {
|
|
14
|
+
const reason = result.error?.message ?? 'the envseal-mcp binary could not be executed';
|
|
15
|
+
console.error(`Error: failed to start the envseal MCP server (is @envseal/mcp-server installed?): ${reason}`);
|
|
16
|
+
finish(EXIT.SINK_FAILURE);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
finish(result.status);
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=mcp.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { emit, fail } from '../output.js';
|
|
2
|
+
import { EXIT } from '../exit-codes.js';
|
|
3
|
+
import { createBroker } from '../cli-utils.js';
|
|
4
|
+
import { finish } from '../exit.js';
|
|
5
|
+
export async function revoke(root, key, json) {
|
|
6
|
+
try {
|
|
7
|
+
const broker = await createBroker(root);
|
|
8
|
+
const results = await broker.revoke({
|
|
9
|
+
keys: [key],
|
|
10
|
+
});
|
|
11
|
+
// `broker.revoke` skips keys that are not in the manifest, so an unknown
|
|
12
|
+
// key comes back as an empty array rather than `removed: false`.
|
|
13
|
+
const result = results[0];
|
|
14
|
+
const removed = result?.removed ?? false;
|
|
15
|
+
const rotateUrl = result?.rotateUrl ?? null;
|
|
16
|
+
if (!json) {
|
|
17
|
+
if (removed) {
|
|
18
|
+
console.log(`✓ ${key} revoked`);
|
|
19
|
+
if (rotateUrl) {
|
|
20
|
+
console.log(` Rotate the credential at: ${rotateUrl}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
else if (result === undefined) {
|
|
24
|
+
console.log(`✗ ${key} is not declared in this project's manifest`);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
console.log(`✗ Failed to revoke ${key}: nothing was removed from the sink`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
emit(json, '', {
|
|
32
|
+
key,
|
|
33
|
+
removed,
|
|
34
|
+
rotateUrl,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
// Exit 0 meant "revoked" even when nothing was removed, so a caller could
|
|
38
|
+
// not distinguish a burned key from a no-op.
|
|
39
|
+
if (!removed) {
|
|
40
|
+
finish(EXIT.UNSATISFIED);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
fail(json, error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=revoke.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { SepError } from '@envseal/protocol';
|
|
3
|
+
import { emit, fail } from '../output.js';
|
|
4
|
+
import { createBroker } from '../cli-utils.js';
|
|
5
|
+
import { finish } from '../exit.js';
|
|
6
|
+
/**
|
|
7
|
+
* Ask the user to approve running a command with secrets injected.
|
|
8
|
+
*
|
|
9
|
+
* Without this the broker's confirmation callback is absent and `use` always
|
|
10
|
+
* throws SEP_CONFIRMATION_DENIED, making the command impossible to use. The
|
|
11
|
+
* prompt reads from the controlling terminal, so it still works when stdout is
|
|
12
|
+
* being piped.
|
|
13
|
+
*
|
|
14
|
+
* Fails closed: no TTY and no --yes means no approval. That is the right
|
|
15
|
+
* default, because the alternative is a CI job silently handing credentials to
|
|
16
|
+
* whatever it was told to run.
|
|
17
|
+
*/
|
|
18
|
+
async function confirmInteractive(info) {
|
|
19
|
+
if (process.env.ENVSEAL_ASSUME_YES === '1')
|
|
20
|
+
return true;
|
|
21
|
+
if (!process.stdin.isTTY) {
|
|
22
|
+
// Distinct from a refusal. Reporting "the user denied the confirmation" when
|
|
23
|
+
// no human was ever asked is actively misleading, and it is the shell-only
|
|
24
|
+
// agents of Tier 4 that hit this path — the ones this binding exists for.
|
|
25
|
+
throw new SepError({
|
|
26
|
+
code: 'SEP_NO_INTERACTIVE_SURFACE',
|
|
27
|
+
userMessage: 'envseal run needs confirmation before injecting secrets, but there is no terminal to ask on. ' +
|
|
28
|
+
'Re-run in an interactive shell, or pass --yes (or set ENVSEAL_ASSUME_YES=1) to approve non-interactively.',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const lines = [
|
|
32
|
+
'',
|
|
33
|
+
'envseal is about to run a command with secrets in its environment:',
|
|
34
|
+
` command: ${info.command.join(' ')}`,
|
|
35
|
+
` keys: ${info.keys.join(', ')}`,
|
|
36
|
+
];
|
|
37
|
+
if (info.networkEgress) {
|
|
38
|
+
lines.push(' WARNING: this command can make network requests, so it could send', ' these values somewhere. Only continue if you trust it.');
|
|
39
|
+
}
|
|
40
|
+
lines.push('');
|
|
41
|
+
process.stderr.write(lines.join('\n'));
|
|
42
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
43
|
+
try {
|
|
44
|
+
const answer = await new Promise((resolve) => {
|
|
45
|
+
rl.question('Continue? [y/N] ', resolve);
|
|
46
|
+
});
|
|
47
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
rl.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function run(root, command, json, assumeYes = false) {
|
|
54
|
+
try {
|
|
55
|
+
const broker = await createBroker(root, {
|
|
56
|
+
onConfirm: assumeYes ? async () => true : confirmInteractive,
|
|
57
|
+
});
|
|
58
|
+
const status = await broker.describe();
|
|
59
|
+
const presentKeys = status.entries.filter((e) => e.present).map((e) => e.key);
|
|
60
|
+
const result = await broker.use({ keys: presentKeys, command });
|
|
61
|
+
if (!json) {
|
|
62
|
+
if (result.stdout)
|
|
63
|
+
process.stdout.write(result.stdout);
|
|
64
|
+
if (result.stderr)
|
|
65
|
+
process.stderr.write(result.stderr);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
emit(json, '', {
|
|
69
|
+
exitCode: result.exitCode,
|
|
70
|
+
stdout: result.stdout,
|
|
71
|
+
stderr: result.stderr,
|
|
72
|
+
redactedCount: result.redactedCount,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
finish(result.exitCode ?? 0);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
fail(json, error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { emit, fail } from '../output.js';
|
|
2
|
+
import { EXIT, exitCodeForOutcome } from '../exit-codes.js';
|
|
3
|
+
import { createBroker, outcomeForKey } from '../cli-utils.js';
|
|
4
|
+
import { finish } from '../exit.js';
|
|
5
|
+
import { loadManifest, projectPaths, saveManifest } from '@envseal/core';
|
|
6
|
+
/**
|
|
7
|
+
* Undo a declaration THIS invocation added, after nothing was stored under it.
|
|
8
|
+
*
|
|
9
|
+
* `set` declares before it requests (the declare-time schema guard has to run
|
|
10
|
+
* first), so every attempt that did not end `stored` — a CI run with no prompt
|
|
11
|
+
* surface, a cancelled or timed-out prompt, a typo the user abandons — used to
|
|
12
|
+
* leave the key behind as required+secret in env.schema.jsonc. Nothing
|
|
13
|
+
* mentioned the mutation: status then shows a phantom key forever, revoke
|
|
14
|
+
* touches only sinks, and init does not prune. An entry that already existed
|
|
15
|
+
* before this run is left alone — it was not ours to remove.
|
|
16
|
+
*
|
|
17
|
+
* stderr only: stdout is the machine-readable channel in --json mode.
|
|
18
|
+
*/
|
|
19
|
+
function rollbackDeclaredEntry(root, key) {
|
|
20
|
+
const paths = projectPaths(root);
|
|
21
|
+
const manifest = loadManifest(paths);
|
|
22
|
+
if (manifest === null)
|
|
23
|
+
return;
|
|
24
|
+
const remaining = manifest.entries.filter((e) => e.key !== key);
|
|
25
|
+
if (remaining.length === manifest.entries.length)
|
|
26
|
+
return;
|
|
27
|
+
manifest.entries = remaining;
|
|
28
|
+
// saveManifest edits only the changed field through jsonc, so the header
|
|
29
|
+
// comments survive.
|
|
30
|
+
saveManifest(paths, manifest);
|
|
31
|
+
console.error(`declared ${key} but nothing was stored; declaration removed`);
|
|
32
|
+
}
|
|
33
|
+
export async function set(root, key, json) {
|
|
34
|
+
// Whether the declare below added the entry, as opposed to finding it already
|
|
35
|
+
// there. Lives outside the try so the catch can roll it back when `request`
|
|
36
|
+
// throws (no interactive surface in CI is the common case).
|
|
37
|
+
let declaredHere = false;
|
|
38
|
+
try {
|
|
39
|
+
const broker = await createBroker(root);
|
|
40
|
+
// Declare the key only if it is not already in the manifest.
|
|
41
|
+
//
|
|
42
|
+
// `declareEntries` replaces an entry wholesale rather than merging, so
|
|
43
|
+
// declaring unconditionally meant `envseal set OPENAI_API_KEY` on an
|
|
44
|
+
// initialised project overwrote the existing entry with this bare stub —
|
|
45
|
+
// silently dropping its format pattern, its provider links and its verify
|
|
46
|
+
// probe. Caught by the invalid_format case in contract-e2e.test.ts, which
|
|
47
|
+
// stored a value the declared pattern rejects.
|
|
48
|
+
const status = await broker.describe();
|
|
49
|
+
if (!status.entries.some((e) => e.key === key)) {
|
|
50
|
+
try {
|
|
51
|
+
const entry = {
|
|
52
|
+
key,
|
|
53
|
+
description: `Configuration for ${key}`,
|
|
54
|
+
required: true,
|
|
55
|
+
secret: true,
|
|
56
|
+
sink: 'dotenv',
|
|
57
|
+
};
|
|
58
|
+
const declareResult = await broker.declare({
|
|
59
|
+
entries: [entry],
|
|
60
|
+
});
|
|
61
|
+
declaredHere = declareResult.added.includes(key);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// A key name the manifest schema rejects. Fall through: `request` then
|
|
65
|
+
// raises SEP_NOT_DECLARED, which maps to exit 2 (usage) — the right
|
|
66
|
+
// answer for a bad argument. Nothing was written, so no rollback.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Request the key
|
|
70
|
+
const ticket = await broker.request({
|
|
71
|
+
keys: [key],
|
|
72
|
+
reason: `Set ${key}`,
|
|
73
|
+
});
|
|
74
|
+
// Await the result (timeoutMs defaults to 90000)
|
|
75
|
+
const result = await broker.await({
|
|
76
|
+
ticket: ticket.ticket,
|
|
77
|
+
timeoutMs: 90000,
|
|
78
|
+
});
|
|
79
|
+
const outcome = outcomeForKey(result, key);
|
|
80
|
+
if (outcome === null) {
|
|
81
|
+
// The ticket resolved without saying anything about the key we asked
|
|
82
|
+
// for. That is an internal inconsistency, not a user decision, so report
|
|
83
|
+
// it as such rather than as a silent success. Nothing was stored, so the
|
|
84
|
+
// declaration we added has nothing to show for itself either.
|
|
85
|
+
if (declaredHere)
|
|
86
|
+
rollbackDeclaredEntry(root, key);
|
|
87
|
+
fail(json, `The prompt for ${key} finished without reporting an outcome.`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!json) {
|
|
91
|
+
if (outcome === 'stored') {
|
|
92
|
+
console.log(`✓ ${key} set successfully`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
console.log(`✗ Failed to set ${key}: ${outcome}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
emit(json, '', {
|
|
100
|
+
key,
|
|
101
|
+
outcome,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (outcome !== 'stored' && declaredHere) {
|
|
105
|
+
rollbackDeclaredEntry(root, key);
|
|
106
|
+
}
|
|
107
|
+
const code = exitCodeForOutcome(outcome);
|
|
108
|
+
if (code !== EXIT.OK) {
|
|
109
|
+
finish(code);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
// `request` throws before any prompt happens (SEP_NO_INTERACTIVE_SURFACE
|
|
115
|
+
// under CI=1 is the common route here), so a declaration we just added
|
|
116
|
+
// would otherwise outlive a run that never even asked for the value.
|
|
117
|
+
if (declaredHere)
|
|
118
|
+
rollbackDeclaredEntry(root, key);
|
|
119
|
+
fail(json, error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=set.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { emit, fail } from '../output.js';
|
|
2
|
+
import { EXIT } from '../exit-codes.js';
|
|
3
|
+
import { createBroker } from '../cli-utils.js';
|
|
4
|
+
import { finish } from '../exit.js';
|
|
5
|
+
export async function status(root, keys, json) {
|
|
6
|
+
try {
|
|
7
|
+
const broker = await createBroker(root);
|
|
8
|
+
const status = await broker.describe();
|
|
9
|
+
let entriesToShow = status.entries;
|
|
10
|
+
if (keys.length > 0) {
|
|
11
|
+
entriesToShow = entriesToShow.filter((e) => keys.includes(e.key));
|
|
12
|
+
}
|
|
13
|
+
const hasRequired = status.missingRequired.length > 0;
|
|
14
|
+
if (!json && keys.length === 0) {
|
|
15
|
+
if (entriesToShow.length === 0) {
|
|
16
|
+
console.log('No environment variables declared.');
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
for (const entry of entriesToShow) {
|
|
20
|
+
const status_str = entry.present ? '✓' : '✗';
|
|
21
|
+
console.log(`${status_str} ${entry.key}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
emit(json, '', {
|
|
27
|
+
entries: entriesToShow.map((e) => ({
|
|
28
|
+
key: e.key,
|
|
29
|
+
present: e.present,
|
|
30
|
+
sink: e.sink,
|
|
31
|
+
formatValid: e.formatValid,
|
|
32
|
+
lengthBucket: e.lengthBucket,
|
|
33
|
+
fingerprint: e.fingerprint,
|
|
34
|
+
lastVerified: e.lastVerified,
|
|
35
|
+
verifyResult: e.verifyResult,
|
|
36
|
+
})),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// Exit with UNSATISFIED if required keys are missing
|
|
40
|
+
if (hasRequired) {
|
|
41
|
+
finish(EXIT.UNSATISFIED);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
fail(json, error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=status.js.map
|