@chriscode/hush 3.1.1 → 4.0.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 +10 -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 +47 -11
- package/dist/commands/skill.d.ts.map +1 -1
- package/dist/commands/skill.js +33 -0
- 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 +15 -1
- 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/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)
|
|
@@ -373,6 +377,12 @@ async function main() {
|
|
|
373
377
|
}
|
|
374
378
|
await traceCommand({ root, env, key });
|
|
375
379
|
break;
|
|
380
|
+
case 'template':
|
|
381
|
+
await templateCommand({ root, env });
|
|
382
|
+
break;
|
|
383
|
+
case 'expansions':
|
|
384
|
+
await expansionsCommand({ root, env });
|
|
385
|
+
break;
|
|
376
386
|
default:
|
|
377
387
|
if (command) {
|
|
378
388
|
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;AA4C/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);
|
|
@@ -34,6 +35,13 @@ function getDecryptedSecrets(root, env, config) {
|
|
|
34
35
|
const merged = mergeVars(...varSources);
|
|
35
36
|
return interpolateVars(merged);
|
|
36
37
|
}
|
|
38
|
+
function getRootSecretsAsRecord(vars) {
|
|
39
|
+
const record = {};
|
|
40
|
+
for (const { key, value } of vars) {
|
|
41
|
+
record[key] = value;
|
|
42
|
+
}
|
|
43
|
+
return record;
|
|
44
|
+
}
|
|
37
45
|
export async function runCommand(options) {
|
|
38
46
|
const { root, env, target, command } = options;
|
|
39
47
|
if (!command || command.length === 0) {
|
|
@@ -43,20 +51,48 @@ export async function runCommand(options) {
|
|
|
43
51
|
console.error(pc.dim(' hush run --target api -- wrangler dev'));
|
|
44
52
|
process.exit(1);
|
|
45
53
|
}
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
if (
|
|
54
|
+
const contextDir = root;
|
|
55
|
+
const projectInfo = findProjectRoot(contextDir);
|
|
56
|
+
if (!projectInfo) {
|
|
57
|
+
console.error(pc.red('No hush.yaml found in current directory or any parent directory.'));
|
|
58
|
+
console.error(pc.dim('Run: npx hush init'));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const { projectRoot } = projectInfo;
|
|
62
|
+
const config = loadConfig(projectRoot);
|
|
63
|
+
const rootSecrets = getDecryptedSecrets(projectRoot, env, config);
|
|
64
|
+
const rootSecretsRecord = getRootSecretsAsRecord(rootSecrets);
|
|
65
|
+
const localTemplate = loadLocalTemplates(contextDir, env);
|
|
66
|
+
let vars;
|
|
67
|
+
if (localTemplate.hasTemplate) {
|
|
68
|
+
vars = resolveTemplateVars(localTemplate.vars, rootSecretsRecord, { processEnv: process.env });
|
|
69
|
+
}
|
|
70
|
+
else if (target) {
|
|
49
71
|
const targetConfig = config.targets.find(t => t.name === target);
|
|
50
72
|
if (!targetConfig) {
|
|
51
73
|
console.error(pc.red(`Target "${target}" not found in hush.yaml`));
|
|
52
74
|
console.error(pc.dim(`Available targets: ${config.targets.map(t => t.name).join(', ')}`));
|
|
53
75
|
process.exit(1);
|
|
54
76
|
}
|
|
55
|
-
vars = filterVarsForTarget(
|
|
77
|
+
vars = filterVarsForTarget(rootSecrets, targetConfig);
|
|
78
|
+
if (targetConfig.format === 'wrangler') {
|
|
79
|
+
vars.push({ key: 'CLOUDFLARE_INCLUDE_PROCESS_ENV', value: 'true' });
|
|
80
|
+
const devVarsPath = join(targetConfig.path, '.dev.vars');
|
|
81
|
+
const absDevVarsPath = join(projectRoot, devVarsPath);
|
|
82
|
+
if (existsSync(absDevVarsPath)) {
|
|
83
|
+
console.warn(pc.yellow('\n⚠️ Wrangler Conflict Detected'));
|
|
84
|
+
console.warn(pc.yellow(` Found .dev.vars in ${targetConfig.path}`));
|
|
85
|
+
console.warn(pc.yellow(' Wrangler will IGNORE Hush secrets while this file exists.'));
|
|
86
|
+
console.warn(pc.bold(` Fix: rm ${devVarsPath}\n`));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
vars = rootSecrets;
|
|
56
92
|
}
|
|
57
93
|
const unresolved = getUnresolvedVars(vars);
|
|
58
94
|
if (unresolved.length > 0) {
|
|
59
|
-
console.warn(pc.yellow(`Warning: ${unresolved.length} vars have unresolved references`));
|
|
95
|
+
console.warn(pc.yellow(`Warning: ${unresolved.length} vars have unresolved references: ${unresolved.join(', ')}`));
|
|
60
96
|
}
|
|
61
97
|
const childEnv = {
|
|
62
98
|
...process.env,
|
|
@@ -67,7 +103,7 @@ export async function runCommand(options) {
|
|
|
67
103
|
stdio: 'inherit',
|
|
68
104
|
env: childEnv,
|
|
69
105
|
shell: true,
|
|
70
|
-
cwd:
|
|
106
|
+
cwd: contextDir,
|
|
71
107
|
});
|
|
72
108
|
if (result.error) {
|
|
73
109
|
console.error(pc.red(`Failed to execute: ${result.error.message}`));
|
|
@@ -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;AAqrChD,wBAAsB,YAAY,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA0CvE"}
|
package/dist/commands/skill.js
CHANGED
|
@@ -859,6 +859,28 @@ targets:
|
|
|
859
859
|
| SvelteKit | \`PUBLIC_*\` | \`include: [PUBLIC_*]\` |
|
|
860
860
|
| Expo | \`EXPO_PUBLIC_*\` | \`include: [EXPO_PUBLIC_*]\` |
|
|
861
861
|
| Gatsby | \`GATSBY_*\` | \`include: [GATSBY_*]\` |
|
|
862
|
+
|
|
863
|
+
### Variable Interpolation (v4+)
|
|
864
|
+
|
|
865
|
+
Reference other variables using \`\${VAR}\` syntax:
|
|
866
|
+
|
|
867
|
+
\`\`\`bash
|
|
868
|
+
# Basic interpolation
|
|
869
|
+
API_URL=\${BASE_URL}/api
|
|
870
|
+
|
|
871
|
+
# Default values (if VAR is unset or empty)
|
|
872
|
+
DEBUG=\${DEBUG:-false}
|
|
873
|
+
PORT=\${PORT:-3000}
|
|
874
|
+
|
|
875
|
+
# System environment (explicit opt-in)
|
|
876
|
+
PATH=\${env:HOME}/.local/bin
|
|
877
|
+
|
|
878
|
+
# Pull from root (subdirectory .env can reference root secrets)
|
|
879
|
+
# apps/web/.env.development:
|
|
880
|
+
DATABASE_URL=\${DATABASE_URL} # Inherited from root .env
|
|
881
|
+
\`\`\`
|
|
882
|
+
|
|
883
|
+
**Resolution order:** Local value → Parent directories → System env (only with \`env:\` prefix)
|
|
862
884
|
`,
|
|
863
885
|
'examples/workflows.md': `# Hush Workflow Examples
|
|
864
886
|
|
|
@@ -1016,6 +1038,17 @@ npx hush resolve <target-name> -e prod # Check production
|
|
|
1016
1038
|
|
|
1017
1039
|
Look at the 🚫 EXCLUDED section to see which pattern is filtering out your variable.
|
|
1018
1040
|
|
|
1041
|
+
### "Wrangler dev not seeing secrets"
|
|
1042
|
+
|
|
1043
|
+
If you are using \`hush run -- wrangler dev\` and secrets are missing, Wrangler is likely being blocked by a local file.
|
|
1044
|
+
|
|
1045
|
+
**The Fix:**
|
|
1046
|
+
1. **Delete .dev.vars**: Run \`rm .dev.vars\` inside your worker directory.
|
|
1047
|
+
2. **Run normally**: \`hush run -- wrangler dev\`
|
|
1048
|
+
|
|
1049
|
+
**Explanation:**
|
|
1050
|
+
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.
|
|
1051
|
+
|
|
1019
1052
|
### "Variable appears in wrong places"
|
|
1020
1053
|
|
|
1021
1054
|
\`\`\`bash
|
|
@@ -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,CAmBnD;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"}
|
|
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,CAmBnD;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) {
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chriscode/hush",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.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": [
|