@everystack/cli 0.2.18 → 0.2.21
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 +9 -1
- package/src/cli/aws.ts +59 -5
- package/src/cli/commands/diag.ts +10 -45
- package/src/cli/commands/secrets.ts +124 -0
- package/src/cli/commands/update.ts +10 -50
- package/src/cli/discover.ts +4 -2
- package/src/cli/index.ts +5 -4
- package/src/cli/utils/dotenv.ts +105 -0
- package/src/env.ts +181 -0
- package/src/sst.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
@@ -30,6 +30,14 @@
|
|
|
30
30
|
"./plugin": {
|
|
31
31
|
"types": "./src/plugin.ts",
|
|
32
32
|
"default": "./src/plugin.ts"
|
|
33
|
+
},
|
|
34
|
+
"./env": {
|
|
35
|
+
"types": "./src/env.ts",
|
|
36
|
+
"default": "./src/env.ts"
|
|
37
|
+
},
|
|
38
|
+
"./sst": {
|
|
39
|
+
"types": "./src/sst.ts",
|
|
40
|
+
"default": "./src/sst.ts"
|
|
33
41
|
}
|
|
34
42
|
},
|
|
35
43
|
"bin": {
|
package/src/cli/aws.ts
CHANGED
|
@@ -123,13 +123,67 @@ export async function invokeAction(
|
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
125
|
* Resolve the CloudWatch log group for a Lambda function.
|
|
126
|
-
*
|
|
126
|
+
*
|
|
127
|
+
* First checks if the conventional `/aws/lambda/{functionName}` log group exists.
|
|
128
|
+
* If not (e.g. SST replaced the function during deploy, changing the hash suffix),
|
|
129
|
+
* searches for a log group matching the function's base name prefix.
|
|
127
130
|
*/
|
|
128
|
-
export function resolveLogGroup(
|
|
129
|
-
|
|
131
|
+
export async function resolveLogGroup(
|
|
132
|
+
region: string,
|
|
130
133
|
functionName: string,
|
|
131
|
-
): string {
|
|
132
|
-
|
|
134
|
+
): Promise<string> {
|
|
135
|
+
const conventional = `/aws/lambda/${functionName}`;
|
|
136
|
+
|
|
137
|
+
const {
|
|
138
|
+
CloudWatchLogsClient,
|
|
139
|
+
DescribeLogGroupsCommand,
|
|
140
|
+
} = await import('@aws-sdk/client-cloudwatch-logs');
|
|
141
|
+
|
|
142
|
+
const client = new CloudWatchLogsClient({ region });
|
|
143
|
+
|
|
144
|
+
// Fast path: conventional name exists
|
|
145
|
+
try {
|
|
146
|
+
const res = await client.send(
|
|
147
|
+
new DescribeLogGroupsCommand({
|
|
148
|
+
logGroupNamePrefix: conventional,
|
|
149
|
+
limit: 1,
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
if (res.logGroups?.some(lg => lg.logGroupName === conventional)) {
|
|
153
|
+
return conventional;
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// Fall through to prefix search
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// SST names functions like {app}-{stage}-{ResourceName}{Random}-{hash}.
|
|
160
|
+
// After deploy, the hash changes but the base prefix stays the same.
|
|
161
|
+
// Strip the final -{hash} segment and search by prefix.
|
|
162
|
+
const lastDash = functionName.lastIndexOf('-');
|
|
163
|
+
if (lastDash > 0) {
|
|
164
|
+
const basePrefix = `/aws/lambda/${functionName.slice(0, lastDash)}`;
|
|
165
|
+
try {
|
|
166
|
+
const res = await client.send(
|
|
167
|
+
new DescribeLogGroupsCommand({
|
|
168
|
+
logGroupNamePrefix: basePrefix,
|
|
169
|
+
limit: 5,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
if (res.logGroups?.length) {
|
|
173
|
+
// Return the most recently created matching log group
|
|
174
|
+
const sorted = res.logGroups
|
|
175
|
+
.filter(lg => lg.logGroupName)
|
|
176
|
+
.sort((a, b) => (b.creationTime || 0) - (a.creationTime || 0));
|
|
177
|
+
if (sorted.length > 0) {
|
|
178
|
+
return sorted[0].logGroupName!;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// Fall through to conventional name
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return conventional;
|
|
133
187
|
}
|
|
134
188
|
|
|
135
189
|
/**
|
package/src/cli/commands/diag.ts
CHANGED
|
@@ -25,10 +25,7 @@ interface DiagResult {
|
|
|
25
25
|
cacheStatus?: string;
|
|
26
26
|
cacheAge?: string;
|
|
27
27
|
cacheControl?: string;
|
|
28
|
-
|
|
29
|
-
updateId?: string;
|
|
30
|
-
runtimeVersion?: string;
|
|
31
|
-
releaseCreated?: string;
|
|
28
|
+
serverDebug?: string;
|
|
32
29
|
}
|
|
33
30
|
|
|
34
31
|
interface Manifest {
|
|
@@ -38,13 +35,6 @@ interface Manifest {
|
|
|
38
35
|
storagePrefix?: string;
|
|
39
36
|
}
|
|
40
37
|
|
|
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
38
|
export async function diagCommand(
|
|
49
39
|
positionalUrl: string | undefined,
|
|
50
40
|
flags: Record<string, string>,
|
|
@@ -95,10 +85,7 @@ export async function diagCommand(
|
|
|
95
85
|
cacheStatus: response.headers.get('x-cache') ?? undefined,
|
|
96
86
|
cacheAge: response.headers.get('age') ?? undefined,
|
|
97
87
|
cacheControl: response.headers.get('cache-control') ?? undefined,
|
|
98
|
-
|
|
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,
|
|
88
|
+
serverDebug: response.headers.get('x-server-debug') ?? undefined,
|
|
102
89
|
};
|
|
103
90
|
|
|
104
91
|
// Fetch full HTML if hydration analysis requested
|
|
@@ -148,20 +135,10 @@ export async function diagCommand(
|
|
|
148
135
|
}
|
|
149
136
|
|
|
150
137
|
// Served release info
|
|
151
|
-
if (diag.
|
|
152
|
-
console.log(`
|
|
153
|
-
}
|
|
154
|
-
|
|
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.');
|
|
138
|
+
if (diag.serverDebug) {
|
|
139
|
+
console.log(` Serving Release: ${diag.serverDebug}`);
|
|
140
|
+
} else {
|
|
141
|
+
warn(' No X-Server-Debug header found. Update @everystack/server to get release timestamp on SSR responses.');
|
|
165
142
|
}
|
|
166
143
|
console.log('');
|
|
167
144
|
|
|
@@ -191,27 +168,15 @@ export async function diagCommand(
|
|
|
191
168
|
// 4. Version comparison
|
|
192
169
|
if (!manifest) {
|
|
193
170
|
warn(' Cannot compare: no manifest available.');
|
|
194
|
-
} else if (diag.
|
|
195
|
-
|
|
196
|
-
if (diag.updateId === manifest.updateId) {
|
|
171
|
+
} else if (diag.serverDebug && manifest.createdAt) {
|
|
172
|
+
if (diag.serverDebug === manifest.createdAt) {
|
|
197
173
|
success(' Version match: serving the latest release');
|
|
198
174
|
} else {
|
|
199
|
-
fail(` Version mismatch: serving ${diag.
|
|
175
|
+
fail(` Version mismatch: serving ${diag.serverDebug}, latest is ${manifest.createdAt}`);
|
|
200
176
|
info(' Run `everystack cache:purge --origin web` to bust the CDN cache.');
|
|
201
177
|
}
|
|
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
178
|
} else {
|
|
214
|
-
warn(' Cannot compare: missing
|
|
179
|
+
warn(' Cannot compare: missing X-Server-Debug header or manifest timestamp.');
|
|
215
180
|
}
|
|
216
181
|
|
|
217
182
|
console.log('');
|
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
* Requires IAM permissions: ssm:GetParameter, s3:GetObject, s3:PutObject, kms:Decrypt.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import fs from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
16
18
|
import { step, success, fail, info, warn } from '../output.js';
|
|
17
19
|
import { loadSstSecrets, putSstSecrets } from '../utils/secrets.js';
|
|
20
|
+
import { parseDotenv, serializeDotenv } from '../utils/dotenv.js';
|
|
18
21
|
|
|
19
22
|
interface SecretsContext {
|
|
20
23
|
appName: string;
|
|
@@ -112,6 +115,101 @@ async function removeSecret(ctx: SecretsContext, name: string): Promise<void> {
|
|
|
112
115
|
success(`${name} removed from stage "${ctx.stage}"`);
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Import
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
async function importSecrets(
|
|
123
|
+
ctx: SecretsContext,
|
|
124
|
+
filePath: string,
|
|
125
|
+
flags: Record<string, string>,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
step(`Importing secrets from ${filePath}...`);
|
|
128
|
+
|
|
129
|
+
let content: string;
|
|
130
|
+
try {
|
|
131
|
+
content = await fs.readFile(path.resolve(filePath), 'utf8');
|
|
132
|
+
} catch (err: any) {
|
|
133
|
+
if (err.code === 'ENOENT') {
|
|
134
|
+
fail(`File not found: ${filePath}`);
|
|
135
|
+
} else {
|
|
136
|
+
fail(`Could not read file: ${err.message}`);
|
|
137
|
+
}
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const parsed = parseDotenv(content);
|
|
142
|
+
const parsedCount = Object.keys(parsed).length;
|
|
143
|
+
|
|
144
|
+
if (parsedCount === 0) {
|
|
145
|
+
warn(`No secrets found in ${filePath}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let merged: Record<string, string>;
|
|
150
|
+
|
|
151
|
+
if (flags.replace === 'true') {
|
|
152
|
+
// Replace mode: parsed entries become the entire secrets map
|
|
153
|
+
const existing = await loadSstSecrets(ctx);
|
|
154
|
+
const existingCount = Object.keys(existing).length;
|
|
155
|
+
if (existingCount > 0) {
|
|
156
|
+
warn(`Replacing all ${existingCount} existing secret(s)`);
|
|
157
|
+
}
|
|
158
|
+
merged = parsed;
|
|
159
|
+
} else {
|
|
160
|
+
// Merge mode (default): add new keys, overwrite matching keys, preserve the rest
|
|
161
|
+
const existing = await loadSstSecrets(ctx);
|
|
162
|
+
const existingKeys = new Set(Object.keys(existing));
|
|
163
|
+
let updated = 0;
|
|
164
|
+
for (const key of Object.keys(parsed)) {
|
|
165
|
+
if (existingKeys.has(key)) updated++;
|
|
166
|
+
}
|
|
167
|
+
merged = { ...existing, ...parsed };
|
|
168
|
+
const newCount = parsedCount - updated;
|
|
169
|
+
const detail = updated > 0 ? ` (${newCount} new, ${updated} updated)` : '';
|
|
170
|
+
await putSstSecrets(ctx, merged);
|
|
171
|
+
success(`${parsedCount} secret(s) imported to stage "${ctx.stage}"${detail}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await putSstSecrets(ctx, merged);
|
|
176
|
+
success(`${parsedCount} secret(s) imported to stage "${ctx.stage}"`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Export
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
async function exportSecrets(ctx: SecretsContext, filePath: string | undefined): Promise<void> {
|
|
184
|
+
if (filePath) {
|
|
185
|
+
step(`Exporting secrets from ${ctx.appName}/${ctx.stage}...`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const secrets = await loadSstSecrets(ctx);
|
|
189
|
+
const count = Object.keys(secrets).length;
|
|
190
|
+
|
|
191
|
+
if (count === 0) {
|
|
192
|
+
warn('No secrets to export');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const serialized = serializeDotenv(secrets);
|
|
197
|
+
|
|
198
|
+
if (filePath) {
|
|
199
|
+
const resolved = path.resolve(filePath);
|
|
200
|
+
try {
|
|
201
|
+
await fs.writeFile(resolved, serialized, 'utf8');
|
|
202
|
+
} catch (err: any) {
|
|
203
|
+
fail(`Could not write file: ${err.message}`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
success(`${count} secret(s) exported to ${filePath}`);
|
|
207
|
+
} else {
|
|
208
|
+
// Stdout mode: raw output, pipeable
|
|
209
|
+
process.stdout.write(serialized);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
115
213
|
// ---------------------------------------------------------------------------
|
|
116
214
|
// Command entry point
|
|
117
215
|
// ---------------------------------------------------------------------------
|
|
@@ -174,6 +272,23 @@ export async function secretsCommand(
|
|
|
174
272
|
break;
|
|
175
273
|
}
|
|
176
274
|
|
|
275
|
+
case 'import': {
|
|
276
|
+
const filePath = positionalArgs[0];
|
|
277
|
+
if (!filePath) {
|
|
278
|
+
fail('Missing file path');
|
|
279
|
+
info('Usage: everystack secrets import <file> --stage <stage> [--replace]');
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
await importSecrets(ctx, filePath, flags);
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case 'export': {
|
|
287
|
+
const filePath = positionalArgs[0]; // optional — stdout if omitted
|
|
288
|
+
await exportSecrets(ctx, filePath);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
|
|
177
292
|
default:
|
|
178
293
|
fail(`Unknown secrets subcommand: ${subcommand}`);
|
|
179
294
|
printSecretsHelp();
|
|
@@ -195,12 +310,21 @@ Usage:
|
|
|
195
310
|
everystack secrets get <name> --stage <name> Print a secret value
|
|
196
311
|
everystack secrets set <name> <value> --stage <name> Set or update a secret
|
|
197
312
|
everystack secrets remove <name> --stage <name> Delete a secret
|
|
313
|
+
everystack secrets import <file> --stage <name> Import secrets from .env file (merge)
|
|
314
|
+
everystack secrets export [file] --stage <name> Export secrets to .env file (or stdout)
|
|
198
315
|
|
|
199
316
|
Examples:
|
|
200
317
|
everystack secrets list --stage production
|
|
201
318
|
everystack secrets set MAPBOX_API_KEY "pk.abc123" --stage production
|
|
202
319
|
everystack secrets get JWT_SECRET --stage dev
|
|
203
320
|
everystack secrets remove OLD_KEY --stage dev
|
|
321
|
+
everystack secrets import .env.production --stage production
|
|
322
|
+
everystack secrets import .env --stage dev --replace
|
|
323
|
+
everystack secrets export --stage dev > .env.local
|
|
324
|
+
everystack secrets export secrets.env --stage production
|
|
325
|
+
|
|
326
|
+
Flags:
|
|
327
|
+
--replace (import only) Replace all secrets instead of merging
|
|
204
328
|
|
|
205
329
|
Interop:
|
|
206
330
|
Secrets are stored in the SST Ion S3 state backend with AES-256-GCM encryption.
|
|
@@ -17,7 +17,6 @@ export interface UpdateFlags {
|
|
|
17
17
|
branch?: string;
|
|
18
18
|
message?: string;
|
|
19
19
|
platform?: string;
|
|
20
|
-
environment?: string;
|
|
21
20
|
export?: string;
|
|
22
21
|
}
|
|
23
22
|
|
|
@@ -28,18 +27,13 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
28
27
|
// (e.g. localhost overrides → LAN IP) to leak into the manifest.
|
|
29
28
|
process.env.EVERYSTACK_UPDATE = '1';
|
|
30
29
|
|
|
31
|
-
//
|
|
32
|
-
// snapshots the correct API URLs and feature flags
|
|
33
|
-
//
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// EAS profiles define ENVIRONMENT in their env blocks (e.g. production → "production").
|
|
39
|
-
const channel = flags.channel || flags.branch || flags.stage || 'production';
|
|
40
|
-
const easEnv = resolveEasEnvironment(channel);
|
|
41
|
-
if (easEnv) {
|
|
42
|
-
process.env.ENVIRONMENT = easEnv;
|
|
30
|
+
// stage === environment. Set ENVIRONMENT before app.config.js evaluation
|
|
31
|
+
// so the manifest snapshots the correct API URLs and feature flags.
|
|
32
|
+
// --stage is the canonical source; fall back to --channel/--branch.
|
|
33
|
+
if (!process.env.ENVIRONMENT) {
|
|
34
|
+
const stage = flags.stage || flags.channel || flags.branch;
|
|
35
|
+
if (stage) {
|
|
36
|
+
process.env.ENVIRONMENT = stage;
|
|
43
37
|
}
|
|
44
38
|
}
|
|
45
39
|
|
|
@@ -61,9 +55,9 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
61
55
|
}
|
|
62
56
|
|
|
63
57
|
// Inject SST secrets into process.env so app.config.js / Metro can read them.
|
|
64
|
-
// The CLI injects ALL secrets — the
|
|
65
|
-
//
|
|
66
|
-
//
|
|
58
|
+
// The CLI injects ALL secrets — @everystack/cli/env is the deny-by-default
|
|
59
|
+
// filter that gates what reaches the bundle (see env.config.js tiers).
|
|
60
|
+
// This avoids requiring `sst shell` in deploy scripts.
|
|
67
61
|
try {
|
|
68
62
|
const { parseAppName } = await import('../discover.js');
|
|
69
63
|
const appName = await parseAppName();
|
|
@@ -619,40 +613,6 @@ async function createArchive(sourceDir: string, archivePath: string): Promise<vo
|
|
|
619
613
|
await tarExit;
|
|
620
614
|
}
|
|
621
615
|
|
|
622
|
-
/**
|
|
623
|
-
* Read eas.json and find ENVIRONMENT for a given channel.
|
|
624
|
-
* Matches --channel to a build profile's `channel` field, then reads
|
|
625
|
-
* ENVIRONMENT from that profile's env block. Falls back to profile name
|
|
626
|
-
* match (e.g. --channel production matches the "production" profile).
|
|
627
|
-
* @internal Exported for testing.
|
|
628
|
-
*/
|
|
629
|
-
export function resolveEasEnvironment(channel: string, cwd?: string): string | undefined {
|
|
630
|
-
try {
|
|
631
|
-
const easPath = path.resolve(cwd || process.cwd(), 'eas.json');
|
|
632
|
-
// Synchronous read — runs once before export, performance irrelevant.
|
|
633
|
-
const eas = JSON.parse(require('fs').readFileSync(easPath, 'utf8'));
|
|
634
|
-
const profiles = eas?.build;
|
|
635
|
-
if (!profiles || typeof profiles !== 'object') return undefined;
|
|
636
|
-
|
|
637
|
-
// First pass: match on explicit channel field
|
|
638
|
-
for (const [, profile] of Object.entries(profiles)) {
|
|
639
|
-
const p = profile as any;
|
|
640
|
-
if (p?.channel === channel && p?.env?.ENVIRONMENT) {
|
|
641
|
-
return p.env.ENVIRONMENT;
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Second pass: match on profile name (e.g. "production" profile for --channel production)
|
|
646
|
-
if (profiles[channel]?.env?.ENVIRONMENT) {
|
|
647
|
-
return profiles[channel].env.ENVIRONMENT;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
return undefined;
|
|
651
|
-
} catch {
|
|
652
|
-
return undefined;
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
616
|
/**
|
|
657
617
|
* Extract expo-router SSR flags from the resolved app config.
|
|
658
618
|
* Checks the plugins array for the expo-router entry.
|
package/src/cli/discover.ts
CHANGED
|
@@ -145,7 +145,7 @@ export async function discoverFunction(
|
|
|
145
145
|
} = await import('@aws-sdk/client-lambda');
|
|
146
146
|
|
|
147
147
|
const client = new LambdaClient({ region });
|
|
148
|
-
const needle = `${prefix}${resourceName}
|
|
148
|
+
const needle = `${prefix}${resourceName}`.toLowerCase();
|
|
149
149
|
|
|
150
150
|
let marker: string | undefined;
|
|
151
151
|
|
|
@@ -259,7 +259,9 @@ export async function discoverConfig(
|
|
|
259
259
|
discoverLambda(region, prefix),
|
|
260
260
|
discoverBuckets(region, prefix),
|
|
261
261
|
discoverFunction(region, prefix, 'ImageFunction').catch(() => undefined),
|
|
262
|
-
discoverFunction(region, prefix, 'WorkerFunction')
|
|
262
|
+
discoverFunction(region, prefix, 'WorkerFunction')
|
|
263
|
+
.then(fn => fn || discoverFunction(region, prefix, 'JobsSubscriber'))
|
|
264
|
+
.catch(() => undefined),
|
|
263
265
|
]);
|
|
264
266
|
|
|
265
267
|
// Distribution discovery is best-effort (not critical for most CLI operations)
|
package/src/cli/index.ts
CHANGED
|
@@ -167,8 +167,8 @@ function printHelp() {
|
|
|
167
167
|
everystack - CLI for Expo apps on everystack
|
|
168
168
|
|
|
169
169
|
Usage:
|
|
170
|
-
everystack update --branch <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--
|
|
171
|
-
everystack update --channel <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--
|
|
170
|
+
everystack update --branch <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
|
|
171
|
+
everystack update --channel <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
|
|
172
172
|
everystack db:migrate [--stage <name>] Run database migrations on deployed Lambda
|
|
173
173
|
everystack db:seed [--stage <name>] Seed database on deployed Lambda (dev only)
|
|
174
174
|
everystack db:reset [--stage <name>] Drop all schemas + re-run migrations (dev only)
|
|
@@ -196,6 +196,8 @@ Usage:
|
|
|
196
196
|
everystack secrets get <name> --stage <name> Print a secret value
|
|
197
197
|
everystack secrets set <name> <value> --stage <name> Set or update a secret
|
|
198
198
|
everystack secrets remove <name> --stage <name> Delete a secret
|
|
199
|
+
everystack secrets import <file> --stage <name> [--replace] Import secrets from .env file
|
|
200
|
+
everystack secrets export [file] --stage <name> Export secrets to .env format
|
|
199
201
|
|
|
200
202
|
Stage resolution:
|
|
201
203
|
--stage <name> Discover AWS resources for the given stage by querying AWS APIs.
|
|
@@ -204,8 +206,7 @@ Stage resolution:
|
|
|
204
206
|
|
|
205
207
|
Without --stage, reads .sst/outputs.json (written by the last \`sst deploy\`).
|
|
206
208
|
|
|
207
|
-
--
|
|
208
|
-
Use this when your SST stage name differs from your EAS build profile name.
|
|
209
|
+
--stage also sets ENVIRONMENT for app.config.js evaluation. stage === environment.
|
|
209
210
|
|
|
210
211
|
Auth:
|
|
211
212
|
All commands use AWS IAM credentials (default credential chain).
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure .env parser and serializer. No dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* KEY=value
|
|
6
|
+
* KEY="double quoted value"
|
|
7
|
+
* KEY='single quoted value'
|
|
8
|
+
* # comments (full-line)
|
|
9
|
+
* Empty/whitespace-only lines (skipped)
|
|
10
|
+
* export KEY=value (optional prefix)
|
|
11
|
+
* Escape sequences in double quotes: \n, \r, \t, \\, \"
|
|
12
|
+
* UTF-8 BOM stripping
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a .env-formatted string into key-value pairs.
|
|
17
|
+
* Duplicate keys: last one wins.
|
|
18
|
+
*/
|
|
19
|
+
export function parseDotenv(content: string): Record<string, string> {
|
|
20
|
+
const result: Record<string, string> = {};
|
|
21
|
+
|
|
22
|
+
// Strip UTF-8 BOM
|
|
23
|
+
const clean = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
|
|
24
|
+
|
|
25
|
+
for (const rawLine of clean.split('\n')) {
|
|
26
|
+
const line = rawLine.trim();
|
|
27
|
+
|
|
28
|
+
// Skip empty lines and comments
|
|
29
|
+
if (!line || line.startsWith('#')) continue;
|
|
30
|
+
|
|
31
|
+
// Strip optional `export ` prefix
|
|
32
|
+
const stripped = line.startsWith('export ') ? line.slice(7) : line;
|
|
33
|
+
|
|
34
|
+
// Find first `=`
|
|
35
|
+
const eqIndex = stripped.indexOf('=');
|
|
36
|
+
if (eqIndex === -1) continue;
|
|
37
|
+
|
|
38
|
+
const key = stripped.slice(0, eqIndex).trim();
|
|
39
|
+
if (!key) continue;
|
|
40
|
+
|
|
41
|
+
let value = stripped.slice(eqIndex + 1);
|
|
42
|
+
|
|
43
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
44
|
+
// Double-quoted: strip quotes and process escapes
|
|
45
|
+
value = value.slice(1, -1);
|
|
46
|
+
// Process escapes — use a single pass to avoid double-expansion
|
|
47
|
+
value = value.replace(/\\([nrt"\\])/g, (_, ch) => {
|
|
48
|
+
switch (ch) {
|
|
49
|
+
case 'n': return '\n';
|
|
50
|
+
case 'r': return '\r';
|
|
51
|
+
case 't': return '\t';
|
|
52
|
+
case '"': return '"';
|
|
53
|
+
case '\\': return '\\';
|
|
54
|
+
default: return ch;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
} else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
|
|
58
|
+
// Single-quoted: strip quotes, literal (no escape processing)
|
|
59
|
+
value = value.slice(1, -1);
|
|
60
|
+
} else {
|
|
61
|
+
// Unquoted: trim whitespace
|
|
62
|
+
value = value.trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
result[key] = value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Serialize a key-value map to .env format.
|
|
73
|
+
*
|
|
74
|
+
* Keys are sorted alphabetically for deterministic output.
|
|
75
|
+
* Values are double-quoted when they contain whitespace, quotes, #, backslashes, or newlines.
|
|
76
|
+
*/
|
|
77
|
+
export function serializeDotenv(secrets: Record<string, string>): string {
|
|
78
|
+
const keys = Object.keys(secrets).sort();
|
|
79
|
+
if (keys.length === 0) return '';
|
|
80
|
+
|
|
81
|
+
const lines: string[] = [];
|
|
82
|
+
|
|
83
|
+
for (const key of keys) {
|
|
84
|
+
const value = secrets[key];
|
|
85
|
+
|
|
86
|
+
if (needsQuoting(value)) {
|
|
87
|
+
const escaped = value
|
|
88
|
+
.replace(/\\/g, '\\\\')
|
|
89
|
+
.replace(/"/g, '\\"')
|
|
90
|
+
.replace(/\n/g, '\\n')
|
|
91
|
+
.replace(/\r/g, '\\r')
|
|
92
|
+
.replace(/\t/g, '\\t');
|
|
93
|
+
lines.push(`${key}="${escaped}"`);
|
|
94
|
+
} else {
|
|
95
|
+
lines.push(`${key}=${value}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return lines.join('\n') + '\n';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function needsQuoting(value: string): boolean {
|
|
103
|
+
if (value === '') return true;
|
|
104
|
+
return /[\s"'#\\]/.test(value) || value.includes('\n');
|
|
105
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/cli/env — Deny-by-default environment variable filtering.
|
|
3
|
+
*
|
|
4
|
+
* One import in app.config.js. Auto-discovers env.config.js.
|
|
5
|
+
* Detects build surface, filters secrets by tier, sets EXPO_PUBLIC_* vars.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const { extra } = require('@everystack/cli/env').load();
|
|
9
|
+
* module.exports = { expo: { extra } };
|
|
10
|
+
*
|
|
11
|
+
* With explicit config path:
|
|
12
|
+
* const { extra } = require('@everystack/cli/env').load({ path: './env.config' });
|
|
13
|
+
*
|
|
14
|
+
* Multiple stages. Four buckets. One gate. One file.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Environment tier configuration.
|
|
24
|
+
*
|
|
25
|
+
* Each key is declared in exactly one tier:
|
|
26
|
+
* - server: Lambda runtime only. NEVER in any client bundle.
|
|
27
|
+
* - build: CI/build tooling only (source maps, etc). NEVER in any client bundle.
|
|
28
|
+
* - mobile: Native binary (iOS/Android). Excluded from web bundles.
|
|
29
|
+
* - web: Safe for all client bundles. Gets EXPO_PUBLIC_* for web delivery.
|
|
30
|
+
*/
|
|
31
|
+
export interface EnvConfig {
|
|
32
|
+
/** Lambda/server secrets. NEVER in any client bundle. */
|
|
33
|
+
server?: string[];
|
|
34
|
+
/** CI/build-time only. NEVER in any client bundle. */
|
|
35
|
+
build?: string[];
|
|
36
|
+
/** Native binary only. Excluded from web bundles. */
|
|
37
|
+
mobile?: string[];
|
|
38
|
+
/** Safe for all client bundles. Gets EXPO_PUBLIC_* for web delivery. */
|
|
39
|
+
web?: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build surface — determines which tiers pass through the filter. */
|
|
43
|
+
export type Surface = 'local' | 'native' | 'web';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Detect the current build surface from environment signals.
|
|
47
|
+
*
|
|
48
|
+
* EAS_BUILD / EAS_BUILD_PLATFORM -> native (EAS native build)
|
|
49
|
+
* EAS_UPDATE -> native (EAS OTA update)
|
|
50
|
+
* EVERYSTACK_UPDATE -> web (everystack update)
|
|
51
|
+
* none of the above -> local (pnpm dev)
|
|
52
|
+
*/
|
|
53
|
+
export function detectSurface(): Surface {
|
|
54
|
+
if (process.env.EAS_BUILD || process.env.EAS_BUILD_PLATFORM || process.env.EAS_UPDATE) {
|
|
55
|
+
return 'native';
|
|
56
|
+
}
|
|
57
|
+
if (process.env.EVERYSTACK_UPDATE) {
|
|
58
|
+
return 'web';
|
|
59
|
+
}
|
|
60
|
+
return 'local';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Load env config from a file path or auto-discover env.config.js in cwd.
|
|
65
|
+
*/
|
|
66
|
+
export function loadEnvConfig(configPath?: string): EnvConfig {
|
|
67
|
+
const resolved = configPath
|
|
68
|
+
? path.resolve(configPath)
|
|
69
|
+
: findEnvConfig();
|
|
70
|
+
|
|
71
|
+
// Synchronous require — same pattern used throughout the CLI package.
|
|
72
|
+
// Works in Metro (CJS interop), tsx (SST), and Node CJS.
|
|
73
|
+
delete require.cache?.[resolved];
|
|
74
|
+
const mod = require(resolved);
|
|
75
|
+
return mod.default || mod;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Auto-discover env.config.js in the current working directory.
|
|
80
|
+
*/
|
|
81
|
+
function findEnvConfig(): string {
|
|
82
|
+
const name = 'env.config.js';
|
|
83
|
+
const fullPath = path.resolve(process.cwd(), name);
|
|
84
|
+
if (fs.existsSync(fullPath)) {
|
|
85
|
+
return fullPath;
|
|
86
|
+
}
|
|
87
|
+
throw new Error(
|
|
88
|
+
'No env.config.js found. Create one:\n\n'
|
|
89
|
+
+ ' module.exports = {\n'
|
|
90
|
+
+ ' server: [],\n'
|
|
91
|
+
+ ' build: [],\n'
|
|
92
|
+
+ ' mobile: [],\n'
|
|
93
|
+
+ ' web: [],\n'
|
|
94
|
+
+ ' };\n'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Filter process.env through tier allowlists based on the detected surface.
|
|
100
|
+
*
|
|
101
|
+
* Deny-by-default: undeclared keys are dropped.
|
|
102
|
+
* Server and build tiers are NEVER passed through, even if duplicated in other tiers.
|
|
103
|
+
*
|
|
104
|
+
* Surface behavior:
|
|
105
|
+
* local -> web + mobile tiers pass
|
|
106
|
+
* native -> web + mobile tiers pass
|
|
107
|
+
* web -> web tier only
|
|
108
|
+
*/
|
|
109
|
+
export function filterEnv(surface: Surface, config: EnvConfig): Record<string, string> {
|
|
110
|
+
// Build the allowlist: web tier always passes
|
|
111
|
+
const allowed = new Set(config.web || []);
|
|
112
|
+
|
|
113
|
+
// Mobile tier: passes on native and local, blocked on web
|
|
114
|
+
if (surface !== 'web') {
|
|
115
|
+
for (const k of (config.mobile || [])) {
|
|
116
|
+
allowed.add(k);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// server and build tiers are NEVER added to the allowlist.
|
|
121
|
+
// Actively remove them in case a key appears in multiple tiers.
|
|
122
|
+
const blocked = new Set([
|
|
123
|
+
...(config.server || []),
|
|
124
|
+
...(config.build || []),
|
|
125
|
+
]);
|
|
126
|
+
for (const k of blocked) {
|
|
127
|
+
allowed.delete(k);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Collect values for allowed keys from process.env
|
|
131
|
+
const result: Record<string, string> = {};
|
|
132
|
+
for (const key of allowed) {
|
|
133
|
+
if (process.env[key] !== undefined) {
|
|
134
|
+
result[key] = process.env[key]!;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Set EXPO_PUBLIC_* process.env vars for web delivery.
|
|
143
|
+
*
|
|
144
|
+
* Constants.expoConfig.extra does NOT work on web — Metro never sets
|
|
145
|
+
* APP_MANIFEST, so Constants.expoConfig is null in the browser. The only
|
|
146
|
+
* way to deliver env values to web client code is via EXPO_PUBLIC_* vars,
|
|
147
|
+
* which Metro inlines as literal strings at compile time.
|
|
148
|
+
*
|
|
149
|
+
* Sets EXPO_PUBLIC_* for ALL filtered keys so consumers can use either
|
|
150
|
+
* delivery path (extra on native, EXPO_PUBLIC_* on web).
|
|
151
|
+
*/
|
|
152
|
+
export function setExpoPublicVars(filtered: Record<string, string>): void {
|
|
153
|
+
for (const [key, value] of Object.entries(filtered)) {
|
|
154
|
+
process.env[`EXPO_PUBLIC_${key}`] = value;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Load and filter environment variables for the current build surface.
|
|
160
|
+
*
|
|
161
|
+
* Auto-discovers env.config.js in the current working directory,
|
|
162
|
+
* or accepts an explicit path. Detects the build surface, filters
|
|
163
|
+
* process.env through the tier allowlists, sets EXPO_PUBLIC_* vars,
|
|
164
|
+
* and returns the filtered object for expo.extra.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* // app.config.js — auto-discover env.config.js
|
|
168
|
+
* const { extra } = require('@everystack/cli/env').load();
|
|
169
|
+
* module.exports = { expo: { extra } };
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* // app.config.js — explicit path
|
|
173
|
+
* const { extra } = require('@everystack/cli/env').load({ path: './env.config' });
|
|
174
|
+
*/
|
|
175
|
+
export function load(options?: { path?: string }): { extra: Record<string, string> } {
|
|
176
|
+
const config = loadEnvConfig(options?.path);
|
|
177
|
+
const surface = detectSurface();
|
|
178
|
+
const filtered = filterEnv(surface, config);
|
|
179
|
+
setExpoPublicVars(filtered);
|
|
180
|
+
return { extra: filtered };
|
|
181
|
+
}
|
package/src/sst.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/cli/sst — Shared env config for SST infrastructure.
|
|
3
|
+
*
|
|
4
|
+
* Reads the same env.config.js that @everystack/cli/env uses,
|
|
5
|
+
* ensuring sst.config.ts and app.config.js stay in sync.
|
|
6
|
+
*
|
|
7
|
+
* Usage in sst.config.ts:
|
|
8
|
+
* import { loadEnvConfig } from '@everystack/cli/sst';
|
|
9
|
+
* const env = loadEnvConfig();
|
|
10
|
+
* // env.server = ['DATABASE_URL', 'JWT_SECRET']
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { loadEnvConfig, type EnvConfig } from './env.js';
|