@everystack/cli 0.2.33 → 0.2.34
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 +6 -1
- package/src/cli/commands/db.ts +6 -1
- package/src/cli/commands/status.ts +262 -0
- package/src/cli/discover-distribution.ts +68 -10
- package/src/cli/discover.ts +11 -4
- package/src/cli/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.34",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
"peerDependencies": {
|
|
62
62
|
"@everystack/server": ">=0.1.0",
|
|
63
|
+
"@aws-sdk/client-cloudwatch": "3.1053.0",
|
|
63
64
|
"@aws-sdk/client-cloudwatch-logs": "3.1053.0",
|
|
64
65
|
"@aws-sdk/client-lambda": "3.1053.0",
|
|
65
66
|
"@aws-sdk/client-s3": "3.1053.0",
|
|
@@ -74,6 +75,9 @@
|
|
|
74
75
|
"@everystack/server": {
|
|
75
76
|
"optional": true
|
|
76
77
|
},
|
|
78
|
+
"@aws-sdk/client-cloudwatch": {
|
|
79
|
+
"optional": true
|
|
80
|
+
},
|
|
77
81
|
"@aws-sdk/client-cloudwatch-logs": {
|
|
78
82
|
"optional": true
|
|
79
83
|
},
|
|
@@ -100,6 +104,7 @@
|
|
|
100
104
|
}
|
|
101
105
|
},
|
|
102
106
|
"devDependencies": {
|
|
107
|
+
"@aws-sdk/client-cloudwatch": "3.1053.0",
|
|
103
108
|
"@aws-sdk/client-cloudwatch-logs": "3.1053.0",
|
|
104
109
|
"@aws-sdk/client-lambda": "3.1053.0",
|
|
105
110
|
"@aws-sdk/client-s3": "3.1053.0",
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
9
9
|
import { invokeAction } from '../aws.js';
|
|
10
|
-
import { step, success, fail, info } from '../output.js';
|
|
10
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
11
11
|
|
|
12
12
|
export async function dbMigrateCommand(flags: Record<string, string>): Promise<void> {
|
|
13
13
|
step('Resolving deployed config...');
|
|
@@ -34,6 +34,11 @@ export async function dbMigrateCommand(flags: Record<string, string>): Promise<v
|
|
|
34
34
|
process.exit(1);
|
|
35
35
|
}
|
|
36
36
|
success(result?.message || 'Migrations applied');
|
|
37
|
+
// Journal drift warnings — a migration file the journal doesn't list
|
|
38
|
+
// will silently never apply. Never swallow these.
|
|
39
|
+
for (const warning of result?.warnings ?? []) {
|
|
40
|
+
warn(warning);
|
|
41
|
+
}
|
|
37
42
|
} catch (err: any) {
|
|
38
43
|
fail(`Migration failed: ${err.message}`);
|
|
39
44
|
info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack status` — platform health from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Three sections, each independent:
|
|
5
|
+
* CDN — CloudFront metrics from CloudWatch (requests, hit rate, error rates)
|
|
6
|
+
* Lambda — per-function invocations / errors / p95 duration
|
|
7
|
+
* Platform — per-origin rollup summary via the ops Lambda's platform:health
|
|
8
|
+
* action (same numbers as /admin/health)
|
|
9
|
+
*
|
|
10
|
+
* On a V1 stack (no database) the platform section degrades to a note —
|
|
11
|
+
* CDN and Lambda metrics still work with nothing but IAM credentials.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
|
|
15
|
+
import { invokeAction } from '../aws.js';
|
|
16
|
+
import { fail, info, step } from '../output.js';
|
|
17
|
+
|
|
18
|
+
export interface MetricSpec {
|
|
19
|
+
namespace: string;
|
|
20
|
+
metricName: string;
|
|
21
|
+
stat: string;
|
|
22
|
+
dimensions: Record<string, string>;
|
|
23
|
+
/** CloudFront metrics live in us-east-1 regardless of stack region. */
|
|
24
|
+
region?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StatusDeps {
|
|
28
|
+
/** Fetch a single aggregated CloudWatch datapoint for the window. Null = no data. */
|
|
29
|
+
getMetric: (region: string, spec: MetricSpec, hours: number) => Promise<number | null>;
|
|
30
|
+
invoke: typeof invokeAction;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface OriginSummaryLite {
|
|
34
|
+
source: string;
|
|
35
|
+
requestCount: number;
|
|
36
|
+
hitRatio: number | null;
|
|
37
|
+
availability: number | null;
|
|
38
|
+
status5xx: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StatusData {
|
|
42
|
+
hours: number;
|
|
43
|
+
cdn: {
|
|
44
|
+
distributionId: string;
|
|
45
|
+
requests: number | null;
|
|
46
|
+
cacheHitRate: number | null;
|
|
47
|
+
rate4xx: number | null;
|
|
48
|
+
rate5xx: number | null;
|
|
49
|
+
} | null;
|
|
50
|
+
lambdas: Array<{
|
|
51
|
+
label: string;
|
|
52
|
+
functionName: string;
|
|
53
|
+
invocations: number | null;
|
|
54
|
+
errors: number | null;
|
|
55
|
+
p95Ms: number | null;
|
|
56
|
+
}>;
|
|
57
|
+
platform: {
|
|
58
|
+
perOrigin: OriginSummaryLite[];
|
|
59
|
+
totals: { requestCount: number; availability: number | null; hitRatio: number | null; status5xx: number };
|
|
60
|
+
percentiles: {
|
|
61
|
+
p50: number | null; p95: number | null; p99: number | null;
|
|
62
|
+
under100ms: number | null; under300ms: number | null; under1s: number | null;
|
|
63
|
+
};
|
|
64
|
+
} | null;
|
|
65
|
+
platformError: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function defaultGetMetric(
|
|
69
|
+
region: string,
|
|
70
|
+
spec: MetricSpec,
|
|
71
|
+
hours: number,
|
|
72
|
+
): Promise<number | null> {
|
|
73
|
+
const { CloudWatchClient, GetMetricDataCommand } = await import('@aws-sdk/client-cloudwatch');
|
|
74
|
+
const client = new CloudWatchClient({ region: spec.region ?? region });
|
|
75
|
+
const end = new Date();
|
|
76
|
+
const start = new Date(end.getTime() - hours * 3600_000);
|
|
77
|
+
const response = await client.send(new GetMetricDataCommand({
|
|
78
|
+
StartTime: start,
|
|
79
|
+
EndTime: end,
|
|
80
|
+
MetricDataQueries: [{
|
|
81
|
+
Id: 'm0',
|
|
82
|
+
MetricStat: {
|
|
83
|
+
Metric: {
|
|
84
|
+
Namespace: spec.namespace,
|
|
85
|
+
MetricName: spec.metricName,
|
|
86
|
+
Dimensions: Object.entries(spec.dimensions).map(([Name, Value]) => ({ Name, Value })),
|
|
87
|
+
},
|
|
88
|
+
Period: hours * 3600,
|
|
89
|
+
Stat: spec.stat,
|
|
90
|
+
},
|
|
91
|
+
}],
|
|
92
|
+
}));
|
|
93
|
+
const values = response.MetricDataResults?.[0]?.Values ?? [];
|
|
94
|
+
if (values.length === 0) return null;
|
|
95
|
+
return values.reduce((a, b) => a + b, 0) / (spec.stat === 'Sum' ? 1 : values.length);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function gatherStatus(
|
|
99
|
+
config: CliConfig,
|
|
100
|
+
hours: number,
|
|
101
|
+
deps: StatusDeps,
|
|
102
|
+
): Promise<StatusData> {
|
|
103
|
+
const data: StatusData = {
|
|
104
|
+
hours,
|
|
105
|
+
cdn: null,
|
|
106
|
+
lambdas: [],
|
|
107
|
+
platform: null,
|
|
108
|
+
platformError: null,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const tasks: Promise<void>[] = [];
|
|
112
|
+
|
|
113
|
+
if (config.distributionId) {
|
|
114
|
+
const cfDims = { DistributionId: config.distributionId, Region: 'Global' };
|
|
115
|
+
const cf = (metricName: string, stat: string) =>
|
|
116
|
+
deps.getMetric(config.region, {
|
|
117
|
+
namespace: 'AWS/CloudFront', metricName, stat, dimensions: cfDims, region: 'us-east-1',
|
|
118
|
+
}, hours).catch(() => null);
|
|
119
|
+
tasks.push(
|
|
120
|
+
Promise.all([
|
|
121
|
+
cf('Requests', 'Sum'),
|
|
122
|
+
cf('CacheHitRate', 'Average'),
|
|
123
|
+
cf('4xxErrorRate', 'Average'),
|
|
124
|
+
cf('5xxErrorRate', 'Average'),
|
|
125
|
+
]).then(([requests, cacheHitRate, rate4xx, rate5xx]) => {
|
|
126
|
+
data.cdn = { distributionId: config.distributionId!, requests, cacheHitRate, rate4xx, rate5xx };
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const lambdaTargets = [
|
|
132
|
+
{ label: 'api', functionName: config.apiFunctionName },
|
|
133
|
+
{ label: 'image', functionName: config.imageFunctionName },
|
|
134
|
+
{ label: 'worker', functionName: config.workerFunctionName },
|
|
135
|
+
].filter((t): t is { label: string; functionName: string } => !!t.functionName);
|
|
136
|
+
|
|
137
|
+
for (const target of lambdaTargets) {
|
|
138
|
+
const dims = { FunctionName: target.functionName };
|
|
139
|
+
const lm = (metricName: string, stat: string) =>
|
|
140
|
+
deps.getMetric(config.region, {
|
|
141
|
+
namespace: 'AWS/Lambda', metricName, stat, dimensions: dims,
|
|
142
|
+
}, hours).catch(() => null);
|
|
143
|
+
tasks.push(
|
|
144
|
+
Promise.all([
|
|
145
|
+
lm('Invocations', 'Sum'),
|
|
146
|
+
lm('Errors', 'Sum'),
|
|
147
|
+
lm('Duration', 'p95'),
|
|
148
|
+
]).then(([invocations, errors, p95Ms]) => {
|
|
149
|
+
data.lambdas.push({ ...target, invocations, errors, p95Ms });
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
tasks.push(
|
|
155
|
+
deps
|
|
156
|
+
.invoke(config.region, opsFunction(config), 'platform:health', { hours })
|
|
157
|
+
.then((result) => {
|
|
158
|
+
const payload = result as StatusData['platform'] & { error?: string };
|
|
159
|
+
if (payload && !payload.error && payload.totals) {
|
|
160
|
+
data.platform = payload;
|
|
161
|
+
} else {
|
|
162
|
+
data.platformError = payload?.error ?? 'empty response';
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
.catch((err: Error) => {
|
|
166
|
+
data.platformError = err.message;
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await Promise.all(tasks);
|
|
171
|
+
// Keep lambda section in declaration order regardless of resolution order
|
|
172
|
+
data.lambdas.sort(
|
|
173
|
+
(a, b) =>
|
|
174
|
+
lambdaTargets.findIndex((t) => t.label === a.label) -
|
|
175
|
+
lambdaTargets.findIndex((t) => t.label === b.label),
|
|
176
|
+
);
|
|
177
|
+
return data;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const DASH = '—';
|
|
181
|
+
|
|
182
|
+
function num(value: number | null | undefined): string {
|
|
183
|
+
if (value === null || value === undefined) return DASH;
|
|
184
|
+
return Math.round(value).toLocaleString();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function pct(value: number | null | undefined, digits = 0): string {
|
|
188
|
+
if (value === null || value === undefined) return DASH;
|
|
189
|
+
return `${(value * 100).toFixed(digits)}%`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** CloudWatch rate metrics arrive as 0–100, ratios from rollups as 0–1. */
|
|
193
|
+
function rate(value: number | null | undefined, digits = 1): string {
|
|
194
|
+
if (value === null || value === undefined) return DASH;
|
|
195
|
+
return `${value.toFixed(digits)}%`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function ms(value: number | null | undefined): string {
|
|
199
|
+
if (value === null || value === undefined) return DASH;
|
|
200
|
+
return `${Math.round(value)}ms`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function formatStatus(data: StatusData): string[] {
|
|
204
|
+
const lines: string[] = [];
|
|
205
|
+
|
|
206
|
+
if (data.cdn) {
|
|
207
|
+
lines.push(`CDN ${data.cdn.distributionId} (last ${data.hours}h)`);
|
|
208
|
+
lines.push(
|
|
209
|
+
` requests ${num(data.cdn.requests)} cache hit ${rate(data.cdn.cacheHitRate)} 4xx ${rate(data.cdn.rate4xx, 2)} 5xx ${rate(data.cdn.rate5xx, 2)}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (data.lambdas.length > 0) {
|
|
214
|
+
lines.push('Lambda');
|
|
215
|
+
for (const fn of data.lambdas) {
|
|
216
|
+
lines.push(
|
|
217
|
+
` ${fn.label.padEnd(7)} invocations ${num(fn.invocations)} errors ${num(fn.errors)} p95 ${ms(fn.p95Ms)}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (data.platform) {
|
|
223
|
+
const p = data.platform;
|
|
224
|
+
lines.push(`Platform (rollups, last ${data.hours}h)`);
|
|
225
|
+
for (const origin of p.perOrigin) {
|
|
226
|
+
lines.push(
|
|
227
|
+
` ${origin.source.padEnd(7)} ${num(origin.requestCount).padStart(8)} reqs hit ${pct(origin.hitRatio)} availability ${pct(origin.availability, 2)}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
lines.push(
|
|
231
|
+
` p50 ${ms(p.percentiles.p50)} · p95 ${ms(p.percentiles.p95)} · p99 ${ms(p.percentiles.p99)} · ${pct(p.percentiles.under300ms, 1)} under 300ms · availability ${pct(p.totals.availability, 2)}`,
|
|
232
|
+
);
|
|
233
|
+
} else {
|
|
234
|
+
lines.push(`Platform: no database rollups (V1 stack or pipeline not deployed) — ${data.platformError ?? ''}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return lines;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function statusCommand(
|
|
241
|
+
flags: Record<string, string>,
|
|
242
|
+
deps?: Partial<StatusDeps>,
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
step('Resolving deployed config...');
|
|
245
|
+
let config: CliConfig;
|
|
246
|
+
try {
|
|
247
|
+
config = await resolveConfig(flags.stage);
|
|
248
|
+
} catch (err: unknown) {
|
|
249
|
+
fail((err as Error).message);
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const hours = Number(flags.hours) > 0 ? Number(flags.hours) : 1;
|
|
254
|
+
const data = await gatherStatus(config, hours, {
|
|
255
|
+
getMetric: deps?.getMetric ?? defaultGetMetric,
|
|
256
|
+
invoke: deps?.invoke ?? invokeAction,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
for (const line of formatStatus(data)) {
|
|
260
|
+
info(line);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -3,34 +3,92 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Separated from discover.ts to avoid Jest ESM module resolution issues
|
|
5
5
|
* with the @aws-sdk/client-cloudfront ListDistributions types.
|
|
6
|
+
*
|
|
7
|
+
* SST Routers do not put the app/stage in the distribution Comment, so
|
|
8
|
+
* matching runs in order of reliability:
|
|
9
|
+
* 1. DomainName / Aliases match the discovered router URL host
|
|
10
|
+
* 2. Comment contains the {app}-{stage}- prefix (legacy setups)
|
|
11
|
+
* 3. sst:app + sst:stage tags (authoritative, but one API call per candidate)
|
|
6
12
|
*/
|
|
7
13
|
|
|
14
|
+
export interface DistributionHints {
|
|
15
|
+
/** Host of the discovered router URL, e.g. d111abc.cloudfront.net or a custom domain. */
|
|
16
|
+
routerHost?: string;
|
|
17
|
+
appName?: string;
|
|
18
|
+
stage?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface DistributionSummaryLite {
|
|
22
|
+
Id?: string;
|
|
23
|
+
Comment?: string;
|
|
24
|
+
DomainName?: string;
|
|
25
|
+
ARN?: string;
|
|
26
|
+
Aliases?: { Items?: string[] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Pure matcher for the cheap passes (domain, alias, legacy comment). Exported for tests. */
|
|
30
|
+
export function matchDistribution(
|
|
31
|
+
items: readonly DistributionSummaryLite[],
|
|
32
|
+
prefix: string,
|
|
33
|
+
routerHost?: string,
|
|
34
|
+
): string | undefined {
|
|
35
|
+
const host = routerHost?.toLowerCase();
|
|
36
|
+
if (host) {
|
|
37
|
+
for (const dist of items) {
|
|
38
|
+
if (dist.DomainName?.toLowerCase() === host) return dist.Id;
|
|
39
|
+
if (dist.Aliases?.Items?.some((alias) => alias.toLowerCase() === host)) return dist.Id;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const needle = prefix.toLowerCase();
|
|
43
|
+
for (const dist of items) {
|
|
44
|
+
if ((dist.Comment || '').toLowerCase().includes(needle)) return dist.Id;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
8
49
|
/**
|
|
9
|
-
* Find the CloudFront distribution for a stage
|
|
10
|
-
* SST sets the Comment to include the app name and stage.
|
|
50
|
+
* Find the CloudFront distribution for a stage.
|
|
11
51
|
* Best-effort: returns undefined if not found.
|
|
12
52
|
*/
|
|
13
53
|
export async function discoverDistribution(
|
|
14
54
|
region: string,
|
|
15
55
|
prefix: string,
|
|
56
|
+
hints: DistributionHints = {},
|
|
16
57
|
): Promise<string | undefined> {
|
|
17
|
-
const { CloudFrontClient, ListDistributionsCommand } =
|
|
58
|
+
const { CloudFrontClient, ListDistributionsCommand, ListTagsForResourceCommand } =
|
|
59
|
+
await import('@aws-sdk/client-cloudfront');
|
|
18
60
|
const client = new CloudFrontClient({ region });
|
|
19
|
-
const distNeedle = prefix.toLowerCase();
|
|
20
61
|
|
|
62
|
+
const items: DistributionSummaryLite[] = [];
|
|
21
63
|
let marker: string | undefined;
|
|
22
64
|
do {
|
|
23
65
|
const res = await client.send(
|
|
24
66
|
new ListDistributionsCommand({ Marker: marker, MaxItems: 50 }),
|
|
25
67
|
);
|
|
26
|
-
|
|
27
|
-
const comment = (dist.Comment || '').toLowerCase();
|
|
28
|
-
if (comment.includes(distNeedle)) {
|
|
29
|
-
return dist.Id;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
68
|
+
items.push(...((res.DistributionList?.Items || []) as DistributionSummaryLite[]));
|
|
32
69
|
marker = res.DistributionList?.IsTruncated ? res.DistributionList.NextMarker : undefined;
|
|
33
70
|
} while (marker);
|
|
34
71
|
|
|
72
|
+
const cheap = matchDistribution(items, prefix, hints.routerHost);
|
|
73
|
+
if (cheap) return cheap;
|
|
74
|
+
|
|
75
|
+
// Tag pass — authoritative but one call per distribution, so it runs last.
|
|
76
|
+
if (hints.appName && hints.stage) {
|
|
77
|
+
for (const dist of items) {
|
|
78
|
+
if (!dist.ARN) continue;
|
|
79
|
+
try {
|
|
80
|
+
const res = await client.send(new ListTagsForResourceCommand({ Resource: dist.ARN }));
|
|
81
|
+
const tags = new Map(
|
|
82
|
+
(res.Tags?.Items || []).map((t) => [t.Key, t.Value]),
|
|
83
|
+
);
|
|
84
|
+
if (tags.get('sst:app') === hints.appName && tags.get('sst:stage') === hints.stage) {
|
|
85
|
+
return dist.Id;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Tag read denied or transient — keep scanning
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
35
93
|
return undefined;
|
|
36
94
|
}
|
package/src/cli/discover.ts
CHANGED
|
@@ -273,13 +273,20 @@ export async function discoverConfig(
|
|
|
273
273
|
.catch(() => undefined),
|
|
274
274
|
]);
|
|
275
275
|
|
|
276
|
-
// Distribution discovery is best-effort (
|
|
276
|
+
// Distribution discovery is best-effort (status CDN section, cache:purge --invalidate)
|
|
277
277
|
let distributionId: string | undefined;
|
|
278
278
|
try {
|
|
279
279
|
const { discoverDistribution } = await import('./discover-distribution.js');
|
|
280
|
-
|
|
280
|
+
const routerHost = lambdaResult.routerUrl
|
|
281
|
+
? new URL(lambdaResult.routerUrl).host
|
|
282
|
+
: undefined;
|
|
283
|
+
distributionId = await discoverDistribution(region, prefix, {
|
|
284
|
+
routerHost,
|
|
285
|
+
appName,
|
|
286
|
+
stage,
|
|
287
|
+
});
|
|
281
288
|
} catch {
|
|
282
|
-
// Non-fatal
|
|
289
|
+
// Non-fatal
|
|
283
290
|
}
|
|
284
291
|
|
|
285
292
|
if (!bucketsResult.updatesBucket) {
|
|
@@ -329,7 +336,7 @@ export async function discoverConfig(
|
|
|
329
336
|
// ---------------------------------------------------------------------------
|
|
330
337
|
|
|
331
338
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
332
|
-
const CACHE_VERSION =
|
|
339
|
+
const CACHE_VERSION = 4; // Bump when CachedOutputs schema changes to invalidate stale caches
|
|
333
340
|
|
|
334
341
|
interface CachedOutputs {
|
|
335
342
|
_cachedAt: number;
|
package/src/cli/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { branchesCommand } from './commands/branches.js';
|
|
|
9
9
|
import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand } from './commands/db.js';
|
|
10
10
|
import { consoleCommand } from './commands/console.js';
|
|
11
11
|
import { cachePurgeCommand } from './commands/cache.js';
|
|
12
|
+
import { statusCommand } from './commands/status.js';
|
|
12
13
|
import { diagCommand } from './commands/diag.js';
|
|
13
14
|
import { analyzeSSRCommand } from './commands/analyze.js';
|
|
14
15
|
import { logsErrorsCommand, logsTailCommand, logsQueryCommand } from './commands/logs.js';
|
|
@@ -119,6 +120,9 @@ async function main() {
|
|
|
119
120
|
case 'cache:purge':
|
|
120
121
|
await cachePurgeCommand(flags);
|
|
121
122
|
break;
|
|
123
|
+
case 'status':
|
|
124
|
+
await statusCommand(flags);
|
|
125
|
+
break;
|
|
122
126
|
case 'diag': {
|
|
123
127
|
// Positional URL: first arg after 'diag' that doesn't start with '--'
|
|
124
128
|
const diagUrl = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
|
|
@@ -174,6 +178,7 @@ Usage:
|
|
|
174
178
|
everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
|
|
175
179
|
everystack db:psql [--stage <name>] -c <command> Execute SQL query via Lambda (private RDS)
|
|
176
180
|
everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
|
|
181
|
+
everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
|
|
177
182
|
everystack certs:generate [--output ./certs]
|
|
178
183
|
everystack certs:configure [--input ./certs] [--keyid main]
|
|
179
184
|
everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
|