@chriscode/hush 1.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/LICENSE +21 -0
- package/README.md +194 -0
- package/bin/hush.js +2 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +107 -0
- package/dist/commands/decrypt.d.ts +11 -0
- package/dist/commands/decrypt.d.ts.map +1 -0
- package/dist/commands/decrypt.js +105 -0
- package/dist/commands/edit.d.ts +9 -0
- package/dist/commands/edit.d.ts.map +1 -0
- package/dist/commands/edit.js +28 -0
- package/dist/commands/encrypt.d.ts +9 -0
- package/dist/commands/encrypt.d.ts.map +1 -0
- package/dist/commands/encrypt.js +30 -0
- package/dist/commands/push.d.ts +10 -0
- package/dist/commands/push.d.ts.map +1 -0
- package/dist/commands/push.js +101 -0
- package/dist/commands/status.d.ts +9 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +58 -0
- package/dist/core/discover.d.ts +6 -0
- package/dist/core/discover.d.ts.map +1 -0
- package/dist/core/discover.js +81 -0
- package/dist/core/parse.d.ts +30 -0
- package/dist/core/parse.d.ts.map +1 -0
- package/dist/core/parse.js +108 -0
- package/dist/core/sops.d.ts +17 -0
- package/dist/core/sops.d.ts.map +1 -0
- package/dist/core/sops.js +112 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Chris Hasson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# @chriscode/hush
|
|
2
|
+
|
|
3
|
+
> SOPS-based secrets management for monorepos. Encrypt once, decrypt everywhere.
|
|
4
|
+
|
|
5
|
+
Hush manages secrets across your monorepo using [SOPS](https://github.com/getsops/sops) with [age](https://github.com/FiloSottile/age) encryption. It automatically detects your packages and generates the right env files for each.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Single encrypted file** - One `.env.encrypted` committed to git
|
|
10
|
+
- **Environment prefixes** - `DEV__` and `PROD__` for env-specific values
|
|
11
|
+
- **Auto-detection** - Finds packages, detects Wrangler vs standard
|
|
12
|
+
- **Smart routing** - `EXPO_PUBLIC_*` to apps, other vars to APIs
|
|
13
|
+
- **Cloudflare integration** - Push secrets to Workers with one command
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add -D @chriscode/hush
|
|
19
|
+
# or
|
|
20
|
+
npm install -D @chriscode/hush
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Prerequisites
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
brew install sops age
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Set up your age key:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
mkdir -p ~/.config/sops/age
|
|
33
|
+
age-keygen -o ~/.config/sops/age/key.txt
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
### 1. Create `.sops.yaml` in your repo root
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
creation_rules:
|
|
42
|
+
- encrypted_regex: '.*'
|
|
43
|
+
age: YOUR_AGE_PUBLIC_KEY
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Get your public key from `~/.config/sops/age/key.txt`.
|
|
47
|
+
|
|
48
|
+
### 2. Create `.env` with your secrets
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Shared across all environments
|
|
52
|
+
DATABASE_URL=postgres://localhost/mydb
|
|
53
|
+
EXPO_PUBLIC_API_KEY=pk_xxx
|
|
54
|
+
|
|
55
|
+
# Development only (prefix stripped on decrypt)
|
|
56
|
+
DEV__EXPO_PUBLIC_API_URL=http://localhost:8787
|
|
57
|
+
|
|
58
|
+
# Production only
|
|
59
|
+
PROD__EXPO_PUBLIC_API_URL=https://api.example.com
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 3. Encrypt and use
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Encrypt your secrets
|
|
66
|
+
npx hush encrypt
|
|
67
|
+
|
|
68
|
+
# Decrypt for local development
|
|
69
|
+
npx hush decrypt
|
|
70
|
+
|
|
71
|
+
# Check your setup
|
|
72
|
+
npx hush status
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
| Command | Description |
|
|
78
|
+
|---------|-------------|
|
|
79
|
+
| `hush decrypt` | Decrypt and generate env files for all packages |
|
|
80
|
+
| `hush decrypt --env prod` | Decrypt with production values |
|
|
81
|
+
| `hush encrypt` | Encrypt `.env` to `.env.encrypted` |
|
|
82
|
+
| `hush edit` | Edit encrypted file in `$EDITOR` |
|
|
83
|
+
| `hush push` | Push production secrets to Cloudflare Workers |
|
|
84
|
+
| `hush push --dry-run` | Preview what would be pushed |
|
|
85
|
+
| `hush status` | Show setup status and discovered packages |
|
|
86
|
+
|
|
87
|
+
## Package Scripts
|
|
88
|
+
|
|
89
|
+
Add to your `package.json`:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"scripts": {
|
|
94
|
+
"secrets": "hush",
|
|
95
|
+
"secrets:decrypt": "hush decrypt",
|
|
96
|
+
"secrets:encrypt": "hush encrypt",
|
|
97
|
+
"secrets:edit": "hush edit",
|
|
98
|
+
"secrets:push": "hush push",
|
|
99
|
+
"secrets:status": "hush status"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## How It Works
|
|
105
|
+
|
|
106
|
+
### Package Detection
|
|
107
|
+
|
|
108
|
+
Hush scans for `package.json` files and determines the package type:
|
|
109
|
+
|
|
110
|
+
| Detection | Type | Output |
|
|
111
|
+
|-----------|------|--------|
|
|
112
|
+
| `wrangler.toml` (Workers) | wrangler | `.dev.vars` |
|
|
113
|
+
| `wrangler.toml` with `pages_build_output_dir` | standard | `.env.development` |
|
|
114
|
+
| No wrangler.toml | standard | `.env.development` |
|
|
115
|
+
|
|
116
|
+
### Variable Routing
|
|
117
|
+
|
|
118
|
+
- `EXPO_PUBLIC_*` → Standard packages only (apps, Pages)
|
|
119
|
+
- Other variables → Wrangler packages only (Workers APIs)
|
|
120
|
+
|
|
121
|
+
This ensures client-side vars go to your app and server secrets go to your API.
|
|
122
|
+
|
|
123
|
+
### Environment Prefixes
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# No prefix = shared
|
|
127
|
+
API_KEY=xxx
|
|
128
|
+
|
|
129
|
+
# DEV__ prefix = development only (stripped to API_KEY)
|
|
130
|
+
DEV__API_KEY=dev_xxx
|
|
131
|
+
|
|
132
|
+
# PROD__ prefix = production only (stripped to API_KEY)
|
|
133
|
+
PROD__API_KEY=prod_xxx
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
When you run `hush decrypt`:
|
|
137
|
+
- `--env dev` (default): Uses `DEV__` vars, ignores `PROD__`
|
|
138
|
+
- `--env prod`: Uses `PROD__` vars, ignores `DEV__`
|
|
139
|
+
|
|
140
|
+
## Local Overrides
|
|
141
|
+
|
|
142
|
+
Create `.env.local` (gitignored) for personal overrides:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# .env.local
|
|
146
|
+
MY_DEBUG_VAR=true
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Local overrides are merged last and take precedence.
|
|
150
|
+
|
|
151
|
+
## Programmatic Usage
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import {
|
|
155
|
+
discoverPackages,
|
|
156
|
+
decrypt,
|
|
157
|
+
parseEnvContent,
|
|
158
|
+
getVarsForEnvironment
|
|
159
|
+
} from '@chriscode/hush';
|
|
160
|
+
|
|
161
|
+
const packages = await discoverPackages('/path/to/monorepo');
|
|
162
|
+
const content = decrypt('/path/to/.env.encrypted');
|
|
163
|
+
const vars = parseEnvContent(content);
|
|
164
|
+
const devVars = getVarsForEnvironment(vars, 'dev');
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## File Reference
|
|
168
|
+
|
|
169
|
+
| File | Committed | Purpose |
|
|
170
|
+
|------|-----------|---------|
|
|
171
|
+
| `.env.encrypted` | Yes | Encrypted secrets (source of truth) |
|
|
172
|
+
| `.sops.yaml` | Yes | SOPS config with public key |
|
|
173
|
+
| `.env` | No | Generated root env |
|
|
174
|
+
| `.env.local` | No | Personal overrides |
|
|
175
|
+
| `.env.development` | No | Generated dev env |
|
|
176
|
+
| `.env.production` | No | Generated prod env |
|
|
177
|
+
| `api/.dev.vars` | No | Generated Wrangler secrets |
|
|
178
|
+
|
|
179
|
+
## Troubleshooting
|
|
180
|
+
|
|
181
|
+
### "No identity matched"
|
|
182
|
+
Your age key doesn't match. Get the correct key from a team member.
|
|
183
|
+
|
|
184
|
+
### "SOPS is not installed"
|
|
185
|
+
```bash
|
|
186
|
+
brew install sops
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Package detected as wrong type
|
|
190
|
+
Check for unexpected `wrangler.toml` files. For Cloudflare Pages, ensure it has `pages_build_output_dir` to be treated as standard.
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
package/bin/hush.js
ADDED
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { decryptCommand } from './commands/decrypt.js';
|
|
5
|
+
import { editCommand } from './commands/edit.js';
|
|
6
|
+
import { encryptCommand } from './commands/encrypt.js';
|
|
7
|
+
import { pushCommand } from './commands/push.js';
|
|
8
|
+
import { statusCommand } from './commands/status.js';
|
|
9
|
+
const HELP = `
|
|
10
|
+
${pc.bold('hush')} - SOPS-based secrets management for monorepos
|
|
11
|
+
|
|
12
|
+
${pc.bold('Usage:')}
|
|
13
|
+
hush <command> [options]
|
|
14
|
+
|
|
15
|
+
${pc.bold('Commands:')}
|
|
16
|
+
decrypt Decrypt .env.encrypted and generate env files for all packages
|
|
17
|
+
encrypt Encrypt .env to .env.encrypted
|
|
18
|
+
push Push production secrets to Cloudflare Workers
|
|
19
|
+
edit Open .env.encrypted in editor (SOPS inline edit)
|
|
20
|
+
status Show discovered packages and their styles
|
|
21
|
+
help Show this help message
|
|
22
|
+
|
|
23
|
+
${pc.bold('Options:')}
|
|
24
|
+
--env <dev|prod> Target environment (default: dev)
|
|
25
|
+
--dry-run Don't make changes, just show what would happen
|
|
26
|
+
--root <path> Monorepo root directory (default: cwd)
|
|
27
|
+
|
|
28
|
+
${pc.bold('Examples:')}
|
|
29
|
+
hush decrypt Decrypt for development
|
|
30
|
+
hush decrypt --env prod Decrypt for production
|
|
31
|
+
hush encrypt Encrypt .env file
|
|
32
|
+
hush push Push prod secrets to Wrangler
|
|
33
|
+
hush push --dry-run Preview what would be pushed
|
|
34
|
+
hush edit Edit encrypted file in $EDITOR
|
|
35
|
+
hush status Show package detection info
|
|
36
|
+
`;
|
|
37
|
+
function parseArgs(args) {
|
|
38
|
+
const result = {
|
|
39
|
+
command: 'help',
|
|
40
|
+
env: 'dev',
|
|
41
|
+
dryRun: false,
|
|
42
|
+
root: process.cwd(),
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < args.length; i++) {
|
|
45
|
+
const arg = args[i];
|
|
46
|
+
if (arg === '--env' && args[i + 1]) {
|
|
47
|
+
const envArg = args[i + 1];
|
|
48
|
+
if (envArg === 'dev' || envArg === 'prod') {
|
|
49
|
+
result.env = envArg;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.error(pc.red(`Invalid environment: ${envArg}`));
|
|
53
|
+
console.error(pc.dim('Valid values: dev, prod'));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
else if (arg === '--dry-run') {
|
|
59
|
+
result.dryRun = true;
|
|
60
|
+
}
|
|
61
|
+
else if (arg === '--root' && args[i + 1]) {
|
|
62
|
+
result.root = resolve(args[i + 1]);
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
else if (!arg.startsWith('-') && !result.command) {
|
|
66
|
+
result.command = arg;
|
|
67
|
+
}
|
|
68
|
+
else if (!arg.startsWith('-')) {
|
|
69
|
+
result.command = arg;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
async function main() {
|
|
75
|
+
const args = process.argv.slice(2);
|
|
76
|
+
const parsed = parseArgs(args);
|
|
77
|
+
switch (parsed.command) {
|
|
78
|
+
case 'decrypt':
|
|
79
|
+
await decryptCommand({ root: parsed.root, env: parsed.env });
|
|
80
|
+
break;
|
|
81
|
+
case 'encrypt':
|
|
82
|
+
await encryptCommand({ root: parsed.root });
|
|
83
|
+
break;
|
|
84
|
+
case 'push':
|
|
85
|
+
await pushCommand({ root: parsed.root, dryRun: parsed.dryRun });
|
|
86
|
+
break;
|
|
87
|
+
case 'edit':
|
|
88
|
+
await editCommand({ root: parsed.root });
|
|
89
|
+
break;
|
|
90
|
+
case 'status':
|
|
91
|
+
await statusCommand({ root: parsed.root });
|
|
92
|
+
break;
|
|
93
|
+
case 'help':
|
|
94
|
+
case '--help':
|
|
95
|
+
case '-h':
|
|
96
|
+
console.log(HELP);
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
console.error(pc.red(`Unknown command: ${parsed.command}`));
|
|
100
|
+
console.log(HELP);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
main().catch((error) => {
|
|
105
|
+
console.error(pc.red('Fatal error:'), error.message);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Environment } from '../types.js';
|
|
2
|
+
interface DecryptOptions {
|
|
3
|
+
root: string;
|
|
4
|
+
env?: Environment;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Decrypt command - decrypt .env.encrypted and generate env files for all packages
|
|
8
|
+
*/
|
|
9
|
+
export declare function decryptCommand(options: DecryptOptions): Promise<void>;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=decrypt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decrypt.d.ts","sourceRoot":"","sources":["../../src/commands/decrypt.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAmB,MAAM,aAAa,CAAC;AAEhE,UAAU,cAAc;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,WAAW,CAAC;CACnB;AAqCD;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAwF3E"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { discoverPackages } from '../core/discover.js';
|
|
5
|
+
import { expandVariables, formatEnvFile, getVarsForEnvironment, mergeEnvVars, parseEnvContent, parseEnvFile, } from '../core/parse.js';
|
|
6
|
+
import { decrypt as sopsDecrypt } from '../core/sops.js';
|
|
7
|
+
/**
|
|
8
|
+
* Get output files for a package based on its style and environment
|
|
9
|
+
*/
|
|
10
|
+
function getOutputFiles(pkg, env) {
|
|
11
|
+
if (pkg.style === 'wrangler') {
|
|
12
|
+
// Wrangler always uses .dev.vars (even for "prod" we generate dev vars for local testing)
|
|
13
|
+
return [{ path: '.dev.vars', env }];
|
|
14
|
+
}
|
|
15
|
+
// Standard style outputs environment-specific files
|
|
16
|
+
if (env === 'dev') {
|
|
17
|
+
return [{ path: '.env.development', env: 'dev' }];
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
return [{ path: '.env.production', env: 'prod' }];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Filter variables for a package based on naming conventions
|
|
25
|
+
* - EXPO_PUBLIC_* vars only go to non-wrangler packages
|
|
26
|
+
* - Other vars go to wrangler packages
|
|
27
|
+
*/
|
|
28
|
+
function filterVarsForPackage(vars, pkg) {
|
|
29
|
+
if (pkg.style === 'wrangler') {
|
|
30
|
+
// Wrangler gets everything EXCEPT EXPO_PUBLIC_* vars
|
|
31
|
+
return vars.filter((v) => !v.key.startsWith('EXPO_PUBLIC_'));
|
|
32
|
+
}
|
|
33
|
+
// Standard packages get all vars (including EXPO_PUBLIC_*)
|
|
34
|
+
return vars;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Decrypt command - decrypt .env.encrypted and generate env files for all packages
|
|
38
|
+
*/
|
|
39
|
+
export async function decryptCommand(options) {
|
|
40
|
+
const { root, env = 'dev' } = options;
|
|
41
|
+
const encryptedPath = join(root, '.env.encrypted');
|
|
42
|
+
const localPath = join(root, '.env.local');
|
|
43
|
+
// Check encrypted file exists
|
|
44
|
+
if (!existsSync(encryptedPath)) {
|
|
45
|
+
console.error(pc.red(`Error: ${encryptedPath} not found`));
|
|
46
|
+
console.error(pc.dim('Create it with: pnpm secrets encrypt (after creating .env)'));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
console.log(pc.blue('Decrypting secrets...'));
|
|
50
|
+
// Decrypt the encrypted file
|
|
51
|
+
let decryptedContent;
|
|
52
|
+
try {
|
|
53
|
+
decryptedContent = sopsDecrypt(encryptedPath);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error(pc.red(error.message));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
// Parse decrypted content
|
|
60
|
+
const allVars = parseEnvContent(decryptedContent);
|
|
61
|
+
console.log(pc.dim(` Parsed ${allVars.length} variables from .env.encrypted`));
|
|
62
|
+
// Get vars for the target environment
|
|
63
|
+
const envVars = getVarsForEnvironment(allVars, env);
|
|
64
|
+
console.log(pc.dim(` ${envVars.length} variables for ${env} environment`));
|
|
65
|
+
// Load local overrides if they exist
|
|
66
|
+
const localVars = parseEnvFile(localPath);
|
|
67
|
+
if (localVars.length > 0) {
|
|
68
|
+
console.log(pc.dim(` ${localVars.length} local overrides from .env.local`));
|
|
69
|
+
}
|
|
70
|
+
// Merge and expand
|
|
71
|
+
const mergedVars = mergeEnvVars(envVars, localVars);
|
|
72
|
+
const expandedVars = expandVariables(mergedVars);
|
|
73
|
+
// Discover packages
|
|
74
|
+
const packages = await discoverPackages(root);
|
|
75
|
+
console.log(pc.blue(`\nDiscovered ${packages.length} packages:`));
|
|
76
|
+
// Generate output files for each package
|
|
77
|
+
for (const pkg of packages) {
|
|
78
|
+
const pkgDir = pkg.path ? join(root, pkg.path) : root;
|
|
79
|
+
const outputFiles = getOutputFiles(pkg, env);
|
|
80
|
+
const pkgVars = filterVarsForPackage(expandedVars, pkg);
|
|
81
|
+
if (pkgVars.length === 0) {
|
|
82
|
+
console.log(pc.dim(` ${pkg.path || '.'} (${pkg.style}) - no applicable vars, skipped`));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const output of outputFiles) {
|
|
86
|
+
const outputPath = join(pkgDir, output.path);
|
|
87
|
+
// Ensure directory exists
|
|
88
|
+
const dir = dirname(outputPath);
|
|
89
|
+
if (!existsSync(dir)) {
|
|
90
|
+
mkdirSync(dir, { recursive: true });
|
|
91
|
+
}
|
|
92
|
+
// Write the file
|
|
93
|
+
const content = formatEnvFile(pkgVars);
|
|
94
|
+
writeFileSync(outputPath, content, 'utf-8');
|
|
95
|
+
const relativePath = pkg.path ? `${pkg.path}/${output.path}` : output.path;
|
|
96
|
+
console.log(pc.green(` ${relativePath}`) +
|
|
97
|
+
pc.dim(` (${pkg.style}, ${pkgVars.length} vars)`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Also write root .env with shared vars (useful for scripts)
|
|
101
|
+
const rootEnvPath = join(root, '.env');
|
|
102
|
+
writeFileSync(rootEnvPath, formatEnvFile(expandedVars), 'utf-8');
|
|
103
|
+
console.log(pc.green(` .env`) + pc.dim(` (root, ${expandedVars.length} vars)`));
|
|
104
|
+
console.log(pc.green('\nDecryption complete'));
|
|
105
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"edit.d.ts","sourceRoot":"","sources":["../../src/commands/edit.ts"],"names":[],"mappings":"AAKA,UAAU,WAAW;IACnB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;GAEG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBrE"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { edit as sopsEdit } from '../core/sops.js';
|
|
5
|
+
/**
|
|
6
|
+
* Edit command - open .env.encrypted in editor via SOPS
|
|
7
|
+
*/
|
|
8
|
+
export async function editCommand(options) {
|
|
9
|
+
const { root } = options;
|
|
10
|
+
const encryptedPath = join(root, '.env.encrypted');
|
|
11
|
+
// Check encrypted file exists
|
|
12
|
+
if (!existsSync(encryptedPath)) {
|
|
13
|
+
console.error(pc.red(`Error: ${encryptedPath} not found`));
|
|
14
|
+
console.error(pc.dim('Create it with: pnpm secrets encrypt (after creating .env)'));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
console.log(pc.blue('Opening encrypted file in editor...'));
|
|
18
|
+
console.log(pc.dim(' (Changes will be encrypted on save)'));
|
|
19
|
+
try {
|
|
20
|
+
sopsEdit(encryptedPath);
|
|
21
|
+
console.log(pc.green('\nEdit complete'));
|
|
22
|
+
console.log(pc.dim(' Run "pnpm secrets decrypt" to update local env files.'));
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
console.error(pc.red(error.message));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encrypt.d.ts","sourceRoot":"","sources":["../../src/commands/encrypt.ts"],"names":[],"mappings":"AAKA,UAAU,cAAc;IACtB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAyB3E"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { encrypt as sopsEncrypt } from '../core/sops.js';
|
|
5
|
+
/**
|
|
6
|
+
* Encrypt command - encrypt .env to .env.encrypted
|
|
7
|
+
*/
|
|
8
|
+
export async function encryptCommand(options) {
|
|
9
|
+
const { root } = options;
|
|
10
|
+
const inputPath = join(root, '.env');
|
|
11
|
+
const outputPath = join(root, '.env.encrypted');
|
|
12
|
+
// Check input file exists
|
|
13
|
+
if (!existsSync(inputPath)) {
|
|
14
|
+
console.error(pc.red(`Error: ${inputPath} not found`));
|
|
15
|
+
console.error(pc.dim('Create a .env file first, then run encrypt.'));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
console.log(pc.blue('Encrypting secrets...'));
|
|
19
|
+
console.log(pc.dim(` Input: .env`));
|
|
20
|
+
console.log(pc.dim(` Output: .env.encrypted`));
|
|
21
|
+
try {
|
|
22
|
+
sopsEncrypt(inputPath, outputPath);
|
|
23
|
+
console.log(pc.green('\nEncryption complete'));
|
|
24
|
+
console.log(pc.dim(' You can now commit .env.encrypted to git.'));
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
console.error(pc.red(error.message));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface PushOptions {
|
|
2
|
+
root: string;
|
|
3
|
+
dryRun?: boolean;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Push command - push production secrets to Cloudflare Workers
|
|
7
|
+
*/
|
|
8
|
+
export declare function pushCommand(options: PushOptions): Promise<void>;
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=push.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"push.d.ts","sourceRoot":"","sources":["../../src/commands/push.ts"],"names":[],"mappings":"AAaA,UAAU,WAAW;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AA0CD;;GAEG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAqErE"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
import { discoverPackages } from '../core/discover.js';
|
|
6
|
+
import { expandVariables, getVarsForEnvironment, parseEnvContent, } from '../core/parse.js';
|
|
7
|
+
import { decrypt as sopsDecrypt } from '../core/sops.js';
|
|
8
|
+
/**
|
|
9
|
+
* Filter variables that should be pushed to Wrangler
|
|
10
|
+
* Excludes EXPO_PUBLIC_* vars
|
|
11
|
+
*/
|
|
12
|
+
function getWranglerVars(vars) {
|
|
13
|
+
return vars.filter((v) => !v.key.startsWith('EXPO_PUBLIC_'));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Push a secret to Wrangler
|
|
17
|
+
*/
|
|
18
|
+
function pushSecret(key, value, wranglerDir, dryRun) {
|
|
19
|
+
if (dryRun) {
|
|
20
|
+
console.log(pc.dim(` [dry-run] Would push: ${key}`));
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
// Use echo to pipe the value to wrangler secret put
|
|
25
|
+
// This avoids the interactive prompt
|
|
26
|
+
execSync(`echo "${value}" | wrangler secret put ${key}`, {
|
|
27
|
+
cwd: wranglerDir,
|
|
28
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
29
|
+
shell: '/bin/bash',
|
|
30
|
+
});
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const err = error;
|
|
35
|
+
console.error(pc.red(` Failed to push ${key}: ${err.stderr || err.message}`));
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Push command - push production secrets to Cloudflare Workers
|
|
41
|
+
*/
|
|
42
|
+
export async function pushCommand(options) {
|
|
43
|
+
const { root, dryRun = false } = options;
|
|
44
|
+
const encryptedPath = join(root, '.env.encrypted');
|
|
45
|
+
// Check encrypted file exists
|
|
46
|
+
if (!existsSync(encryptedPath)) {
|
|
47
|
+
console.error(pc.red(`Error: ${encryptedPath} not found`));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
// Find wrangler packages
|
|
51
|
+
const packages = await discoverPackages(root);
|
|
52
|
+
const wranglerPackages = packages.filter((p) => p.style === 'wrangler');
|
|
53
|
+
if (wranglerPackages.length === 0) {
|
|
54
|
+
console.error(pc.red('Error: No Wrangler packages found'));
|
|
55
|
+
console.error(pc.dim('A Wrangler package must have a wrangler.toml file.'));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
console.log(pc.blue('Pushing secrets to Cloudflare Workers...'));
|
|
59
|
+
if (dryRun) {
|
|
60
|
+
console.log(pc.yellow(' (dry-run mode - no changes will be made)'));
|
|
61
|
+
}
|
|
62
|
+
// Decrypt and get production vars
|
|
63
|
+
let decryptedContent;
|
|
64
|
+
try {
|
|
65
|
+
decryptedContent = sopsDecrypt(encryptedPath);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error(pc.red(error.message));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
const allVars = parseEnvContent(decryptedContent);
|
|
72
|
+
const prodVars = getVarsForEnvironment(allVars, 'prod');
|
|
73
|
+
const expandedVars = expandVariables(prodVars);
|
|
74
|
+
const wranglerVars = getWranglerVars(expandedVars);
|
|
75
|
+
console.log(pc.dim(` ${wranglerVars.length} secrets to push`));
|
|
76
|
+
// Push to each wrangler package
|
|
77
|
+
for (const pkg of wranglerPackages) {
|
|
78
|
+
const pkgDir = pkg.path ? join(root, pkg.path) : root;
|
|
79
|
+
console.log(pc.blue(`\n${pkg.path || '.'} (${pkg.name}):`));
|
|
80
|
+
let successCount = 0;
|
|
81
|
+
let failCount = 0;
|
|
82
|
+
for (const v of wranglerVars) {
|
|
83
|
+
const success = pushSecret(v.key, v.value, pkgDir, dryRun);
|
|
84
|
+
if (success) {
|
|
85
|
+
console.log(pc.green(` ${v.key}`));
|
|
86
|
+
successCount++;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
failCount++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
console.log(pc.dim(` ${successCount} pushed, ${failCount} failed`));
|
|
93
|
+
}
|
|
94
|
+
if (dryRun) {
|
|
95
|
+
console.log(pc.yellow('\n[dry-run] No secrets were actually pushed.'));
|
|
96
|
+
console.log(pc.dim('Run without --dry-run to push secrets.'));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
console.log(pc.green('\nPush complete'));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AAMA,UAAU,aAAa;IACrB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;GAEG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CA6EzE"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { discoverPackages } from '../core/discover.js';
|
|
5
|
+
import { isSopsInstalled } from '../core/sops.js';
|
|
6
|
+
/**
|
|
7
|
+
* Status command - show discovered packages and their styles
|
|
8
|
+
*/
|
|
9
|
+
export async function statusCommand(options) {
|
|
10
|
+
const { root } = options;
|
|
11
|
+
console.log(pc.blue('Secrets Status\n'));
|
|
12
|
+
// Check prerequisites
|
|
13
|
+
console.log(pc.bold('Prerequisites:'));
|
|
14
|
+
const sopsInstalled = isSopsInstalled();
|
|
15
|
+
console.log(sopsInstalled
|
|
16
|
+
? pc.green(' SOPS installed')
|
|
17
|
+
: pc.red(' SOPS not installed (brew install sops)'));
|
|
18
|
+
const ageKeyPath = join(process.env.HOME || '~', '.config/sops/age/key.txt');
|
|
19
|
+
const ageKeyExists = existsSync(ageKeyPath);
|
|
20
|
+
console.log(ageKeyExists
|
|
21
|
+
? pc.green(' age key found')
|
|
22
|
+
: pc.yellow(' age key not found at ~/.config/sops/age/key.txt'));
|
|
23
|
+
// Check files
|
|
24
|
+
console.log(pc.bold('\nFiles:'));
|
|
25
|
+
const encryptedPath = join(root, '.env.encrypted');
|
|
26
|
+
const encryptedExists = existsSync(encryptedPath);
|
|
27
|
+
console.log(encryptedExists
|
|
28
|
+
? pc.green(' .env.encrypted exists')
|
|
29
|
+
: pc.yellow(' .env.encrypted not found'));
|
|
30
|
+
const localPath = join(root, '.env.local');
|
|
31
|
+
const localExists = existsSync(localPath);
|
|
32
|
+
console.log(localExists
|
|
33
|
+
? pc.green(' .env.local exists (local overrides)')
|
|
34
|
+
: pc.dim(' - .env.local not found (optional)'));
|
|
35
|
+
const sopsConfigPath = join(root, '.sops.yaml');
|
|
36
|
+
const sopsConfigExists = existsSync(sopsConfigPath);
|
|
37
|
+
console.log(sopsConfigExists
|
|
38
|
+
? pc.green(' .sops.yaml exists')
|
|
39
|
+
: pc.yellow(' .sops.yaml not found (SOPS config)'));
|
|
40
|
+
// Discover packages
|
|
41
|
+
console.log(pc.bold('\nDiscovered Packages:'));
|
|
42
|
+
const packages = await discoverPackages(root);
|
|
43
|
+
if (packages.length === 0) {
|
|
44
|
+
console.log(pc.dim(' No packages found'));
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
for (const pkg of packages) {
|
|
48
|
+
const styleColor = pkg.style === 'wrangler' ? pc.cyan : pc.magenta;
|
|
49
|
+
const output = pkg.style === 'wrangler'
|
|
50
|
+
? '.dev.vars'
|
|
51
|
+
: '.env.development, .env.production';
|
|
52
|
+
console.log(` ${pkg.path || '.'} ` +
|
|
53
|
+
styleColor(`(${pkg.style})`) +
|
|
54
|
+
pc.dim(` -> ${output}`));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
console.log('');
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../../src/core/discover.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAgB,MAAM,aAAa,CAAC;AAkDzD;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAmCvE"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { glob } from 'glob';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Patterns to ignore when discovering packages
|
|
6
|
+
*/
|
|
7
|
+
const IGNORE_PATTERNS = [
|
|
8
|
+
'**/node_modules/**',
|
|
9
|
+
'**/.git/**',
|
|
10
|
+
'**/dist/**',
|
|
11
|
+
'**/build/**',
|
|
12
|
+
'**/.turbo/**',
|
|
13
|
+
'**/.next/**',
|
|
14
|
+
'**/coverage/**',
|
|
15
|
+
'**/.expo/**',
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Detect the style for a package directory
|
|
19
|
+
* - If wrangler.toml exists -> wrangler
|
|
20
|
+
* - Otherwise -> standard
|
|
21
|
+
*/
|
|
22
|
+
function detectStyle(packageDir) {
|
|
23
|
+
const wranglerPath = join(packageDir, 'wrangler.toml');
|
|
24
|
+
const wranglerDevPath = join(packageDir, 'wrangler.dev.toml');
|
|
25
|
+
if (existsSync(wranglerPath) || existsSync(wranglerDevPath)) {
|
|
26
|
+
const configPath = existsSync(wranglerPath) ? wranglerPath : wranglerDevPath;
|
|
27
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
28
|
+
if (content.includes('pages_build_output_dir')) {
|
|
29
|
+
return 'standard';
|
|
30
|
+
}
|
|
31
|
+
return 'wrangler';
|
|
32
|
+
}
|
|
33
|
+
return 'standard';
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read package name from package.json
|
|
37
|
+
*/
|
|
38
|
+
function readPackageName(packageJsonPath) {
|
|
39
|
+
try {
|
|
40
|
+
const content = readFileSync(packageJsonPath, 'utf-8');
|
|
41
|
+
const pkg = JSON.parse(content);
|
|
42
|
+
return pkg.name || dirname(packageJsonPath);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return dirname(packageJsonPath);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Discover all packages in the monorepo
|
|
50
|
+
*/
|
|
51
|
+
export async function discoverPackages(root) {
|
|
52
|
+
// Find all package.json files
|
|
53
|
+
const packageJsonPaths = await glob('**/package.json', {
|
|
54
|
+
cwd: root,
|
|
55
|
+
ignore: IGNORE_PATTERNS,
|
|
56
|
+
});
|
|
57
|
+
const packages = [];
|
|
58
|
+
for (const pkgPath of packageJsonPaths) {
|
|
59
|
+
const dir = dirname(pkgPath);
|
|
60
|
+
const fullDir = join(root, dir);
|
|
61
|
+
const style = detectStyle(fullDir);
|
|
62
|
+
const name = readPackageName(join(root, pkgPath));
|
|
63
|
+
if (name === '@chriscode/hush') {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
packages.push({
|
|
67
|
+
name,
|
|
68
|
+
path: dir === '.' ? '' : dir,
|
|
69
|
+
style,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
// Sort by path depth (root first, then alphabetically)
|
|
73
|
+
packages.sort((a, b) => {
|
|
74
|
+
const depthA = a.path.split('/').length;
|
|
75
|
+
const depthB = b.path.split('/').length;
|
|
76
|
+
if (depthA !== depthB)
|
|
77
|
+
return depthA - depthB;
|
|
78
|
+
return a.path.localeCompare(b.path);
|
|
79
|
+
});
|
|
80
|
+
return packages;
|
|
81
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { EnvVar, Environment } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a .env file content into key-value pairs
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseEnvContent(content: string): EnvVar[];
|
|
6
|
+
/**
|
|
7
|
+
* Parse a .env file from disk
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseEnvFile(filePath: string): EnvVar[];
|
|
10
|
+
/**
|
|
11
|
+
* Filter and transform variables for a specific environment
|
|
12
|
+
* - Includes shared vars (no prefix)
|
|
13
|
+
* - Includes vars with matching prefix (stripped)
|
|
14
|
+
* - Excludes vars with other prefix
|
|
15
|
+
*/
|
|
16
|
+
export declare function getVarsForEnvironment(vars: EnvVar[], env: Environment): EnvVar[];
|
|
17
|
+
/**
|
|
18
|
+
* Expand variable references in env vars
|
|
19
|
+
* e.g., ${OTHER_VAR} gets replaced with its value
|
|
20
|
+
*/
|
|
21
|
+
export declare function expandVariables(vars: EnvVar[]): EnvVar[];
|
|
22
|
+
/**
|
|
23
|
+
* Merge multiple env var arrays, later arrays override earlier ones
|
|
24
|
+
*/
|
|
25
|
+
export declare function mergeEnvVars(...varArrays: EnvVar[][]): EnvVar[];
|
|
26
|
+
/**
|
|
27
|
+
* Format env vars as .env file content
|
|
28
|
+
*/
|
|
29
|
+
export declare function formatEnvFile(vars: EnvVar[]): string;
|
|
30
|
+
//# sourceMappingURL=parse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/core/parse.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAGvD;;GAEG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CA+BzD;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,CAOvD;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EAAE,EACd,GAAG,EAAE,WAAW,GACf,MAAM,EAAE,CA0BV;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAkBxD;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,GAAG,SAAS,EAAE,MAAM,EAAE,EAAE,GAAG,MAAM,EAAE,CAU/D;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,CAEpD"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { expand } from 'dotenv-expand';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { ENV_PREFIXES } from '../types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Parse a .env file content into key-value pairs
|
|
6
|
+
*/
|
|
7
|
+
export function parseEnvContent(content) {
|
|
8
|
+
const vars = [];
|
|
9
|
+
const lines = content.split('\n');
|
|
10
|
+
for (const line of lines) {
|
|
11
|
+
const trimmed = line.trim();
|
|
12
|
+
// Skip empty lines and comments
|
|
13
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
// Find the first = sign
|
|
17
|
+
const eqIndex = trimmed.indexOf('=');
|
|
18
|
+
if (eqIndex === -1)
|
|
19
|
+
continue;
|
|
20
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
21
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
22
|
+
// Remove surrounding quotes if present
|
|
23
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
24
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
25
|
+
value = value.slice(1, -1);
|
|
26
|
+
}
|
|
27
|
+
vars.push({ key, value });
|
|
28
|
+
}
|
|
29
|
+
return vars;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse a .env file from disk
|
|
33
|
+
*/
|
|
34
|
+
export function parseEnvFile(filePath) {
|
|
35
|
+
if (!existsSync(filePath)) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
39
|
+
return parseEnvContent(content);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Filter and transform variables for a specific environment
|
|
43
|
+
* - Includes shared vars (no prefix)
|
|
44
|
+
* - Includes vars with matching prefix (stripped)
|
|
45
|
+
* - Excludes vars with other prefix
|
|
46
|
+
*/
|
|
47
|
+
export function getVarsForEnvironment(vars, env) {
|
|
48
|
+
const prefix = ENV_PREFIXES[env];
|
|
49
|
+
const otherPrefix = env === 'dev' ? ENV_PREFIXES.prod : ENV_PREFIXES.dev;
|
|
50
|
+
const result = [];
|
|
51
|
+
for (const v of vars) {
|
|
52
|
+
// Skip vars with other environment's prefix
|
|
53
|
+
if (v.key.startsWith(otherPrefix)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Strip prefix if it matches this environment
|
|
57
|
+
if (v.key.startsWith(prefix)) {
|
|
58
|
+
result.push({
|
|
59
|
+
key: v.key.slice(prefix.length),
|
|
60
|
+
value: v.value,
|
|
61
|
+
originalKey: v.key,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// Shared var (no prefix)
|
|
66
|
+
result.push(v);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Expand variable references in env vars
|
|
73
|
+
* e.g., ${OTHER_VAR} gets replaced with its value
|
|
74
|
+
*/
|
|
75
|
+
export function expandVariables(vars) {
|
|
76
|
+
// Create a process.env-like object for expansion
|
|
77
|
+
const envObject = {};
|
|
78
|
+
for (const v of vars) {
|
|
79
|
+
envObject[v.key] = v.value;
|
|
80
|
+
}
|
|
81
|
+
// Use dotenv-expand with processEnv set to empty object to avoid mixing with process.env
|
|
82
|
+
const expanded = expand({ parsed: envObject, processEnv: {} });
|
|
83
|
+
if (!expanded.parsed) {
|
|
84
|
+
return vars;
|
|
85
|
+
}
|
|
86
|
+
return vars.map((v) => ({
|
|
87
|
+
...v,
|
|
88
|
+
value: expanded.parsed[v.key] ?? v.value,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Merge multiple env var arrays, later arrays override earlier ones
|
|
93
|
+
*/
|
|
94
|
+
export function mergeEnvVars(...varArrays) {
|
|
95
|
+
const merged = new Map();
|
|
96
|
+
for (const vars of varArrays) {
|
|
97
|
+
for (const v of vars) {
|
|
98
|
+
merged.set(v.key, v);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return Array.from(merged.values());
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Format env vars as .env file content
|
|
105
|
+
*/
|
|
106
|
+
export function formatEnvFile(vars) {
|
|
107
|
+
return vars.map((v) => `${v.key}=${v.value}`).join('\n') + '\n';
|
|
108
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if SOPS is installed
|
|
3
|
+
*/
|
|
4
|
+
export declare function isSopsInstalled(): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Decrypt a SOPS-encrypted file and return the content
|
|
7
|
+
*/
|
|
8
|
+
export declare function decrypt(filePath: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Encrypt content to a SOPS-encrypted file
|
|
11
|
+
*/
|
|
12
|
+
export declare function encrypt(inputPath: string, outputPath: string): void;
|
|
13
|
+
/**
|
|
14
|
+
* Open encrypted file in editor (SOPS inline edit)
|
|
15
|
+
*/
|
|
16
|
+
export declare function edit(filePath: string): void;
|
|
17
|
+
//# sourceMappingURL=sops.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sops.d.ts","sourceRoot":"","sources":["../../src/core/sops.ts"],"names":[],"mappings":"AAgCA;;GAEG;AACH,wBAAgB,eAAe,IAAI,OAAO,CAOzC;AAED;;GAEG;AACH,wBAAgB,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA+BhD;AAED;;GAEG;AACH,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAwBnE;AAED;;GAEG;AACH,wBAAgB,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAuB3C"}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Get the SOPS age key file path
|
|
6
|
+
* Checks SOPS_AGE_KEY_FILE env var, then falls back to default location
|
|
7
|
+
*/
|
|
8
|
+
function getAgeKeyFile() {
|
|
9
|
+
if (process.env.SOPS_AGE_KEY_FILE) {
|
|
10
|
+
return process.env.SOPS_AGE_KEY_FILE;
|
|
11
|
+
}
|
|
12
|
+
const defaultPath = join(process.env.HOME || '~', '.config/sops/age/key.txt');
|
|
13
|
+
if (existsSync(defaultPath)) {
|
|
14
|
+
return defaultPath;
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Get environment variables for SOPS commands
|
|
20
|
+
*/
|
|
21
|
+
function getSopsEnv() {
|
|
22
|
+
const ageKeyFile = getAgeKeyFile();
|
|
23
|
+
if (ageKeyFile) {
|
|
24
|
+
return { ...process.env, SOPS_AGE_KEY_FILE: ageKeyFile };
|
|
25
|
+
}
|
|
26
|
+
return process.env;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Check if SOPS is installed
|
|
30
|
+
*/
|
|
31
|
+
export function isSopsInstalled() {
|
|
32
|
+
try {
|
|
33
|
+
execSync('which sops', { stdio: 'ignore' });
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Decrypt a SOPS-encrypted file and return the content
|
|
42
|
+
*/
|
|
43
|
+
export function decrypt(filePath) {
|
|
44
|
+
if (!existsSync(filePath)) {
|
|
45
|
+
throw new Error(`Encrypted file not found: ${filePath}`);
|
|
46
|
+
}
|
|
47
|
+
if (!isSopsInstalled()) {
|
|
48
|
+
throw new Error('SOPS is not installed. Install with: brew install sops');
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
// Use --input-type dotenv to handle .env format files
|
|
52
|
+
const result = execSync(`sops --input-type dotenv --output-type dotenv --decrypt "${filePath}"`, {
|
|
53
|
+
encoding: 'utf-8',
|
|
54
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
55
|
+
env: getSopsEnv(),
|
|
56
|
+
});
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const err = error;
|
|
61
|
+
if (err.stderr?.includes('No identity matched')) {
|
|
62
|
+
throw new Error('SOPS decryption failed: No matching age key found.\n' +
|
|
63
|
+
'Ensure your age key is at ~/.config/sops/age/key.txt\n' +
|
|
64
|
+
'Or set SOPS_AGE_KEY_FILE environment variable.');
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`SOPS decryption failed: ${err.stderr || err.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Encrypt content to a SOPS-encrypted file
|
|
71
|
+
*/
|
|
72
|
+
export function encrypt(inputPath, outputPath) {
|
|
73
|
+
if (!existsSync(inputPath)) {
|
|
74
|
+
throw new Error(`Input file not found: ${inputPath}`);
|
|
75
|
+
}
|
|
76
|
+
if (!isSopsInstalled()) {
|
|
77
|
+
throw new Error('SOPS is not installed. Install with: brew install sops');
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
// Use --input-type dotenv to handle .env format files
|
|
81
|
+
execSync(`sops --input-type dotenv --output-type dotenv --encrypt "${inputPath}" > "${outputPath}"`, {
|
|
82
|
+
encoding: 'utf-8',
|
|
83
|
+
shell: '/bin/bash',
|
|
84
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
85
|
+
env: getSopsEnv(),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
const err = error;
|
|
90
|
+
throw new Error(`SOPS encryption failed: ${err.stderr || err.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Open encrypted file in editor (SOPS inline edit)
|
|
95
|
+
*/
|
|
96
|
+
export function edit(filePath) {
|
|
97
|
+
if (!existsSync(filePath)) {
|
|
98
|
+
throw new Error(`Encrypted file not found: ${filePath}`);
|
|
99
|
+
}
|
|
100
|
+
if (!isSopsInstalled()) {
|
|
101
|
+
throw new Error('SOPS is not installed. Install with: brew install sops');
|
|
102
|
+
}
|
|
103
|
+
// Use spawnSync with inherit to allow interactive editing
|
|
104
|
+
// Specify input/output type for dotenv format
|
|
105
|
+
const result = spawnSync('sops', ['--input-type', 'dotenv', '--output-type', 'dotenv', filePath], {
|
|
106
|
+
stdio: 'inherit',
|
|
107
|
+
env: getSopsEnv(),
|
|
108
|
+
});
|
|
109
|
+
if (result.status !== 0) {
|
|
110
|
+
throw new Error(`SOPS edit failed with exit code ${result.status}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { discoverPackages } from './core/discover.js';
|
|
2
|
+
export { expandVariables, formatEnvFile, getVarsForEnvironment, mergeEnvVars, parseEnvContent, parseEnvFile, } from './core/parse.js';
|
|
3
|
+
export { decrypt, edit, encrypt, isSopsInstalled } from './core/sops.js';
|
|
4
|
+
export { ENV_PREFIXES } from './types.js';
|
|
5
|
+
export type { EnvVar, Environment, Package, PackageStyle } from './types.js';
|
|
6
|
+
export { decryptCommand } from './commands/decrypt.js';
|
|
7
|
+
export { editCommand } from './commands/edit.js';
|
|
8
|
+
export { encryptCommand } from './commands/encrypt.js';
|
|
9
|
+
export { pushCommand } from './commands/push.js';
|
|
10
|
+
export { statusCommand } from './commands/status.js';
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EACL,eAAe,EACf,aAAa,EACb,qBAAqB,EACrB,YAAY,EACZ,eAAe,EACf,YAAY,GACb,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAGzE,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1C,YAAY,EAAE,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAG7E,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Core exports for programmatic usage
|
|
2
|
+
export { discoverPackages } from './core/discover.js';
|
|
3
|
+
export { expandVariables, formatEnvFile, getVarsForEnvironment, mergeEnvVars, parseEnvContent, parseEnvFile, } from './core/parse.js';
|
|
4
|
+
export { decrypt, edit, encrypt, isSopsInstalled } from './core/sops.js';
|
|
5
|
+
// Types
|
|
6
|
+
export { ENV_PREFIXES } from './types.js';
|
|
7
|
+
// Commands (for programmatic usage)
|
|
8
|
+
export { decryptCommand } from './commands/decrypt.js';
|
|
9
|
+
export { editCommand } from './commands/edit.js';
|
|
10
|
+
export { encryptCommand } from './commands/encrypt.js';
|
|
11
|
+
export { pushCommand } from './commands/push.js';
|
|
12
|
+
export { statusCommand } from './commands/status.js';
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package style for env file output
|
|
3
|
+
* - wrangler: outputs .dev.vars (for Cloudflare Workers)
|
|
4
|
+
* - standard: outputs .env, .env.development, .env.production
|
|
5
|
+
*/
|
|
6
|
+
export type PackageStyle = 'wrangler' | 'standard';
|
|
7
|
+
/**
|
|
8
|
+
* Discovered package in the monorepo
|
|
9
|
+
*/
|
|
10
|
+
export interface Package {
|
|
11
|
+
/** Package name from package.json */
|
|
12
|
+
name: string;
|
|
13
|
+
/** Relative path from monorepo root */
|
|
14
|
+
path: string;
|
|
15
|
+
/** Detected style */
|
|
16
|
+
style: PackageStyle;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parsed environment variable
|
|
20
|
+
*/
|
|
21
|
+
export interface EnvVar {
|
|
22
|
+
key: string;
|
|
23
|
+
value: string;
|
|
24
|
+
/** Original key before prefix stripping */
|
|
25
|
+
originalKey?: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Environment type for prefix handling
|
|
29
|
+
*/
|
|
30
|
+
export type Environment = 'dev' | 'prod';
|
|
31
|
+
/**
|
|
32
|
+
* Prefix constants
|
|
33
|
+
*/
|
|
34
|
+
export declare const ENV_PREFIXES: {
|
|
35
|
+
readonly dev: "DEV__";
|
|
36
|
+
readonly prod: "PROD__";
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,UAAU,CAAC;AAEnD;;GAEG;AACH,MAAM,WAAW,OAAO;IACtB,qCAAqC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,qBAAqB;IACrB,KAAK,EAAE,YAAY,CAAC;CACrB;AAED;;GAEG;AACH,MAAM,WAAW,MAAM;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,2CAA2C;IAC3C,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,MAAM,CAAC;AAEzC;;GAEG;AACH,eAAO,MAAM,YAAY;;;CAGf,CAAC"}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chriscode/hush",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SOPS-based secrets management for monorepos. Encrypt once, decrypt everywhere.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hush": "./bin/hush.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"dev": "tsc --watch",
|
|
18
|
+
"prepublishOnly": "pnpm build",
|
|
19
|
+
"type-check": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"secrets",
|
|
23
|
+
"sops",
|
|
24
|
+
"age",
|
|
25
|
+
"encryption",
|
|
26
|
+
"env",
|
|
27
|
+
"dotenv",
|
|
28
|
+
"monorepo",
|
|
29
|
+
"cloudflare",
|
|
30
|
+
"wrangler",
|
|
31
|
+
"expo"
|
|
32
|
+
],
|
|
33
|
+
"author": "Chris Hasson",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/hassoncs/hush.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/hassoncs/hush/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/hassoncs/hush#readme",
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"dotenv": "^16.4.5",
|
|
48
|
+
"dotenv-expand": "^11.0.6",
|
|
49
|
+
"glob": "^10.3.10",
|
|
50
|
+
"picocolors": "^1.0.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^20.11.0",
|
|
54
|
+
"typescript": "^5.8.3"
|
|
55
|
+
},
|
|
56
|
+
"files": [
|
|
57
|
+
"dist",
|
|
58
|
+
"bin"
|
|
59
|
+
],
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
}
|
|
63
|
+
}
|