@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -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
+ }