@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,321 @@
1
+ /**
2
+ * AWS resource discovery for stage-aware CLI operations.
3
+ *
4
+ * When --stage is provided, discovers deployed resources by querying AWS
5
+ * using SST's naming convention: {appName}-{stage}-{ResourceName}-{hash}.
6
+ *
7
+ * Discovery chain:
8
+ * 1. Parse app name from sst.config.ts
9
+ * 2. Find CloudFront function → get KVS ARN from its association
10
+ * 3. Find Lambda function → get function name (+ CDN_URL env var if present)
11
+ * 4. Find S3 buckets → get updatesBucket + clientBundlesBucket
12
+ */
13
+
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+
17
+ /** Mirrors DiscoveredConfig from config.ts — defined locally to avoid circular imports. */
18
+ interface DiscoveredConfig {
19
+ baseUrl: string;
20
+ region: string;
21
+ apiFunctionName: string;
22
+ updatesBucket: string;
23
+ clientBundlesBucket: string;
24
+ kvsArn?: string;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // App name parsing
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Parse SST app name from sst.config.ts.
33
+ *
34
+ * SST app names are always string literals in the app() return block:
35
+ * name: 'my-app' or name: "my-app"
36
+ */
37
+ export async function parseAppName(configPath?: string): Promise<string> {
38
+ const filePath = configPath || path.resolve('sst.config.ts');
39
+ let content: string;
40
+ try {
41
+ content = await fs.readFile(filePath, 'utf8');
42
+ } catch {
43
+ throw new Error(
44
+ `Could not find sst.config.ts at ${filePath}. Run from the SST project directory.`,
45
+ );
46
+ }
47
+
48
+ const match = content.match(/name:\s*['"]([^'"]+)['"]/);
49
+ if (!match) {
50
+ throw new Error(
51
+ `Could not parse app name from ${filePath}. Expected name: 'your-app' in the app() function.`,
52
+ );
53
+ }
54
+
55
+ return match[1];
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // CloudFront function discovery (→ KVS ARN)
60
+ // ---------------------------------------------------------------------------
61
+
62
+ interface CfFunctionResult {
63
+ kvsArn?: string;
64
+ }
65
+
66
+ /**
67
+ * Find the Router viewer-request CloudFront function for a stage and extract
68
+ * its associated KVS ARN.
69
+ */
70
+ export async function discoverCfFunction(
71
+ region: string,
72
+ prefix: string,
73
+ ): Promise<CfFunctionResult> {
74
+ const {
75
+ CloudFrontClient,
76
+ ListFunctionsCommand,
77
+ DescribeFunctionCommand,
78
+ } = await import('@aws-sdk/client-cloudfront');
79
+
80
+ const client = new CloudFrontClient({ region });
81
+ const needle = `${prefix}RouterCloudfrontFunctionRequest-`.toLowerCase();
82
+
83
+ let marker: string | undefined;
84
+ let matchName: string | undefined;
85
+
86
+ // Paginate through all CF functions
87
+ do {
88
+ const res = await client.send(
89
+ new ListFunctionsCommand({ Marker: marker, MaxItems: 50 }),
90
+ );
91
+ const items = res.FunctionList?.Items || [];
92
+ for (const fn of items) {
93
+ if (fn.Name && fn.Name.toLowerCase().startsWith(needle)) {
94
+ matchName = fn.Name;
95
+ break;
96
+ }
97
+ }
98
+ if (matchName) break;
99
+ marker = res.FunctionList?.NextMarker;
100
+ } while (marker);
101
+
102
+ if (!matchName) {
103
+ throw new Error(
104
+ `No CloudFront function found matching "${prefix}RouterCloudfrontFunctionRequest-*". ` +
105
+ `Check that the stage has been deployed.`,
106
+ );
107
+ }
108
+
109
+ // Describe to get KVS association
110
+ const desc = await client.send(
111
+ new DescribeFunctionCommand({ Name: matchName, Stage: 'LIVE' }),
112
+ );
113
+ const kvAssociations =
114
+ desc.FunctionSummary?.FunctionConfig?.KeyValueStoreAssociations?.Items;
115
+ const kvsArn = kvAssociations?.[0]?.KeyValueStoreARN;
116
+
117
+ return { kvsArn };
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Lambda discovery (→ function name, routerUrl)
122
+ // ---------------------------------------------------------------------------
123
+
124
+ interface LambdaResult {
125
+ functionName: string;
126
+ routerUrl?: string;
127
+ }
128
+
129
+ /**
130
+ * Find the API Lambda function for a stage.
131
+ * Optionally extracts CDN_URL from environment variables.
132
+ */
133
+ export async function discoverLambda(
134
+ region: string,
135
+ prefix: string,
136
+ ): Promise<LambdaResult> {
137
+ const {
138
+ LambdaClient,
139
+ ListFunctionsCommand,
140
+ GetFunctionConfigurationCommand,
141
+ } = await import('@aws-sdk/client-lambda');
142
+
143
+ const client = new LambdaClient({ region });
144
+ const needle = `${prefix}ApiFunction-`.toLowerCase();
145
+
146
+ let marker: string | undefined;
147
+ let matchName: string | undefined;
148
+
149
+ do {
150
+ const res = await client.send(
151
+ new ListFunctionsCommand({ Marker: marker, MaxItems: 50 }),
152
+ );
153
+ const items = res.Functions || [];
154
+ for (const fn of items) {
155
+ if (fn.FunctionName && fn.FunctionName.toLowerCase().startsWith(needle)) {
156
+ matchName = fn.FunctionName;
157
+ break;
158
+ }
159
+ }
160
+ if (matchName) break;
161
+ marker = res.NextMarker;
162
+ } while (marker);
163
+
164
+ if (!matchName) {
165
+ throw new Error(
166
+ `No API Lambda found for prefix "${prefix}ApiFunction-*". ` +
167
+ `Check that the stage has been deployed and you have lambda:ListFunctions permission.`,
168
+ );
169
+ }
170
+
171
+ // Get env vars for routerUrl (CDN_URL)
172
+ const config = await client.send(
173
+ new GetFunctionConfigurationCommand({ FunctionName: matchName }),
174
+ );
175
+ const envVars = config.Environment?.Variables || {};
176
+
177
+ return {
178
+ functionName: matchName,
179
+ routerUrl: envVars.CDN_URL,
180
+ };
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // S3 bucket discovery
185
+ // ---------------------------------------------------------------------------
186
+
187
+ interface BucketsResult {
188
+ updatesBucket?: string;
189
+ clientBundlesBucket?: string;
190
+ }
191
+
192
+ /**
193
+ * Find the updates and client-bundles S3 buckets for a stage.
194
+ */
195
+ export async function discoverBuckets(
196
+ region: string,
197
+ prefix: string,
198
+ ): Promise<BucketsResult> {
199
+ const { S3Client, ListBucketsCommand } = await import('@aws-sdk/client-s3');
200
+
201
+ const client = new S3Client({ region });
202
+ const res = await client.send(new ListBucketsCommand({}));
203
+ const buckets = res.Buckets || [];
204
+
205
+ const updatesNeedle = `${prefix}updatesbucket-`.toLowerCase();
206
+ const clientNeedle = `${prefix}clientbundlesbucket-`.toLowerCase();
207
+
208
+ let updatesBucket: string | undefined;
209
+ let clientBundlesBucket: string | undefined;
210
+
211
+ for (const b of buckets) {
212
+ if (!b.Name) continue;
213
+ const lower = b.Name.toLowerCase();
214
+ if (lower.startsWith(updatesNeedle)) updatesBucket = b.Name;
215
+ if (lower.startsWith(clientNeedle)) clientBundlesBucket = b.Name;
216
+ if (updatesBucket && clientBundlesBucket) break;
217
+ }
218
+
219
+ return { updatesBucket, clientBundlesBucket };
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Orchestrator
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Discover all CLI resources for a given app + stage by querying AWS.
228
+ */
229
+ export async function discoverConfig(
230
+ appName: string,
231
+ stage: string,
232
+ region: string,
233
+ ): Promise<DiscoveredConfig> {
234
+ const prefix = `${appName}-${stage}-`;
235
+
236
+ // Run CF function + Lambda + S3 discovery in parallel
237
+ const [cfResult, lambdaResult, bucketsResult] = await Promise.all([
238
+ discoverCfFunction(region, prefix),
239
+ discoverLambda(region, prefix),
240
+ discoverBuckets(region, prefix),
241
+ ]);
242
+
243
+ if (!bucketsResult.updatesBucket) {
244
+ throw new Error(
245
+ `Could not find S3 bucket "${prefix}updatesbucket-*". ` +
246
+ `Check that stage "${stage}" has been deployed and you have s3:ListAllMyBuckets permission.`,
247
+ );
248
+ }
249
+
250
+ if (!bucketsResult.clientBundlesBucket) {
251
+ throw new Error(
252
+ `Could not find S3 bucket "${prefix}clientbundlesbucket-*". ` +
253
+ `Check that stage "${stage}" has been deployed.`,
254
+ );
255
+ }
256
+
257
+ return {
258
+ baseUrl: lambdaResult.routerUrl || '',
259
+ region,
260
+ apiFunctionName: lambdaResult.functionName,
261
+ updatesBucket: bucketsResult.updatesBucket,
262
+ clientBundlesBucket: bucketsResult.clientBundlesBucket,
263
+ kvsArn: cfResult.kvsArn,
264
+ };
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Config cache (per-stage)
269
+ // ---------------------------------------------------------------------------
270
+
271
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
272
+
273
+ interface CachedOutputs {
274
+ _cachedAt: number;
275
+ routerUrl?: string;
276
+ apiFunctionName?: string;
277
+ updatesBucket?: string;
278
+ clientBundlesBucket?: string;
279
+ kvsArn?: string;
280
+ }
281
+
282
+ function cachePath(stage: string): string {
283
+ return path.resolve('.sst', `outputs.${stage}.json`);
284
+ }
285
+
286
+ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig | null> {
287
+ try {
288
+ const raw = await fs.readFile(cachePath(stage), 'utf8');
289
+ const cached: CachedOutputs = JSON.parse(raw);
290
+ if (Date.now() - cached._cachedAt > CACHE_TTL_MS) return null;
291
+ if (!cached.apiFunctionName || !cached.updatesBucket || !cached.clientBundlesBucket) {
292
+ return null;
293
+ }
294
+ return {
295
+ baseUrl: cached.routerUrl || '',
296
+ region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
297
+ apiFunctionName: cached.apiFunctionName,
298
+ updatesBucket: cached.updatesBucket,
299
+ clientBundlesBucket: cached.clientBundlesBucket,
300
+ kvsArn: cached.kvsArn,
301
+ };
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
307
+ export async function setCachedConfig(stage: string, config: DiscoveredConfig): Promise<void> {
308
+ const data: CachedOutputs = {
309
+ _cachedAt: Date.now(),
310
+ routerUrl: config.baseUrl,
311
+ apiFunctionName: config.apiFunctionName,
312
+ updatesBucket: config.updatesBucket,
313
+ clientBundlesBucket: config.clientBundlesBucket,
314
+ kvsArn: config.kvsArn,
315
+ };
316
+ try {
317
+ await fs.writeFile(cachePath(stage), JSON.stringify(data, null, 2));
318
+ } catch {
319
+ // Cache write failure is non-fatal
320
+ }
321
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * hydration-analyzer — detect SSR hydration issues.
3
+ *
4
+ * Analyzes HTML responses to detect common hydration mismatches:
5
+ * - Type inconsistencies (string aggregates vs numbers)
6
+ * - Timezone rendering differences
7
+ * - Relative time in cached responses
8
+ * - Stale data indicators
9
+ */
10
+
11
+ interface HydrationIssue {
12
+ type: 'aggregate-string' | 'timezone-mismatch' | 'relative-time' | 'stale-data' | 'other';
13
+ severity: 'error' | 'warning' | 'info';
14
+ message: string;
15
+ fix?: string;
16
+ location?: string;
17
+ }
18
+
19
+ interface HydrationAnalysis {
20
+ issues: HydrationIssue[];
21
+ cacheAge?: number;
22
+ hasLoaderData: boolean;
23
+ suspectPatterns: {
24
+ aggregateNumbers: string[];
25
+ dateStrings: string[];
26
+ relativeTimeStrings: string[];
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Analyze HTML for potential hydration issues.
32
+ */
33
+ export function analyzeHtml(html: string, cacheAge?: number): HydrationAnalysis {
34
+ const issues: HydrationIssue[] = [];
35
+ const suspectPatterns = {
36
+ aggregateNumbers: [] as string[],
37
+ dateStrings: [] as string[],
38
+ relativeTimeStrings: [] as string[],
39
+ };
40
+
41
+ // Check for loader data presence (indicates SSR ran)
42
+ const hasLoaderData = html.includes('__EXPO_ROUTER_HYDRATE__');
43
+
44
+ // 1. Detect potential aggregate strings that should be numbers
45
+ // Common patterns: "0 followers", "42 posts", "123 likes"
46
+ const aggregatePattern = /(\d+)\s+(followers?|following|posts?|likes?|comments?|views?)/gi;
47
+ let match;
48
+ while ((match = aggregatePattern.exec(html)) !== null) {
49
+ const count = match[1];
50
+ const entity = match[2];
51
+ suspectPatterns.aggregateNumbers.push(`${count} ${entity}`);
52
+
53
+ // If count looks like it might be rendered from a string (no formatting, just digits)
54
+ if (/^\d+$/.test(count) && parseInt(count, 10) > 0) {
55
+ // This is a heuristic - we can't definitively say it's wrong without comparing to client render
56
+ // But we can flag it for manual inspection
57
+ }
58
+ }
59
+
60
+ // 2. Detect date strings that might have timezone issues
61
+ // Patterns: "January 15, 2026", "Jan 15, 2026", "2026-01-15"
62
+ const datePattern = /\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s+\d{4}\b/gi;
63
+ while ((match = datePattern.exec(html)) !== null) {
64
+ suspectPatterns.dateStrings.push(match[0]);
65
+ }
66
+
67
+ // Short date pattern: "Jan 15, 2026"
68
+ const shortDatePattern = /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s+\d{4}\b/gi;
69
+ while ((match = shortDatePattern.exec(html)) !== null) {
70
+ suspectPatterns.dateStrings.push(match[0]);
71
+ }
72
+
73
+ // 3. Detect relative time strings (these should NOT be in SSR HTML)
74
+ const relativeTimePatterns = [
75
+ /\b(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago\b/gi,
76
+ /\bjust now\b/gi,
77
+ /\ba (few seconds|moment) ago\b/gi,
78
+ ];
79
+
80
+ for (const pattern of relativeTimePatterns) {
81
+ while ((match = pattern.exec(html)) !== null) {
82
+ suspectPatterns.relativeTimeStrings.push(match[0]);
83
+ }
84
+ }
85
+
86
+ if (suspectPatterns.relativeTimeStrings.length > 0) {
87
+ issues.push({
88
+ type: 'relative-time',
89
+ severity: 'warning',
90
+ message: `Found relative time strings in SSR HTML: ${suspectPatterns.relativeTimeStrings.join(', ')}`,
91
+ fix: 'Use absolute timestamps for SSR. Render relative time on client only.',
92
+ });
93
+
94
+ // If cached for a long time, this is definitely stale
95
+ if (cacheAge && cacheAge > 300) {
96
+ issues.push({
97
+ type: 'stale-data',
98
+ severity: 'error',
99
+ message: `Relative time strings in HTML cached for ${cacheAge}s will be stale`,
100
+ fix: 'Never render relative time during SSR. Use absolute dates instead.',
101
+ });
102
+ }
103
+ }
104
+
105
+ // 4. Check cache age for date staleness
106
+ if (cacheAge && cacheAge > 86400 && suspectPatterns.dateStrings.length > 0) {
107
+ issues.push({
108
+ type: 'stale-data',
109
+ severity: 'warning',
110
+ message: `HTML cached for ${Math.floor(cacheAge / 86400)} days. Dates may be stale.`,
111
+ fix: 'Consider shorter cache TTL for pages with time-sensitive content.',
112
+ });
113
+ }
114
+
115
+ return {
116
+ issues,
117
+ cacheAge,
118
+ hasLoaderData,
119
+ suspectPatterns,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Compare SSR HTML with expected client render to detect mismatches.
125
+ * This is a heuristic analysis - true comparison requires running client-side React.
126
+ */
127
+ export function compareRendering(ssrHtml: string, expectedClientHtml?: string): HydrationIssue[] {
128
+ const issues: HydrationIssue[] = [];
129
+
130
+ // If we have both versions, we can do a more detailed comparison
131
+ if (expectedClientHtml) {
132
+ // Check for text content differences
133
+ const ssrText = stripHtmlTags(ssrHtml);
134
+ const clientText = stripHtmlTags(expectedClientHtml);
135
+
136
+ if (ssrText !== clientText) {
137
+ issues.push({
138
+ type: 'other',
139
+ severity: 'error',
140
+ message: 'SSR and client render produce different text content',
141
+ fix: 'Check for timezone issues, aggregate type coercions, and relative time rendering.',
142
+ });
143
+ }
144
+ }
145
+
146
+ return issues;
147
+ }
148
+
149
+ /**
150
+ * Strip HTML tags to get text content for comparison.
151
+ */
152
+ function stripHtmlTags(html: string): string {
153
+ return html
154
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
155
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
156
+ .replace(/<[^>]+>/g, ' ')
157
+ .replace(/\s+/g, ' ')
158
+ .trim();
159
+ }
160
+
161
+ /**
162
+ * Generate a diagnostic report from hydration analysis.
163
+ */
164
+ export function generateHydrationReport(analysis: HydrationAnalysis): string {
165
+ const lines: string[] = [];
166
+
167
+ if (!analysis.hasLoaderData) {
168
+ lines.push(' ⚠ No __EXPO_ROUTER_HYDRATE__ flag found. SSR may not be running correctly.');
169
+ lines.push('');
170
+ }
171
+
172
+ const hasSuspectPatterns =
173
+ analysis.suspectPatterns.aggregateNumbers.length > 0 ||
174
+ analysis.suspectPatterns.dateStrings.length > 0 ||
175
+ analysis.suspectPatterns.relativeTimeStrings.length > 0;
176
+
177
+ if (analysis.issues.length === 0 && !hasSuspectPatterns) {
178
+ lines.push(' ✓ No hydration issues detected');
179
+ return lines.join('\n');
180
+ }
181
+
182
+ if (analysis.issues.length > 0) {
183
+ lines.push(' Potential Hydration Issues:');
184
+ lines.push(' ' + '='.repeat(58));
185
+ lines.push('');
186
+
187
+ for (const issue of analysis.issues) {
188
+ const icon = issue.severity === 'error' ? '✗' : issue.severity === 'warning' ? '⚠' : 'ℹ';
189
+ lines.push(` ${icon} ${issue.message}`);
190
+ if (issue.fix) {
191
+ lines.push(` Fix: ${issue.fix}`);
192
+ }
193
+ if (issue.location) {
194
+ lines.push(` Location: ${issue.location}`);
195
+ }
196
+ lines.push('');
197
+ }
198
+ }
199
+
200
+ // Add suspect patterns if found (show even without critical issues)
201
+ if (analysis.suspectPatterns.aggregateNumbers.length > 0) {
202
+ lines.push(' Found aggregate numbers (check for string/number mismatches):');
203
+ for (const pattern of analysis.suspectPatterns.aggregateNumbers.slice(0, 5)) {
204
+ lines.push(` - ${pattern}`);
205
+ }
206
+ if (analysis.suspectPatterns.aggregateNumbers.length > 5) {
207
+ lines.push(` ... and ${analysis.suspectPatterns.aggregateNumbers.length - 5} more`);
208
+ }
209
+ lines.push('');
210
+ }
211
+
212
+ if (analysis.suspectPatterns.dateStrings.length > 0) {
213
+ lines.push(' Found date strings (check for timezone: \'UTC\' in toLocaleDateString):');
214
+ for (const pattern of analysis.suspectPatterns.dateStrings.slice(0, 3)) {
215
+ lines.push(` - ${pattern}`);
216
+ }
217
+ if (analysis.suspectPatterns.dateStrings.length > 3) {
218
+ lines.push(` ... and ${analysis.suspectPatterns.dateStrings.length - 3} more`);
219
+ }
220
+ lines.push('');
221
+ }
222
+
223
+ return lines.join('\n');
224
+ }