@chriscode/hush 3.1.0 → 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 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,7 @@
1
+ import type { Environment } from '../types.js';
2
+ export interface ExpansionsOptions {
3
+ root: string;
4
+ env: Environment;
5
+ }
6
+ export declare function expansionsCommand(options: ExpansionsOptions): Promise<void>;
7
+ //# sourceMappingURL=expansions.d.ts.map
@@ -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":"AAUA,OAAO,KAAK,EAAE,UAAU,EAAmC,MAAM,aAAa,CAAC;AAoC/E,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAkDnE"}
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"}
@@ -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(root, env, config) {
15
- const sharedEncrypted = join(root, getEncryptedPath(config.sources.shared));
16
- const envEncrypted = join(root, getEncryptedPath(config.sources[env]));
17
- const localEncrypted = join(root, getEncryptedPath(config.sources.local));
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 config = loadConfig(root);
47
- let vars = getDecryptedSecrets(root, env, config);
48
- if (target) {
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(vars, targetConfig);
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: root,
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;AAopChD,wBAAsB,YAAY,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA0CvE"}
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"}
@@ -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,7 @@
1
+ import type { Environment } from '../types.js';
2
+ export interface TemplateOptions {
3
+ root: string;
4
+ env: Environment;
5
+ }
6
+ export declare function templateCommand(options: TemplateOptions): Promise<void>;
7
+ //# sourceMappingURL=template.d.ts.map
@@ -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
+ }
@@ -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"}
@@ -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 declare function interpolateValue(value: string, context: Record<string, string>): string;
3
- export declare function interpolateVars(vars: EnvVar[]): EnvVar[];
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;AAI1C,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAOvF;AAED,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CA2BxD;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAU1D"}
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"}
@@ -1,14 +1,42 @@
1
1
  const VAR_PATTERN = /\$\{([^}]+)\}/g;
2
- export function interpolateValue(value, context) {
3
- return value.replace(VAR_PATTERN, (match, varName) => {
4
- if (varName in context) {
5
- return context[varName];
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;
@@ -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
@@ -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;;;GAGG;AACH,wBAAgB,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CA+CzE"}
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 // Required to find executable on Windows
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.1.0",
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": "pnpm build && pnpm test",
20
+ "prepublishOnly": "bun run build && bun run test",
21
21
  "type-check": "tsc --noEmit"
22
22
  },
23
23
  "keywords": [