@everystack/cli 0.2.18 → 0.2.19
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/package.json +1 -1
- package/src/cli/commands/secrets.ts +124 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/utils/dotenv.ts +105 -0
package/package.json
CHANGED
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
* Requires IAM permissions: ssm:GetParameter, s3:GetObject, s3:PutObject, kms:Decrypt.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import fs from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
16
18
|
import { step, success, fail, info, warn } from '../output.js';
|
|
17
19
|
import { loadSstSecrets, putSstSecrets } from '../utils/secrets.js';
|
|
20
|
+
import { parseDotenv, serializeDotenv } from '../utils/dotenv.js';
|
|
18
21
|
|
|
19
22
|
interface SecretsContext {
|
|
20
23
|
appName: string;
|
|
@@ -112,6 +115,101 @@ async function removeSecret(ctx: SecretsContext, name: string): Promise<void> {
|
|
|
112
115
|
success(`${name} removed from stage "${ctx.stage}"`);
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Import
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
async function importSecrets(
|
|
123
|
+
ctx: SecretsContext,
|
|
124
|
+
filePath: string,
|
|
125
|
+
flags: Record<string, string>,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
step(`Importing secrets from ${filePath}...`);
|
|
128
|
+
|
|
129
|
+
let content: string;
|
|
130
|
+
try {
|
|
131
|
+
content = await fs.readFile(path.resolve(filePath), 'utf8');
|
|
132
|
+
} catch (err: any) {
|
|
133
|
+
if (err.code === 'ENOENT') {
|
|
134
|
+
fail(`File not found: ${filePath}`);
|
|
135
|
+
} else {
|
|
136
|
+
fail(`Could not read file: ${err.message}`);
|
|
137
|
+
}
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const parsed = parseDotenv(content);
|
|
142
|
+
const parsedCount = Object.keys(parsed).length;
|
|
143
|
+
|
|
144
|
+
if (parsedCount === 0) {
|
|
145
|
+
warn(`No secrets found in ${filePath}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let merged: Record<string, string>;
|
|
150
|
+
|
|
151
|
+
if (flags.replace === 'true') {
|
|
152
|
+
// Replace mode: parsed entries become the entire secrets map
|
|
153
|
+
const existing = await loadSstSecrets(ctx);
|
|
154
|
+
const existingCount = Object.keys(existing).length;
|
|
155
|
+
if (existingCount > 0) {
|
|
156
|
+
warn(`Replacing all ${existingCount} existing secret(s)`);
|
|
157
|
+
}
|
|
158
|
+
merged = parsed;
|
|
159
|
+
} else {
|
|
160
|
+
// Merge mode (default): add new keys, overwrite matching keys, preserve the rest
|
|
161
|
+
const existing = await loadSstSecrets(ctx);
|
|
162
|
+
const existingKeys = new Set(Object.keys(existing));
|
|
163
|
+
let updated = 0;
|
|
164
|
+
for (const key of Object.keys(parsed)) {
|
|
165
|
+
if (existingKeys.has(key)) updated++;
|
|
166
|
+
}
|
|
167
|
+
merged = { ...existing, ...parsed };
|
|
168
|
+
const newCount = parsedCount - updated;
|
|
169
|
+
const detail = updated > 0 ? ` (${newCount} new, ${updated} updated)` : '';
|
|
170
|
+
await putSstSecrets(ctx, merged);
|
|
171
|
+
success(`${parsedCount} secret(s) imported to stage "${ctx.stage}"${detail}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await putSstSecrets(ctx, merged);
|
|
176
|
+
success(`${parsedCount} secret(s) imported to stage "${ctx.stage}"`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Export
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
async function exportSecrets(ctx: SecretsContext, filePath: string | undefined): Promise<void> {
|
|
184
|
+
if (filePath) {
|
|
185
|
+
step(`Exporting secrets from ${ctx.appName}/${ctx.stage}...`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const secrets = await loadSstSecrets(ctx);
|
|
189
|
+
const count = Object.keys(secrets).length;
|
|
190
|
+
|
|
191
|
+
if (count === 0) {
|
|
192
|
+
warn('No secrets to export');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const serialized = serializeDotenv(secrets);
|
|
197
|
+
|
|
198
|
+
if (filePath) {
|
|
199
|
+
const resolved = path.resolve(filePath);
|
|
200
|
+
try {
|
|
201
|
+
await fs.writeFile(resolved, serialized, 'utf8');
|
|
202
|
+
} catch (err: any) {
|
|
203
|
+
fail(`Could not write file: ${err.message}`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
success(`${count} secret(s) exported to ${filePath}`);
|
|
207
|
+
} else {
|
|
208
|
+
// Stdout mode: raw output, pipeable
|
|
209
|
+
process.stdout.write(serialized);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
115
213
|
// ---------------------------------------------------------------------------
|
|
116
214
|
// Command entry point
|
|
117
215
|
// ---------------------------------------------------------------------------
|
|
@@ -174,6 +272,23 @@ export async function secretsCommand(
|
|
|
174
272
|
break;
|
|
175
273
|
}
|
|
176
274
|
|
|
275
|
+
case 'import': {
|
|
276
|
+
const filePath = positionalArgs[0];
|
|
277
|
+
if (!filePath) {
|
|
278
|
+
fail('Missing file path');
|
|
279
|
+
info('Usage: everystack secrets import <file> --stage <stage> [--replace]');
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
await importSecrets(ctx, filePath, flags);
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case 'export': {
|
|
287
|
+
const filePath = positionalArgs[0]; // optional — stdout if omitted
|
|
288
|
+
await exportSecrets(ctx, filePath);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
|
|
177
292
|
default:
|
|
178
293
|
fail(`Unknown secrets subcommand: ${subcommand}`);
|
|
179
294
|
printSecretsHelp();
|
|
@@ -195,12 +310,21 @@ Usage:
|
|
|
195
310
|
everystack secrets get <name> --stage <name> Print a secret value
|
|
196
311
|
everystack secrets set <name> <value> --stage <name> Set or update a secret
|
|
197
312
|
everystack secrets remove <name> --stage <name> Delete a secret
|
|
313
|
+
everystack secrets import <file> --stage <name> Import secrets from .env file (merge)
|
|
314
|
+
everystack secrets export [file] --stage <name> Export secrets to .env file (or stdout)
|
|
198
315
|
|
|
199
316
|
Examples:
|
|
200
317
|
everystack secrets list --stage production
|
|
201
318
|
everystack secrets set MAPBOX_API_KEY "pk.abc123" --stage production
|
|
202
319
|
everystack secrets get JWT_SECRET --stage dev
|
|
203
320
|
everystack secrets remove OLD_KEY --stage dev
|
|
321
|
+
everystack secrets import .env.production --stage production
|
|
322
|
+
everystack secrets import .env --stage dev --replace
|
|
323
|
+
everystack secrets export --stage dev > .env.local
|
|
324
|
+
everystack secrets export secrets.env --stage production
|
|
325
|
+
|
|
326
|
+
Flags:
|
|
327
|
+
--replace (import only) Replace all secrets instead of merging
|
|
204
328
|
|
|
205
329
|
Interop:
|
|
206
330
|
Secrets are stored in the SST Ion S3 state backend with AES-256-GCM encryption.
|
package/src/cli/index.ts
CHANGED
|
@@ -196,6 +196,8 @@ Usage:
|
|
|
196
196
|
everystack secrets get <name> --stage <name> Print a secret value
|
|
197
197
|
everystack secrets set <name> <value> --stage <name> Set or update a secret
|
|
198
198
|
everystack secrets remove <name> --stage <name> Delete a secret
|
|
199
|
+
everystack secrets import <file> --stage <name> [--replace] Import secrets from .env file
|
|
200
|
+
everystack secrets export [file] --stage <name> Export secrets to .env format
|
|
199
201
|
|
|
200
202
|
Stage resolution:
|
|
201
203
|
--stage <name> Discover AWS resources for the given stage by querying AWS APIs.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure .env parser and serializer. No dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* KEY=value
|
|
6
|
+
* KEY="double quoted value"
|
|
7
|
+
* KEY='single quoted value'
|
|
8
|
+
* # comments (full-line)
|
|
9
|
+
* Empty/whitespace-only lines (skipped)
|
|
10
|
+
* export KEY=value (optional prefix)
|
|
11
|
+
* Escape sequences in double quotes: \n, \r, \t, \\, \"
|
|
12
|
+
* UTF-8 BOM stripping
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a .env-formatted string into key-value pairs.
|
|
17
|
+
* Duplicate keys: last one wins.
|
|
18
|
+
*/
|
|
19
|
+
export function parseDotenv(content: string): Record<string, string> {
|
|
20
|
+
const result: Record<string, string> = {};
|
|
21
|
+
|
|
22
|
+
// Strip UTF-8 BOM
|
|
23
|
+
const clean = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
|
|
24
|
+
|
|
25
|
+
for (const rawLine of clean.split('\n')) {
|
|
26
|
+
const line = rawLine.trim();
|
|
27
|
+
|
|
28
|
+
// Skip empty lines and comments
|
|
29
|
+
if (!line || line.startsWith('#')) continue;
|
|
30
|
+
|
|
31
|
+
// Strip optional `export ` prefix
|
|
32
|
+
const stripped = line.startsWith('export ') ? line.slice(7) : line;
|
|
33
|
+
|
|
34
|
+
// Find first `=`
|
|
35
|
+
const eqIndex = stripped.indexOf('=');
|
|
36
|
+
if (eqIndex === -1) continue;
|
|
37
|
+
|
|
38
|
+
const key = stripped.slice(0, eqIndex).trim();
|
|
39
|
+
if (!key) continue;
|
|
40
|
+
|
|
41
|
+
let value = stripped.slice(eqIndex + 1);
|
|
42
|
+
|
|
43
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
44
|
+
// Double-quoted: strip quotes and process escapes
|
|
45
|
+
value = value.slice(1, -1);
|
|
46
|
+
// Process escapes — use a single pass to avoid double-expansion
|
|
47
|
+
value = value.replace(/\\([nrt"\\])/g, (_, ch) => {
|
|
48
|
+
switch (ch) {
|
|
49
|
+
case 'n': return '\n';
|
|
50
|
+
case 'r': return '\r';
|
|
51
|
+
case 't': return '\t';
|
|
52
|
+
case '"': return '"';
|
|
53
|
+
case '\\': return '\\';
|
|
54
|
+
default: return ch;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
} else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
|
|
58
|
+
// Single-quoted: strip quotes, literal (no escape processing)
|
|
59
|
+
value = value.slice(1, -1);
|
|
60
|
+
} else {
|
|
61
|
+
// Unquoted: trim whitespace
|
|
62
|
+
value = value.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
result[key] = value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Serialize a key-value map to .env format.
|
|
73
|
+
*
|
|
74
|
+
* Keys are sorted alphabetically for deterministic output.
|
|
75
|
+
* Values are double-quoted when they contain whitespace, quotes, #, backslashes, or newlines.
|
|
76
|
+
*/
|
|
77
|
+
export function serializeDotenv(secrets: Record<string, string>): string {
|
|
78
|
+
const keys = Object.keys(secrets).sort();
|
|
79
|
+
if (keys.length === 0) return '';
|
|
80
|
+
|
|
81
|
+
const lines: string[] = [];
|
|
82
|
+
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
const value = secrets[key];
|
|
85
|
+
|
|
86
|
+
if (needsQuoting(value)) {
|
|
87
|
+
const escaped = value
|
|
88
|
+
.replace(/\\/g, '\\\\')
|
|
89
|
+
.replace(/"/g, '\\"')
|
|
90
|
+
.replace(/\n/g, '\\n')
|
|
91
|
+
.replace(/\r/g, '\\r')
|
|
92
|
+
.replace(/\t/g, '\\t');
|
|
93
|
+
lines.push(`${key}="${escaped}"`);
|
|
94
|
+
} else {
|
|
95
|
+
lines.push(`${key}=${value}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return lines.join('\n') + '\n';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function needsQuoting(value: string): boolean {
|
|
103
|
+
if (value === '') return true;
|
|
104
|
+
return /[\s"'#\\]/.test(value) || value.includes('\n');
|
|
105
|
+
}
|