@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.
Files changed (42) hide show
  1. package/README.md +255 -0
  2. package/package.json +104 -0
  3. package/src/cli/aws.ts +121 -0
  4. package/src/cli/commands/analyze.ts +61 -0
  5. package/src/cli/commands/branches.ts +97 -0
  6. package/src/cli/commands/cache.ts +72 -0
  7. package/src/cli/commands/certs.ts +117 -0
  8. package/src/cli/commands/channels.ts +109 -0
  9. package/src/cli/commands/console.ts +68 -0
  10. package/src/cli/commands/db.ts +183 -0
  11. package/src/cli/commands/diag.ts +242 -0
  12. package/src/cli/commands/logs.ts +282 -0
  13. package/src/cli/commands/update.ts +432 -0
  14. package/src/cli/config.ts +98 -0
  15. package/src/cli/discover.ts +321 -0
  16. package/src/cli/hydration-analyzer.ts +224 -0
  17. package/src/cli/index.ts +178 -0
  18. package/src/cli/output.ts +25 -0
  19. package/src/cli/ssr-analyzer.ts +445 -0
  20. package/src/cli/utils/export.ts +8 -0
  21. package/src/cli/utils/table.ts +39 -0
  22. package/src/cli/utils/upload.ts +52 -0
  23. package/src/cli/utils/walk.ts +59 -0
  24. package/src/client/app-state-provider.tsx +83 -0
  25. package/src/client/index.ts +2 -0
  26. package/src/client/updates-provider.tsx +69 -0
  27. package/src/handler/assets.ts +30 -0
  28. package/src/handler/branches.ts +70 -0
  29. package/src/handler/channels-crud.ts +174 -0
  30. package/src/handler/helpers.ts +239 -0
  31. package/src/handler/index.ts +78 -0
  32. package/src/handler/manifest.ts +276 -0
  33. package/src/handler/multipart.ts +74 -0
  34. package/src/handler/publish-web.ts +311 -0
  35. package/src/handler/publish.ts +346 -0
  36. package/src/handler/signing.ts +29 -0
  37. package/src/handler/types.ts +16 -0
  38. package/src/index.ts +4 -0
  39. package/src/schema.ts +245 -0
  40. package/src/storage/filesystem.ts +103 -0
  41. package/src/storage/index.ts +27 -0
  42. 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
+ }