@everystack/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +255 -0
  2. package/package.json +104 -0
  3. package/src/cli/aws.ts +121 -0
  4. package/src/cli/commands/analyze.ts +61 -0
  5. package/src/cli/commands/branches.ts +97 -0
  6. package/src/cli/commands/cache.ts +72 -0
  7. package/src/cli/commands/certs.ts +117 -0
  8. package/src/cli/commands/channels.ts +109 -0
  9. package/src/cli/commands/console.ts +68 -0
  10. package/src/cli/commands/db.ts +183 -0
  11. package/src/cli/commands/diag.ts +242 -0
  12. package/src/cli/commands/logs.ts +282 -0
  13. package/src/cli/commands/update.ts +432 -0
  14. package/src/cli/config.ts +98 -0
  15. package/src/cli/discover.ts +321 -0
  16. package/src/cli/hydration-analyzer.ts +224 -0
  17. package/src/cli/index.ts +178 -0
  18. package/src/cli/output.ts +25 -0
  19. package/src/cli/ssr-analyzer.ts +445 -0
  20. package/src/cli/utils/export.ts +8 -0
  21. package/src/cli/utils/table.ts +39 -0
  22. package/src/cli/utils/upload.ts +52 -0
  23. package/src/cli/utils/walk.ts +59 -0
  24. package/src/client/app-state-provider.tsx +83 -0
  25. package/src/client/index.ts +2 -0
  26. package/src/client/updates-provider.tsx +69 -0
  27. package/src/handler/assets.ts +30 -0
  28. package/src/handler/branches.ts +70 -0
  29. package/src/handler/channels-crud.ts +174 -0
  30. package/src/handler/helpers.ts +239 -0
  31. package/src/handler/index.ts +78 -0
  32. package/src/handler/manifest.ts +276 -0
  33. package/src/handler/multipart.ts +74 -0
  34. package/src/handler/publish-web.ts +311 -0
  35. package/src/handler/publish.ts +346 -0
  36. package/src/handler/signing.ts +29 -0
  37. package/src/handler/types.ts +16 -0
  38. package/src/index.ts +4 -0
  39. package/src/schema.ts +245 -0
  40. package/src/storage/filesystem.ts +103 -0
  41. package/src/storage/index.ts +27 -0
  42. package/src/storage/s3.ts +125 -0
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
+ }