@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 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,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;AAkD/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);
@@ -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. Expected: ${sharedEncrypted}`);
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 config = loadConfig(root);
47
- let vars = getDecryptedSecrets(root, env, config);
48
- if (target) {
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(vars, targetConfig);
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: root,
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;AAwK9C,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA0CnE"}
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"}
@@ -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;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;AAu+ChD,wBAAsB,YAAY,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA0CvE"}
@@ -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**. When properly set up, all secrets are stored in \`.env.encrypted\` files and plaintext \`.env\` files should NOT exist.
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 exist! | Run \`npx hush encrypt\` immediately |
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
- | \`1Password backup: not synced\` | Key not backed up | \`npx hush keys push\` |
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
- | \`cat .env.encrypted\` | Read encrypted file (safe!) |
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;AA8DjD,wBAAsB,aAAa,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAyHzE"}
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"}
@@ -1,45 +1,21 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
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 { opAvailable, opListKeys } from '../lib/onepassword.js';
8
+ import { opInstalled } from '../lib/onepassword.js';
9
9
  import { FORMAT_OUTPUT_FILES } from '../types.js';
10
- function findPlaintextEnvFiles(root) {
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
- const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.nuxt']);
14
- function scanDir(dir, relativePath = '') {
15
- let entries;
16
- try {
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 = findPlaintextEnvFiles(root);
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 (opAvailable()) {
121
- console.log(has1PasswordBackup
122
- ? pc.green(' 1Password backup: synced')
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 available'));
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,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,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"}
@@ -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: parsed.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 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
+ }
@@ -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;AAcjD,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"}
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"}
@@ -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.1.1",
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": "pnpm build && pnpm test",
20
+ "prepublishOnly": "bun run build && bun run test",
21
21
  "type-check": "tsc --noEmit"
22
22
  },
23
23
  "keywords": [