@everystack/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -0
- package/package.json +104 -0
- package/src/cli/aws.ts +121 -0
- package/src/cli/commands/analyze.ts +61 -0
- package/src/cli/commands/branches.ts +97 -0
- package/src/cli/commands/cache.ts +72 -0
- package/src/cli/commands/certs.ts +117 -0
- package/src/cli/commands/channels.ts +109 -0
- package/src/cli/commands/console.ts +68 -0
- package/src/cli/commands/db.ts +183 -0
- package/src/cli/commands/diag.ts +242 -0
- package/src/cli/commands/logs.ts +282 -0
- package/src/cli/commands/update.ts +432 -0
- package/src/cli/config.ts +98 -0
- package/src/cli/discover.ts +321 -0
- package/src/cli/hydration-analyzer.ts +224 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/output.ts +25 -0
- package/src/cli/ssr-analyzer.ts +445 -0
- package/src/cli/utils/export.ts +8 -0
- package/src/cli/utils/table.ts +39 -0
- package/src/cli/utils/upload.ts +52 -0
- package/src/cli/utils/walk.ts +59 -0
- package/src/client/app-state-provider.tsx +83 -0
- package/src/client/index.ts +2 -0
- package/src/client/updates-provider.tsx +69 -0
- package/src/handler/assets.ts +30 -0
- package/src/handler/branches.ts +70 -0
- package/src/handler/channels-crud.ts +174 -0
- package/src/handler/helpers.ts +239 -0
- package/src/handler/index.ts +78 -0
- package/src/handler/manifest.ts +276 -0
- package/src/handler/multipart.ts +74 -0
- package/src/handler/publish-web.ts +311 -0
- package/src/handler/publish.ts +346 -0
- package/src/handler/signing.ts +29 -0
- package/src/handler/types.ts +16 -0
- package/src/index.ts +4 -0
- package/src/schema.ts +245 -0
- package/src/storage/filesystem.ts +103 -0
- package/src/storage/index.ts +27 -0
- package/src/storage/s3.ts +125 -0
package/README.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# @everystack/cli
|
|
2
|
+
|
|
3
|
+
CLI and OTA updates for Expo apps on everystack. Database management (`db:migrate`, `db:seed`), OTA publishing, Expo Updates protocol (v0/v1), pluggable storage, RSA-SHA256 code signing, and channels.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @everystack/cli drizzle-orm structured-headers
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For S3 storage:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @aws-sdk/client-s3
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Entry Points
|
|
18
|
+
|
|
19
|
+
| Import | Description |
|
|
20
|
+
|--------|-------------|
|
|
21
|
+
| `@everystack/cli` | Server: handler, storage adapters |
|
|
22
|
+
| `@everystack/cli/client` | Client: UpdatesProvider, AppStateUpdateProvider |
|
|
23
|
+
| `@everystack/cli/schema` | Drizzle tables (channels, releases, assets) |
|
|
24
|
+
|
|
25
|
+
## Server: Handler
|
|
26
|
+
|
|
27
|
+
Creates a Web Standard handler implementing the Expo Updates manifest protocol:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { createUpdatesHandler, createStorage } from '@everystack/cli';
|
|
31
|
+
import { db } from './db';
|
|
32
|
+
|
|
33
|
+
const storage = createStorage({
|
|
34
|
+
type: 'filesystem',
|
|
35
|
+
directory: './updates',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const handler = createUpdatesHandler({
|
|
39
|
+
db,
|
|
40
|
+
storage,
|
|
41
|
+
baseUrl: 'https://myapp.com',
|
|
42
|
+
basePath: '/api/updates',
|
|
43
|
+
defaultChannel: 'production',
|
|
44
|
+
auth: {
|
|
45
|
+
verifyToken: async (token) => verifyJWT(token),
|
|
46
|
+
},
|
|
47
|
+
privateKey: process.env.CODE_SIGNING_PRIVATE_KEY, // PEM for RSA-SHA256
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Mounting
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
// app/api/updates/[...path]+api.ts
|
|
55
|
+
export function GET(request: Request) { return handler(request); }
|
|
56
|
+
export function POST(request: Request) { return handler(request); }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Handler Endpoints
|
|
60
|
+
|
|
61
|
+
The handler serves:
|
|
62
|
+
- **Manifest requests** — Expo Updates protocol v0/v1 manifest responses with multipart/mixed format
|
|
63
|
+
- **Asset downloads** — Binary assets from your configured storage
|
|
64
|
+
- **Publish endpoint** — Authenticated upload of new releases (used by the CLI)
|
|
65
|
+
|
|
66
|
+
### Handler Options
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
interface UpdatesHandlerOptions {
|
|
70
|
+
db: DrizzleDb; // Drizzle instance
|
|
71
|
+
storage: StorageAdapter; // Filesystem or S3
|
|
72
|
+
baseUrl: string; // Public URL (for asset references in manifests)
|
|
73
|
+
basePath?: string; // URL prefix to strip
|
|
74
|
+
auth?: {
|
|
75
|
+
verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
|
|
76
|
+
};
|
|
77
|
+
privateKey?: string; // PEM for RSA-SHA256 code signing
|
|
78
|
+
defaultChannel?: string; // Default channel (default: 'production')
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Storage Adapters
|
|
83
|
+
|
|
84
|
+
### Filesystem
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { createStorage } from '@everystack/cli';
|
|
88
|
+
|
|
89
|
+
const storage = createStorage({
|
|
90
|
+
type: 'filesystem',
|
|
91
|
+
directory: './updates',
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### S3
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
const storage = createStorage({
|
|
99
|
+
type: 's3',
|
|
100
|
+
bucket: 'my-updates-bucket',
|
|
101
|
+
region: 'us-east-1',
|
|
102
|
+
endpoint: 'https://s3.us-east-1.amazonaws.com', // Optional
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Custom Adapter
|
|
107
|
+
|
|
108
|
+
Implement the `StorageAdapter` interface:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
interface StorageAdapter {
|
|
112
|
+
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
|
113
|
+
get(key: string): Promise<{ data: Buffer; contentType: string } | null>;
|
|
114
|
+
exists(key: string): Promise<boolean>;
|
|
115
|
+
list(prefix: string): Promise<string[]>;
|
|
116
|
+
delete(key: string): Promise<void>;
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## CLI
|
|
121
|
+
|
|
122
|
+
Publish updates, manage certificates, and channels from the command line.
|
|
123
|
+
|
|
124
|
+
### Publish an Update
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
everystack update \
|
|
128
|
+
--channel production \
|
|
129
|
+
--message "Fix login bug" \
|
|
130
|
+
--platform ios # ios | android | web | all (default: all)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
This bundles your app, uploads assets to storage, creates a release record, and signs the manifest.
|
|
134
|
+
|
|
135
|
+
### Code Signing
|
|
136
|
+
|
|
137
|
+
Generate RSA key pair for manifest signing:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
everystack certs:generate --output ./certs
|
|
141
|
+
# Creates ./certs/private-key.pem and ./certs/certificate.pem
|
|
142
|
+
|
|
143
|
+
everystack certs:configure --input ./certs --keyid main
|
|
144
|
+
# Configures your app to use the generated certificates
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Channel Management
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
everystack channels list
|
|
151
|
+
everystack channels create --name staging
|
|
152
|
+
everystack channels create --name production
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Database Management
|
|
156
|
+
|
|
157
|
+
These commands invoke your Lambda function directly via IAM (no database credentials exposed):
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
everystack db:migrate # Run Drizzle migrations
|
|
161
|
+
everystack db:seed # Seed database (dev only)
|
|
162
|
+
everystack db:psql --stage dev # Open a psql session via Lambda
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`db:migrate` and `db:seed` dispatch to your Lambda's `onAction` handler. `db:psql` proxies a PostgreSQL session through the Lambda, so your database credentials never leave AWS.
|
|
166
|
+
|
|
167
|
+
## Client: React Native
|
|
168
|
+
|
|
169
|
+
### UpdatesProvider
|
|
170
|
+
|
|
171
|
+
Wraps your app to check for and apply OTA updates:
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
import { UpdatesProvider } from '@everystack/cli/client';
|
|
175
|
+
|
|
176
|
+
function App() {
|
|
177
|
+
return (
|
|
178
|
+
<UpdatesProvider
|
|
179
|
+
url="https://myapp.com/api/updates"
|
|
180
|
+
channel="production"
|
|
181
|
+
checkInterval={60000} // Check every 60 seconds
|
|
182
|
+
onUpdateAvailable={(update) => {
|
|
183
|
+
// Optional: prompt user or auto-apply
|
|
184
|
+
console.log('Update available:', update.message);
|
|
185
|
+
}}
|
|
186
|
+
onUpdateApplied={() => {
|
|
187
|
+
console.log('Update applied, restarting...');
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
<MyApp />
|
|
191
|
+
</UpdatesProvider>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### AppStateUpdateProvider
|
|
197
|
+
|
|
198
|
+
Checks for updates when the app returns from background:
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
import { AppStateUpdateProvider } from '@everystack/cli/client';
|
|
202
|
+
|
|
203
|
+
function App() {
|
|
204
|
+
return (
|
|
205
|
+
<AppStateUpdateProvider url="https://myapp.com/api/updates" channel="production">
|
|
206
|
+
<MyApp />
|
|
207
|
+
</AppStateUpdateProvider>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Schema
|
|
213
|
+
|
|
214
|
+
Add the updates tables to your Drizzle migrations:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
import { channels, releases, assets } from '@everystack/cli/schema';
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Tables:
|
|
221
|
+
- **channels** — Named release channels (production, staging, etc.)
|
|
222
|
+
- **releases** — Published update bundles with metadata
|
|
223
|
+
- **assets** — Individual asset files referenced by releases
|
|
224
|
+
|
|
225
|
+
## Expo Updates Protocol
|
|
226
|
+
|
|
227
|
+
The handler implements the full Expo Updates manifest protocol:
|
|
228
|
+
|
|
229
|
+
- **Protocol v0**: Legacy format for older Expo SDK versions
|
|
230
|
+
- **Protocol v1**: Modern multipart/mixed response format
|
|
231
|
+
- **Code signing**: RSA-SHA256 signatures on manifest directives
|
|
232
|
+
- **Platform filtering**: Serves platform-specific bundles based on request headers
|
|
233
|
+
- **Channel routing**: Multiple release channels with independent version tracks
|
|
234
|
+
|
|
235
|
+
### How It Works
|
|
236
|
+
|
|
237
|
+
1. The Expo app sends a manifest request with platform, runtime version, and current update ID
|
|
238
|
+
2. The handler finds the latest release for the requested channel and platform
|
|
239
|
+
3. If a newer release exists, it returns a signed manifest with asset URLs
|
|
240
|
+
4. The Expo runtime downloads assets and applies the update
|
|
241
|
+
|
|
242
|
+
## Peer Dependencies
|
|
243
|
+
|
|
244
|
+
| Package | Version | Required |
|
|
245
|
+
|---------|---------|----------|
|
|
246
|
+
| `drizzle-orm` | `>=0.30.0` | Yes |
|
|
247
|
+
| `structured-headers` | `^1.0.0` | Yes (runtime dep) |
|
|
248
|
+
| `@aws-sdk/client-s3` | `>=3.0.0` | For S3 storage |
|
|
249
|
+
| `expo-updates` | `>=0.25.0` | Client SDK |
|
|
250
|
+
| `react` | `>=18.0.0` | Client SDK |
|
|
251
|
+
| `react-native` | `>=18.0.0` | Client SDK |
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@everystack/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"default": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"./client": {
|
|
20
|
+
"types": "./src/client/index.ts",
|
|
21
|
+
"default": "./src/client/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"./schema": {
|
|
24
|
+
"types": "./src/schema.ts",
|
|
25
|
+
"default": "./src/schema.ts"
|
|
26
|
+
},
|
|
27
|
+
"./handler": {
|
|
28
|
+
"types": "./src/handler/index.ts",
|
|
29
|
+
"default": "./src/handler/index.ts"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"bin": {
|
|
33
|
+
"everystack": "./src/cli/index.ts"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"glob": "13.0.6",
|
|
37
|
+
"node-forge": "^1.4.0",
|
|
38
|
+
"structured-headers": "^1.0.0",
|
|
39
|
+
"tsx": "^4.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@aws-sdk/client-cloudfront": ">=3.0.0",
|
|
43
|
+
"@aws-sdk/client-cloudfront-keyvaluestore": ">=3.0.0",
|
|
44
|
+
"@aws-sdk/client-cloudwatch-logs": ">=3.0.0",
|
|
45
|
+
"@aws-sdk/client-lambda": ">=3.0.0",
|
|
46
|
+
"@aws-sdk/client-s3": ">=3.0.0",
|
|
47
|
+
"@aws-sdk/signature-v4a": ">=3.0.0",
|
|
48
|
+
"drizzle-orm": ">=0.30.0",
|
|
49
|
+
"expo-updates": ">=0.25.0",
|
|
50
|
+
"react": ">=18.0.0",
|
|
51
|
+
"react-native": ">=0.72.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"@aws-sdk/client-cloudfront": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"@aws-sdk/client-cloudwatch-logs": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"@aws-sdk/client-s3": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"@aws-sdk/client-lambda": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"@aws-sdk/client-cloudfront-keyvaluestore": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"@aws-sdk/signature-v4a": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"expo-updates": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"react": {
|
|
76
|
+
"optional": true
|
|
77
|
+
},
|
|
78
|
+
"react-native": {
|
|
79
|
+
"optional": true
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"devDependencies": {
|
|
83
|
+
"@aws-sdk/client-cloudfront": "^3.700.0",
|
|
84
|
+
"@aws-sdk/client-cloudfront-keyvaluestore": "^3.700.0",
|
|
85
|
+
"@aws-sdk/client-cloudwatch-logs": "^3.1047.0",
|
|
86
|
+
"@aws-sdk/client-lambda": "^3.700.0",
|
|
87
|
+
"@aws-sdk/client-s3": "^3.700.0",
|
|
88
|
+
"@aws-sdk/signature-v4a": "^3.1031.0",
|
|
89
|
+
"@types/jest": "^29.5.14",
|
|
90
|
+
"@types/node": "^22.0.0",
|
|
91
|
+
"@types/node-forge": "^1.3.14",
|
|
92
|
+
"@types/react": "~19.2.14",
|
|
93
|
+
"drizzle-orm": "^0.41.0",
|
|
94
|
+
"jest": "^29.7.0",
|
|
95
|
+
"react": "19.2.0",
|
|
96
|
+
"ts-jest": "^29.3.0",
|
|
97
|
+
"typescript": "^5.7.0"
|
|
98
|
+
},
|
|
99
|
+
"scripts": {
|
|
100
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
|
101
|
+
"build": "tsc --build",
|
|
102
|
+
"lint": "tsc --noEmit"
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/cli/aws.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS SDK wrappers for CLI operations.
|
|
3
|
+
*
|
|
4
|
+
* S3 for direct uploads, Lambda for direct invoke, KVS for cache versioning.
|
|
5
|
+
* Authentication uses the developer's IAM credentials (default credential chain).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
9
|
+
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
|
|
10
|
+
|
|
11
|
+
let s3Client: S3Client | null = null;
|
|
12
|
+
let lambdaClient: LambdaClient | null = null;
|
|
13
|
+
let kvsClient: import('@aws-sdk/client-cloudfront-keyvaluestore').CloudFrontKeyValueStoreClient | null = null;
|
|
14
|
+
|
|
15
|
+
function getS3(region: string): S3Client {
|
|
16
|
+
if (!s3Client) s3Client = new S3Client({ region });
|
|
17
|
+
return s3Client;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getLambda(region: string): LambdaClient {
|
|
21
|
+
if (!lambdaClient) lambdaClient = new LambdaClient({ region });
|
|
22
|
+
return lambdaClient;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function uploadToS3(
|
|
26
|
+
region: string,
|
|
27
|
+
bucket: string,
|
|
28
|
+
key: string,
|
|
29
|
+
body: Buffer | Uint8Array,
|
|
30
|
+
contentType: string,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const client = getS3(region);
|
|
33
|
+
await client.send(new PutObjectCommand({
|
|
34
|
+
Bucket: bucket,
|
|
35
|
+
Key: key,
|
|
36
|
+
Body: body,
|
|
37
|
+
ContentType: contentType,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function getFromS3(
|
|
42
|
+
region: string,
|
|
43
|
+
bucket: string,
|
|
44
|
+
key: string,
|
|
45
|
+
): Promise<Buffer | null> {
|
|
46
|
+
const client = getS3(region);
|
|
47
|
+
try {
|
|
48
|
+
const response = await client.send(new GetObjectCommand({
|
|
49
|
+
Bucket: bucket,
|
|
50
|
+
Key: key,
|
|
51
|
+
}));
|
|
52
|
+
if (!response.Body) return null;
|
|
53
|
+
const bytes = await response.Body.transformToByteArray();
|
|
54
|
+
return Buffer.from(bytes);
|
|
55
|
+
} catch (err: unknown) {
|
|
56
|
+
if (err && typeof err === 'object' && 'name' in err && (err as { name: string }).name === 'NoSuchKey') {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function invokeAction(
|
|
64
|
+
region: string,
|
|
65
|
+
functionName: string,
|
|
66
|
+
action: string,
|
|
67
|
+
payload: unknown,
|
|
68
|
+
): Promise<unknown> {
|
|
69
|
+
const client = getLambda(region);
|
|
70
|
+
const response = await client.send(new InvokeCommand({
|
|
71
|
+
FunctionName: functionName,
|
|
72
|
+
Payload: new TextEncoder().encode(JSON.stringify({
|
|
73
|
+
_action: action,
|
|
74
|
+
_payload: payload,
|
|
75
|
+
})),
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
if (response.FunctionError) {
|
|
79
|
+
const errorBody = response.Payload
|
|
80
|
+
? JSON.parse(new TextDecoder().decode(response.Payload))
|
|
81
|
+
: { errorMessage: 'Unknown Lambda error' };
|
|
82
|
+
throw new Error(errorBody.errorMessage || response.FunctionError);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!response.Payload) return null;
|
|
86
|
+
return JSON.parse(new TextDecoder().decode(response.Payload));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Write a versioning key to CloudFront KeyValueStore.
|
|
91
|
+
* KVS requires ETag for optimistic concurrency — DescribeKeyValueStore first.
|
|
92
|
+
*/
|
|
93
|
+
export async function putKvsKey(
|
|
94
|
+
region: string,
|
|
95
|
+
kvsArn: string,
|
|
96
|
+
key: string,
|
|
97
|
+
value: string,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
// KVS requires SigV4a (asymmetric) signing — load pure-JS implementation
|
|
100
|
+
await import('@aws-sdk/signature-v4a');
|
|
101
|
+
const {
|
|
102
|
+
CloudFrontKeyValueStoreClient,
|
|
103
|
+
DescribeKeyValueStoreCommand,
|
|
104
|
+
PutKeyCommand,
|
|
105
|
+
} = await import('@aws-sdk/client-cloudfront-keyvaluestore');
|
|
106
|
+
|
|
107
|
+
if (!kvsClient) kvsClient = new CloudFrontKeyValueStoreClient({ region });
|
|
108
|
+
|
|
109
|
+
const desc = await kvsClient.send(
|
|
110
|
+
new DescribeKeyValueStoreCommand({ KvsARN: kvsArn })
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
await kvsClient.send(
|
|
114
|
+
new PutKeyCommand({
|
|
115
|
+
KvsARN: kvsArn,
|
|
116
|
+
Key: key,
|
|
117
|
+
Value: value,
|
|
118
|
+
IfMatch: desc.ETag!,
|
|
119
|
+
})
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* analyze — static analysis commands.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* analyze:ssr — scan app code for SSR anti-patterns
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* everystack analyze:ssr [--app ./app] [--json]
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { analyzeSSRPatterns, generateSSRReport } from '../ssr-analyzer.js';
|
|
13
|
+
import { step, success, fail, info } from '../output.js';
|
|
14
|
+
|
|
15
|
+
export async function analyzeSSRCommand(
|
|
16
|
+
_positional: string | undefined,
|
|
17
|
+
flags: Record<string, string>,
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const appDir = flags.app || path.join(process.cwd(), 'app');
|
|
20
|
+
|
|
21
|
+
step(`Scanning ${appDir} for SSR patterns...`);
|
|
22
|
+
|
|
23
|
+
let analysis;
|
|
24
|
+
try {
|
|
25
|
+
analysis = await analyzeSSRPatterns(appDir);
|
|
26
|
+
} catch (err: any) {
|
|
27
|
+
fail(`Failed to analyze app directory: ${err.message}`);
|
|
28
|
+
info('Make sure you\'re running this from an Expo Router project root.');
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// JSON output for scripting
|
|
33
|
+
if (flags.json === 'true' || flags.json === '1') {
|
|
34
|
+
console.log(JSON.stringify(analysis, null, 2));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Human-readable output
|
|
39
|
+
console.log('');
|
|
40
|
+
const report = generateSSRReport(analysis);
|
|
41
|
+
console.log(report);
|
|
42
|
+
console.log('');
|
|
43
|
+
|
|
44
|
+
if (analysis.issues.length > 0) {
|
|
45
|
+
const errors = analysis.issues.filter(i => i.severity === 'error').length;
|
|
46
|
+
const warnings = analysis.issues.filter(i => i.severity === 'warning').length;
|
|
47
|
+
const infos = analysis.issues.filter(i => i.severity === 'info').length;
|
|
48
|
+
|
|
49
|
+
if (errors > 0) {
|
|
50
|
+
fail(`Found ${errors} error(s), ${warnings} warning(s), ${infos} info(s)`);
|
|
51
|
+
} else if (warnings > 0) {
|
|
52
|
+
info(`Found ${warnings} warning(s), ${infos} info(s)`);
|
|
53
|
+
} else {
|
|
54
|
+
info(`Found ${infos} info(s) — review for optimization opportunities`);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
success('No SSR anti-patterns detected');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log('');
|
|
61
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export async function branchesCommand(subcommand: string, flags: Record<string, string>): Promise<void> {
|
|
2
|
+
switch (subcommand) {
|
|
3
|
+
case 'list':
|
|
4
|
+
await listBranches(flags);
|
|
5
|
+
break;
|
|
6
|
+
case 'create':
|
|
7
|
+
await createBranch(flags);
|
|
8
|
+
break;
|
|
9
|
+
case 'delete':
|
|
10
|
+
await deleteBranch(flags);
|
|
11
|
+
break;
|
|
12
|
+
default:
|
|
13
|
+
console.log('Usage: everystack branches <list|create|delete> [--name <name>]');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function listBranches(_flags: Record<string, string>): Promise<void> {
|
|
18
|
+
const baseUrl = getBaseUrl();
|
|
19
|
+
const token = getToken();
|
|
20
|
+
|
|
21
|
+
const response = await fetch(`${baseUrl}/branches`, {
|
|
22
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
throw new Error(`Failed to list branches: ${response.status}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const body = await response.json() as any;
|
|
30
|
+
const branches = body.branches || [];
|
|
31
|
+
if (branches.length === 0) {
|
|
32
|
+
console.log('No branches found.');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log('Branches:');
|
|
37
|
+
for (const branch of branches) {
|
|
38
|
+
console.log(` ${branch.name} (created: ${branch.createdAt})`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function createBranch(flags: Record<string, string>): Promise<void> {
|
|
43
|
+
const name = flags.name;
|
|
44
|
+
if (!name) {
|
|
45
|
+
throw new Error('--name is required');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const baseUrl = getBaseUrl();
|
|
49
|
+
const token = getToken();
|
|
50
|
+
|
|
51
|
+
const response = await fetch(`${baseUrl}/branches`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'content-type': 'application/json',
|
|
55
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({ name }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const body = await response.json().catch(() => ({}));
|
|
62
|
+
throw new Error(`Failed to create branch: ${(body as any).error || response.status}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(`Branch "${name}" created.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function deleteBranch(flags: Record<string, string>): Promise<void> {
|
|
69
|
+
const name = flags.name;
|
|
70
|
+
if (!name) {
|
|
71
|
+
throw new Error('--name is required');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const baseUrl = getBaseUrl();
|
|
75
|
+
const token = getToken();
|
|
76
|
+
|
|
77
|
+
const response = await fetch(`${baseUrl}/branches/${encodeURIComponent(name)}`, {
|
|
78
|
+
method: 'DELETE',
|
|
79
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
const body = await response.json().catch(() => ({}));
|
|
84
|
+
throw new Error(`Failed to delete branch: ${(body as any).error || response.status}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log(`Branch "${name}" deleted.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getBaseUrl(): string {
|
|
91
|
+
if (process.env.EVERYSTACK_URL) return process.env.EVERYSTACK_URL;
|
|
92
|
+
throw new Error('EVERYSTACK_URL environment variable is required');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getToken(): string | undefined {
|
|
96
|
+
return process.env.EVERYSTACK_TOKEN;
|
|
97
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cache:purge — bust CloudFront cache via KVS version bump.
|
|
3
|
+
*
|
|
4
|
+
* No args → global epoch bump → all cached content refreshes.
|
|
5
|
+
* --origin api|media|web → per-origin epoch bump → only that origin refreshes.
|
|
6
|
+
* --path "/api/posts" → per-URL version bump → only that path refreshes.
|
|
7
|
+
*
|
|
8
|
+
* Writes directly to CloudFront KVS — no Lambda invoke, no CF invalidation API.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { resolveConfig } from '../config.js';
|
|
12
|
+
import { putKvsKey } from '../aws.js';
|
|
13
|
+
import { step, success, fail, info } from '../output.js';
|
|
14
|
+
|
|
15
|
+
const VALID_ORIGINS = ['api', 'media', 'web'] as const;
|
|
16
|
+
type CacheOrigin = typeof VALID_ORIGINS[number];
|
|
17
|
+
|
|
18
|
+
export async function cachePurgeCommand(flags: Record<string, string>): Promise<void> {
|
|
19
|
+
step('Resolving deployed config...');
|
|
20
|
+
let config;
|
|
21
|
+
try {
|
|
22
|
+
config = await resolveConfig(flags.stage);
|
|
23
|
+
} catch (err: any) {
|
|
24
|
+
fail(err.message);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!config.kvsArn) {
|
|
29
|
+
fail('No kvsArn found in .sst/outputs.json. Deploy with cache versioning enabled first.');
|
|
30
|
+
info('Add `kvsArn: cacheVersionStore.arn` to the return block in sst.config.ts and redeploy.');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Key resolution: --path wins > --origin > global
|
|
35
|
+
let key: string;
|
|
36
|
+
if (flags.path) {
|
|
37
|
+
// Normalize: strip query params (they're not part of the cache key) and ensure leading /
|
|
38
|
+
let path = flags.path.split('?')[0];
|
|
39
|
+
if (!path.startsWith('/')) path = '/' + path;
|
|
40
|
+
key = path;
|
|
41
|
+
} else if (flags.origin) {
|
|
42
|
+
if (!VALID_ORIGINS.includes(flags.origin as CacheOrigin)) {
|
|
43
|
+
fail(`Invalid origin: ${flags.origin}. Valid origins: ${VALID_ORIGINS.join(', ')}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
key = `__epoch__:${flags.origin}`;
|
|
47
|
+
} else {
|
|
48
|
+
key = '__epoch__';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const epoch = String(Math.floor(Date.now() / 1000));
|
|
52
|
+
|
|
53
|
+
step(`Writing version ${epoch} for key "${key}"...`);
|
|
54
|
+
try {
|
|
55
|
+
await putKvsKey(config.region, config.kvsArn, key, epoch);
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
fail(`KVS write failed: ${err.message}`);
|
|
58
|
+
info('Ensure your IAM user/role has cloudfront-keyvaluestore:PutKey and DescribeKeyValueStore permissions.');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (flags.path) {
|
|
63
|
+
success(`Cache purged: ${flags.path} → version ${epoch}`);
|
|
64
|
+
info(`Cached content at ${flags.path} will refresh on next request.`);
|
|
65
|
+
} else if (flags.origin) {
|
|
66
|
+
success(`Cache purged: ${flags.origin} origin → version ${epoch}`);
|
|
67
|
+
info(`All ${flags.origin} content will refresh on next request.`);
|
|
68
|
+
} else {
|
|
69
|
+
success(`Cache epoch updated to ${epoch}`);
|
|
70
|
+
info('All cached content will refresh on next request.');
|
|
71
|
+
}
|
|
72
|
+
}
|