@chriscode/hush 3.1.1 → 4.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/dist/cli.js +24 -0
- package/dist/commands/expansions.d.ts +7 -0
- package/dist/commands/expansions.d.ts.map +1 -0
- package/dist/commands/expansions.js +103 -0
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +52 -12
- package/dist/commands/set.d.ts.map +1 -1
- package/dist/commands/set.js +32 -1
- package/dist/commands/skill.d.ts.map +1 -1
- package/dist/commands/skill.js +344 -5
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +13 -42
- package/dist/commands/template.d.ts +7 -0
- package/dist/commands/template.d.ts.map +1 -0
- package/dist/commands/template.js +111 -0
- package/dist/config/loader.d.ts +4 -0
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +17 -2
- package/dist/core/interpolate.d.ts +6 -2
- package/dist/core/interpolate.d.ts.map +1 -1
- package/dist/core/interpolate.js +35 -7
- package/dist/core/sops.d.ts +0 -4
- package/dist/core/sops.d.ts.map +1 -1
- package/dist/core/sops.js +1 -5
- package/dist/core/template.d.ts +11 -0
- package/dist/core/template.d.ts.map +1 -0
- package/dist/core/template.js +52 -0
- package/dist/lib/onepassword.d.ts +1 -0
- package/dist/lib/onepassword.d.ts.map +1 -1
- package/dist/lib/onepassword.js +9 -4
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,8 @@ import { skillCommand } from './commands/skill.js';
|
|
|
17
17
|
import { keysCommand } from './commands/keys.js';
|
|
18
18
|
import { resolveCommand } from './commands/resolve.js';
|
|
19
19
|
import { traceCommand } from './commands/trace.js';
|
|
20
|
+
import { templateCommand } from './commands/template.js';
|
|
21
|
+
import { expansionsCommand } from './commands/expansions.js';
|
|
20
22
|
import { findConfigPath, loadConfig, checkSchemaVersion } from './config/loader.js';
|
|
21
23
|
import { checkForUpdate } from './utils/version-check.js';
|
|
22
24
|
const require = createRequire(import.meta.url);
|
|
@@ -46,6 +48,8 @@ ${pc.bold('Commands:')}
|
|
|
46
48
|
${pc.bold('Debugging Commands:')}
|
|
47
49
|
resolve <target> Show what variables a target receives (AI-safe)
|
|
48
50
|
trace <key> Trace a variable through sources and targets (AI-safe)
|
|
51
|
+
template Show resolved template for current directory (AI-safe)
|
|
52
|
+
expansions Show expansion graph across all subdirectories (AI-safe)
|
|
49
53
|
|
|
50
54
|
${pc.bold('Advanced Commands:')}
|
|
51
55
|
decrypt --force Write secrets to disk (requires confirmation, last resort)
|
|
@@ -68,12 +72,26 @@ ${pc.bold('Options:')}
|
|
|
68
72
|
-h, --help Show this help message
|
|
69
73
|
-v, --version Show version number
|
|
70
74
|
|
|
75
|
+
${pc.bold('Variable Expansion (v4+):')}
|
|
76
|
+
Subdirectory .env files can reference root secrets:
|
|
77
|
+
|
|
78
|
+
\${VAR} Pull VAR from root secrets
|
|
79
|
+
\${VAR:-default} Pull VAR, use default if missing
|
|
80
|
+
\${env:VAR} Read from system environment (CI, etc.)
|
|
81
|
+
|
|
82
|
+
Example subdirectory template (apps/mobile/.env):
|
|
83
|
+
EXPO_PUBLIC_API_URL=\${API_URL}
|
|
84
|
+
PORT=\${PORT:-3000}
|
|
85
|
+
|
|
86
|
+
Subdirectory templates are safe to commit - they contain no secrets.
|
|
87
|
+
|
|
71
88
|
${pc.bold('Examples:')}
|
|
72
89
|
hush init Initialize config + generate keys
|
|
73
90
|
hush encrypt Encrypt .env files
|
|
74
91
|
hush run -- npm start Run with secrets in memory (AI-safe!)
|
|
75
92
|
hush run -e prod -- npm build Run with production secrets
|
|
76
93
|
hush run -t api -- wrangler dev Run filtered for 'api' target
|
|
94
|
+
cd apps/mobile && hush run -- expo start Run from subdirectory with templates
|
|
77
95
|
hush set DATABASE_URL Set a secret interactively (AI-safe)
|
|
78
96
|
hush set API_KEY --gui Set secret via macOS dialog (for AI agents)
|
|
79
97
|
hush set API_KEY -e prod Set a production secret
|
|
@@ -373,6 +391,12 @@ async function main() {
|
|
|
373
391
|
}
|
|
374
392
|
await traceCommand({ root, env, key });
|
|
375
393
|
break;
|
|
394
|
+
case 'template':
|
|
395
|
+
await templateCommand({ root, env });
|
|
396
|
+
break;
|
|
397
|
+
case 'expansions':
|
|
398
|
+
await expansionsCommand({ root, env });
|
|
399
|
+
break;
|
|
376
400
|
default:
|
|
377
401
|
if (command) {
|
|
378
402
|
console.error(pc.red(`Unknown command: ${command}`));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"expansions.d.ts","sourceRoot":"","sources":["../../src/commands/expansions.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAU,MAAM,aAAa,CAAC;AAEvD,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,WAAW,CAAC;CAClB;AA+DD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDjF"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { findProjectRoot } from '../config/loader.js';
|
|
5
|
+
import { parseEnvContent } from '../core/parse.js';
|
|
6
|
+
const TEMPLATE_FILES = ['.env', '.env.development', '.env.production', '.env.local'];
|
|
7
|
+
function findTemplateDirectories(projectRoot, maxDepth = 4) {
|
|
8
|
+
const templateDirs = [];
|
|
9
|
+
function walk(dir, depth) {
|
|
10
|
+
if (depth > maxDepth)
|
|
11
|
+
return;
|
|
12
|
+
const hasTemplate = TEMPLATE_FILES.some(f => existsSync(join(dir, f)));
|
|
13
|
+
if (hasTemplate && dir !== projectRoot) {
|
|
14
|
+
templateDirs.push(dir);
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
if (!entry.isDirectory())
|
|
20
|
+
continue;
|
|
21
|
+
if (entry.name.startsWith('.'))
|
|
22
|
+
continue;
|
|
23
|
+
if (entry.name === 'node_modules')
|
|
24
|
+
continue;
|
|
25
|
+
if (entry.name === 'dist')
|
|
26
|
+
continue;
|
|
27
|
+
if (entry.name === 'build')
|
|
28
|
+
continue;
|
|
29
|
+
walk(join(dir, entry.name), depth + 1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
walk(projectRoot, 0);
|
|
37
|
+
return templateDirs;
|
|
38
|
+
}
|
|
39
|
+
function loadTemplateVars(dir, env) {
|
|
40
|
+
const varSources = [];
|
|
41
|
+
const basePath = join(dir, '.env');
|
|
42
|
+
if (existsSync(basePath)) {
|
|
43
|
+
varSources.push(parseEnvContent(readFileSync(basePath, 'utf-8')));
|
|
44
|
+
}
|
|
45
|
+
const envPath = join(dir, env === 'development' ? '.env.development' : '.env.production');
|
|
46
|
+
if (existsSync(envPath)) {
|
|
47
|
+
varSources.push(parseEnvContent(readFileSync(envPath, 'utf-8')));
|
|
48
|
+
}
|
|
49
|
+
const localPath = join(dir, '.env.local');
|
|
50
|
+
if (existsSync(localPath)) {
|
|
51
|
+
varSources.push(parseEnvContent(readFileSync(localPath, 'utf-8')));
|
|
52
|
+
}
|
|
53
|
+
const merged = {};
|
|
54
|
+
for (const vars of varSources) {
|
|
55
|
+
for (const { key, value } of vars) {
|
|
56
|
+
merged[key] = value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return Object.entries(merged).map(([key, value]) => ({ key, value }));
|
|
60
|
+
}
|
|
61
|
+
export async function expansionsCommand(options) {
|
|
62
|
+
const { root, env } = options;
|
|
63
|
+
const projectInfo = findProjectRoot(root);
|
|
64
|
+
if (!projectInfo) {
|
|
65
|
+
console.error(pc.red('No hush.yaml found in current directory or any parent directory.'));
|
|
66
|
+
console.error(pc.dim('Run: npx hush init'));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const { projectRoot } = projectInfo;
|
|
70
|
+
const templateDirs = findTemplateDirectories(projectRoot);
|
|
71
|
+
if (templateDirs.length === 0) {
|
|
72
|
+
console.log(pc.yellow('No subdirectory templates found.'));
|
|
73
|
+
console.log(pc.dim('Templates are .env files in subdirectories that reference root secrets.'));
|
|
74
|
+
console.log(pc.dim('Create apps/myapp/.env with content like: MY_VAR=${ROOT_SECRET}'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
console.log('');
|
|
78
|
+
console.log(pc.bold(`Expansion Graph (from ${projectRoot})`));
|
|
79
|
+
console.log(pc.dim(`Environment: ${env}`));
|
|
80
|
+
console.log('');
|
|
81
|
+
for (const dir of templateDirs) {
|
|
82
|
+
const relPath = relative(projectRoot, dir);
|
|
83
|
+
const vars = loadTemplateVars(dir, env);
|
|
84
|
+
const expansions = vars.filter(v => v.value.includes('${'));
|
|
85
|
+
const literals = vars.filter(v => !v.value.includes('${'));
|
|
86
|
+
console.log(pc.cyan(`${relPath}/`));
|
|
87
|
+
if (expansions.length > 0) {
|
|
88
|
+
for (const { key, value } of expansions) {
|
|
89
|
+
const isEnvRef = value.includes('${env:');
|
|
90
|
+
const symbol = isEnvRef ? pc.blue('←') : pc.green('←');
|
|
91
|
+
console.log(` ${key.padEnd(30)} ${symbol} ${pc.dim(value)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (literals.length > 0) {
|
|
95
|
+
for (const { key } of literals) {
|
|
96
|
+
console.log(` ${key.padEnd(30)} ${pc.dim('= (literal)')}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
console.log('');
|
|
100
|
+
}
|
|
101
|
+
console.log(pc.dim(`Found ${templateDirs.length} subdirectory templates.`));
|
|
102
|
+
console.log('');
|
|
103
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/commands/run.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/commands/run.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,UAAU,EAAmC,MAAM,aAAa,CAAC;AAkD/E,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAuFnE"}
|
package/dist/commands/run.js
CHANGED
|
@@ -2,19 +2,20 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import pc from 'picocolors';
|
|
5
|
-
import { loadConfig } from '../config/loader.js';
|
|
5
|
+
import { loadConfig, findProjectRoot } from '../config/loader.js';
|
|
6
6
|
import { filterVarsForTarget } from '../core/filter.js';
|
|
7
7
|
import { interpolateVars, getUnresolvedVars } from '../core/interpolate.js';
|
|
8
8
|
import { mergeVars } from '../core/merge.js';
|
|
9
9
|
import { parseEnvContent } from '../core/parse.js';
|
|
10
10
|
import { decrypt as sopsDecrypt } from '../core/sops.js';
|
|
11
|
+
import { loadLocalTemplates, resolveTemplateVars } from '../core/template.js';
|
|
11
12
|
function getEncryptedPath(sourcePath) {
|
|
12
13
|
return sourcePath + '.encrypted';
|
|
13
14
|
}
|
|
14
|
-
function getDecryptedSecrets(
|
|
15
|
-
const sharedEncrypted = join(
|
|
16
|
-
const envEncrypted = join(
|
|
17
|
-
const localEncrypted = join(
|
|
15
|
+
function getDecryptedSecrets(projectRoot, env, config) {
|
|
16
|
+
const sharedEncrypted = join(projectRoot, getEncryptedPath(config.sources.shared));
|
|
17
|
+
const envEncrypted = join(projectRoot, getEncryptedPath(config.sources[env]));
|
|
18
|
+
const localEncrypted = join(projectRoot, getEncryptedPath(config.sources.local));
|
|
18
19
|
const varSources = [];
|
|
19
20
|
if (existsSync(sharedEncrypted)) {
|
|
20
21
|
const content = sopsDecrypt(sharedEncrypted);
|
|
@@ -29,11 +30,22 @@ function getDecryptedSecrets(root, env, config) {
|
|
|
29
30
|
varSources.push(parseEnvContent(content));
|
|
30
31
|
}
|
|
31
32
|
if (varSources.length === 0) {
|
|
32
|
-
throw new Error(`No encrypted files found
|
|
33
|
+
throw new Error(`No encrypted files found at project root.\n` +
|
|
34
|
+
` Expected: ${sharedEncrypted}\n` +
|
|
35
|
+
` Project root: ${projectRoot}\n\n` +
|
|
36
|
+
` If you haven't encrypted yet, run: npx hush encrypt\n` +
|
|
37
|
+
` If running from a subdirectory, ensure hush.yaml exists at the project root.`);
|
|
33
38
|
}
|
|
34
39
|
const merged = mergeVars(...varSources);
|
|
35
40
|
return interpolateVars(merged);
|
|
36
41
|
}
|
|
42
|
+
function getRootSecretsAsRecord(vars) {
|
|
43
|
+
const record = {};
|
|
44
|
+
for (const { key, value } of vars) {
|
|
45
|
+
record[key] = value;
|
|
46
|
+
}
|
|
47
|
+
return record;
|
|
48
|
+
}
|
|
37
49
|
export async function runCommand(options) {
|
|
38
50
|
const { root, env, target, command } = options;
|
|
39
51
|
if (!command || command.length === 0) {
|
|
@@ -43,20 +55,48 @@ export async function runCommand(options) {
|
|
|
43
55
|
console.error(pc.dim(' hush run --target api -- wrangler dev'));
|
|
44
56
|
process.exit(1);
|
|
45
57
|
}
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
if (
|
|
58
|
+
const contextDir = root;
|
|
59
|
+
const projectInfo = findProjectRoot(contextDir);
|
|
60
|
+
if (!projectInfo) {
|
|
61
|
+
console.error(pc.red('No hush.yaml found in current directory or any parent directory.'));
|
|
62
|
+
console.error(pc.dim('Run: npx hush init'));
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
const { projectRoot } = projectInfo;
|
|
66
|
+
const config = loadConfig(projectRoot);
|
|
67
|
+
const rootSecrets = getDecryptedSecrets(projectRoot, env, config);
|
|
68
|
+
const rootSecretsRecord = getRootSecretsAsRecord(rootSecrets);
|
|
69
|
+
const localTemplate = loadLocalTemplates(contextDir, env);
|
|
70
|
+
let vars;
|
|
71
|
+
if (localTemplate.hasTemplate) {
|
|
72
|
+
vars = resolveTemplateVars(localTemplate.vars, rootSecretsRecord, { processEnv: process.env });
|
|
73
|
+
}
|
|
74
|
+
else if (target) {
|
|
49
75
|
const targetConfig = config.targets.find(t => t.name === target);
|
|
50
76
|
if (!targetConfig) {
|
|
51
77
|
console.error(pc.red(`Target "${target}" not found in hush.yaml`));
|
|
52
78
|
console.error(pc.dim(`Available targets: ${config.targets.map(t => t.name).join(', ')}`));
|
|
53
79
|
process.exit(1);
|
|
54
80
|
}
|
|
55
|
-
vars = filterVarsForTarget(
|
|
81
|
+
vars = filterVarsForTarget(rootSecrets, targetConfig);
|
|
82
|
+
if (targetConfig.format === 'wrangler') {
|
|
83
|
+
vars.push({ key: 'CLOUDFLARE_INCLUDE_PROCESS_ENV', value: 'true' });
|
|
84
|
+
const devVarsPath = join(targetConfig.path, '.dev.vars');
|
|
85
|
+
const absDevVarsPath = join(projectRoot, devVarsPath);
|
|
86
|
+
if (existsSync(absDevVarsPath)) {
|
|
87
|
+
console.warn(pc.yellow('\n⚠️ Wrangler Conflict Detected'));
|
|
88
|
+
console.warn(pc.yellow(` Found .dev.vars in ${targetConfig.path}`));
|
|
89
|
+
console.warn(pc.yellow(' Wrangler will IGNORE Hush secrets while this file exists.'));
|
|
90
|
+
console.warn(pc.bold(` Fix: rm ${devVarsPath}\n`));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
vars = rootSecrets;
|
|
56
96
|
}
|
|
57
97
|
const unresolved = getUnresolvedVars(vars);
|
|
58
98
|
if (unresolved.length > 0) {
|
|
59
|
-
console.warn(pc.yellow(`Warning: ${unresolved.length} vars have unresolved references`));
|
|
99
|
+
console.warn(pc.yellow(`Warning: ${unresolved.length} vars have unresolved references: ${unresolved.join(', ')}`));
|
|
60
100
|
}
|
|
61
101
|
const childEnv = {
|
|
62
102
|
...process.env,
|
|
@@ -67,7 +107,7 @@ export async function runCommand(options) {
|
|
|
67
107
|
stdio: 'inherit',
|
|
68
108
|
env: childEnv,
|
|
69
109
|
shell: true,
|
|
70
|
-
cwd:
|
|
110
|
+
cwd: contextDir,
|
|
71
111
|
});
|
|
72
112
|
if (result.error) {
|
|
73
113
|
console.error(pc.red(`Failed to execute: ${result.error.message}`));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"set.d.ts","sourceRoot":"","sources":["../../src/commands/set.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"set.d.ts","sourceRoot":"","sources":["../../src/commands/set.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AA2M9C,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA0CnE"}
|
package/dist/commands/set.js
CHANGED
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, fstatSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { platform } from 'node:os';
|
|
5
5
|
import pc from 'picocolors';
|
|
6
6
|
import { loadConfig } from '../config/loader.js';
|
|
7
7
|
import { setKey } from '../core/sops.js';
|
|
8
|
+
const STDIN_FD = 0;
|
|
9
|
+
function hasStdinPipe() {
|
|
10
|
+
try {
|
|
11
|
+
if (process.stdin.isTTY) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
const stat = fstatSync(STDIN_FD);
|
|
15
|
+
return stat.isFIFO() || stat.isFile();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function readFromStdinPipe() {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
let data = '';
|
|
24
|
+
process.stdin.setEncoding('utf8');
|
|
25
|
+
process.stdin.on('data', (chunk) => {
|
|
26
|
+
data += chunk;
|
|
27
|
+
});
|
|
28
|
+
process.stdin.on('end', () => {
|
|
29
|
+
const trimTrailingNewlines = /\n+$/;
|
|
30
|
+
resolve(data.replace(trimTrailingNewlines, ''));
|
|
31
|
+
});
|
|
32
|
+
process.stdin.on('error', reject);
|
|
33
|
+
process.stdin.resume();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
8
36
|
function promptViaMacOSDialog(key) {
|
|
9
37
|
try {
|
|
10
38
|
const script = `display dialog "Enter value for ${key}:" default answer "" with hidden answer with title "Hush - Set Secret"`;
|
|
@@ -133,6 +161,9 @@ function promptViaTTY(key) {
|
|
|
133
161
|
});
|
|
134
162
|
}
|
|
135
163
|
async function promptForValue(key, forceGui) {
|
|
164
|
+
if (hasStdinPipe()) {
|
|
165
|
+
return readFromStdinPipe();
|
|
166
|
+
}
|
|
136
167
|
if (process.stdin.isTTY && !forceGui) {
|
|
137
168
|
return promptViaTTY(key);
|
|
138
169
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"skill.d.ts","sourceRoot":"","sources":["../../src/commands/skill.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"skill.d.ts","sourceRoot":"","sources":["../../src/commands/skill.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAu+ChD,wBAAsB,YAAY,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA0CvE"}
|
package/dist/commands/skill.js
CHANGED
|
@@ -12,9 +12,9 @@ allowed-tools: Bash(hush:*), Bash(npx hush:*), Bash(brew:*), Bash(npm:*), Bash(p
|
|
|
12
12
|
|
|
13
13
|
# Hush - AI-Native Secrets Management
|
|
14
14
|
|
|
15
|
-
**CRITICAL: NEVER read .env files directly.** Always use \`npx hush status\`, \`npx hush inspect\`, or \`npx hush has\` to check secrets.
|
|
15
|
+
**CRITICAL: NEVER read root .env files directly.** Always use \`npx hush status\`, \`npx hush inspect\`, or \`npx hush has\` to check secrets.
|
|
16
16
|
|
|
17
|
-
Hush keeps secrets **encrypted at rest
|
|
17
|
+
Hush keeps secrets **encrypted at rest** at the project root. Subdirectory \`.env\` files are **templates** (safe to commit and read) that reference root secrets via \`\${VAR}\` syntax.
|
|
18
18
|
|
|
19
19
|
## First Step: Investigate Current State
|
|
20
20
|
|
|
@@ -35,12 +35,13 @@ This tells you:
|
|
|
35
35
|
|
|
36
36
|
| You See | What It Means | Action |
|
|
37
37
|
|---------|---------------|--------|
|
|
38
|
-
| \`SECURITY WARNING: Unencrypted .env files\` | Plaintext secrets
|
|
38
|
+
| \`SECURITY WARNING: Unencrypted .env files\` | Plaintext secrets at project root! | Run \`npx hush encrypt\` immediately |
|
|
39
39
|
| \`No hush.yaml found\` | Hush not initialized | Run \`npx hush init\` |
|
|
40
40
|
| \`SOPS not installed\` | Missing prerequisite | \`brew install sops\` |
|
|
41
41
|
| \`age key not found\` | Missing encryption key | \`npx hush keys setup\` |
|
|
42
42
|
| \`Project: not set\` | Key management limited | Add \`project:\` to hush.yaml |
|
|
43
|
-
|
|
43
|
+
|
|
44
|
+
**Note:** Security warnings only apply to root-level \`.env\` files. Subdirectory \`.env\` files are templates (safe to commit).
|
|
44
45
|
|
|
45
46
|
## Decision Tree: What Do I Do?
|
|
46
47
|
|
|
@@ -85,6 +86,122 @@ npx hush run -e production -- npm build # Production
|
|
|
85
86
|
|
|
86
87
|
---
|
|
87
88
|
|
|
89
|
+
## Monorepo Architecture: Push vs Pull
|
|
90
|
+
|
|
91
|
+
Hush supports two ways to distribute secrets in monorepos. **Choose based on the use case:**
|
|
92
|
+
|
|
93
|
+
| Need | Use | Example |
|
|
94
|
+
|------|-----|---------|
|
|
95
|
+
| Pattern-based filtering | **Push** | "All \`NEXT_PUBLIC_*\` vars → web app" |
|
|
96
|
+
| Auto-flow new vars | **Push** | Add var at root, it flows automatically |
|
|
97
|
+
| Rename variables | **Pull** | \`API_URL\` → \`EXPO_PUBLIC_API_URL\` |
|
|
98
|
+
| Default values | **Pull** | \`PORT=\${PORT:-3000}\` |
|
|
99
|
+
| Combine variables | **Pull** | \`URL=\${HOST}:\${PORT}\` |
|
|
100
|
+
|
|
101
|
+
### Push (include/exclude in hush.yaml)
|
|
102
|
+
|
|
103
|
+
Best for simple filtering where new vars should auto-flow:
|
|
104
|
+
|
|
105
|
+
\`\`\`yaml
|
|
106
|
+
# hush.yaml
|
|
107
|
+
targets:
|
|
108
|
+
- name: web
|
|
109
|
+
path: ./apps/web
|
|
110
|
+
include: [NEXT_PUBLIC_*] # All matching vars auto-flow
|
|
111
|
+
\`\`\`
|
|
112
|
+
|
|
113
|
+
### Pull (subdirectory .env templates)
|
|
114
|
+
|
|
115
|
+
Best for transformation, renaming, or explicit dependencies:
|
|
116
|
+
|
|
117
|
+
\`\`\`bash
|
|
118
|
+
# apps/mobile/.env (committed - it's just a template)
|
|
119
|
+
EXPO_PUBLIC_API_URL=\${API_URL} # Rename from root
|
|
120
|
+
PORT=\${PORT:-8081} # Default value
|
|
121
|
+
\`\`\`
|
|
122
|
+
|
|
123
|
+
**Decision rule:** Use push for "all X goes to Y" patterns. Use pull when you need to rename, transform, or add defaults.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Subdirectory Templates (Pull-Based)
|
|
128
|
+
|
|
129
|
+
When a subdirectory needs to rename, transform, or add defaults to root secrets, create a \`.env\` template file in that subdirectory.
|
|
130
|
+
|
|
131
|
+
### Step-by-Step Setup
|
|
132
|
+
|
|
133
|
+
**1. Ensure root secrets exist:**
|
|
134
|
+
\`\`\`bash
|
|
135
|
+
npx hush inspect # From repo root - verify secrets are configured
|
|
136
|
+
\`\`\`
|
|
137
|
+
|
|
138
|
+
**2. Create subdirectory template (this file is committed to git):**
|
|
139
|
+
\`\`\`bash
|
|
140
|
+
# apps/mobile/.env
|
|
141
|
+
EXPO_PUBLIC_API_URL=\${API_URL} # Pull API_URL from root, rename it
|
|
142
|
+
EXPO_PUBLIC_STRIPE_KEY=\${STRIPE_KEY} # Pull and rename
|
|
143
|
+
PORT=\${PORT:-8081} # Use root PORT, or default to 8081
|
|
144
|
+
DEBUG=\${DEBUG:-false} # Use root DEBUG, or default to false
|
|
145
|
+
\`\`\`
|
|
146
|
+
|
|
147
|
+
**3. Run from the subdirectory:**
|
|
148
|
+
\`\`\`bash
|
|
149
|
+
cd apps/mobile
|
|
150
|
+
npx hush run -- npm start
|
|
151
|
+
\`\`\`
|
|
152
|
+
|
|
153
|
+
Hush automatically:
|
|
154
|
+
1. Finds the project root (where \`hush.yaml\` is)
|
|
155
|
+
2. Decrypts root secrets
|
|
156
|
+
3. Loads the local \`.env\` template
|
|
157
|
+
4. Resolves \`\${VAR}\` references against root secrets
|
|
158
|
+
5. Injects the result into your command
|
|
159
|
+
|
|
160
|
+
### Variable Expansion Syntax
|
|
161
|
+
|
|
162
|
+
| Syntax | Meaning | Example |
|
|
163
|
+
|--------|---------|---------|
|
|
164
|
+
| \`\${VAR}\` | Pull VAR from root secrets | \`API_URL=\${API_URL}\` |
|
|
165
|
+
| \`\${VAR:-default}\` | Pull VAR, use default if missing/empty | \`PORT=\${PORT:-3000}\` |
|
|
166
|
+
| \`\${env:VAR}\` | Read from system environment (CI, etc.) | \`CI=\${env:CI}\` |
|
|
167
|
+
|
|
168
|
+
### Common Patterns
|
|
169
|
+
|
|
170
|
+
**Expo/React Native app:**
|
|
171
|
+
\`\`\`bash
|
|
172
|
+
# apps/mobile/.env
|
|
173
|
+
EXPO_PUBLIC_API_URL=\${API_URL}
|
|
174
|
+
EXPO_PUBLIC_STRIPE_KEY=\${STRIPE_PUBLISHABLE_KEY}
|
|
175
|
+
EXPO_PUBLIC_ENV=\${ENV:-development}
|
|
176
|
+
\`\`\`
|
|
177
|
+
|
|
178
|
+
**Next.js app:**
|
|
179
|
+
\`\`\`bash
|
|
180
|
+
# apps/web/.env
|
|
181
|
+
NEXT_PUBLIC_API_URL=\${API_URL}
|
|
182
|
+
NEXT_PUBLIC_STRIPE_KEY=\${STRIPE_PUBLISHABLE_KEY}
|
|
183
|
+
DATABASE_URL=\${DATABASE_URL}
|
|
184
|
+
\`\`\`
|
|
185
|
+
|
|
186
|
+
**API server with defaults:**
|
|
187
|
+
\`\`\`bash
|
|
188
|
+
# apps/api/.env
|
|
189
|
+
DATABASE_URL=\${DATABASE_URL}
|
|
190
|
+
PORT=\${PORT:-8787}
|
|
191
|
+
LOG_LEVEL=\${LOG_LEVEL:-info}
|
|
192
|
+
\`\`\`
|
|
193
|
+
|
|
194
|
+
### Important Notes
|
|
195
|
+
|
|
196
|
+
- **Subdirectory .env files ARE committed to git** - they're templates, not secrets
|
|
197
|
+
- **Can contain expansions AND constants** - \`APP_NAME=MyApp\` alongside \`API_URL=\${API_URL}\`
|
|
198
|
+
- **Run from the subdirectory** - \`hush run\` auto-detects the project root
|
|
199
|
+
- **Root secrets stay encrypted** - subdirectory templates just reference them
|
|
200
|
+
- **Self-reference works** - \`PORT=\${PORT:-3000}\` uses root PORT if set, else 3000
|
|
201
|
+
- **Security warnings only apply to root** - subdirectory .env files are always safe
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
88
205
|
## Commands Quick Reference
|
|
89
206
|
|
|
90
207
|
| Command | Purpose | When to Use |
|
|
@@ -196,6 +313,15 @@ npx hush set DEBUG --local # Set personal local override
|
|
|
196
313
|
The user will be prompted to enter the value (hidden input).
|
|
197
314
|
**You never see the actual secret - just invoke the command!**
|
|
198
315
|
|
|
316
|
+
### Add a secret via pipe (for scripts/automation)
|
|
317
|
+
|
|
318
|
+
\`\`\`bash
|
|
319
|
+
echo "my-secret-value" | npx hush set MY_KEY
|
|
320
|
+
cat secret.txt | npx hush set CERT_CONTENT
|
|
321
|
+
\`\`\`
|
|
322
|
+
|
|
323
|
+
When stdin has piped data, Hush reads from it instead of prompting.
|
|
324
|
+
|
|
199
325
|
---
|
|
200
326
|
|
|
201
327
|
## Additional Resources
|
|
@@ -516,6 +642,12 @@ hush set DEBUG --local # Set personal local override
|
|
|
516
642
|
|
|
517
643
|
User will be prompted with hidden input - the value is never visible.
|
|
518
644
|
|
|
645
|
+
**Pipe support:** You can also pipe values directly:
|
|
646
|
+
\`\`\`bash
|
|
647
|
+
echo "my-secret" | hush set MY_KEY
|
|
648
|
+
cat cert.pem | hush set CERTIFICATE
|
|
649
|
+
\`\`\`
|
|
650
|
+
|
|
519
651
|
---
|
|
520
652
|
|
|
521
653
|
### hush edit [file]
|
|
@@ -635,6 +767,43 @@ hush trace STRIPE_SECRET_KEY # Trace another variable
|
|
|
635
767
|
|
|
636
768
|
---
|
|
637
769
|
|
|
770
|
+
### hush template
|
|
771
|
+
|
|
772
|
+
Show the resolved template for the current directory's \`.env\` file.
|
|
773
|
+
|
|
774
|
+
\`\`\`bash
|
|
775
|
+
cd apps/mobile
|
|
776
|
+
hush template # Show resolved expansions
|
|
777
|
+
hush template -e production # Show for production
|
|
778
|
+
\`\`\`
|
|
779
|
+
|
|
780
|
+
**Output shows:**
|
|
781
|
+
- Original template values (e.g., \`\${API_URL}\`)
|
|
782
|
+
- Resolved values from root secrets (masked)
|
|
783
|
+
- Any unresolved references
|
|
784
|
+
|
|
785
|
+
**Use when:** Debugging why a subdirectory template isn't resolving correctly
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
### hush expansions
|
|
790
|
+
|
|
791
|
+
Show the expansion graph across all subdirectories that have \`.env\` templates.
|
|
792
|
+
|
|
793
|
+
\`\`\`bash
|
|
794
|
+
hush expansions # Scan all subdirectories
|
|
795
|
+
hush expansions -e production # Show for production
|
|
796
|
+
\`\`\`
|
|
797
|
+
|
|
798
|
+
**Output shows:**
|
|
799
|
+
- Which subdirectories have \`.env\` templates
|
|
800
|
+
- What variables each template references from root
|
|
801
|
+
- Resolution status for each reference
|
|
802
|
+
|
|
803
|
+
**Use when:** Getting an overview of pull-based templates across a monorepo
|
|
804
|
+
|
|
805
|
+
---
|
|
806
|
+
|
|
638
807
|
## Quick Reference
|
|
639
808
|
|
|
640
809
|
| Command | Purpose |
|
|
@@ -645,7 +814,10 @@ hush trace STRIPE_SECRET_KEY # Trace another variable
|
|
|
645
814
|
| \`hush inspect\` | See variables (masked) |
|
|
646
815
|
| \`hush has <KEY>\` | Check if variable exists |
|
|
647
816
|
| \`hush status\` | View configuration |
|
|
648
|
-
| \`
|
|
817
|
+
| \`hush resolve <target>\` | See what a target receives |
|
|
818
|
+
| \`hush trace <KEY>\` | Trace variable through targets |
|
|
819
|
+
| \`hush template\` | Show resolved subdirectory template |
|
|
820
|
+
| \`hush expansions\` | Show all subdirectory templates |
|
|
649
821
|
|
|
650
822
|
---
|
|
651
823
|
|
|
@@ -859,6 +1031,46 @@ targets:
|
|
|
859
1031
|
| SvelteKit | \`PUBLIC_*\` | \`include: [PUBLIC_*]\` |
|
|
860
1032
|
| Expo | \`EXPO_PUBLIC_*\` | \`include: [EXPO_PUBLIC_*]\` |
|
|
861
1033
|
| Gatsby | \`GATSBY_*\` | \`include: [GATSBY_*]\` |
|
|
1034
|
+
|
|
1035
|
+
### Variable Interpolation (v4+)
|
|
1036
|
+
|
|
1037
|
+
Reference other variables using \`\${VAR}\` syntax:
|
|
1038
|
+
|
|
1039
|
+
\`\`\`bash
|
|
1040
|
+
# Basic interpolation
|
|
1041
|
+
API_URL=\${BASE_URL}/api
|
|
1042
|
+
|
|
1043
|
+
# Default values (if VAR is unset or empty)
|
|
1044
|
+
DEBUG=\${DEBUG:-false}
|
|
1045
|
+
PORT=\${PORT:-3000}
|
|
1046
|
+
|
|
1047
|
+
# System environment (explicit opt-in)
|
|
1048
|
+
CI=\${env:CI}
|
|
1049
|
+
|
|
1050
|
+
# Pull from root (subdirectory .env can reference root secrets)
|
|
1051
|
+
# apps/mobile/.env:
|
|
1052
|
+
EXPO_PUBLIC_API_URL=\${API_URL} # Renamed from root
|
|
1053
|
+
\`\`\`
|
|
1054
|
+
|
|
1055
|
+
**Resolution order:** Local value → Root secrets → System env (only with \`env:\` prefix)
|
|
1056
|
+
|
|
1057
|
+
### Push vs Pull Architecture
|
|
1058
|
+
|
|
1059
|
+
**Push (hush.yaml targets):** Pattern-based filtering, auto-flow
|
|
1060
|
+
\`\`\`yaml
|
|
1061
|
+
targets:
|
|
1062
|
+
- name: web
|
|
1063
|
+
include: [NEXT_PUBLIC_*] # All matching vars flow automatically
|
|
1064
|
+
\`\`\`
|
|
1065
|
+
|
|
1066
|
+
**Pull (subdirectory templates):** Transformation, renaming, defaults
|
|
1067
|
+
\`\`\`bash
|
|
1068
|
+
# apps/mobile/.env
|
|
1069
|
+
EXPO_PUBLIC_API_URL=\${API_URL} # Rename required
|
|
1070
|
+
PORT=\${PORT:-3000} # Default value
|
|
1071
|
+
\`\`\`
|
|
1072
|
+
|
|
1073
|
+
**Decision:** Use push for "all X → Y". Use pull for rename/transform/defaults.
|
|
862
1074
|
`,
|
|
863
1075
|
'examples/workflows.md': `# Hush Workflow Examples
|
|
864
1076
|
|
|
@@ -1016,6 +1228,17 @@ npx hush resolve <target-name> -e prod # Check production
|
|
|
1016
1228
|
|
|
1017
1229
|
Look at the 🚫 EXCLUDED section to see which pattern is filtering out your variable.
|
|
1018
1230
|
|
|
1231
|
+
### "Wrangler dev not seeing secrets"
|
|
1232
|
+
|
|
1233
|
+
If you are using \`hush run -- wrangler dev\` and secrets are missing, Wrangler is likely being blocked by a local file.
|
|
1234
|
+
|
|
1235
|
+
**The Fix:**
|
|
1236
|
+
1. **Delete .dev.vars**: Run \`rm .dev.vars\` inside your worker directory.
|
|
1237
|
+
2. **Run normally**: \`hush run -- wrangler dev\`
|
|
1238
|
+
|
|
1239
|
+
**Explanation:**
|
|
1240
|
+
Wrangler completely ignores environment variables if a \`.dev.vars\` file exists. Hush automatically handles the necessary environment configuration (\`CLOUDFLARE_INCLUDE_PROCESS_ENV=true\`) for you, but you MUST ensure the conflicting file is removed.
|
|
1241
|
+
|
|
1019
1242
|
### "Variable appears in wrong places"
|
|
1020
1243
|
|
|
1021
1244
|
\`\`\`bash
|
|
@@ -1077,6 +1300,122 @@ npx hush push
|
|
|
1077
1300
|
|
|
1078
1301
|
---
|
|
1079
1302
|
|
|
1303
|
+
## Setting Up Subdirectory Templates (Pull-Based Secrets)
|
|
1304
|
+
|
|
1305
|
+
### "Set up secrets for a subdirectory app (Expo, Next.js, etc.)"
|
|
1306
|
+
|
|
1307
|
+
**Use this when:** You need to rename, transform, or add defaults to root secrets for a specific package.
|
|
1308
|
+
|
|
1309
|
+
**Step 1: Verify root secrets exist**
|
|
1310
|
+
\`\`\`bash
|
|
1311
|
+
cd /path/to/repo/root
|
|
1312
|
+
npx hush inspect
|
|
1313
|
+
\`\`\`
|
|
1314
|
+
|
|
1315
|
+
**Step 2: Create the subdirectory template file**
|
|
1316
|
+
|
|
1317
|
+
Create a \`.env\` file in the subdirectory. This file is committed to git - it's just a template, not actual secrets.
|
|
1318
|
+
|
|
1319
|
+
\`\`\`bash
|
|
1320
|
+
# Example: apps/mobile/.env
|
|
1321
|
+
EXPO_PUBLIC_API_URL=\${API_URL} # Pulls API_URL from root, renames it
|
|
1322
|
+
EXPO_PUBLIC_STRIPE_KEY=\${STRIPE_KEY} # Pulls and renames
|
|
1323
|
+
PORT=\${PORT:-8081} # Uses root PORT if set, otherwise 8081
|
|
1324
|
+
DEBUG=\${DEBUG:-false} # Uses root DEBUG if set, otherwise false
|
|
1325
|
+
\`\`\`
|
|
1326
|
+
|
|
1327
|
+
**Step 3: Run from the subdirectory**
|
|
1328
|
+
\`\`\`bash
|
|
1329
|
+
cd apps/mobile
|
|
1330
|
+
npx hush run -- npm start
|
|
1331
|
+
\`\`\`
|
|
1332
|
+
|
|
1333
|
+
### Variable Expansion Syntax Reference
|
|
1334
|
+
|
|
1335
|
+
| Syntax | What It Does | Example |
|
|
1336
|
+
|--------|--------------|---------|
|
|
1337
|
+
| \`\${VAR}\` | Pull VAR from root secrets | \`API_URL=\${API_URL}\` |
|
|
1338
|
+
| \`\${VAR:-default}\` | Pull VAR, use default if not set | \`PORT=\${PORT:-3000}\` |
|
|
1339
|
+
| \`\${env:VAR}\` | Read from system environment | \`CI=\${env:CI}\` |
|
|
1340
|
+
|
|
1341
|
+
### Framework Examples
|
|
1342
|
+
|
|
1343
|
+
**Expo/React Native:**
|
|
1344
|
+
\`\`\`bash
|
|
1345
|
+
# apps/mobile/.env
|
|
1346
|
+
EXPO_PUBLIC_API_URL=\${API_URL}
|
|
1347
|
+
EXPO_PUBLIC_STRIPE_KEY=\${STRIPE_PUBLISHABLE_KEY}
|
|
1348
|
+
EXPO_PUBLIC_ENV=\${ENV:-development}
|
|
1349
|
+
\`\`\`
|
|
1350
|
+
|
|
1351
|
+
**Next.js:**
|
|
1352
|
+
\`\`\`bash
|
|
1353
|
+
# apps/web/.env
|
|
1354
|
+
NEXT_PUBLIC_API_URL=\${API_URL}
|
|
1355
|
+
NEXT_PUBLIC_STRIPE_KEY=\${STRIPE_PUBLISHABLE_KEY}
|
|
1356
|
+
DATABASE_URL=\${DATABASE_URL}
|
|
1357
|
+
\`\`\`
|
|
1358
|
+
|
|
1359
|
+
**Cloudflare Worker:**
|
|
1360
|
+
\`\`\`bash
|
|
1361
|
+
# apps/api/.env
|
|
1362
|
+
DATABASE_URL=\${DATABASE_URL}
|
|
1363
|
+
STRIPE_SECRET_KEY=\${STRIPE_SECRET_KEY}
|
|
1364
|
+
PORT=\${PORT:-8787}
|
|
1365
|
+
\`\`\`
|
|
1366
|
+
|
|
1367
|
+
### Important Notes
|
|
1368
|
+
|
|
1369
|
+
- **Template files ARE committed** to git (they contain no secrets)
|
|
1370
|
+
- **Root secrets stay encrypted** - templates just reference them
|
|
1371
|
+
- **Run from subdirectory** - \`hush run\` finds the project root automatically
|
|
1372
|
+
- **Self-reference works** - \`PORT=\${PORT:-3000}\` uses root PORT if set
|
|
1373
|
+
|
|
1374
|
+
---
|
|
1375
|
+
|
|
1376
|
+
## Choosing Push vs Pull (Monorepos)
|
|
1377
|
+
|
|
1378
|
+
### "How should I set up secrets for a new package?"
|
|
1379
|
+
|
|
1380
|
+
**Ask yourself:** Does this package need to rename variables or add defaults?
|
|
1381
|
+
|
|
1382
|
+
#### If NO (simple filtering) → Use Push
|
|
1383
|
+
|
|
1384
|
+
Edit \`hush.yaml\` to add a target:
|
|
1385
|
+
\`\`\`yaml
|
|
1386
|
+
targets:
|
|
1387
|
+
- name: new-package
|
|
1388
|
+
path: ./packages/new-package
|
|
1389
|
+
format: dotenv
|
|
1390
|
+
include:
|
|
1391
|
+
- NEXT_PUBLIC_* # Or whatever pattern fits
|
|
1392
|
+
\`\`\`
|
|
1393
|
+
|
|
1394
|
+
**Benefits:** New \`NEXT_PUBLIC_*\` vars at root auto-flow. Zero maintenance.
|
|
1395
|
+
|
|
1396
|
+
#### If YES (transformation needed) → Use Pull
|
|
1397
|
+
|
|
1398
|
+
Create a template \`.env\` in the package:
|
|
1399
|
+
\`\`\`bash
|
|
1400
|
+
# packages/mobile/.env (committed to git)
|
|
1401
|
+
EXPO_PUBLIC_API_URL=\${API_URL} # Rename from root
|
|
1402
|
+
EXPO_PUBLIC_DEBUG=\${DEBUG:-false} # With default
|
|
1403
|
+
PORT=\${PORT:-8081} # Local default
|
|
1404
|
+
\`\`\`
|
|
1405
|
+
|
|
1406
|
+
**Benefits:** Full control over naming and defaults. Explicit dependencies.
|
|
1407
|
+
|
|
1408
|
+
### "When do I update templates vs hush.yaml?"
|
|
1409
|
+
|
|
1410
|
+
| Scenario | Update |
|
|
1411
|
+
|----------|--------|
|
|
1412
|
+
| New \`NEXT_PUBLIC_*\` var, web uses push | Nothing! Auto-flows |
|
|
1413
|
+
| New var mobile needs, mobile uses pull | \`packages/mobile/.env\` template |
|
|
1414
|
+
| New package needs secrets | \`hush.yaml\` (push) or new template (pull) |
|
|
1415
|
+
| Change var routing | \`hush.yaml\` include/exclude patterns |
|
|
1416
|
+
|
|
1417
|
+
---
|
|
1418
|
+
|
|
1080
1419
|
## Understanding the Output
|
|
1081
1420
|
|
|
1082
1421
|
### npx hush status output explained
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAwCjD,wBAAsB,aAAa,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAmHzE"}
|
package/dist/commands/status.js
CHANGED
|
@@ -1,45 +1,21 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import pc from 'picocolors';
|
|
4
4
|
import { findConfigPath, loadConfig } from '../config/loader.js';
|
|
5
5
|
import { describeFilter } from '../core/filter.js';
|
|
6
6
|
import { isAgeKeyConfigured, isSopsInstalled } from '../core/sops.js';
|
|
7
7
|
import { keyExists } from '../lib/age.js';
|
|
8
|
-
import {
|
|
8
|
+
import { opInstalled } from '../lib/onepassword.js';
|
|
9
9
|
import { FORMAT_OUTPUT_FILES } from '../types.js';
|
|
10
|
-
function
|
|
10
|
+
function findRootPlaintextEnvFiles(root) {
|
|
11
11
|
const results = [];
|
|
12
12
|
const plaintextPatterns = ['.env', '.env.development', '.env.production', '.env.local', '.env.staging', '.env.test', '.dev.vars'];
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
entries = readdirSync(dir);
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
for (const entry of entries) {
|
|
23
|
-
if (skipDirs.has(entry))
|
|
24
|
-
continue;
|
|
25
|
-
if (entry.endsWith('.encrypted'))
|
|
26
|
-
continue;
|
|
27
|
-
const fullPath = join(dir, entry);
|
|
28
|
-
const relPath = relativePath ? `${relativePath}/${entry}` : entry;
|
|
29
|
-
try {
|
|
30
|
-
if (statSync(fullPath).isDirectory()) {
|
|
31
|
-
scanDir(fullPath, relPath);
|
|
32
|
-
}
|
|
33
|
-
else if (plaintextPatterns.includes(entry)) {
|
|
34
|
-
results.push(relPath);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
13
|
+
for (const pattern of plaintextPatterns) {
|
|
14
|
+
const filePath = join(root, pattern);
|
|
15
|
+
if (existsSync(filePath)) {
|
|
16
|
+
results.push(pattern);
|
|
40
17
|
}
|
|
41
18
|
}
|
|
42
|
-
scanDir(root);
|
|
43
19
|
return results;
|
|
44
20
|
}
|
|
45
21
|
function getProjectFromConfig(root) {
|
|
@@ -72,10 +48,10 @@ export async function statusCommand(options) {
|
|
|
72
48
|
const config = loadConfig(root);
|
|
73
49
|
const configPath = findConfigPath(root);
|
|
74
50
|
console.log(pc.blue('Hush Status\n'));
|
|
75
|
-
const plaintextFiles =
|
|
51
|
+
const plaintextFiles = findRootPlaintextEnvFiles(root);
|
|
76
52
|
if (plaintextFiles.length > 0) {
|
|
77
53
|
console.log(pc.bgRed(pc.white(pc.bold(' SECURITY WARNING '))));
|
|
78
|
-
console.log(pc.red(pc.bold('\nUnencrypted .env files detected!\n')));
|
|
54
|
+
console.log(pc.red(pc.bold('\nUnencrypted .env files detected at project root!\n')));
|
|
79
55
|
for (const file of plaintextFiles) {
|
|
80
56
|
console.log(pc.red(` ${file}`));
|
|
81
57
|
}
|
|
@@ -112,21 +88,16 @@ export async function statusCommand(options) {
|
|
|
112
88
|
: pc.yellow(' age key not found at ~/.config/sops/age/key.txt'));
|
|
113
89
|
if (project) {
|
|
114
90
|
const hasLocalKey = keyExists(project);
|
|
115
|
-
const has1PasswordBackup = opAvailable() && opListKeys().includes(project);
|
|
116
91
|
console.log(pc.bold('\nKey Status:'));
|
|
117
92
|
console.log(hasLocalKey
|
|
118
93
|
? pc.green(` Local key: ~/.config/sops/age/keys/${project.replace(/\//g, '-')}.txt`)
|
|
119
94
|
: pc.yellow(' Local key: not found'));
|
|
120
|
-
if (
|
|
121
|
-
console.log(
|
|
122
|
-
|
|
123
|
-
: pc.yellow(' 1Password backup: not synced'));
|
|
124
|
-
if (!has1PasswordBackup && hasLocalKey) {
|
|
125
|
-
console.log(pc.dim(' Run "npx hush keys push" to backup to 1Password'));
|
|
126
|
-
}
|
|
95
|
+
if (opInstalled()) {
|
|
96
|
+
console.log(pc.dim(' 1Password CLI: installed'));
|
|
97
|
+
console.log(pc.dim(' Run "npx hush keys list" to check backup status'));
|
|
127
98
|
}
|
|
128
99
|
else {
|
|
129
|
-
console.log(pc.dim(' 1Password CLI: not
|
|
100
|
+
console.log(pc.dim(' 1Password CLI: not installed'));
|
|
130
101
|
}
|
|
131
102
|
if (!hasLocalKey) {
|
|
132
103
|
console.log(pc.bold('\n To set up keys:'));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../../src/commands/template.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,WAAW,EAAsB,MAAM,aAAa,CAAC;AAEnE,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,WAAW,CAAC;CAClB;AA4CD,wBAAsB,eAAe,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAuF7E"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { loadConfig, findProjectRoot } from '../config/loader.js';
|
|
5
|
+
import { interpolateVars } from '../core/interpolate.js';
|
|
6
|
+
import { mergeVars } from '../core/merge.js';
|
|
7
|
+
import { parseEnvContent } from '../core/parse.js';
|
|
8
|
+
import { decrypt as sopsDecrypt } from '../core/sops.js';
|
|
9
|
+
import { maskValue } from '../core/mask.js';
|
|
10
|
+
import { loadLocalTemplates, resolveTemplateVars } from '../core/template.js';
|
|
11
|
+
function getEncryptedPath(sourcePath) {
|
|
12
|
+
return sourcePath + '.encrypted';
|
|
13
|
+
}
|
|
14
|
+
function getDecryptedSecrets(projectRoot, env, config) {
|
|
15
|
+
const sharedEncrypted = join(projectRoot, getEncryptedPath(config.sources.shared));
|
|
16
|
+
const envEncrypted = join(projectRoot, getEncryptedPath(config.sources[env]));
|
|
17
|
+
const localEncrypted = join(projectRoot, getEncryptedPath(config.sources.local));
|
|
18
|
+
const varSources = [];
|
|
19
|
+
if (existsSync(sharedEncrypted)) {
|
|
20
|
+
const content = sopsDecrypt(sharedEncrypted);
|
|
21
|
+
varSources.push(parseEnvContent(content));
|
|
22
|
+
}
|
|
23
|
+
if (existsSync(envEncrypted)) {
|
|
24
|
+
const content = sopsDecrypt(envEncrypted);
|
|
25
|
+
varSources.push(parseEnvContent(content));
|
|
26
|
+
}
|
|
27
|
+
if (existsSync(localEncrypted)) {
|
|
28
|
+
const content = sopsDecrypt(localEncrypted);
|
|
29
|
+
varSources.push(parseEnvContent(content));
|
|
30
|
+
}
|
|
31
|
+
if (varSources.length === 0) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const merged = mergeVars(...varSources);
|
|
35
|
+
return interpolateVars(merged);
|
|
36
|
+
}
|
|
37
|
+
function getRootSecretsAsRecord(vars) {
|
|
38
|
+
const record = {};
|
|
39
|
+
for (const { key, value } of vars) {
|
|
40
|
+
record[key] = value;
|
|
41
|
+
}
|
|
42
|
+
return record;
|
|
43
|
+
}
|
|
44
|
+
export async function templateCommand(options) {
|
|
45
|
+
const { root, env } = options;
|
|
46
|
+
const contextDir = root;
|
|
47
|
+
const projectInfo = findProjectRoot(contextDir);
|
|
48
|
+
if (!projectInfo) {
|
|
49
|
+
console.error(pc.red('No hush.yaml found in current directory or any parent directory.'));
|
|
50
|
+
console.error(pc.dim('Run: npx hush init'));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const { projectRoot } = projectInfo;
|
|
54
|
+
const config = loadConfig(projectRoot);
|
|
55
|
+
const localTemplate = loadLocalTemplates(contextDir, env);
|
|
56
|
+
if (!localTemplate.hasTemplate) {
|
|
57
|
+
console.log(pc.yellow('No local template found in current directory.'));
|
|
58
|
+
console.log(pc.dim(`Looked for: .env, .env.${env}, .env.local`));
|
|
59
|
+
console.log('');
|
|
60
|
+
console.log(pc.dim('Without a local template, hush run will inject all root secrets.'));
|
|
61
|
+
console.log(pc.dim('Create a .env file to define which variables this directory needs.'));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const rootSecrets = getDecryptedSecrets(projectRoot, env, config);
|
|
65
|
+
const rootSecretsRecord = getRootSecretsAsRecord(rootSecrets);
|
|
66
|
+
const resolvedVars = resolveTemplateVars(localTemplate.vars, rootSecretsRecord, { processEnv: process.env });
|
|
67
|
+
const relPath = relative(projectRoot, contextDir) || '.';
|
|
68
|
+
console.log('');
|
|
69
|
+
console.log(pc.bold(`Template: ${relPath}/`));
|
|
70
|
+
console.log(pc.dim(`Project root: ${projectRoot}`));
|
|
71
|
+
console.log(pc.dim(`Environment: ${env}`));
|
|
72
|
+
console.log(pc.dim(`Files: ${localTemplate.files.join(', ')}`));
|
|
73
|
+
console.log('');
|
|
74
|
+
const templateVarKeys = new Set(localTemplate.vars.map(v => v.key));
|
|
75
|
+
const rootSecretKeys = new Set(Object.keys(rootSecretsRecord));
|
|
76
|
+
let fromRoot = 0;
|
|
77
|
+
let fromLocal = 0;
|
|
78
|
+
console.log(pc.bold('Variables:'));
|
|
79
|
+
console.log('');
|
|
80
|
+
const maxKeyLen = Math.max(...resolvedVars.map(v => v.key.length), 20);
|
|
81
|
+
for (const resolved of resolvedVars) {
|
|
82
|
+
const original = localTemplate.vars.find(v => v.key === resolved.key);
|
|
83
|
+
const originalValue = original?.value ?? '';
|
|
84
|
+
const hasReference = originalValue.includes('${');
|
|
85
|
+
const masked = maskValue(resolved.value);
|
|
86
|
+
const keyPadded = resolved.key.padEnd(maxKeyLen);
|
|
87
|
+
if (hasReference) {
|
|
88
|
+
const refMatch = originalValue.match(/\$\{([^}]+)\}/);
|
|
89
|
+
const refName = refMatch ? refMatch[1] : '';
|
|
90
|
+
const isEnvRef = refName.startsWith('env:');
|
|
91
|
+
if (isEnvRef) {
|
|
92
|
+
console.log(` ${pc.cyan(keyPadded)} = ${pc.dim(originalValue)} ${pc.dim('→')} ${masked}`);
|
|
93
|
+
fromLocal++;
|
|
94
|
+
}
|
|
95
|
+
else if (rootSecretKeys.has(refName)) {
|
|
96
|
+
console.log(` ${pc.green(keyPadded)} = ${pc.dim(originalValue)} ${pc.dim('→')} ${masked}`);
|
|
97
|
+
fromRoot++;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
console.log(` ${pc.yellow(keyPadded)} = ${pc.dim(originalValue)} ${pc.dim('→')} ${pc.yellow('(unresolved)')}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
console.log(` ${pc.white(keyPadded)} = ${masked} ${pc.dim('(literal)')}`);
|
|
105
|
+
fromLocal++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
console.log('');
|
|
109
|
+
console.log(pc.dim(`Total: ${resolvedVars.length} variables (${fromRoot} from root, ${fromLocal} local/literal)`));
|
|
110
|
+
console.log('');
|
|
111
|
+
}
|
package/dist/config/loader.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { HushConfig } from '../types.js';
|
|
2
2
|
export declare function findConfigPath(root: string): string | null;
|
|
3
|
+
export declare function findProjectRoot(startDir: string): {
|
|
4
|
+
configPath: string;
|
|
5
|
+
projectRoot: string;
|
|
6
|
+
} | null;
|
|
3
7
|
export declare function loadConfig(root: string): HushConfig;
|
|
4
8
|
export declare function checkSchemaVersion(config: HushConfig): {
|
|
5
9
|
needsMigration: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../src/config/loader.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAK9C,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ1D;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,
|
|
1
|
+
{"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../../src/config/loader.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAK9C,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ1D;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAepG;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAoBnD;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,UAAU,GAAG;IAAE,cAAc,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAO5G;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,EAAE,CA8B3D"}
|
package/dist/config/loader.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, dirname, resolve } from 'node:path';
|
|
3
3
|
import { parse as parseYaml } from 'yaml';
|
|
4
4
|
import { DEFAULT_SOURCES, CURRENT_SCHEMA_VERSION } from '../types.js';
|
|
5
5
|
const CONFIG_FILENAMES = ['hush.yaml', 'hush.yml'];
|
|
@@ -12,6 +12,20 @@ export function findConfigPath(root) {
|
|
|
12
12
|
}
|
|
13
13
|
return null;
|
|
14
14
|
}
|
|
15
|
+
export function findProjectRoot(startDir) {
|
|
16
|
+
let currentDir = resolve(startDir);
|
|
17
|
+
while (true) {
|
|
18
|
+
const configPath = findConfigPath(currentDir);
|
|
19
|
+
if (configPath) {
|
|
20
|
+
return { configPath, projectRoot: currentDir };
|
|
21
|
+
}
|
|
22
|
+
const parentDir = dirname(currentDir);
|
|
23
|
+
if (parentDir === currentDir) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
currentDir = parentDir;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
15
29
|
export function loadConfig(root) {
|
|
16
30
|
const configPath = findConfigPath(root);
|
|
17
31
|
if (!configPath) {
|
|
@@ -23,7 +37,8 @@ export function loadConfig(root) {
|
|
|
23
37
|
const content = readFileSync(configPath, 'utf-8');
|
|
24
38
|
const parsed = parseYaml(content);
|
|
25
39
|
return {
|
|
26
|
-
version
|
|
40
|
+
// Support both 'version' and 'schema_version' (prefer schema_version)
|
|
41
|
+
version: parsed.schema_version ?? parsed.version,
|
|
27
42
|
project: parsed.project,
|
|
28
43
|
sources: { ...DEFAULT_SOURCES, ...parsed.sources },
|
|
29
44
|
targets: parsed.targets ?? [{ name: 'root', path: '.', format: 'dotenv' }],
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { EnvVar } from '../types.js';
|
|
2
|
-
export
|
|
3
|
-
|
|
2
|
+
export interface InterpolateOptions {
|
|
3
|
+
processEnv?: Record<string, string | undefined>;
|
|
4
|
+
baseContext?: Record<string, string>;
|
|
5
|
+
}
|
|
6
|
+
export declare function interpolateValue(value: string, context: Record<string, string>, options?: InterpolateOptions): string;
|
|
7
|
+
export declare function interpolateVars(vars: EnvVar[], options?: InterpolateOptions): EnvVar[];
|
|
4
8
|
export declare function hasUnresolvedVars(value: string): boolean;
|
|
5
9
|
export declare function getUnresolvedVars(vars: EnvVar[]): string[];
|
|
6
10
|
//# sourceMappingURL=interpolate.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"interpolate.d.ts","sourceRoot":"","sources":["../../src/core/interpolate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"interpolate.d.ts","sourceRoot":"","sources":["../../src/core/interpolate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAK1C,MAAM,WAAW,kBAAkB;IACjC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IAChD,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAED,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,OAAO,GAAE,kBAAuB,GAC/B,MAAM,CAoCR;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,GAAE,kBAAuB,GAAG,MAAM,EAAE,CA2B1F;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAU1D"}
|
package/dist/core/interpolate.js
CHANGED
|
@@ -1,14 +1,42 @@
|
|
|
1
1
|
const VAR_PATTERN = /\$\{([^}]+)\}/g;
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
const ENV_PREFIX = 'env:';
|
|
3
|
+
export function interpolateValue(value, context, options = {}) {
|
|
4
|
+
return value.replace(VAR_PATTERN, (match, expression) => {
|
|
5
|
+
if (expression.startsWith(ENV_PREFIX)) {
|
|
6
|
+
const envVarName = expression.slice(ENV_PREFIX.length);
|
|
7
|
+
const envValue = options.processEnv?.[envVarName];
|
|
8
|
+
return envValue ?? '';
|
|
9
|
+
}
|
|
10
|
+
const defaultMatch = expression.match(/^([^:]+):-(.*)$/);
|
|
11
|
+
if (defaultMatch) {
|
|
12
|
+
const [, varName, defaultValue] = defaultMatch;
|
|
13
|
+
if (varName in context && context[varName] !== '') {
|
|
14
|
+
const val = context[varName];
|
|
15
|
+
if (val === match) {
|
|
16
|
+
if (options.baseContext && varName in options.baseContext && options.baseContext[varName] !== '') {
|
|
17
|
+
return options.baseContext[varName];
|
|
18
|
+
}
|
|
19
|
+
return defaultValue;
|
|
20
|
+
}
|
|
21
|
+
return val;
|
|
22
|
+
}
|
|
23
|
+
return defaultValue;
|
|
24
|
+
}
|
|
25
|
+
if (expression in context) {
|
|
26
|
+
const val = context[expression];
|
|
27
|
+
if (val === match) {
|
|
28
|
+
if (options.baseContext && expression in options.baseContext) {
|
|
29
|
+
return options.baseContext[expression];
|
|
30
|
+
}
|
|
31
|
+
return match;
|
|
32
|
+
}
|
|
33
|
+
return val;
|
|
6
34
|
}
|
|
7
35
|
return match;
|
|
8
36
|
});
|
|
9
37
|
}
|
|
10
|
-
export function interpolateVars(vars) {
|
|
11
|
-
const context = {};
|
|
38
|
+
export function interpolateVars(vars, options = {}) {
|
|
39
|
+
const context = { ...(options.baseContext || {}) };
|
|
12
40
|
for (const { key, value } of vars) {
|
|
13
41
|
context[key] = value;
|
|
14
42
|
}
|
|
@@ -20,7 +48,7 @@ export function interpolateVars(vars) {
|
|
|
20
48
|
iteration++;
|
|
21
49
|
for (const key of Object.keys(context)) {
|
|
22
50
|
const original = context[key];
|
|
23
|
-
const interpolated = interpolateValue(original, context);
|
|
51
|
+
const interpolated = interpolateValue(original, context, options);
|
|
24
52
|
if (interpolated !== original) {
|
|
25
53
|
context[key] = interpolated;
|
|
26
54
|
changed = true;
|
package/dist/core/sops.d.ts
CHANGED
|
@@ -3,9 +3,5 @@ export declare function isAgeKeyConfigured(): boolean;
|
|
|
3
3
|
export declare function decrypt(filePath: string): string;
|
|
4
4
|
export declare function encrypt(inputPath: string, outputPath: string): void;
|
|
5
5
|
export declare function edit(filePath: string): void;
|
|
6
|
-
/**
|
|
7
|
-
* Set a single key in an encrypted file.
|
|
8
|
-
* Decrypts to memory, updates the key, re-encrypts.
|
|
9
|
-
*/
|
|
10
6
|
export declare function setKey(filePath: string, key: string, value: string): void;
|
|
11
7
|
//# sourceMappingURL=sops.d.ts.map
|
package/dist/core/sops.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sops.d.ts","sourceRoot":"","sources":["../../src/core/sops.ts"],"names":[],"mappings":"AA0BA,wBAAgB,eAAe,IAAI,OAAO,CAUzC;AAED,wBAAgB,kBAAkB,IAAI,OAAO,CAE5C;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA6BhD;AAED,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAsBnE;AAED,wBAAgB,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAsB3C;AAED
|
|
1
|
+
{"version":3,"file":"sops.d.ts","sourceRoot":"","sources":["../../src/core/sops.ts"],"names":[],"mappings":"AA0BA,wBAAgB,eAAe,IAAI,OAAO,CAUzC;AAED,wBAAgB,kBAAkB,IAAI,OAAO,CAE5C;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA6BhD;AAED,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAsBnE;AAED,wBAAgB,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAsB3C;AAED,wBAAgB,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CA+CzE"}
|
package/dist/core/sops.js
CHANGED
|
@@ -87,16 +87,12 @@ export function edit(filePath) {
|
|
|
87
87
|
const result = spawnSync('sops', ['--input-type', 'dotenv', '--output-type', 'dotenv', filePath], {
|
|
88
88
|
stdio: 'inherit',
|
|
89
89
|
env: getSopsEnv(),
|
|
90
|
-
shell: true
|
|
90
|
+
shell: true
|
|
91
91
|
});
|
|
92
92
|
if (result.status !== 0) {
|
|
93
93
|
throw new Error(`SOPS edit failed with exit code ${result.status}`);
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
-
/**
|
|
97
|
-
* Set a single key in an encrypted file.
|
|
98
|
-
* Decrypts to memory, updates the key, re-encrypts.
|
|
99
|
-
*/
|
|
100
96
|
export function setKey(filePath, key, value) {
|
|
101
97
|
if (!isSopsInstalled()) {
|
|
102
98
|
throw new Error('SOPS is not installed. Install with: brew install sops');
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type InterpolateOptions } from './interpolate.js';
|
|
2
|
+
import type { EnvVar, Environment } from '../types.js';
|
|
3
|
+
export interface LocalTemplateResult {
|
|
4
|
+
hasTemplate: boolean;
|
|
5
|
+
templateDir: string;
|
|
6
|
+
vars: EnvVar[];
|
|
7
|
+
files: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function loadLocalTemplates(contextDir: string, env: Environment): LocalTemplateResult;
|
|
10
|
+
export declare function resolveTemplateVars(templateVars: EnvVar[], rootSecrets: Record<string, string>, options?: InterpolateOptions): EnvVar[];
|
|
11
|
+
//# sourceMappingURL=template.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../../src/core/template.ts"],"names":[],"mappings":"AAIA,OAAO,EAAmB,KAAK,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC5E,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AASvD,MAAM,WAAW,mBAAmB;IAClC,WAAW,EAAE,OAAO,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,WAAW,GACf,mBAAmB,CAqCrB;AAED,wBAAgB,mBAAmB,CACjC,YAAY,EAAE,MAAM,EAAE,EACtB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACnC,OAAO,GAAE,kBAAuB,GAC/B,MAAM,EAAE,CAQV"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseEnvContent } from './parse.js';
|
|
4
|
+
import { mergeVars } from './merge.js';
|
|
5
|
+
import { interpolateVars } from './interpolate.js';
|
|
6
|
+
const TEMPLATE_FILES = {
|
|
7
|
+
base: '.env',
|
|
8
|
+
development: '.env.development',
|
|
9
|
+
production: '.env.production',
|
|
10
|
+
local: '.env.local',
|
|
11
|
+
};
|
|
12
|
+
export function loadLocalTemplates(contextDir, env) {
|
|
13
|
+
const files = [];
|
|
14
|
+
const varSources = [];
|
|
15
|
+
const basePath = join(contextDir, TEMPLATE_FILES.base);
|
|
16
|
+
if (existsSync(basePath)) {
|
|
17
|
+
files.push(TEMPLATE_FILES.base);
|
|
18
|
+
varSources.push(parseEnvContent(readFileSync(basePath, 'utf-8')));
|
|
19
|
+
}
|
|
20
|
+
const envPath = join(contextDir, TEMPLATE_FILES[env]);
|
|
21
|
+
if (existsSync(envPath)) {
|
|
22
|
+
files.push(TEMPLATE_FILES[env]);
|
|
23
|
+
varSources.push(parseEnvContent(readFileSync(envPath, 'utf-8')));
|
|
24
|
+
}
|
|
25
|
+
const localPath = join(contextDir, TEMPLATE_FILES.local);
|
|
26
|
+
if (existsSync(localPath)) {
|
|
27
|
+
files.push(TEMPLATE_FILES.local);
|
|
28
|
+
varSources.push(parseEnvContent(readFileSync(localPath, 'utf-8')));
|
|
29
|
+
}
|
|
30
|
+
if (varSources.length === 0) {
|
|
31
|
+
return {
|
|
32
|
+
hasTemplate: false,
|
|
33
|
+
templateDir: contextDir,
|
|
34
|
+
vars: [],
|
|
35
|
+
files: [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
hasTemplate: true,
|
|
40
|
+
templateDir: contextDir,
|
|
41
|
+
vars: mergeVars(...varSources),
|
|
42
|
+
files,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function resolveTemplateVars(templateVars, rootSecrets, options = {}) {
|
|
46
|
+
const interpolated = interpolateVars(templateVars, {
|
|
47
|
+
...options,
|
|
48
|
+
baseContext: rootSecrets
|
|
49
|
+
});
|
|
50
|
+
const templateKeys = new Set(templateVars.map(v => v.key));
|
|
51
|
+
return interpolated.filter(v => templateKeys.has(v.key));
|
|
52
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare const OP_ITEM_PREFIX = "SOPS Key - hush/";
|
|
2
|
+
export declare function opInstalled(): boolean;
|
|
2
3
|
export declare function opAvailable(): boolean;
|
|
3
4
|
export declare function opGetKey(project: string, vault?: string): string | null;
|
|
4
5
|
export declare function opStoreKey(project: string, privateKey: string, publicKey: string, vault?: string): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"onepassword.d.ts","sourceRoot":"","sources":["../../src/lib/onepassword.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,cAAc,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"onepassword.d.ts","sourceRoot":"","sources":["../../src/lib/onepassword.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,cAAc,qBAAqB,CAAC;AAUjD,wBAAgB,WAAW,IAAI,OAAO,CAOrC;AAED,wBAAgB,WAAW,IAAI,OAAO,CAOrC;AAED,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CASvE;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAiBvG;AAED,wBAAgB,UAAU,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAYnD"}
|
package/dist/lib/onepassword.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
2
|
export const OP_ITEM_PREFIX = 'SOPS Key - hush/';
|
|
3
|
-
/**
|
|
4
|
-
* 1Password CLI sessions don't persist across subprocesses, so we run
|
|
5
|
-
* `op signin` before every command to trigger biometric auth.
|
|
6
|
-
*/
|
|
7
3
|
function opExec(command) {
|
|
8
4
|
return execSync(`op signin && ${command}`, {
|
|
9
5
|
encoding: 'utf-8',
|
|
@@ -11,6 +7,15 @@ function opExec(command) {
|
|
|
11
7
|
shell: '/bin/bash',
|
|
12
8
|
});
|
|
13
9
|
}
|
|
10
|
+
export function opInstalled() {
|
|
11
|
+
try {
|
|
12
|
+
execSync('which op', { encoding: 'utf-8', stdio: 'pipe' });
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
14
19
|
export function opAvailable() {
|
|
15
20
|
try {
|
|
16
21
|
opExec('op whoami');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chriscode/hush",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "SOPS-based secrets management for monorepos. Encrypt once, decrypt everywhere.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"dev": "tsc --watch",
|
|
18
18
|
"test": "vitest run",
|
|
19
19
|
"test:watch": "vitest",
|
|
20
|
-
"prepublishOnly": "
|
|
20
|
+
"prepublishOnly": "bun run build && bun run test",
|
|
21
21
|
"type-check": "tsc --noEmit"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|