@everystack/cli 0.2.31 → 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/console.ts +5 -5
- package/src/cli/commands/db.ts +15 -10
- package/src/cli/commands/status.ts +262 -0
- package/src/cli/config.ts +9 -0
- package/src/cli/discover-distribution.ts +68 -10
- package/src/cli/discover.ts +18 -5
- package/src/cli/index.ts +5 -0
- package/src/env.js +31 -4
- package/src/env.ts +40 -4
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",
|
|
@@ -15,7 +15,7 @@ import * as readline from 'node:readline';
|
|
|
15
15
|
import * as fs from 'node:fs';
|
|
16
16
|
import * as path from 'node:path';
|
|
17
17
|
import * as os from 'node:os';
|
|
18
|
-
import { resolveConfig, type CliConfig } from '../config.js';
|
|
18
|
+
import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
|
|
19
19
|
import { invokeAction } from '../aws.js';
|
|
20
20
|
import { success, fail, info } from '../output.js';
|
|
21
21
|
import { formatResult } from '../utils/table.js';
|
|
@@ -189,7 +189,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
|
|
|
189
189
|
process.exit(1);
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
success(`Connected to ${stage} (${config.region}) — ${config
|
|
192
|
+
success(`Connected to ${stage} (${config.region}) — ${opsFunction(config)}`);
|
|
193
193
|
if (sandbox) {
|
|
194
194
|
info('Sandbox mode: all changes will be rolled back after each expression.');
|
|
195
195
|
}
|
|
@@ -219,7 +219,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
|
|
|
219
219
|
if (cachedMeta) return cachedMeta;
|
|
220
220
|
cachedMeta = await invokeAction(
|
|
221
221
|
config.region,
|
|
222
|
-
config
|
|
222
|
+
opsFunction(config),
|
|
223
223
|
'console:meta',
|
|
224
224
|
{},
|
|
225
225
|
) as ConsoleMeta;
|
|
@@ -342,7 +342,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
|
|
|
342
342
|
|
|
343
343
|
const loginResult: any = await invokeAction(
|
|
344
344
|
config.region,
|
|
345
|
-
config
|
|
345
|
+
opsFunction(config),
|
|
346
346
|
'console',
|
|
347
347
|
loginPayload,
|
|
348
348
|
);
|
|
@@ -423,7 +423,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
|
|
|
423
423
|
|
|
424
424
|
const result: any = await invokeAction(
|
|
425
425
|
config.region,
|
|
426
|
-
config
|
|
426
|
+
opsFunction(config),
|
|
427
427
|
'console',
|
|
428
428
|
payload,
|
|
429
429
|
);
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* No HTTP endpoints, no shared secrets — IAM is the authorization layer.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { resolveConfig } from '../config.js';
|
|
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...');
|
|
@@ -19,13 +19,13 @@ export async function dbMigrateCommand(flags: Record<string, string>): Promise<v
|
|
|
19
19
|
process.exit(1);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
info(`Region: ${config.region}, Function: ${config
|
|
22
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
23
23
|
step('Running migrations via Lambda invoke...');
|
|
24
24
|
|
|
25
25
|
try {
|
|
26
26
|
const result: any = await invokeAction(
|
|
27
27
|
config.region,
|
|
28
|
-
config
|
|
28
|
+
opsFunction(config),
|
|
29
29
|
'migrate',
|
|
30
30
|
{},
|
|
31
31
|
);
|
|
@@ -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.');
|
|
@@ -51,13 +56,13 @@ export async function dbSeedCommand(flags: Record<string, string>): Promise<void
|
|
|
51
56
|
process.exit(1);
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
info(`Region: ${config.region}, Function: ${config
|
|
59
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
55
60
|
step('Running seed via Lambda invoke...');
|
|
56
61
|
|
|
57
62
|
try {
|
|
58
63
|
const result: any = await invokeAction(
|
|
59
64
|
config.region,
|
|
60
|
-
config
|
|
65
|
+
opsFunction(config),
|
|
61
66
|
'seed',
|
|
62
67
|
{},
|
|
63
68
|
);
|
|
@@ -87,14 +92,14 @@ export async function dbResetCommand(flags: Record<string, string>): Promise<voi
|
|
|
87
92
|
process.exit(1);
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
info(`Region: ${config.region}, Function: ${config
|
|
95
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
91
96
|
info('This will DROP every schema (auth, dist, logs, ops, public) and re-run migrations.');
|
|
92
97
|
step('Resetting database via Lambda invoke...');
|
|
93
98
|
|
|
94
99
|
try {
|
|
95
100
|
const result: any = await invokeAction(
|
|
96
101
|
config.region,
|
|
97
|
-
config
|
|
102
|
+
opsFunction(config),
|
|
98
103
|
'db:reset',
|
|
99
104
|
{},
|
|
100
105
|
);
|
|
@@ -120,7 +125,7 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
|
|
|
120
125
|
process.exit(1);
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
info(`Region: ${config.region}, Function: ${config
|
|
128
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
124
129
|
|
|
125
130
|
// If -c flag is provided, execute SQL via Lambda and return results
|
|
126
131
|
if (flags.c) {
|
|
@@ -128,7 +133,7 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
|
|
|
128
133
|
try {
|
|
129
134
|
const result: any = await invokeAction(
|
|
130
135
|
config.region,
|
|
131
|
-
config
|
|
136
|
+
opsFunction(config),
|
|
132
137
|
'db:query',
|
|
133
138
|
{ sql: flags.c },
|
|
134
139
|
);
|
|
@@ -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
|
+
}
|
package/src/cli/config.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface CliConfig {
|
|
|
15
15
|
baseUrl: string;
|
|
16
16
|
region: string;
|
|
17
17
|
apiFunctionName: string;
|
|
18
|
+
/** Dedicated operator function (db/console actions). Falls back to apiFunctionName. */
|
|
19
|
+
opsFunctionName?: string;
|
|
18
20
|
imageFunctionName?: string;
|
|
19
21
|
workerFunctionName?: string;
|
|
20
22
|
updatesBucket: string;
|
|
@@ -24,11 +26,17 @@ export interface CliConfig {
|
|
|
24
26
|
distributionId?: string;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
/** Operator invoke target — dedicated ops function when deployed, else the API function. */
|
|
30
|
+
export function opsFunction(config: CliConfig): string {
|
|
31
|
+
return config.opsFunctionName ?? config.apiFunctionName;
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
interface SstOutputs {
|
|
28
35
|
routerUrl?: string;
|
|
29
36
|
apiUrl?: string;
|
|
30
37
|
imageUrl?: string;
|
|
31
38
|
apiFunctionName?: string;
|
|
39
|
+
opsFunctionName?: string;
|
|
32
40
|
imageFunctionName?: string;
|
|
33
41
|
workerFunctionName?: string;
|
|
34
42
|
updatesBucket?: string;
|
|
@@ -99,6 +107,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
|
99
107
|
baseUrl: outputs.routerUrl,
|
|
100
108
|
region,
|
|
101
109
|
apiFunctionName: outputs.apiFunctionName,
|
|
110
|
+
opsFunctionName: outputs.opsFunctionName,
|
|
102
111
|
imageFunctionName: outputs.imageFunctionName,
|
|
103
112
|
workerFunctionName: outputs.workerFunctionName,
|
|
104
113
|
updatesBucket: outputs.updatesBucket,
|
|
@@ -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
|
@@ -19,6 +19,7 @@ interface DiscoveredConfig {
|
|
|
19
19
|
baseUrl: string;
|
|
20
20
|
region: string;
|
|
21
21
|
apiFunctionName: string;
|
|
22
|
+
opsFunctionName?: string;
|
|
22
23
|
imageFunctionName?: string;
|
|
23
24
|
workerFunctionName?: string;
|
|
24
25
|
updatesBucket: string;
|
|
@@ -261,23 +262,31 @@ export async function discoverConfig(
|
|
|
261
262
|
const prefix = `${appName}-${stage}-`;
|
|
262
263
|
|
|
263
264
|
// Run CF function + Lambda + S3 discovery in parallel
|
|
264
|
-
const [cfResult, lambdaResult, bucketsResult, imageFn, workerFn] = await Promise.all([
|
|
265
|
+
const [cfResult, lambdaResult, bucketsResult, opsFn, imageFn, workerFn] = await Promise.all([
|
|
265
266
|
discoverCfFunction(region, prefix),
|
|
266
267
|
discoverLambda(region, prefix),
|
|
267
268
|
discoverBuckets(region, prefix),
|
|
269
|
+
discoverFunction(region, prefix, 'OpsFunction').catch(() => undefined),
|
|
268
270
|
discoverFunction(region, prefix, 'ImageFunction').catch(() => undefined),
|
|
269
271
|
discoverFunction(region, prefix, 'WorkerFunction')
|
|
270
272
|
.then(fn => fn || discoverFunction(region, prefix, 'JobsSubscriber'))
|
|
271
273
|
.catch(() => undefined),
|
|
272
274
|
]);
|
|
273
275
|
|
|
274
|
-
// Distribution discovery is best-effort (
|
|
276
|
+
// Distribution discovery is best-effort (status CDN section, cache:purge --invalidate)
|
|
275
277
|
let distributionId: string | undefined;
|
|
276
278
|
try {
|
|
277
279
|
const { discoverDistribution } = await import('./discover-distribution.js');
|
|
278
|
-
|
|
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
|
+
});
|
|
279
288
|
} catch {
|
|
280
|
-
// Non-fatal
|
|
289
|
+
// Non-fatal
|
|
281
290
|
}
|
|
282
291
|
|
|
283
292
|
if (!bucketsResult.updatesBucket) {
|
|
@@ -311,6 +320,7 @@ export async function discoverConfig(
|
|
|
311
320
|
baseUrl: lambdaResult.routerUrl || '',
|
|
312
321
|
region,
|
|
313
322
|
apiFunctionName: lambdaResult.functionName,
|
|
323
|
+
opsFunctionName: opsFn,
|
|
314
324
|
imageFunctionName: imageFn,
|
|
315
325
|
workerFunctionName: workerFn,
|
|
316
326
|
updatesBucket: bucketsResult.updatesBucket,
|
|
@@ -326,13 +336,14 @@ export async function discoverConfig(
|
|
|
326
336
|
// ---------------------------------------------------------------------------
|
|
327
337
|
|
|
328
338
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
329
|
-
const CACHE_VERSION =
|
|
339
|
+
const CACHE_VERSION = 4; // Bump when CachedOutputs schema changes to invalidate stale caches
|
|
330
340
|
|
|
331
341
|
interface CachedOutputs {
|
|
332
342
|
_cachedAt: number;
|
|
333
343
|
_version?: number;
|
|
334
344
|
routerUrl?: string;
|
|
335
345
|
apiFunctionName?: string;
|
|
346
|
+
opsFunctionName?: string;
|
|
336
347
|
imageFunctionName?: string;
|
|
337
348
|
workerFunctionName?: string;
|
|
338
349
|
updatesBucket?: string;
|
|
@@ -359,6 +370,7 @@ export async function getCachedConfig(stage: string): Promise<DiscoveredConfig |
|
|
|
359
370
|
baseUrl: cached.routerUrl || '',
|
|
360
371
|
region: process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
|
|
361
372
|
apiFunctionName: cached.apiFunctionName,
|
|
373
|
+
opsFunctionName: cached.opsFunctionName,
|
|
362
374
|
imageFunctionName: cached.imageFunctionName,
|
|
363
375
|
workerFunctionName: cached.workerFunctionName,
|
|
364
376
|
updatesBucket: cached.updatesBucket,
|
|
@@ -378,6 +390,7 @@ export async function setCachedConfig(stage: string, config: DiscoveredConfig):
|
|
|
378
390
|
_version: CACHE_VERSION,
|
|
379
391
|
routerUrl: config.baseUrl,
|
|
380
392
|
apiFunctionName: config.apiFunctionName,
|
|
393
|
+
opsFunctionName: config.opsFunctionName,
|
|
381
394
|
imageFunctionName: config.imageFunctionName,
|
|
382
395
|
workerFunctionName: config.workerFunctionName,
|
|
383
396
|
updatesBucket: config.updatesBucket,
|
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)
|
package/src/env.js
CHANGED
|
@@ -120,14 +120,25 @@ function filterEnv(surface, config) {
|
|
|
120
120
|
* way to deliver env values to web client code is via EXPO_PUBLIC_* vars,
|
|
121
121
|
* which Metro inlines as literal strings at compile time.
|
|
122
122
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
123
|
+
* When `webKeys` is provided, only those keys get EXPO_PUBLIC_* vars.
|
|
124
|
+
* The inline path exists solely because web has no `extra` — so it must
|
|
125
|
+
* carry only web-tier values. Mobile-tier keys are delivered via `extra`
|
|
126
|
+
* on native and must never reach the Metro inline path: a mis-surfaced
|
|
127
|
+
* web export would otherwise bake them into public JS.
|
|
125
128
|
*/
|
|
126
|
-
function setExpoPublicVars(filtered) {
|
|
129
|
+
function setExpoPublicVars(filtered, webKeys) {
|
|
130
|
+
const allowed = webKeys ? new Set(webKeys) : null;
|
|
127
131
|
for (const [key, value] of Object.entries(filtered)) {
|
|
132
|
+
if (allowed && !allowed.has(key))
|
|
133
|
+
continue;
|
|
128
134
|
process.env[`EXPO_PUBLIC_${key}`] = value;
|
|
129
135
|
}
|
|
130
136
|
}
|
|
137
|
+
/** Stages where the permissive `local` tier set is acceptable. */
|
|
138
|
+
const NON_DEPLOYABLE_STAGES = new Set(['', 'development', 'dev', 'local', 'test']);
|
|
139
|
+
function isDeployableStage(stage) {
|
|
140
|
+
return !NON_DEPLOYABLE_STAGES.has((stage ?? '').toLowerCase());
|
|
141
|
+
}
|
|
131
142
|
/**
|
|
132
143
|
* Load and filter environment variables for the current build surface.
|
|
133
144
|
*
|
|
@@ -148,7 +159,23 @@ function setExpoPublicVars(filtered) {
|
|
|
148
159
|
function load(options) {
|
|
149
160
|
const config = loadEnvConfig(options?.path);
|
|
150
161
|
const surface = detectSurface();
|
|
162
|
+
// Fail closed: surface fell through to `local` (the most permissive tier
|
|
163
|
+
// set) while ENVIRONMENT names a deployable stage. Mixed signals mean a
|
|
164
|
+
// misconfigured build — refuse to bundle rather than risk shipping the
|
|
165
|
+
// mobile tier in a web export. EVERYSTACK_SURFACE=local is the explicit
|
|
166
|
+
// escape hatch for debugging.
|
|
167
|
+
if (surface === 'local' && isDeployableStage(process.env.ENVIRONMENT)) {
|
|
168
|
+
if (process.env.EVERYSTACK_SURFACE === 'local') {
|
|
169
|
+
console.warn(`[everystack/env] ENVIRONMENT=${process.env.ENVIRONMENT} but EVERYSTACK_SURFACE=local — `
|
|
170
|
+
+ 'bundling with the local tier set (mobile keys included). Debug builds only.');
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
throw new Error(`[everystack/env] Surface resolved to 'local' but ENVIRONMENT=${process.env.ENVIRONMENT}. `
|
|
174
|
+
+ "Deployable builds must run under 'everystack update' (EVERYSTACK_UPDATE) or EAS (EAS_BUILD). "
|
|
175
|
+
+ 'Refusing to bundle with the local tier set. Set EVERYSTACK_SURFACE=local to override for debugging.');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
151
178
|
const filtered = filterEnv(surface, config);
|
|
152
|
-
setExpoPublicVars(filtered);
|
|
179
|
+
setExpoPublicVars(filtered, config.web ?? []);
|
|
153
180
|
return { extra: filtered };
|
|
154
181
|
}
|
package/src/env.ts
CHANGED
|
@@ -148,15 +148,30 @@ export function filterEnv(surface: Surface, config: EnvConfig): Record<string, s
|
|
|
148
148
|
* way to deliver env values to web client code is via EXPO_PUBLIC_* vars,
|
|
149
149
|
* which Metro inlines as literal strings at compile time.
|
|
150
150
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
151
|
+
* When `webKeys` is provided, only those keys get EXPO_PUBLIC_* vars.
|
|
152
|
+
* The inline path exists solely because web has no `extra` — so it must
|
|
153
|
+
* carry only web-tier values. Mobile-tier keys are delivered via `extra`
|
|
154
|
+
* on native and must never reach the Metro inline path: a mis-surfaced
|
|
155
|
+
* web export would otherwise bake them into public JS.
|
|
153
156
|
*/
|
|
154
|
-
export function setExpoPublicVars(
|
|
157
|
+
export function setExpoPublicVars(
|
|
158
|
+
filtered: Record<string, string>,
|
|
159
|
+
webKeys?: string[]
|
|
160
|
+
): void {
|
|
161
|
+
const allowed = webKeys ? new Set(webKeys) : null;
|
|
155
162
|
for (const [key, value] of Object.entries(filtered)) {
|
|
163
|
+
if (allowed && !allowed.has(key)) continue;
|
|
156
164
|
process.env[`EXPO_PUBLIC_${key}`] = value;
|
|
157
165
|
}
|
|
158
166
|
}
|
|
159
167
|
|
|
168
|
+
/** Stages where the permissive `local` tier set is acceptable. */
|
|
169
|
+
const NON_DEPLOYABLE_STAGES = new Set(['', 'development', 'dev', 'local', 'test']);
|
|
170
|
+
|
|
171
|
+
function isDeployableStage(stage: string | undefined): boolean {
|
|
172
|
+
return !NON_DEPLOYABLE_STAGES.has((stage ?? '').toLowerCase());
|
|
173
|
+
}
|
|
174
|
+
|
|
160
175
|
/**
|
|
161
176
|
* Load and filter environment variables for the current build surface.
|
|
162
177
|
*
|
|
@@ -177,7 +192,28 @@ export function setExpoPublicVars(filtered: Record<string, string>): void {
|
|
|
177
192
|
export function load(options?: { path?: string }): { extra: Record<string, string> } {
|
|
178
193
|
const config = loadEnvConfig(options?.path);
|
|
179
194
|
const surface = detectSurface();
|
|
195
|
+
|
|
196
|
+
// Fail closed: surface fell through to `local` (the most permissive tier
|
|
197
|
+
// set) while ENVIRONMENT names a deployable stage. Mixed signals mean a
|
|
198
|
+
// misconfigured build — refuse to bundle rather than risk shipping the
|
|
199
|
+
// mobile tier in a web export. EVERYSTACK_SURFACE=local is the explicit
|
|
200
|
+
// escape hatch for debugging.
|
|
201
|
+
if (surface === 'local' && isDeployableStage(process.env.ENVIRONMENT)) {
|
|
202
|
+
if (process.env.EVERYSTACK_SURFACE === 'local') {
|
|
203
|
+
console.warn(
|
|
204
|
+
`[everystack/env] ENVIRONMENT=${process.env.ENVIRONMENT} but EVERYSTACK_SURFACE=local — `
|
|
205
|
+
+ 'bundling with the local tier set (mobile keys included). Debug builds only.'
|
|
206
|
+
);
|
|
207
|
+
} else {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`[everystack/env] Surface resolved to 'local' but ENVIRONMENT=${process.env.ENVIRONMENT}. `
|
|
210
|
+
+ "Deployable builds must run under 'everystack update' (EVERYSTACK_UPDATE) or EAS (EAS_BUILD). "
|
|
211
|
+
+ 'Refusing to bundle with the local tier set. Set EVERYSTACK_SURFACE=local to override for debugging.'
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
180
216
|
const filtered = filterEnv(surface, config);
|
|
181
|
-
setExpoPublicVars(filtered);
|
|
217
|
+
setExpoPublicVars(filtered, config.web ?? []);
|
|
182
218
|
return { extra: filtered };
|
|
183
219
|
}
|