@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,242 @@
1
+ /**
2
+ * diag — diagnose deployed page freshness and hydration issues.
3
+ *
4
+ * Fetches the deployed page via HEAD, reads the latest channel manifest
5
+ * from S3, and compares. Reports CDN cache status, release version,
6
+ * and whether the served content matches the latest OTA update.
7
+ *
8
+ * With --hydration flag, fetches full HTML and analyzes for common
9
+ * SSR hydration issues: type mismatches, timezone problems, relative time.
10
+ *
11
+ * Usage:
12
+ * everystack diag [url] [--path /about] [--channel production] [--stage dev]
13
+ * everystack diag [url] --hydration # Analyze SSR hydration issues
14
+ */
15
+
16
+ import { resolveConfig } from '../config.js';
17
+ import { getFromS3 } from '../aws.js';
18
+ import { step, success, fail, info, warn } from '../output.js';
19
+ import { analyzeHtml, generateHydrationReport } from '../hydration-analyzer.js';
20
+
21
+ interface DiagResult {
22
+ url: string;
23
+ status: number;
24
+ statusText: string;
25
+ cacheStatus?: string;
26
+ cacheAge?: string;
27
+ cacheControl?: string;
28
+ bundleKey?: string;
29
+ updateId?: string;
30
+ runtimeVersion?: string;
31
+ releaseCreated?: string;
32
+ }
33
+
34
+ interface Manifest {
35
+ updateId?: string;
36
+ runtimeVersion?: string;
37
+ createdAt?: string;
38
+ storagePrefix?: string;
39
+ }
40
+
41
+ function extractStoragePrefix(bundleKey: string): string | null {
42
+ // Bundle key format: releases/{branch}/{version}/{groupId}/{platform}/bundle.tar.br
43
+ // Storage prefix: releases/{branch}/{version}/{groupId}
44
+ const match = bundleKey.match(/^(.+)\/web\/bundle\.tar\.br$/);
45
+ return match?.[1] ?? null;
46
+ }
47
+
48
+ export async function diagCommand(
49
+ positionalUrl: string | undefined,
50
+ flags: Record<string, string>,
51
+ ): Promise<void> {
52
+ const channel = flags.channel || 'production';
53
+
54
+ step('Resolving deployed config...');
55
+ let config;
56
+ try {
57
+ config = await resolveConfig(flags.stage);
58
+ } catch (err: any) {
59
+ fail(err.message);
60
+ process.exit(1);
61
+ }
62
+
63
+ // Build the URL: positional arg > baseUrl + --path
64
+ let url = positionalUrl;
65
+ if (!url) {
66
+ if (!config.baseUrl) {
67
+ fail('No URL provided and no baseUrl in config. Pass a URL or use --stage.');
68
+ process.exit(1);
69
+ }
70
+ url = config.baseUrl;
71
+ }
72
+ // Strip trailing slash from base URL before appending path
73
+ url = url.replace(/\/+$/, '');
74
+ if (flags.path) {
75
+ const p = flags.path.startsWith('/') ? flags.path : '/' + flags.path;
76
+ url = url + p;
77
+ }
78
+
79
+ // 1. Fetch the deployed page
80
+ const checkHydration = flags.hydration === 'true' || flags.hydration === '1';
81
+ const method = checkHydration ? 'GET' : 'HEAD';
82
+
83
+ step(`Fetching ${url}...`);
84
+ let diag: DiagResult;
85
+ let html: string | undefined;
86
+ try {
87
+ const response = await fetch(url, {
88
+ method,
89
+ redirect: 'follow',
90
+ });
91
+ diag = {
92
+ url,
93
+ status: response.status,
94
+ statusText: response.statusText,
95
+ cacheStatus: response.headers.get('x-cache') ?? undefined,
96
+ cacheAge: response.headers.get('age') ?? undefined,
97
+ cacheControl: response.headers.get('cache-control') ?? undefined,
98
+ bundleKey: response.headers.get('x-bundle-key') ?? undefined,
99
+ updateId: response.headers.get('x-update-id') ?? undefined,
100
+ runtimeVersion: response.headers.get('x-runtime-version') ?? undefined,
101
+ releaseCreated: response.headers.get('x-release-created') ?? undefined,
102
+ };
103
+
104
+ // Fetch full HTML if hydration analysis requested
105
+ if (checkHydration) {
106
+ html = await response.text();
107
+ }
108
+ } catch (err: any) {
109
+ fail(`Failed to fetch ${url}: ${err.message}`);
110
+ info('Check the URL is correct and includes the protocol (https://).');
111
+ process.exit(1);
112
+ }
113
+
114
+ // 2. Fetch the latest manifest from S3
115
+ step(`Reading latest ${channel} manifest from S3...`);
116
+ let manifest: Manifest | null = null;
117
+ try {
118
+ const manifestKey = `${channel}/web/manifest.json`;
119
+ const data = await getFromS3(config.region, config.updatesBucket, manifestKey);
120
+ if (data) {
121
+ manifest = JSON.parse(data.toString('utf8'));
122
+ }
123
+ } catch {
124
+ // Non-fatal — report what we have
125
+ }
126
+
127
+ // 3. Compare and report
128
+ console.log('');
129
+ console.log(' Diagnostic Report');
130
+ console.log(' ============================================================');
131
+ console.log('');
132
+ console.log(` URL: ${diag.url}`);
133
+ console.log(` Status: ${diag.status} ${diag.statusText}`);
134
+ console.log('');
135
+
136
+ // CDN info
137
+ if (diag.cacheStatus) {
138
+ console.log(` CDN Cache: ${diag.cacheStatus}`);
139
+ }
140
+ if (diag.cacheAge) {
141
+ console.log(` Cache Age: ${diag.cacheAge}s`);
142
+ }
143
+ if (diag.cacheControl) {
144
+ console.log(` Cache-Control: ${diag.cacheControl}`);
145
+ }
146
+ if (diag.cacheStatus || diag.cacheAge || diag.cacheControl) {
147
+ console.log('');
148
+ }
149
+
150
+ // Served release info
151
+ if (diag.bundleKey) {
152
+ console.log(` Bundle Key: ${diag.bundleKey}`);
153
+ }
154
+ if (diag.updateId) {
155
+ console.log(` Update ID: ${diag.updateId}`);
156
+ }
157
+ if (diag.runtimeVersion) {
158
+ console.log(` Runtime Version: ${diag.runtimeVersion}`);
159
+ }
160
+ if (diag.releaseCreated) {
161
+ console.log(` Release Created: ${diag.releaseCreated}`);
162
+ }
163
+ if (!diag.bundleKey && !diag.updateId) {
164
+ warn(' No release headers found. Update @everystack/server to get version headers on SSR responses.');
165
+ }
166
+ console.log('');
167
+
168
+ // Latest manifest info
169
+ if (manifest) {
170
+ console.log(` Latest (${channel}):`);
171
+ if (manifest.updateId) {
172
+ console.log(` Update ID: ${manifest.updateId}`);
173
+ }
174
+ if (manifest.runtimeVersion) {
175
+ console.log(` Runtime Ver: ${manifest.runtimeVersion}`);
176
+ }
177
+ if (manifest.createdAt) {
178
+ console.log(` Created At: ${manifest.createdAt}`);
179
+ }
180
+ if (manifest.storagePrefix) {
181
+ console.log(` Storage Prefix: ${manifest.storagePrefix}`);
182
+ }
183
+ } else {
184
+ warn(` No manifest found in S3 for channel "${channel}".`);
185
+ info(' Run `everystack update` to publish a release first.');
186
+ }
187
+
188
+ console.log('');
189
+ console.log(' ------------------------------------------------------------');
190
+
191
+ // 4. Version comparison
192
+ if (!manifest) {
193
+ warn(' Cannot compare: no manifest available.');
194
+ } else if (diag.updateId && manifest.updateId) {
195
+ // Primary comparison: updateId
196
+ if (diag.updateId === manifest.updateId) {
197
+ success(' Version match: serving the latest release');
198
+ } else {
199
+ fail(` Version mismatch: serving ${diag.updateId}, latest is ${manifest.updateId}`);
200
+ info(' Run `everystack cache:purge --origin web` to bust the CDN cache.');
201
+ }
202
+ } else if (diag.bundleKey && manifest.storagePrefix) {
203
+ // Fallback comparison: storagePrefix from bundle key
204
+ const servedPrefix = extractStoragePrefix(diag.bundleKey);
205
+ if (servedPrefix && servedPrefix === manifest.storagePrefix) {
206
+ success(' Version match: serving the latest release (matched via bundle key)');
207
+ } else if (servedPrefix) {
208
+ fail(` Version mismatch: serving ${servedPrefix}, latest is ${manifest.storagePrefix}`);
209
+ info(' Run `everystack cache:purge --origin web` to bust the CDN cache.');
210
+ } else {
211
+ warn(' Cannot determine served version from bundle key.');
212
+ }
213
+ } else {
214
+ warn(' Cannot compare: missing version headers. Update @everystack/server to emit release metadata.');
215
+ }
216
+
217
+ console.log('');
218
+
219
+ // 5. Hydration analysis (if requested)
220
+ if (html) {
221
+ console.log(' ------------------------------------------------------------');
222
+ console.log('');
223
+ step('Analyzing SSR hydration...');
224
+
225
+ const cacheAge = diag.cacheAge ? parseInt(diag.cacheAge, 10) : undefined;
226
+ const analysis = analyzeHtml(html, cacheAge);
227
+ const report = generateHydrationReport(analysis);
228
+
229
+ console.log('');
230
+ console.log(report);
231
+ console.log('');
232
+
233
+ if (analysis.issues.length > 0) {
234
+ info(' Use these findings to identify type mismatches, timezone issues, and');
235
+ info(' relative time rendering that may cause hydration errors in production.');
236
+ }
237
+ console.log('');
238
+ } else if (checkHydration) {
239
+ info(' Hydration analysis skipped: no HTML content received.');
240
+ console.log('');
241
+ }
242
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Logs commands — query application logs and tail CloudWatch logs.
3
+ *
4
+ * These commands help debug production issues by accessing:
5
+ * - Application logs stored in the database (logs:errors)
6
+ * - Lambda container logs from CloudWatch (logs:tail)
7
+ */
8
+
9
+ import { resolveConfig } from '../config.js';
10
+ import { invokeAction } from '../aws.js';
11
+ import { step, success, fail, info } from '../output.js';
12
+
13
+ export async function logsErrorsCommand(flags: Record<string, string>): Promise<void> {
14
+ step('Resolving deployed config...');
15
+ let config;
16
+ try {
17
+ config = await resolveConfig(flags.stage);
18
+ } catch (err: any) {
19
+ fail(err.message);
20
+ process.exit(1);
21
+ }
22
+
23
+ info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
24
+
25
+ const limit = flags.limit ? parseInt(flags.limit, 10) : 20;
26
+ const source = flags.source;
27
+ const level = flags.level || 'error'; // error or fatal
28
+
29
+ step(`Fetching last ${limit} ${level} logs...`);
30
+
31
+ try {
32
+ const result: any = await invokeAction(
33
+ config.region,
34
+ config.apiFunctionName,
35
+ 'logs:errors',
36
+ { limit, source, level },
37
+ );
38
+
39
+ if (result?.error) {
40
+ fail(`Failed to fetch errors: ${result.error}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ const errors = result.errors || [];
45
+
46
+ if (errors.length === 0) {
47
+ success('No errors found!');
48
+ return;
49
+ }
50
+
51
+ const sourceLabel = result.source === 's3' ? ' (s3)' : '';
52
+ success(`Found ${errors.length} ${level} log(s)${sourceLabel}:\n`);
53
+
54
+ // Format and display errors
55
+ for (const error of errors) {
56
+ console.log(`[${error.timestamp}] ${error.level.toUpperCase()}`);
57
+ console.log(`Source: ${error.source || 'unknown'}`);
58
+ console.log(`Message: ${error.message}`);
59
+
60
+ if (error.traceId) {
61
+ console.log(`TraceID: ${error.traceId}`);
62
+ }
63
+
64
+ if (error.userId) {
65
+ console.log(`UserID: ${error.userId}`);
66
+ }
67
+
68
+ if (error.error) {
69
+ console.log(`Error: ${error.error.name}: ${error.error.message}`);
70
+ if (error.error.stack) {
71
+ console.log(`Stack:\n${error.error.stack.split('\n').slice(0, 5).join('\n')}`);
72
+ }
73
+ }
74
+
75
+ if (error.data) {
76
+ console.log(`Data: ${JSON.stringify(error.data, null, 2)}`);
77
+ }
78
+
79
+ console.log('---\n');
80
+ }
81
+ } catch (err: any) {
82
+ fail(`Failed to fetch errors: ${err.message}`);
83
+ info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
84
+ process.exit(1);
85
+ }
86
+ }
87
+
88
+ export async function logsTailCommand(flags: Record<string, string>): Promise<void> {
89
+ step('Resolving deployed config...');
90
+ let config;
91
+ try {
92
+ config = await resolveConfig(flags.stage);
93
+ } catch (err: any) {
94
+ fail(err.message);
95
+ process.exit(1);
96
+ }
97
+
98
+ info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
99
+
100
+ const filterPattern = flags.filter;
101
+ const since = flags.since || '5m'; // 5m, 1h, 1d
102
+
103
+ step(`Tailing logs (filter: ${filterPattern || 'none'}, since: ${since})...`);
104
+
105
+ try {
106
+ // Import AWS SDK dynamically
107
+ const { CloudWatchLogsClient, FilterLogEventsCommand, DescribeLogStreamsCommand } =
108
+ await import('@aws-sdk/client-cloudwatch-logs');
109
+
110
+ const client = new CloudWatchLogsClient({ region: config.region });
111
+
112
+ // Determine log group name from function name
113
+ const logGroupName = `/aws/lambda/${config.apiFunctionName}`;
114
+
115
+ // Parse since duration to milliseconds
116
+ const sinceMs = parseDuration(since);
117
+ const startTime = Date.now() - sinceMs;
118
+
119
+ success(`Streaming logs from ${logGroupName}...\n`);
120
+
121
+ // Poll for new logs every 2 seconds
122
+ let nextToken: string | undefined;
123
+ let lastTimestamp = startTime;
124
+
125
+ const poll = async () => {
126
+ try {
127
+ const params: any = {
128
+ logGroupName,
129
+ startTime: lastTimestamp,
130
+ filterPattern,
131
+ };
132
+
133
+ if (nextToken) {
134
+ params.nextToken = nextToken;
135
+ }
136
+
137
+ const response = await client.send(new FilterLogEventsCommand(params));
138
+
139
+ if (response.events && response.events.length > 0) {
140
+ for (const event of response.events) {
141
+ if (event.timestamp && event.timestamp > lastTimestamp) {
142
+ lastTimestamp = event.timestamp;
143
+ }
144
+
145
+ const timestamp = event.timestamp
146
+ ? new Date(event.timestamp).toISOString()
147
+ : '';
148
+ const message = event.message || '';
149
+
150
+ console.log(`[${timestamp}] ${message.trimEnd()}`);
151
+ }
152
+ }
153
+
154
+ nextToken = response.nextToken;
155
+
156
+ // Continue polling
157
+ setTimeout(poll, 2000);
158
+ } catch (err: any) {
159
+ if (err.name === 'ResourceNotFoundException') {
160
+ fail(`Log group not found: ${logGroupName}`);
161
+ info('The Lambda function may not have been invoked yet.');
162
+ process.exit(1);
163
+ }
164
+ throw err;
165
+ }
166
+ };
167
+
168
+ await poll();
169
+ } catch (err: any) {
170
+ fail(`Failed to tail logs: ${err.message}`);
171
+ info('Ensure your IAM user/role has logs:FilterLogEvents permission.');
172
+ process.exit(1);
173
+ }
174
+ }
175
+
176
+ export async function logsQueryCommand(flags: Record<string, string>): Promise<void> {
177
+ step('Resolving deployed config...');
178
+ let config;
179
+ try {
180
+ config = await resolveConfig(flags.stage);
181
+ } catch (err: any) {
182
+ fail(err.message);
183
+ process.exit(1);
184
+ }
185
+
186
+ info(`Region: ${config.region}, Function: ${config.apiFunctionName}`);
187
+
188
+ // Build query parameters
189
+ const query: Record<string, any> = {
190
+ limit: flags.limit ? parseInt(flags.limit, 10) : 50,
191
+ };
192
+
193
+ if (flags.level) query.level = flags.level;
194
+ if (flags.source) query.source = flags.source;
195
+ if (flags.traceId) query.traceId = flags.traceId;
196
+ if (flags.userId) query.userId = flags.userId;
197
+ if (flags.deviceId) query.deviceId = flags.deviceId;
198
+ if (flags.sessionId) query.sessionId = flags.sessionId;
199
+ if (flags.platform) query.platform = flags.platform;
200
+ if (flags.environment) query.environment = flags.environment;
201
+ if (flags.search) query.search = flags.search;
202
+ if (flags.since) query.since = flags.since;
203
+
204
+ step(`Querying logs with filters: ${JSON.stringify(query)}...`);
205
+
206
+ try {
207
+ const result: any = await invokeAction(
208
+ config.region,
209
+ config.apiFunctionName,
210
+ 'logs:query',
211
+ query,
212
+ );
213
+
214
+ if (result?.error) {
215
+ fail(`Failed to query logs: ${result.error}`);
216
+ process.exit(1);
217
+ }
218
+
219
+ const logs = result.logs || [];
220
+
221
+ if (logs.length === 0) {
222
+ success('No logs found matching the criteria.');
223
+ return;
224
+ }
225
+
226
+ const sourceLabel = result.source === 's3' ? ' (s3)' : '';
227
+ success(`Found ${logs.length} log(s)${sourceLabel}:\n`);
228
+
229
+ // Format and display logs
230
+ for (const log of logs) {
231
+ const timestamp = new Date(log.timestamp).toISOString();
232
+ const level = (log.level || 'info').toUpperCase().padEnd(5);
233
+
234
+ console.log(`[${timestamp}] ${level} ${log.source || 'unknown'}`);
235
+ console.log(` ${log.message || '(no message)'}`);
236
+
237
+ if (log.traceId) {
238
+ console.log(` TraceID: ${log.traceId}`);
239
+ }
240
+
241
+ if (log.userId) {
242
+ console.log(` UserID: ${log.userId}`);
243
+ }
244
+
245
+ if (log.deviceId) {
246
+ console.log(` DeviceID: ${log.deviceId}`);
247
+ }
248
+
249
+ if (log.error) {
250
+ console.log(` Error: ${log.error.name}: ${log.error.message}`);
251
+ }
252
+
253
+ if (log.data && Object.keys(log.data).length > 0) {
254
+ console.log(` Data: ${JSON.stringify(log.data, null, 2).split('\n').join('\n ')}`);
255
+ }
256
+
257
+ console.log('');
258
+ }
259
+ } catch (err: any) {
260
+ fail(`Failed to query logs: ${err.message}`);
261
+ info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
262
+ process.exit(1);
263
+ }
264
+ }
265
+
266
+ function parseDuration(duration: string): number {
267
+ const match = duration.match(/^(\d+)([smhd])$/);
268
+ if (!match) {
269
+ return 5 * 60 * 1000; // Default: 5 minutes
270
+ }
271
+
272
+ const value = parseInt(match[1], 10);
273
+ const unit = match[2];
274
+
275
+ switch (unit) {
276
+ case 's': return value * 1000;
277
+ case 'm': return value * 60 * 1000;
278
+ case 'h': return value * 60 * 60 * 1000;
279
+ case 'd': return value * 24 * 60 * 60 * 1000;
280
+ default: return 5 * 60 * 1000;
281
+ }
282
+ }