@everystack/cli 0.2.17 → 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.17",
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": {
package/src/cli/aws.ts CHANGED
@@ -42,6 +42,35 @@ export async function uploadToS3(
42
42
  }));
43
43
  }
44
44
 
45
+ export async function headS3(
46
+ region: string,
47
+ bucket: string,
48
+ key: string,
49
+ ): Promise<{ etag: string; size?: number } | null> {
50
+ const client = await getS3(region);
51
+ const { HeadObjectCommand } = await import('@aws-sdk/client-s3');
52
+ try {
53
+ const response = await client.send(new HeadObjectCommand({
54
+ Bucket: bucket,
55
+ Key: key,
56
+ }));
57
+ return {
58
+ etag: response.ETag || '',
59
+ size: response.ContentLength,
60
+ };
61
+ } catch (err: unknown) {
62
+ if (err && typeof err === 'object' && 'name' in err) {
63
+ const name = (err as { name: string }).name;
64
+ if (name === 'NotFound' || name === '404') return null;
65
+ }
66
+ if (err && typeof err === 'object' && '$metadata' in err) {
67
+ const meta = (err as { $metadata: { httpStatusCode?: number } }).$metadata;
68
+ if (meta?.httpStatusCode === 404) return null;
69
+ }
70
+ throw err;
71
+ }
72
+ }
73
+
45
74
  export async function getFromS3(
46
75
  region: string,
47
76
  bucket: string,
@@ -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.
@@ -6,7 +6,7 @@ import { pipeline } from 'node:stream/promises';
6
6
  import { createWriteStream } from 'node:fs';
7
7
  import { spawn } from 'node:child_process';
8
8
  import { resolveConfig, type CliConfig } from '../config.js';
9
- import { uploadToS3, invokeAction } from '../aws.js';
9
+ import { uploadToS3, headS3, invokeAction } from '../aws.js';
10
10
  import { step, success, warn, fail, info } from '../output.js';
11
11
  import { exportApp } from '../utils/export.js';
12
12
  import { walkDirectory } from '../utils/walk.js';
@@ -186,7 +186,10 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
186
186
  const branchName = branch || channel;
187
187
  const storagePrefix = `releases/${branchName}/${runtimeVersion}/${groupId}/${platform}`;
188
188
 
189
- // Upload each asset directly to S3 with content hashing
189
+ // Upload each asset directly to S3 with content hashing.
190
+ // Non-launch assets use a shared content-addressed store (assets/{hash}{ext})
191
+ // so identical files across releases are stored once. Launch assets (JS bundles)
192
+ // remain per-release for rollback isolation.
190
193
  const uploaded: Array<{
191
194
  path: string;
192
195
  s3Key: string;
@@ -199,12 +202,42 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
199
202
  }> = [];
200
203
 
201
204
  const UPLOAD_CONCURRENCY = 6;
205
+ let newAssets = 0;
206
+ let skippedAssets = 0;
202
207
  for (let i = 0; i < assets.length; i += UPLOAD_CONCURRENCY) {
203
208
  const batch = assets.slice(i, i + UPLOAD_CONCURRENCY);
204
209
  try {
205
210
  await Promise.all(batch.map(async (asset) => {
206
211
  const data = Buffer.from(asset.data, 'base64');
207
- const s3Key = `${storagePrefix}/${asset.path}`;
212
+ const sha256 = crypto.createHash('sha256').update(data).digest('base64')
213
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
214
+ const md5 = crypto.createHash('md5').update(data).digest('hex');
215
+
216
+ // Launch assets → per-release path (changes every build, needs rollback isolation)
217
+ // Non-launch assets → shared content-addressed path (fonts, images — rarely change)
218
+ const s3Key = asset.isLaunchAsset
219
+ ? `${storagePrefix}/${asset.path}`
220
+ : `assets/${sha256}${asset.fileExtension}`;
221
+
222
+ // Skip upload if shared asset already exists
223
+ if (!asset.isLaunchAsset) {
224
+ const existing = await headS3(config.region, config.updatesBucket, s3Key);
225
+ if (existing) {
226
+ skippedAssets++;
227
+ uploaded.push({
228
+ path: asset.path,
229
+ s3Key,
230
+ contentHash: sha256,
231
+ filename: md5,
232
+ mimeType: asset.contentType,
233
+ fileExtension: asset.fileExtension,
234
+ isLaunchAsset: asset.isLaunchAsset,
235
+ sizeBytes: data.length,
236
+ });
237
+ return;
238
+ }
239
+ }
240
+
208
241
  await uploadToS3(
209
242
  config.region,
210
243
  config.updatesBucket,
@@ -212,9 +245,7 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
212
245
  data,
213
246
  asset.contentType,
214
247
  );
215
- const sha256 = crypto.createHash('sha256').update(data).digest('base64')
216
- .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
217
- const md5 = crypto.createHash('md5').update(data).digest('hex');
248
+ newAssets++;
218
249
  uploaded.push({
219
250
  path: asset.path,
220
251
  s3Key,
@@ -258,7 +289,11 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
258
289
  process.exit(1);
259
290
  }
260
291
  if (result?.groupId && !groupId) groupId = result.groupId;
261
- success(`${platform}: ${uploaded.length} assets`);
292
+ if (skippedAssets > 0) {
293
+ success(`${platform}: ${newAssets} new, ${skippedAssets} unchanged (skipped)`);
294
+ } else {
295
+ success(`${platform}: ${uploaded.length} assets`);
296
+ }
262
297
  } catch (err: any) {
263
298
  fail(`Registration failed for ${platform}: ${err.message}`);
264
299
  info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
@@ -345,11 +380,18 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
345
380
  step(`Uploading ${clientAssets.length} client assets...`);
346
381
  const UPLOAD_CONCURRENCY = 10;
347
382
  let uploaded = 0;
383
+ let skipped = 0;
348
384
 
349
385
  for (let i = 0; i < clientAssets.length; i += UPLOAD_CONCURRENCY) {
350
386
  const batch = clientAssets.slice(i, i + UPLOAD_CONCURRENCY);
351
387
  try {
352
388
  await Promise.all(batch.map(async (asset) => {
389
+ // Skip upload if asset already exists (content-hash filenames = identical content)
390
+ const existing = await headS3(config.region, config.clientBundlesBucket, asset.relativePath);
391
+ if (existing) {
392
+ skipped++;
393
+ return;
394
+ }
353
395
  const data = Buffer.from(asset.data, 'base64');
354
396
  await uploadToS3(
355
397
  config.region,
@@ -367,7 +409,11 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
367
409
  process.exit(1);
368
410
  }
369
411
  }
370
- success(`${uploaded}/${clientAssets.length} assets uploaded to s3://${config.clientBundlesBucket}/`);
412
+ if (skipped > 0) {
413
+ success(`${uploaded} new, ${skipped} unchanged (skipped)`);
414
+ } else {
415
+ success(`${uploaded}/${clientAssets.length} assets uploaded to s3://${config.clientBundlesBucket}/`);
416
+ }
371
417
  }
372
418
 
373
419
  // 4. Write release manifests directly to S3 (source of truth for SSR).
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
+ }
@@ -304,20 +304,40 @@ export async function handlePublish(request: Request, options: UpdatesHandlerOpt
304
304
  const groupId = body.groupId || crypto.randomUUID();
305
305
  const storagePrefix = `releases/${branchName}/${body.runtimeVersion}/${groupId}/${body.platform}`;
306
306
 
307
- // Upload assets to storage, building post-upload records
307
+ // Upload assets to storage, building post-upload records.
308
+ // Non-launch assets use a shared content-addressed store (assets/{hash}{ext})
309
+ // so identical files across releases are stored once. Launch assets (JS bundles)
310
+ // remain per-release for rollback isolation.
308
311
  const uploadedAssets: RegisterMobileAsset[] = [];
309
312
  for (const asset of body.assets) {
310
313
  const data = Buffer.from(asset.data, 'base64');
311
- const s3Key = `${storagePrefix}/${asset.path}`;
312
- await options.storage.put(s3Key, data, asset.contentType);
314
+ const isLaunch = asset.isLaunchAsset ?? false;
315
+ const contentHash = sha256Base64Url(data);
316
+ const ext = asset.fileExtension.startsWith('.') ? asset.fileExtension : `.${asset.fileExtension}`;
317
+
318
+ // Launch assets → per-release path; non-launch → shared content-addressed path
319
+ const s3Key = isLaunch
320
+ ? `${storagePrefix}/${asset.path}`
321
+ : `assets/${contentHash}${ext}`;
322
+
323
+ // Skip upload if shared asset already exists
324
+ if (!isLaunch) {
325
+ const exists = await options.storage.exists(s3Key);
326
+ if (!exists) {
327
+ await options.storage.put(s3Key, data, asset.contentType);
328
+ }
329
+ } else {
330
+ await options.storage.put(s3Key, data, asset.contentType);
331
+ }
332
+
313
333
  uploadedAssets.push({
314
334
  path: asset.path,
315
335
  s3Key,
316
- contentHash: sha256Base64Url(data),
336
+ contentHash,
317
337
  filename: md5Hex(data),
318
338
  mimeType: asset.contentType,
319
339
  fileExtension: asset.fileExtension,
320
- isLaunchAsset: asset.isLaunchAsset ?? false,
340
+ isLaunchAsset: isLaunch,
321
341
  sizeBytes: data.length,
322
342
  });
323
343
  }