@everystack/cli 0.1.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/README.md +255 -0
- package/package.json +104 -0
- package/src/cli/aws.ts +121 -0
- package/src/cli/commands/analyze.ts +61 -0
- package/src/cli/commands/branches.ts +97 -0
- package/src/cli/commands/cache.ts +72 -0
- package/src/cli/commands/certs.ts +117 -0
- package/src/cli/commands/channels.ts +109 -0
- package/src/cli/commands/console.ts +68 -0
- package/src/cli/commands/db.ts +183 -0
- package/src/cli/commands/diag.ts +242 -0
- package/src/cli/commands/logs.ts +282 -0
- package/src/cli/commands/update.ts +432 -0
- package/src/cli/config.ts +98 -0
- package/src/cli/discover.ts +321 -0
- package/src/cli/hydration-analyzer.ts +224 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/output.ts +25 -0
- package/src/cli/ssr-analyzer.ts +445 -0
- package/src/cli/utils/export.ts +8 -0
- package/src/cli/utils/table.ts +39 -0
- package/src/cli/utils/upload.ts +52 -0
- package/src/cli/utils/walk.ts +59 -0
- package/src/client/app-state-provider.tsx +83 -0
- package/src/client/index.ts +2 -0
- package/src/client/updates-provider.tsx +69 -0
- package/src/handler/assets.ts +30 -0
- package/src/handler/branches.ts +70 -0
- package/src/handler/channels-crud.ts +174 -0
- package/src/handler/helpers.ts +239 -0
- package/src/handler/index.ts +78 -0
- package/src/handler/manifest.ts +276 -0
- package/src/handler/multipart.ts +74 -0
- package/src/handler/publish-web.ts +311 -0
- package/src/handler/publish.ts +346 -0
- package/src/handler/signing.ts +29 -0
- package/src/handler/types.ts +16 -0
- package/src/index.ts +4 -0
- package/src/schema.ts +245 -0
- package/src/storage/filesystem.ts +103 -0
- package/src/storage/index.ts +27 -0
- package/src/storage/s3.ts +125 -0
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import zlib from 'node:zlib';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { createWriteStream } from 'node:fs';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { resolveConfig } from '../config.js';
|
|
9
|
+
import { uploadToS3, invokeAction } from '../aws.js';
|
|
10
|
+
import { step, success, warn, fail, info } from '../output.js';
|
|
11
|
+
import { exportApp } from '../utils/export.js';
|
|
12
|
+
import { walkDirectory } from '../utils/walk.js';
|
|
13
|
+
|
|
14
|
+
export interface UpdateFlags {
|
|
15
|
+
channel?: string;
|
|
16
|
+
branch?: string;
|
|
17
|
+
message?: string;
|
|
18
|
+
platform?: string;
|
|
19
|
+
export?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function updateCommand(flags: UpdateFlags & Record<string, string>): Promise<void> {
|
|
23
|
+
const branch = flags.branch;
|
|
24
|
+
const channel = flags.channel || branch || flags.stage || 'production';
|
|
25
|
+
const message = flags.message || '';
|
|
26
|
+
const platformFilter = flags.platform || 'all';
|
|
27
|
+
|
|
28
|
+
// Read app config
|
|
29
|
+
step('Reading app config...');
|
|
30
|
+
const appConfig = await loadAppConfig();
|
|
31
|
+
const runtimeVersion = appConfig.runtimeVersion;
|
|
32
|
+
|
|
33
|
+
if (!runtimeVersion) {
|
|
34
|
+
fail('No runtimeVersion found in app.json/app.config.js');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
success(`Runtime version: ${runtimeVersion}`);
|
|
38
|
+
|
|
39
|
+
// Export if needed
|
|
40
|
+
const distDir = path.resolve('dist');
|
|
41
|
+
const shouldExport = flags.export === 'true' || !(await dirExists(distDir));
|
|
42
|
+
if (shouldExport) {
|
|
43
|
+
step('Exporting app...');
|
|
44
|
+
await exportApp();
|
|
45
|
+
success('Export complete');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Read metadata (mobile exports produce metadata.json; web-only exports don't)
|
|
49
|
+
const metadataPath = path.join(distDir, 'metadata.json');
|
|
50
|
+
let metadata: any = null;
|
|
51
|
+
try {
|
|
52
|
+
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
// No metadata.json — web-only export
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Determine platforms to publish
|
|
58
|
+
const platforms = getPlatforms(platformFilter, metadata);
|
|
59
|
+
const publishTarget = branch ? `branch "${branch}"` : `channel "${channel}"`;
|
|
60
|
+
info(`Publishing to ${publishTarget} (${platforms.join(', ')})`);
|
|
61
|
+
|
|
62
|
+
// Separate web from mobile platforms — web uses direct S3/Lambda; mobile uses HTTP API
|
|
63
|
+
const mobilePlatforms = platforms.filter(p => p !== 'web');
|
|
64
|
+
const hasWeb = platforms.includes('web');
|
|
65
|
+
|
|
66
|
+
// Track groupId across platforms for atomic multi-platform publish
|
|
67
|
+
let groupId: string | undefined;
|
|
68
|
+
|
|
69
|
+
// Mobile platforms — direct S3 upload + IAM-authed Lambda invoke (mirrors web path).
|
|
70
|
+
// No HTTP publish endpoint, no shared bearer token, no WAF body-size limits.
|
|
71
|
+
if (mobilePlatforms.length > 0) {
|
|
72
|
+
step('Resolving deployed config...');
|
|
73
|
+
let config;
|
|
74
|
+
try {
|
|
75
|
+
config = await resolveConfig(flags.stage);
|
|
76
|
+
} catch (err: any) {
|
|
77
|
+
fail(err.message);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
success(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
|
|
81
|
+
|
|
82
|
+
for (const platform of mobilePlatforms) {
|
|
83
|
+
const platformMetadata = metadata?.fileMetadata?.[platform];
|
|
84
|
+
if (!platformMetadata) {
|
|
85
|
+
warn(`Skipping ${platform} (no export found)`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
step(`Publishing ${platform}...`);
|
|
90
|
+
const assets = await gatherAssets(distDir, platform, platformMetadata);
|
|
91
|
+
|
|
92
|
+
// Group is per-publish-run, shared across mobile platforms + web
|
|
93
|
+
if (!groupId) groupId = crypto.randomUUID();
|
|
94
|
+
const branchName = branch || channel;
|
|
95
|
+
const storagePrefix = `releases/${branchName}/${runtimeVersion}/${groupId}/${platform}`;
|
|
96
|
+
|
|
97
|
+
// Upload each asset directly to S3 with content hashing
|
|
98
|
+
const uploaded: Array<{
|
|
99
|
+
path: string;
|
|
100
|
+
s3Key: string;
|
|
101
|
+
contentHash: string;
|
|
102
|
+
filename: string;
|
|
103
|
+
mimeType: string;
|
|
104
|
+
fileExtension: string;
|
|
105
|
+
isLaunchAsset: boolean;
|
|
106
|
+
sizeBytes: number;
|
|
107
|
+
}> = [];
|
|
108
|
+
|
|
109
|
+
const UPLOAD_CONCURRENCY = 6;
|
|
110
|
+
for (let i = 0; i < assets.length; i += UPLOAD_CONCURRENCY) {
|
|
111
|
+
const batch = assets.slice(i, i + UPLOAD_CONCURRENCY);
|
|
112
|
+
try {
|
|
113
|
+
await Promise.all(batch.map(async (asset) => {
|
|
114
|
+
const data = Buffer.from(asset.data, 'base64');
|
|
115
|
+
const s3Key = `${storagePrefix}/${asset.path}`;
|
|
116
|
+
await uploadToS3(
|
|
117
|
+
config.region,
|
|
118
|
+
config.updatesBucket,
|
|
119
|
+
s3Key,
|
|
120
|
+
data,
|
|
121
|
+
asset.contentType,
|
|
122
|
+
);
|
|
123
|
+
const sha256 = crypto.createHash('sha256').update(data).digest('base64')
|
|
124
|
+
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
125
|
+
const md5 = crypto.createHash('md5').update(data).digest('hex');
|
|
126
|
+
uploaded.push({
|
|
127
|
+
path: asset.path,
|
|
128
|
+
s3Key,
|
|
129
|
+
contentHash: sha256,
|
|
130
|
+
filename: md5,
|
|
131
|
+
mimeType: asset.contentType,
|
|
132
|
+
fileExtension: asset.fileExtension,
|
|
133
|
+
isLaunchAsset: asset.isLaunchAsset,
|
|
134
|
+
sizeBytes: data.length,
|
|
135
|
+
});
|
|
136
|
+
}));
|
|
137
|
+
} catch (err: any) {
|
|
138
|
+
fail(`Asset upload failed: ${err.message}`);
|
|
139
|
+
info(`Ensure your IAM user/role has s3:PutObject on the Updates bucket.`);
|
|
140
|
+
info(`Bucket: ${config.updatesBucket}`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Register release via Lambda invoke (IAM auth)
|
|
146
|
+
try {
|
|
147
|
+
const result: any = await invokeAction(
|
|
148
|
+
config.region,
|
|
149
|
+
config.apiFunctionName,
|
|
150
|
+
'register-mobile',
|
|
151
|
+
{
|
|
152
|
+
channel,
|
|
153
|
+
branch,
|
|
154
|
+
groupId,
|
|
155
|
+
runtimeVersion,
|
|
156
|
+
platform,
|
|
157
|
+
message,
|
|
158
|
+
expoConfig: appConfig,
|
|
159
|
+
metadata: { fileMetadata: { [platform]: platformMetadata } },
|
|
160
|
+
storagePrefix,
|
|
161
|
+
assets: uploaded,
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
if (result?.error) {
|
|
165
|
+
fail(`Registration failed for ${platform}: ${result.error}`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
if (result?.groupId && !groupId) groupId = result.groupId;
|
|
169
|
+
success(`${platform}: ${uploaded.length} assets`);
|
|
170
|
+
} catch (err: any) {
|
|
171
|
+
fail(`Registration failed for ${platform}: ${err.message}`);
|
|
172
|
+
info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Web publish — direct S3 upload + Lambda invoke (IAM auth)
|
|
179
|
+
if (hasWeb) {
|
|
180
|
+
const serverDir = path.join(distDir, 'server');
|
|
181
|
+
const clientDir = path.join(distDir, 'client');
|
|
182
|
+
const routesJson = path.join(serverDir, '_expo', 'routes.json');
|
|
183
|
+
|
|
184
|
+
if (!(await fileExists(routesJson))) {
|
|
185
|
+
warn('Skipping web (no server export found — ensure app.json has "output": "server")');
|
|
186
|
+
} else {
|
|
187
|
+
// Resolve SST config for direct AWS access
|
|
188
|
+
step('Resolving deployed config...');
|
|
189
|
+
let config;
|
|
190
|
+
try {
|
|
191
|
+
config = await resolveConfig(flags.stage);
|
|
192
|
+
} catch (err: any) {
|
|
193
|
+
fail(err.message);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
success(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
|
|
197
|
+
|
|
198
|
+
// 1. Create tar+brotli archive of server bundle
|
|
199
|
+
step('Creating server bundle archive...');
|
|
200
|
+
const archivePath = path.join(distDir, 'bundle.tar.br');
|
|
201
|
+
await createArchive(serverDir, archivePath);
|
|
202
|
+
const archiveSize = (await fs.stat(archivePath)).size;
|
|
203
|
+
const archiveSizeKb = Math.round(archiveSize / 1024);
|
|
204
|
+
success(`Archive: ${archiveSizeKb}KB (brotli compressed)`);
|
|
205
|
+
|
|
206
|
+
// 2. Upload server bundle to S3 at the versioned path
|
|
207
|
+
// Same structure as mobile: releases/{branch}/{runtimeVersion}/{groupId}/web/
|
|
208
|
+
if (!groupId) {
|
|
209
|
+
groupId = crypto.randomUUID();
|
|
210
|
+
}
|
|
211
|
+
const branchName = branch || channel;
|
|
212
|
+
const storagePrefix = `releases/${branchName}/${runtimeVersion}/${groupId}/web`;
|
|
213
|
+
const versionedKey = `${storagePrefix}/bundle.tar.br`;
|
|
214
|
+
|
|
215
|
+
step(`Uploading server bundle to S3...`);
|
|
216
|
+
const archiveData = await fs.readFile(archivePath);
|
|
217
|
+
try {
|
|
218
|
+
await uploadToS3(
|
|
219
|
+
config.region,
|
|
220
|
+
config.updatesBucket,
|
|
221
|
+
versionedKey,
|
|
222
|
+
archiveData,
|
|
223
|
+
'application/x-tar+br',
|
|
224
|
+
);
|
|
225
|
+
} catch (err: any) {
|
|
226
|
+
fail(`S3 upload failed: ${err.message}`);
|
|
227
|
+
info(`Ensure your IAM user/role has s3:PutObject on the Updates bucket.`);
|
|
228
|
+
info(`Bucket: ${config.updatesBucket}`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
success(`Uploaded to s3://${config.updatesBucket}/${versionedKey}`);
|
|
232
|
+
|
|
233
|
+
// 3. Upload client assets to S3 (parallel, bounded)
|
|
234
|
+
step('Scanning client assets...');
|
|
235
|
+
const clientAssets = await walkDirectory(clientDir);
|
|
236
|
+
success(`Found ${clientAssets.length} client assets`);
|
|
237
|
+
|
|
238
|
+
if (clientAssets.length > 0) {
|
|
239
|
+
step(`Uploading ${clientAssets.length} client assets...`);
|
|
240
|
+
const UPLOAD_CONCURRENCY = 10;
|
|
241
|
+
let uploaded = 0;
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < clientAssets.length; i += UPLOAD_CONCURRENCY) {
|
|
244
|
+
const batch = clientAssets.slice(i, i + UPLOAD_CONCURRENCY);
|
|
245
|
+
try {
|
|
246
|
+
await Promise.all(batch.map(async (asset) => {
|
|
247
|
+
const data = Buffer.from(asset.data, 'base64');
|
|
248
|
+
await uploadToS3(
|
|
249
|
+
config.region,
|
|
250
|
+
config.clientBundlesBucket,
|
|
251
|
+
asset.relativePath,
|
|
252
|
+
data,
|
|
253
|
+
asset.contentType,
|
|
254
|
+
);
|
|
255
|
+
uploaded++;
|
|
256
|
+
}));
|
|
257
|
+
} catch (err: any) {
|
|
258
|
+
fail(`Client asset upload failed: ${err.message}`);
|
|
259
|
+
info(`Ensure your IAM user/role has s3:PutObject on the ClientBundles bucket.`);
|
|
260
|
+
info(`Bucket: ${config.clientBundlesBucket}`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
success(`${uploaded}/${clientAssets.length} assets uploaded to s3://${config.clientBundlesBucket}/`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 4. Register release via Lambda invoke (IAM auth)
|
|
268
|
+
// Writes versioned manifest, channel pointer, meta.json, and optional DB mirror.
|
|
269
|
+
step('Registering release...');
|
|
270
|
+
const updateId = crypto.createHash('sha256')
|
|
271
|
+
.update(`${channel}:${runtimeVersion}:${Date.now()}`)
|
|
272
|
+
.digest('hex');
|
|
273
|
+
try {
|
|
274
|
+
const result: any = await invokeAction(
|
|
275
|
+
config.region,
|
|
276
|
+
config.apiFunctionName,
|
|
277
|
+
'register-web',
|
|
278
|
+
{
|
|
279
|
+
channel,
|
|
280
|
+
branch: branchName,
|
|
281
|
+
groupId,
|
|
282
|
+
runtimeVersion,
|
|
283
|
+
updateId,
|
|
284
|
+
storagePrefix,
|
|
285
|
+
message,
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
if (result?.error) {
|
|
289
|
+
warn(`Registration failed: ${result.error}`);
|
|
290
|
+
} else {
|
|
291
|
+
success(`Release registered: channel=${result?.channel || channel}, updateId=${result?.updateId || 'n/a'}`);
|
|
292
|
+
}
|
|
293
|
+
} catch (err: any) {
|
|
294
|
+
warn(`Registration failed: ${err.message}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Clean up archive
|
|
298
|
+
await fs.unlink(archivePath).catch(() => {});
|
|
299
|
+
|
|
300
|
+
info(`${archiveSizeKb}KB server bundle + ${clientAssets.length} client assets`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const target = branch ? `branch "${branch}"` : `channel "${channel}"`;
|
|
305
|
+
const groupInfo = groupId ? ` (group: ${groupId})` : '';
|
|
306
|
+
console.log(`\n Published to ${target}${groupInfo}\n`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function loadAppConfig(): Promise<any> {
|
|
310
|
+
// Try app.json first, then app.config.js
|
|
311
|
+
const appJsonPath = path.resolve('app.json');
|
|
312
|
+
try {
|
|
313
|
+
const raw = await fs.readFile(appJsonPath, 'utf8');
|
|
314
|
+
const parsed = JSON.parse(raw);
|
|
315
|
+
return parsed.expo || parsed;
|
|
316
|
+
} catch {
|
|
317
|
+
// Try app.config.js/ts
|
|
318
|
+
try {
|
|
319
|
+
const config = await import(path.resolve('app.config.js'));
|
|
320
|
+
// Unwrap ESM default export (may be double-wrapped by tsx CJS interop)
|
|
321
|
+
let resolved = config.default || config;
|
|
322
|
+
if (resolved.default) resolved = resolved.default;
|
|
323
|
+
// Handle Expo's function export pattern: export default () => ({ ... })
|
|
324
|
+
if (typeof resolved === 'function') resolved = resolved();
|
|
325
|
+
return resolved.expo || resolved;
|
|
326
|
+
} catch {
|
|
327
|
+
throw new Error('Could not find app.json or app.config.js');
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function getPlatforms(filter: string, metadata: any): string[] {
|
|
333
|
+
const available = Object.keys(metadata?.fileMetadata || {});
|
|
334
|
+
// Web-only: no metadata, but server export exists
|
|
335
|
+
if (filter === 'web') return ['web'];
|
|
336
|
+
if (filter === 'all') {
|
|
337
|
+
// Include web if server export dir exists (detected later), plus mobile platforms from metadata
|
|
338
|
+
return [...available, 'web'];
|
|
339
|
+
}
|
|
340
|
+
if (available.includes(filter)) return [filter];
|
|
341
|
+
throw new Error(`Platform "${filter}" not found in export. Available: ${[...available, 'web'].join(', ')}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function gatherAssets(
|
|
345
|
+
distDir: string,
|
|
346
|
+
platform: string,
|
|
347
|
+
platformMetadata: any
|
|
348
|
+
): Promise<Array<{ path: string; data: string; contentType: string; fileExtension: string; isLaunchAsset: boolean }>> {
|
|
349
|
+
const assets: Array<{ path: string; data: string; contentType: string; fileExtension: string; isLaunchAsset: boolean }> = [];
|
|
350
|
+
|
|
351
|
+
// Bundle (launch asset)
|
|
352
|
+
const bundlePath = path.join(distDir, platformMetadata.bundle);
|
|
353
|
+
const bundleData = await fs.readFile(bundlePath);
|
|
354
|
+
assets.push({
|
|
355
|
+
path: platformMetadata.bundle,
|
|
356
|
+
data: bundleData.toString('base64'),
|
|
357
|
+
contentType: 'application/javascript',
|
|
358
|
+
fileExtension: '.bundle',
|
|
359
|
+
isLaunchAsset: true,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// Other assets
|
|
363
|
+
for (const asset of platformMetadata.assets || []) {
|
|
364
|
+
const assetPath = path.join(distDir, asset.path);
|
|
365
|
+
const assetData = await fs.readFile(assetPath);
|
|
366
|
+
assets.push({
|
|
367
|
+
path: asset.path,
|
|
368
|
+
data: assetData.toString('base64'),
|
|
369
|
+
contentType: mimeFromExt(asset.ext),
|
|
370
|
+
fileExtension: `.${asset.ext}`,
|
|
371
|
+
isLaunchAsset: false,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return assets;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function mimeFromExt(ext: string): string {
|
|
379
|
+
const map: Record<string, string> = {
|
|
380
|
+
js: 'application/javascript',
|
|
381
|
+
png: 'image/png',
|
|
382
|
+
jpg: 'image/jpeg',
|
|
383
|
+
jpeg: 'image/jpeg',
|
|
384
|
+
gif: 'image/gif',
|
|
385
|
+
svg: 'image/svg+xml',
|
|
386
|
+
ttf: 'font/ttf',
|
|
387
|
+
otf: 'font/otf',
|
|
388
|
+
json: 'application/json',
|
|
389
|
+
};
|
|
390
|
+
return map[ext] || 'application/octet-stream';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function dirExists(dir: string): Promise<boolean> {
|
|
394
|
+
try {
|
|
395
|
+
const stat = await fs.stat(dir);
|
|
396
|
+
return stat.isDirectory();
|
|
397
|
+
} catch {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
403
|
+
try {
|
|
404
|
+
const stat = await fs.stat(filePath);
|
|
405
|
+
return stat.isFile();
|
|
406
|
+
} catch {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Creates a tar+brotli archive of a directory.
|
|
413
|
+
* Pipeline: tar cf - -C dir . | brotli > archivePath
|
|
414
|
+
*/
|
|
415
|
+
async function createArchive(sourceDir: string, archivePath: string): Promise<void> {
|
|
416
|
+
const tar = spawn('tar', ['cf', '-', '-C', sourceDir, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
417
|
+
const brotli = zlib.createBrotliCompress();
|
|
418
|
+
const output = createWriteStream(archivePath);
|
|
419
|
+
|
|
420
|
+
// Set up exit listener BEFORE pipeline to avoid race condition
|
|
421
|
+
// (tar may exit before pipeline resolves)
|
|
422
|
+
const tarExit = new Promise<void>((resolve, reject) => {
|
|
423
|
+
tar.on('close', (code) => {
|
|
424
|
+
if (code === 0) resolve();
|
|
425
|
+
else reject(new Error(`tar exited with code ${code}`));
|
|
426
|
+
});
|
|
427
|
+
tar.on('error', reject);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
await pipeline(tar.stdout!, brotli, output);
|
|
431
|
+
await tarExit;
|
|
432
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI configuration — resolves deployed resource names from SST outputs.
|
|
3
|
+
*
|
|
4
|
+
* Without --stage: reads .sst/outputs.json (written by `sst deploy`).
|
|
5
|
+
* With --stage: queries AWS to discover resources by SST naming convention.
|
|
6
|
+
*
|
|
7
|
+
* No auth tokens. No config files. AWS SDK's default credential chain handles IAM.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { step } from './output.js';
|
|
13
|
+
|
|
14
|
+
export interface CliConfig {
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
region: string;
|
|
17
|
+
apiFunctionName: string;
|
|
18
|
+
updatesBucket: string;
|
|
19
|
+
clientBundlesBucket: string;
|
|
20
|
+
kvsArn?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SstOutputs {
|
|
24
|
+
routerUrl?: string;
|
|
25
|
+
apiUrl?: string;
|
|
26
|
+
imageUrl?: string;
|
|
27
|
+
apiFunctionName?: string;
|
|
28
|
+
updatesBucket?: string;
|
|
29
|
+
clientBundlesBucket?: string;
|
|
30
|
+
kvsArn?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
34
|
+
const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
|
|
35
|
+
|
|
36
|
+
// Stage-aware discovery: query AWS for the correct resources
|
|
37
|
+
if (stage) {
|
|
38
|
+
const {
|
|
39
|
+
parseAppName,
|
|
40
|
+
discoverConfig,
|
|
41
|
+
getCachedConfig,
|
|
42
|
+
setCachedConfig,
|
|
43
|
+
} = await import('./discover.js');
|
|
44
|
+
|
|
45
|
+
// Check cache first
|
|
46
|
+
const cached = await getCachedConfig(stage);
|
|
47
|
+
if (cached) return cached;
|
|
48
|
+
|
|
49
|
+
step(`Discovering resources for stage "${stage}"...`);
|
|
50
|
+
const appName = await parseAppName();
|
|
51
|
+
const config = await discoverConfig(appName, stage, region);
|
|
52
|
+
|
|
53
|
+
// Cache for future fast lookups
|
|
54
|
+
await setCachedConfig(stage, config);
|
|
55
|
+
return config;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Default: read .sst/outputs.json (unchanged behavior)
|
|
59
|
+
const outputsPath = path.resolve('.sst', 'outputs.json');
|
|
60
|
+
|
|
61
|
+
let outputs: SstOutputs;
|
|
62
|
+
try {
|
|
63
|
+
const raw = await fs.readFile(outputsPath, 'utf8');
|
|
64
|
+
outputs = JSON.parse(raw);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Could not read .sst/outputs.json. Run \`sst deploy\` first, or run from the SST project directory.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!outputs.routerUrl) {
|
|
72
|
+
throw new Error('.sst/outputs.json is missing routerUrl. Run `sst deploy` to generate outputs.');
|
|
73
|
+
}
|
|
74
|
+
if (!outputs.apiFunctionName) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'.sst/outputs.json is missing apiFunctionName. Add `apiFunctionName: api.name` to the return block in sst.config.ts and redeploy.'
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!outputs.updatesBucket) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
'.sst/outputs.json is missing updatesBucket. Add `updatesBucket: updates.name` to the return block in sst.config.ts and redeploy.'
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (!outputs.clientBundlesBucket) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
'.sst/outputs.json is missing clientBundlesBucket. Add `clientBundlesBucket: clientBundles.name` to the return block in sst.config.ts and redeploy.'
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
baseUrl: outputs.routerUrl,
|
|
92
|
+
region,
|
|
93
|
+
apiFunctionName: outputs.apiFunctionName,
|
|
94
|
+
updatesBucket: outputs.updatesBucket,
|
|
95
|
+
clientBundlesBucket: outputs.clientBundlesBucket,
|
|
96
|
+
kvsArn: outputs.kvsArn,
|
|
97
|
+
};
|
|
98
|
+
}
|