@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 +1 -1
- package/src/cli/aws.ts +29 -0
- package/src/cli/commands/secrets.ts +124 -0
- package/src/cli/commands/update.ts +54 -8
- package/src/cli/index.ts +2 -0
- package/src/cli/utils/dotenv.ts +105 -0
- package/src/handler/publish.ts +25 -5
package/package.json
CHANGED
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/handler/publish.ts
CHANGED
|
@@ -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
|
|
312
|
-
|
|
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
|
|
336
|
+
contentHash,
|
|
317
337
|
filename: md5Hex(data),
|
|
318
338
|
mimeType: asset.contentType,
|
|
319
339
|
fileExtension: asset.fileExtension,
|
|
320
|
-
isLaunchAsset:
|
|
340
|
+
isLaunchAsset: isLaunch,
|
|
321
341
|
sizeBytes: data.length,
|
|
322
342
|
});
|
|
323
343
|
}
|