@everystack/cli 0.2.28 → 0.2.30
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 +34 -0
- package/package.json +1 -1
- package/src/cli/aws.ts +45 -0
- package/src/cli/commands/console.ts +87 -3
- package/src/cli/commands/update.ts +62 -6
- package/src/cli/utils/completer.ts +1 -1
package/README.md
CHANGED
|
@@ -132,6 +132,22 @@ everystack update \
|
|
|
132
132
|
|
|
133
133
|
This bundles your app, uploads assets to storage, creates a release record, and signs the manifest.
|
|
134
134
|
|
|
135
|
+
Media assets configured in `app.config.js` under `extra.everystack.assets` are fingerprinted with 8-char content hashes (e.g., `logo.png` → `logo-a1b2c3d4.png`) and synced to the media bucket. A `media-manifest.json` mapping logical names to fingerprinted paths is written to the bucket root. Stale fingerprinted files from previous deploys are pruned automatically.
|
|
136
|
+
|
|
137
|
+
```javascript
|
|
138
|
+
// app.config.js
|
|
139
|
+
extra: {
|
|
140
|
+
everystack: {
|
|
141
|
+
assets: [
|
|
142
|
+
{ from: 'server/email/assets', to: 'email/', include: '*.{png,jpg}' },
|
|
143
|
+
{ from: 'assets/og', to: 'og/' },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Use `@everystack/server/media` to resolve fingerprinted URLs at runtime in SSR, email templates, and workers.
|
|
150
|
+
|
|
135
151
|
### Code Signing
|
|
136
152
|
|
|
137
153
|
Generate RSA key pair for manifest signing:
|
|
@@ -164,6 +180,24 @@ everystack db:psql --stage dev # Open a psql session via Lambda
|
|
|
164
180
|
|
|
165
181
|
`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
182
|
|
|
183
|
+
### Interactive Console
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
everystack console --stage dev # Connect to deployed database
|
|
187
|
+
everystack console --stage dev --sandbox # Sandbox mode: rolls back after each expression
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
A REPL connected to your deployed database with `db`, `schema`, and `eq`/`and`/`or` helpers in scope. Supports multiline input, persistent history, and tab completion.
|
|
191
|
+
|
|
192
|
+
**Dot commands:**
|
|
193
|
+
- `.tables` / `.schema [table]` — Inspect database schema
|
|
194
|
+
- `.login` — Authenticate with email/password (hidden input)
|
|
195
|
+
- `.user` — Show current auth context
|
|
196
|
+
- `.logout` — Clear auth context
|
|
197
|
+
- `.sandbox` — Toggle sandbox mode
|
|
198
|
+
|
|
199
|
+
After `.login`, queries run with RLS policies applied via `pgSettings`. The `current_user` variable is available in scope. Passwords are redacted from history.
|
|
200
|
+
|
|
167
201
|
## Client: React Native
|
|
168
202
|
|
|
169
203
|
### UpdatesProvider
|
package/package.json
CHANGED
package/src/cli/aws.ts
CHANGED
|
@@ -96,6 +96,51 @@ export async function getFromS3(
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
export async function listS3(
|
|
100
|
+
region: string,
|
|
101
|
+
bucket: string,
|
|
102
|
+
prefix?: string,
|
|
103
|
+
): Promise<string[]> {
|
|
104
|
+
const client = await getS3(region);
|
|
105
|
+
const { ListObjectsV2Command } = await import('@aws-sdk/client-s3');
|
|
106
|
+
const keys: string[] = [];
|
|
107
|
+
let continuationToken: string | undefined;
|
|
108
|
+
do {
|
|
109
|
+
const response = await client.send(new ListObjectsV2Command({
|
|
110
|
+
Bucket: bucket,
|
|
111
|
+
...(prefix && { Prefix: prefix }),
|
|
112
|
+
ContinuationToken: continuationToken,
|
|
113
|
+
}));
|
|
114
|
+
if (response.Contents) {
|
|
115
|
+
for (const obj of response.Contents) {
|
|
116
|
+
if (obj.Key) keys.push(obj.Key);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
|
120
|
+
} while (continuationToken);
|
|
121
|
+
return keys;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function deleteS3(
|
|
125
|
+
region: string,
|
|
126
|
+
bucket: string,
|
|
127
|
+
keys: string[],
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
if (keys.length === 0) return;
|
|
130
|
+
const client = await getS3(region);
|
|
131
|
+
const { DeleteObjectsCommand } = await import('@aws-sdk/client-s3');
|
|
132
|
+
for (let i = 0; i < keys.length; i += 1000) {
|
|
133
|
+
const batch = keys.slice(i, i + 1000);
|
|
134
|
+
await client.send(new DeleteObjectsCommand({
|
|
135
|
+
Bucket: bucket,
|
|
136
|
+
Delete: {
|
|
137
|
+
Objects: batch.map(key => ({ Key: key })),
|
|
138
|
+
Quiet: true,
|
|
139
|
+
},
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
99
144
|
export async function invokeAction(
|
|
100
145
|
region: string,
|
|
101
146
|
functionName: string,
|
|
@@ -67,7 +67,7 @@ export function formatScope(meta: ConsoleMeta): string {
|
|
|
67
67
|
|
|
68
68
|
/** Format .user output: current auth context. */
|
|
69
69
|
export function formatUser(user: Record<string, unknown> | null): string {
|
|
70
|
-
if (!user) return 'No user context. Use login
|
|
70
|
+
if (!user) return 'No user context. Use .login or asUser(id) to authenticate.';
|
|
71
71
|
return Object.entries(user).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join('\n');
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -78,12 +78,13 @@ export function formatHelp(): string {
|
|
|
78
78
|
' .columns <Table> Show columns for a table',
|
|
79
79
|
' .rpc List registered RPC functions',
|
|
80
80
|
' .scope List all variables in scope',
|
|
81
|
+
' .login Log in with hidden password prompt',
|
|
81
82
|
' .user Show current auth context',
|
|
82
83
|
' .logout Clear auth context',
|
|
83
84
|
' .clear Clear the terminal',
|
|
84
85
|
' .exit Exit the console',
|
|
85
86
|
'',
|
|
86
|
-
' Auth:
|
|
87
|
+
' Auth: asUser(id), current_user, logout()',
|
|
87
88
|
].join('\n');
|
|
88
89
|
}
|
|
89
90
|
|
|
@@ -104,6 +105,44 @@ interface ConsoleMeta {
|
|
|
104
105
|
scope: string[];
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
// ─── Password Prompt ────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/** Prompt for a value with output suppressed (password entry). */
|
|
111
|
+
export function questionHidden(
|
|
112
|
+
rl: readline.Interface,
|
|
113
|
+
prompt: string,
|
|
114
|
+
): Promise<string> {
|
|
115
|
+
return new Promise((resolve) => {
|
|
116
|
+
const output = (rl as any).output as NodeJS.WritableStream;
|
|
117
|
+
const origWrite = output.write.bind(output);
|
|
118
|
+
let promptShown = false;
|
|
119
|
+
|
|
120
|
+
(output as any).write = function (str: string, ...args: any[]) {
|
|
121
|
+
if (!promptShown) {
|
|
122
|
+
promptShown = true;
|
|
123
|
+
return origWrite(str, ...args);
|
|
124
|
+
}
|
|
125
|
+
// Suppress echo of password characters
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
rl.question(prompt, (answer) => {
|
|
130
|
+
(output as any).write = origWrite;
|
|
131
|
+
origWrite('\n');
|
|
132
|
+
resolve(answer);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Redact password arguments in login() calls for history storage. */
|
|
138
|
+
export function redactLogin(expression: string): string {
|
|
139
|
+
// Match login('email', 'password') or login("email", "password")
|
|
140
|
+
return expression.replace(
|
|
141
|
+
/(login\s*\([^,]+,\s*)(['"`]).*?\2(\s*\))/g,
|
|
142
|
+
"$1$2***$2$3",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
107
146
|
// ─── History ─────────────────────────────────────────────────────────────────
|
|
108
147
|
|
|
109
148
|
/** Load history from disk, truncating to HISTORY_MAX lines. */
|
|
@@ -278,6 +317,51 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
|
|
|
278
317
|
return true;
|
|
279
318
|
}
|
|
280
319
|
|
|
320
|
+
case '.login': {
|
|
321
|
+
const email = await new Promise<string>((resolve) => {
|
|
322
|
+
rl.question(' Email: ', resolve);
|
|
323
|
+
});
|
|
324
|
+
if (!email.trim()) {
|
|
325
|
+
fail('Email is required');
|
|
326
|
+
rl.setPrompt(getPrompt());
|
|
327
|
+
rl.prompt();
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
const password = await questionHidden(rl, ' Password: ');
|
|
331
|
+
if (!password) {
|
|
332
|
+
rl.setPrompt(getPrompt());
|
|
333
|
+
rl.prompt();
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const loginPayload: Record<string, unknown> = {
|
|
338
|
+
expression: `await login(${JSON.stringify(email.trim())}, ${JSON.stringify(password)})`,
|
|
339
|
+
};
|
|
340
|
+
if (currentUser) loginPayload.user = currentUser;
|
|
341
|
+
if (sandbox) loginPayload.sandbox = true;
|
|
342
|
+
|
|
343
|
+
const loginResult: any = await invokeAction(
|
|
344
|
+
config.region,
|
|
345
|
+
config.apiFunctionName,
|
|
346
|
+
'console',
|
|
347
|
+
loginPayload,
|
|
348
|
+
);
|
|
349
|
+
if (loginResult?._auth !== undefined) {
|
|
350
|
+
currentUser = loginResult._auth.user;
|
|
351
|
+
}
|
|
352
|
+
if (loginResult?.error) {
|
|
353
|
+
fail(loginResult.error);
|
|
354
|
+
} else {
|
|
355
|
+
console.log(formatResult(loginResult?.result));
|
|
356
|
+
}
|
|
357
|
+
} catch (err: any) {
|
|
358
|
+
fail(err.message);
|
|
359
|
+
}
|
|
360
|
+
rl.setPrompt(getPrompt());
|
|
361
|
+
rl.prompt();
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
281
365
|
case '.user':
|
|
282
366
|
console.log(formatUser(currentUser));
|
|
283
367
|
rl.prompt();
|
|
@@ -355,7 +439,7 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
|
|
|
355
439
|
} else {
|
|
356
440
|
console.log(formatResult(result?.result));
|
|
357
441
|
}
|
|
358
|
-
appendHistory(expression);
|
|
442
|
+
appendHistory(redactLogin(expression));
|
|
359
443
|
} catch (err: any) {
|
|
360
444
|
fail(err.message);
|
|
361
445
|
}
|
|
@@ -6,12 +6,23 @@ import { pipeline } from 'node:stream/promises';
|
|
|
6
6
|
import { createWriteStream } from 'node:fs';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import { resolveConfig, type CliConfig } from '../config.js';
|
|
9
|
-
import { uploadToS3, headS3, invokeAction } from '../aws.js';
|
|
9
|
+
import { uploadToS3, headS3, listS3, deleteS3, invokeAction } from '../aws.js';
|
|
10
10
|
import { step, success, warn, fail, info } from '../output.js';
|
|
11
11
|
import { exportApp } from '../utils/export.js';
|
|
12
12
|
import { walkDirectory } from '../utils/walk.js';
|
|
13
13
|
import { loadSstSecrets } from '../utils/secrets.js';
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Embed an 8-char content hash in a filename for cache busting.
|
|
17
|
+
* `logo.png` + data → `logo-a1b2c3d4.png`
|
|
18
|
+
*/
|
|
19
|
+
export function fingerprintPath(relativePath: string, data: Buffer): string {
|
|
20
|
+
const hash8 = crypto.createHash('md5').update(data).digest('hex').slice(0, 8);
|
|
21
|
+
const ext = path.extname(relativePath);
|
|
22
|
+
const base = ext ? relativePath.slice(0, -ext.length) : relativePath;
|
|
23
|
+
return `${base}-${hash8}${ext}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
export interface UpdateFlags {
|
|
16
27
|
channel?: string;
|
|
17
28
|
branch?: string;
|
|
@@ -410,7 +421,7 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
410
421
|
}
|
|
411
422
|
}
|
|
412
423
|
|
|
413
|
-
// 3.5. Sync media assets to the media bucket (email logos, OG images, etc.)
|
|
424
|
+
// 3.5. Sync fingerprinted media assets to the media bucket (email logos, OG images, etc.)
|
|
414
425
|
const assetDirs: Array<{ from: string; to: string; include?: string }> | undefined =
|
|
415
426
|
appConfig.extra?.everystack?.assets;
|
|
416
427
|
if (assetDirs?.length) {
|
|
@@ -426,6 +437,7 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
426
437
|
const MEDIA_CONCURRENCY = 10;
|
|
427
438
|
let mediaUploaded = 0;
|
|
428
439
|
let mediaSkipped = 0;
|
|
440
|
+
const manifestFiles: Record<string, string> = {};
|
|
429
441
|
|
|
430
442
|
for (const { from: fromDir, to: toPrefix, include } of assetDirs) {
|
|
431
443
|
const absFrom = path.resolve(fromDir);
|
|
@@ -445,13 +457,17 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
445
457
|
for (let i = 0; i < files.length; i += MEDIA_CONCURRENCY) {
|
|
446
458
|
const batch = files.slice(i, i + MEDIA_CONCURRENCY);
|
|
447
459
|
await Promise.all(batch.map(async (file) => {
|
|
448
|
-
const s3Key = `${toPrefix}${file.relativePath}`;
|
|
449
460
|
const data = Buffer.from(file.data, 'base64');
|
|
461
|
+
const fingerprinted = fingerprintPath(file.relativePath, data);
|
|
462
|
+
const logicalKey = `${toPrefix}${file.relativePath}`;
|
|
463
|
+
const s3Key = `${toPrefix}${fingerprinted}`;
|
|
464
|
+
|
|
465
|
+
manifestFiles[logicalKey] = s3Key;
|
|
450
466
|
|
|
451
|
-
//
|
|
452
|
-
const md5 = `"${crypto.createHash('md5').update(data).digest('hex')}"`;
|
|
467
|
+
// Fingerprinted key encodes the content hash — if it exists, content is identical
|
|
453
468
|
const existing = await headS3(config.region, config.mediaBucket!, s3Key);
|
|
454
|
-
if (existing
|
|
469
|
+
if (existing) {
|
|
470
|
+
info(` ${logicalKey} → ${s3Key} (unchanged)`);
|
|
455
471
|
mediaSkipped++;
|
|
456
472
|
return;
|
|
457
473
|
}
|
|
@@ -464,11 +480,51 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
464
480
|
file.contentType,
|
|
465
481
|
'public, max-age=31536000, immutable',
|
|
466
482
|
);
|
|
483
|
+
info(` ${logicalKey} → ${s3Key}`);
|
|
467
484
|
mediaUploaded++;
|
|
468
485
|
}));
|
|
469
486
|
}
|
|
470
487
|
}
|
|
471
488
|
|
|
489
|
+
// Write manifest (no-cache so CDN always revalidates)
|
|
490
|
+
const manifest = {
|
|
491
|
+
version: 1,
|
|
492
|
+
generatedAt: new Date().toISOString(),
|
|
493
|
+
files: manifestFiles,
|
|
494
|
+
};
|
|
495
|
+
await uploadToS3(
|
|
496
|
+
config.region,
|
|
497
|
+
config.mediaBucket!,
|
|
498
|
+
'media-manifest.json',
|
|
499
|
+
Buffer.from(JSON.stringify(manifest, null, 2)),
|
|
500
|
+
'application/json',
|
|
501
|
+
'no-cache',
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
// Prune stale fingerprinted files — scoped to configured prefixes only.
|
|
505
|
+
// Skip pruning entirely if any prefix is empty (would list the whole bucket).
|
|
506
|
+
const FINGERPRINT_RE = /-[0-9a-f]{8}\.[^.]+$/;
|
|
507
|
+
const prefixes = [...new Set(assetDirs.map(d => d.to))];
|
|
508
|
+
const allKeys: string[] = [];
|
|
509
|
+
const hasSafePrefix = prefixes.every(p => p.length > 0);
|
|
510
|
+
if (hasSafePrefix) {
|
|
511
|
+
for (const prefix of prefixes) {
|
|
512
|
+
const keys = await listS3(config.region, config.mediaBucket!, prefix);
|
|
513
|
+
allKeys.push(...keys);
|
|
514
|
+
}
|
|
515
|
+
} else {
|
|
516
|
+
warn('Skipping media prune: asset prefix is empty (would list entire bucket).');
|
|
517
|
+
}
|
|
518
|
+
const currentValues = new Set(Object.values(manifestFiles));
|
|
519
|
+
currentValues.add('media-manifest.json');
|
|
520
|
+
const staleKeys = allKeys.filter(key =>
|
|
521
|
+
FINGERPRINT_RE.test(key) && !currentValues.has(key)
|
|
522
|
+
);
|
|
523
|
+
if (staleKeys.length > 0) {
|
|
524
|
+
await deleteS3(config.region, config.mediaBucket!, staleKeys);
|
|
525
|
+
info(`Pruned ${staleKeys.length} stale media asset(s)`);
|
|
526
|
+
}
|
|
527
|
+
|
|
472
528
|
if (mediaUploaded > 0 || mediaSkipped > 0) {
|
|
473
529
|
success(`Media: ${mediaUploaded} new, ${mediaSkipped} unchanged (skipped)`);
|
|
474
530
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
const DOT_COMMANDS = ['.tables', '.columns', '.rpc', '.scope', '.user', '.logout', '.help', '.clear', '.exit'];
|
|
14
|
+
const DOT_COMMANDS = ['.tables', '.columns', '.rpc', '.scope', '.login', '.user', '.logout', '.help', '.clear', '.exit'];
|
|
15
15
|
|
|
16
16
|
const MODEL_METHODS = ['find(', 'where(', 'all()', 'first()', 'last()', 'count()', 'table'];
|
|
17
17
|
const CHAIN_METHODS = ['where(', 'orderBy(', 'limit(', 'offset(', 'first()', 'last()', 'count()', 'all()'];
|