@everystack/cli 0.2.27 → 0.2.29

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
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,
@@ -1,25 +1,187 @@
1
1
  /**
2
- * everystack console --stage <name>
2
+ * everystack console --stage <name> [--sandbox]
3
3
  *
4
4
  * Interactive REPL that executes expressions inside the deployed Lambda.
5
5
  * Each line is sent via IAM-authed Lambda invoke (_action: 'console').
6
- * The Lambda evaluates with db, schema, and Drizzle operators in scope.
6
+ * The Lambda evaluates with db, schema, Drizzle operators, and models in scope.
7
+ *
8
+ * Supports multiline input (bracket balancing), persistent history,
9
+ * dot commands (.tables, .columns, .rpc, .scope, .help, .clear),
10
+ * auth context (login/asUser/logout with pgSettings), and sandbox mode
11
+ * (--sandbox rolls back all changes after each expression).
7
12
  */
8
13
 
9
14
  import * as readline from 'node:readline';
10
- import { resolveConfig } from '../config.js';
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import * as os from 'node:os';
18
+ import { resolveConfig, type CliConfig } from '../config.js';
11
19
  import { invokeAction } from '../aws.js';
12
20
  import { success, fail, info } from '../output.js';
13
21
  import { formatResult } from '../utils/table.js';
22
+ import { isComplete } from '../utils/bracket-counter.js';
23
+ import { createCompleter } from '../utils/completer.js';
24
+
25
+ const HISTORY_DIR = path.join(os.homedir(), '.everystack');
26
+ const HISTORY_FILE = path.join(HISTORY_DIR, 'console_history');
27
+ const HISTORY_MAX = 1000;
28
+
29
+ // ─── Dot Command Formatting ─────────────────────────────────────────────────
30
+
31
+ /** Format .tables output: comma-separated model names. */
32
+ export function formatTables(meta: ConsoleMeta): string {
33
+ return meta.tables.map(t => t.name).join(', ');
34
+ }
35
+
36
+ /** Format .columns <Table> output: one column per line. */
37
+ export function formatColumns(meta: ConsoleMeta, tableName: string): string {
38
+ const table = meta.tables.find(
39
+ t => t.name.toLowerCase() === tableName.toLowerCase(),
40
+ );
41
+ if (!table) {
42
+ const available = meta.tables.map(t => t.name).join(', ');
43
+ return `Unknown table: ${tableName}. Available: ${available}`;
44
+ }
45
+ return table.columns
46
+ .map(c => {
47
+ const flags: string[] = [];
48
+ if (c.primaryKey) flags.push('pk');
49
+ if (c.notNull && !c.primaryKey) flags.push('not null');
50
+ if (c.hasDefault) flags.push('default');
51
+ const suffix = flags.length ? `, ${flags.join(', ')}` : '';
52
+ return ` ${c.property} (${c.type}${suffix})`;
53
+ })
54
+ .join('\n');
55
+ }
56
+
57
+ /** Format .rpc output: comma-separated rpc names. */
58
+ export function formatRpc(meta: ConsoleMeta): string {
59
+ if (meta.rpcNames.length === 0) return 'No RPCs registered';
60
+ return meta.rpcNames.join(', ');
61
+ }
62
+
63
+ /** Format .scope output: comma-separated scope variable names. */
64
+ export function formatScope(meta: ConsoleMeta): string {
65
+ return meta.scope.join(', ');
66
+ }
67
+
68
+ /** Format .user output: current auth context. */
69
+ export function formatUser(user: Record<string, unknown> | null): string {
70
+ if (!user) return 'No user context. Use .login or asUser(id) to authenticate.';
71
+ return Object.entries(user).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`).join('\n');
72
+ }
73
+
74
+ /** Format .help output. */
75
+ export function formatHelp(): string {
76
+ return [
77
+ ' .tables List all model names',
78
+ ' .columns <Table> Show columns for a table',
79
+ ' .rpc List registered RPC functions',
80
+ ' .scope List all variables in scope',
81
+ ' .login Log in with hidden password prompt',
82
+ ' .user Show current auth context',
83
+ ' .logout Clear auth context',
84
+ ' .clear Clear the terminal',
85
+ ' .exit Exit the console',
86
+ '',
87
+ ' Auth: asUser(id), current_user, logout()',
88
+ ].join('\n');
89
+ }
90
+
91
+ interface ConsoleMeta {
92
+ tables: Array<{
93
+ name: string;
94
+ dbName: string;
95
+ columns: Array<{
96
+ name: string;
97
+ property: string;
98
+ type: string;
99
+ notNull: boolean;
100
+ hasDefault: boolean;
101
+ primaryKey: boolean;
102
+ }>;
103
+ }>;
104
+ rpcNames: string[];
105
+ scope: string[];
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
+
146
+ // ─── History ─────────────────────────────────────────────────────────────────
147
+
148
+ /** Load history from disk, truncating to HISTORY_MAX lines. */
149
+ function loadHistory(): string[] {
150
+ try {
151
+ const content = fs.readFileSync(HISTORY_FILE, 'utf8');
152
+ const lines = content.split('\n').filter(Boolean);
153
+ if (lines.length > HISTORY_MAX) {
154
+ const trimmed = lines.slice(-HISTORY_MAX);
155
+ fs.writeFileSync(HISTORY_FILE, trimmed.join('\n') + '\n');
156
+ return trimmed;
157
+ }
158
+ return lines;
159
+ } catch {
160
+ return [];
161
+ }
162
+ }
163
+
164
+ /** Append a single expression to the history file. */
165
+ function appendHistory(expression: string): void {
166
+ try {
167
+ fs.mkdirSync(HISTORY_DIR, { recursive: true });
168
+ fs.appendFileSync(HISTORY_FILE, expression + '\n');
169
+ } catch {
170
+ // Non-critical — don't let history IO break the REPL
171
+ }
172
+ }
173
+
174
+ // ─── Console Command ─────────────────────────────────────────────────────────
14
175
 
15
176
  export async function consoleCommand(flags: Record<string, string>): Promise<void> {
16
177
  const stage = flags.stage;
178
+ const sandbox = flags.sandbox !== undefined;
17
179
  if (!stage) {
18
180
  fail('--stage is required. Example: everystack console --stage dev');
19
181
  process.exit(1);
20
182
  }
21
183
 
22
- let config;
184
+ let config: CliConfig;
23
185
  try {
24
186
  config = await resolveConfig(stage);
25
187
  } catch (err: any) {
@@ -28,33 +190,256 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
28
190
  }
29
191
 
30
192
  success(`Connected to ${stage} (${config.region}) — ${config.apiFunctionName}`);
31
- console.log(' Type expressions to evaluate. Ctrl+C or .exit to quit.\n');
193
+ if (sandbox) {
194
+ info('Sandbox mode: all changes will be rolled back after each expression.');
195
+ }
196
+ console.log(' Type expressions to evaluate. .help for commands. Ctrl+C or .exit to quit.\n');
197
+
198
+ const continuationPrompt = '... ';
199
+ const history = loadHistory();
200
+
201
+ const buffer: string[] = [];
202
+ let cachedMeta: ConsoleMeta | null = null;
203
+ let currentUser: Record<string, unknown> | null = null;
204
+
205
+ /** Build prompt string based on auth and sandbox state. */
206
+ function getPrompt(): string {
207
+ const suffix = sandbox ? '(sandbox)> ' : '> ';
208
+ if (currentUser) {
209
+ const label = (currentUser.email as string)?.split('@')[0]
210
+ ?? (currentUser.sub as string)?.slice(0, 8)
211
+ ?? '?';
212
+ return `everystack:${stage}[${label}]${suffix}`;
213
+ }
214
+ return `everystack:${stage}${suffix}`;
215
+ }
216
+
217
+ /** Fetch and cache console metadata from Lambda. */
218
+ async function getMeta(): Promise<ConsoleMeta> {
219
+ if (cachedMeta) return cachedMeta;
220
+ cachedMeta = await invokeAction(
221
+ config.region,
222
+ config.apiFunctionName,
223
+ 'console:meta',
224
+ {},
225
+ ) as ConsoleMeta;
226
+ return cachedMeta;
227
+ }
228
+
229
+ const completer = createCompleter(async () => {
230
+ try { return await getMeta(); } catch { return null; }
231
+ });
32
232
 
33
233
  const rl = readline.createInterface({
34
234
  input: process.stdin,
35
235
  output: process.stdout,
36
- prompt: `everystack:${stage}> `,
37
- });
236
+ prompt: getPrompt(),
237
+ history,
238
+ historySize: HISTORY_MAX,
239
+ completer,
240
+ } as readline.ReadLineOptions);
38
241
 
39
242
  rl.prompt();
40
243
 
41
- rl.on('line', async (line) => {
244
+ /**
245
+ * Handle a dot command. Returns true if the line was a dot command
246
+ * (even if it was unrecognized), false if it should be treated as code.
247
+ */
248
+ async function handleDotCommand(line: string): Promise<boolean> {
42
249
  const trimmed = line.trim();
43
- if (!trimmed) { rl.prompt(); return; }
44
- if (trimmed === '.exit') { rl.close(); return; }
250
+ if (!trimmed.startsWith('.') || trimmed.startsWith('..')) return false;
251
+
252
+ const parts = trimmed.split(/\s+/);
253
+ const cmd = parts[0].toLowerCase();
254
+ const arg = parts[1];
255
+
256
+ switch (cmd) {
257
+ case '.exit':
258
+ rl.close();
259
+ return true;
260
+
261
+ case '.clear':
262
+ process.stdout.write('\x1B[2J\x1B[0f');
263
+ rl.prompt();
264
+ return true;
265
+
266
+ case '.help':
267
+ console.log(formatHelp());
268
+ rl.prompt();
269
+ return true;
270
+
271
+ case '.tables': {
272
+ try {
273
+ const meta = await getMeta();
274
+ console.log(formatTables(meta));
275
+ } catch (err: any) {
276
+ fail(err.message);
277
+ }
278
+ rl.prompt();
279
+ return true;
280
+ }
281
+
282
+ case '.columns': {
283
+ if (!arg) {
284
+ fail('Usage: .columns <TableName>');
285
+ rl.prompt();
286
+ return true;
287
+ }
288
+ try {
289
+ const meta = await getMeta();
290
+ console.log(formatColumns(meta, arg));
291
+ } catch (err: any) {
292
+ fail(err.message);
293
+ }
294
+ rl.prompt();
295
+ return true;
296
+ }
297
+
298
+ case '.rpc': {
299
+ try {
300
+ const meta = await getMeta();
301
+ console.log(formatRpc(meta));
302
+ } catch (err: any) {
303
+ fail(err.message);
304
+ }
305
+ rl.prompt();
306
+ return true;
307
+ }
308
+
309
+ case '.scope': {
310
+ try {
311
+ const meta = await getMeta();
312
+ console.log(formatScope(meta));
313
+ } catch (err: any) {
314
+ fail(err.message);
315
+ }
316
+ rl.prompt();
317
+ return true;
318
+ }
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
+
365
+ case '.user':
366
+ console.log(formatUser(currentUser));
367
+ rl.prompt();
368
+ return true;
369
+
370
+ case '.logout':
371
+ currentUser = null;
372
+ rl.setPrompt(getPrompt());
373
+ info('Logged out');
374
+ rl.prompt();
375
+ return true;
376
+
377
+ default:
378
+ if (trimmed.startsWith('.')) {
379
+ fail(`Unknown command: ${cmd}. Type .help for available commands.`);
380
+ rl.prompt();
381
+ return true;
382
+ }
383
+ return false;
384
+ }
385
+ }
386
+
387
+ rl.on('line', async (line) => {
388
+ // Empty line: if buffer is empty just re-prompt, otherwise keep it (preserves formatting)
389
+ if (!line.trim() && buffer.length === 0) {
390
+ rl.prompt();
391
+ return;
392
+ }
393
+
394
+ // Dot commands only when not in multiline mode
395
+ if (buffer.length === 0 && line.trim().startsWith('.')) {
396
+ const handled = await handleDotCommand(line);
397
+ if (handled) return;
398
+ }
399
+
400
+ buffer.push(line);
401
+ const joined = buffer.join('\n');
402
+
403
+ if (!isComplete(joined)) {
404
+ rl.setPrompt(continuationPrompt);
405
+ rl.prompt();
406
+ return;
407
+ }
408
+
409
+ // Expression is complete — send it
410
+ const expression = joined.trim();
411
+ buffer.length = 0;
412
+ rl.setPrompt(getPrompt());
413
+
414
+ if (!expression) {
415
+ rl.prompt();
416
+ return;
417
+ }
45
418
 
46
419
  try {
420
+ const payload: Record<string, unknown> = { expression };
421
+ if (currentUser) payload.user = currentUser;
422
+ if (sandbox) payload.sandbox = true;
423
+
47
424
  const result: any = await invokeAction(
48
425
  config.region,
49
426
  config.apiFunctionName,
50
427
  'console',
51
- { expression: trimmed },
428
+ payload,
52
429
  );
430
+
431
+ // Handle auth side-channel (login/asUser/logout signals)
432
+ if (result?._auth !== undefined) {
433
+ currentUser = result._auth.user;
434
+ rl.setPrompt(getPrompt());
435
+ }
436
+
53
437
  if (result?.error) {
54
438
  fail(result.error);
55
439
  } else {
56
440
  console.log(formatResult(result?.result));
57
441
  }
442
+ appendHistory(redactLogin(expression));
58
443
  } catch (err: any) {
59
444
  fail(err.message);
60
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}`;
450
464
 
451
- // Compare MD5 against S3 ETag for diffing
452
- const md5 = `"${crypto.createHash('md5').update(data).digest('hex')}"`;
465
+ manifestFiles[logicalKey] = s3Key;
466
+
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?.etag === md5) {
469
+ if (existing) {
470
+ info(` ${logicalKey} → ${s3Key} (unchanged)`);
455
471
  mediaSkipped++;
456
472
  return;
457
473
  }
@@ -464,11 +480,40 @@ 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
505
+ const FINGERPRINT_RE = /-[0-9a-f]{8}\.[^.]+$/;
506
+ const allKeys = await listS3(config.region, config.mediaBucket!);
507
+ const currentValues = new Set(Object.values(manifestFiles));
508
+ currentValues.add('media-manifest.json');
509
+ const staleKeys = allKeys.filter(key =>
510
+ FINGERPRINT_RE.test(key) && !currentValues.has(key)
511
+ );
512
+ if (staleKeys.length > 0) {
513
+ await deleteS3(config.region, config.mediaBucket!, staleKeys);
514
+ info(`Pruned ${staleKeys.length} stale media asset(s)`);
515
+ }
516
+
472
517
  if (mediaUploaded > 0 || mediaSkipped > 0) {
473
518
  success(`Media: ${mediaUploaded} new, ${mediaSkipped} unchanged (skipped)`);
474
519
  }
package/src/cli/index.ts CHANGED
@@ -173,7 +173,7 @@ Usage:
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)
175
175
  everystack db:psql [--stage <name>] -c <command> Execute SQL query via Lambda (private RDS)
176
- everystack console --stage <name> Interactive REPL on deployed Lambda
176
+ everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
177
177
  everystack certs:generate [--output ./certs]
178
178
  everystack certs:configure [--input ./certs] [--keyid main]
179
179
  everystack cache:purge [--stage <name>] Bust all cached content (global epoch)
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Bracket counter for multiline REPL input.
3
+ *
4
+ * Determines whether a code string has balanced brackets, accounting for
5
+ * string literals (single, double, template), escape sequences, template
6
+ * expression nesting, and comments (line + block).
7
+ */
8
+
9
+ /**
10
+ * Returns `true` when all brackets/parens/braces are balanced and all
11
+ * strings and comments are closed. Used by the console REPL to decide
12
+ * whether to send or continue accumulating lines.
13
+ */
14
+ export function isComplete(code: string): boolean {
15
+ let parens = 0; // ()
16
+ let braces = 0; // {}
17
+ let brackets = 0; // []
18
+
19
+ // Template literal depth: when we hit ` outside a string we push 0;
20
+ // when inside a template and we hit ${ we push the current brace depth;
21
+ // when we close a brace back to that depth we pop and resume the template.
22
+ const templateStack: number[] = [];
23
+
24
+ let inSingle = false;
25
+ let inDouble = false;
26
+ let inTemplate = false;
27
+ let inLineComment = false;
28
+ let inBlockComment = false;
29
+
30
+ for (let i = 0; i < code.length; i++) {
31
+ const ch = code[i];
32
+ const next = code[i + 1];
33
+
34
+ // --- Line comment: skip to end of line ---
35
+ if (inLineComment) {
36
+ if (ch === '\n') inLineComment = false;
37
+ continue;
38
+ }
39
+
40
+ // --- Block comment: skip to */ ---
41
+ if (inBlockComment) {
42
+ if (ch === '*' && next === '/') {
43
+ inBlockComment = false;
44
+ i++; // skip /
45
+ }
46
+ continue;
47
+ }
48
+
49
+ // --- Inside single-quoted string ---
50
+ if (inSingle) {
51
+ if (ch === '\\') { i++; continue; } // skip escaped char
52
+ if (ch === "'") inSingle = false;
53
+ continue;
54
+ }
55
+
56
+ // --- Inside double-quoted string ---
57
+ if (inDouble) {
58
+ if (ch === '\\') { i++; continue; }
59
+ if (ch === '"') inDouble = false;
60
+ continue;
61
+ }
62
+
63
+ // --- Inside template literal ---
64
+ if (inTemplate) {
65
+ if (ch === '\\') { i++; continue; }
66
+ if (ch === '$' && next === '{') {
67
+ // Enter template expression — push current brace depth as marker
68
+ templateStack.push(braces);
69
+ braces++;
70
+ inTemplate = false;
71
+ i++; // skip {
72
+ continue;
73
+ }
74
+ if (ch === '`') {
75
+ inTemplate = false;
76
+ }
77
+ continue;
78
+ }
79
+
80
+ // --- Normal code context ---
81
+
82
+ // Comment detection (before string detection — // and /* aren't strings)
83
+ if (ch === '/' && next === '/') {
84
+ inLineComment = true;
85
+ i++; // skip second /
86
+ continue;
87
+ }
88
+ if (ch === '/' && next === '*') {
89
+ inBlockComment = true;
90
+ i++; // skip *
91
+ continue;
92
+ }
93
+
94
+ // String openers
95
+ if (ch === "'") { inSingle = true; continue; }
96
+ if (ch === '"') { inDouble = true; continue; }
97
+ if (ch === '`') { inTemplate = true; continue; }
98
+
99
+ // Brackets
100
+ if (ch === '(') parens++;
101
+ else if (ch === ')') parens--;
102
+ else if (ch === '[') brackets++;
103
+ else if (ch === ']') brackets--;
104
+ else if (ch === '{') braces++;
105
+ else if (ch === '}') {
106
+ braces--;
107
+ // Check if we're closing a template expression
108
+ if (templateStack.length > 0 && braces === templateStack[templateStack.length - 1]) {
109
+ templateStack.pop();
110
+ inTemplate = true; // back inside the template literal
111
+ }
112
+ }
113
+ }
114
+
115
+ return (
116
+ parens === 0 &&
117
+ braces === 0 &&
118
+ brackets === 0 &&
119
+ !inSingle &&
120
+ !inDouble &&
121
+ !inTemplate &&
122
+ !inBlockComment
123
+ );
124
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Tab completion for the everystack console REPL.
3
+ *
4
+ * Completion sources (in priority order):
5
+ * 1. Dot commands: `.tables`, `.columns`, `.help`, etc.
6
+ * 2. Model methods: `Users.whe` → `Users.where(`
7
+ * 3. Chain methods: `.where({}).ord` → `.where({}).orderBy(`
8
+ * 4. RPC names: `rpc.forg` → `rpc.forgot_password(`
9
+ * 5. Scope variables: `Use` → `Users`, `sch` → `schema`
10
+ */
11
+
12
+ // ─── Constants ──────────────────────────────────────────────────────────────
13
+
14
+ const DOT_COMMANDS = ['.tables', '.columns', '.rpc', '.scope', '.login', '.user', '.logout', '.help', '.clear', '.exit'];
15
+
16
+ const MODEL_METHODS = ['find(', 'where(', 'all()', 'first()', 'last()', 'count()', 'table'];
17
+ const CHAIN_METHODS = ['where(', 'orderBy(', 'limit(', 'offset(', 'first()', 'last()', 'count()', 'all()'];
18
+
19
+ // ─── Types ──────────────────────────────────────────────────────────────────
20
+
21
+ interface ConsoleMeta {
22
+ tables: Array<{ name: string; dbName: string; columns: Array<{ property: string }> }>;
23
+ rpcNames: string[];
24
+ scope: string[];
25
+ }
26
+
27
+ // ─── Token Extraction ───────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Extract the completable token from the end of a line.
31
+ *
32
+ * Returns `{ prefix, token }` where:
33
+ * - `prefix` is everything before the token (for reinsertion)
34
+ * - `token` is the fragment the user is typing
35
+ *
36
+ * Splits on characters that can't appear in identifiers or member access:
37
+ * space, `(`, `)`, `[`, `]`, `{`, `}`, `,`, `;`, `=`, `+`, `-`, `*`, `/`, `!`, `<`, `>`, `?`, `:`, `&`, `|`, `~`, `^`, `%`
38
+ *
39
+ * Preserves `.` in the token so we can detect member access (e.g. `Users.whe`).
40
+ */
41
+ export function extractToken(line: string): { prefix: string; token: string } {
42
+ // Walk backwards from end, collecting identifier chars and dots
43
+ let i = line.length - 1;
44
+ while (i >= 0) {
45
+ const ch = line[i];
46
+ if (/[a-zA-Z0-9_.$]/.test(ch)) {
47
+ i--;
48
+ } else {
49
+ break;
50
+ }
51
+ }
52
+ const start = i + 1;
53
+ return {
54
+ prefix: line.slice(0, start),
55
+ token: line.slice(start),
56
+ };
57
+ }
58
+
59
+ // ─── Completion Logic ───────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Generate completions for a given line and cursor position.
63
+ *
64
+ * Returns `[completions, token]` in the format readline expects:
65
+ * - `completions`: full replacement strings for the token
66
+ * - `token`: the text being completed (readline uses this for display)
67
+ */
68
+ export function getCompletions(
69
+ line: string,
70
+ meta: ConsoleMeta | null,
71
+ ): [string[], string] {
72
+ const trimmed = line.trimStart();
73
+
74
+ // 1. Dot commands (only at start of line)
75
+ if (trimmed.startsWith('.') && !trimmed.includes(' ') && !trimmed.includes('(')) {
76
+ const matches = DOT_COMMANDS.filter(c => c.startsWith(trimmed));
77
+ return [matches, trimmed];
78
+ }
79
+
80
+ // No meta yet — can't complete anything else
81
+ if (!meta) return [[], ''];
82
+
83
+ const { prefix, token } = extractToken(line);
84
+
85
+ // 2. Member access: `something.partial`
86
+ const dotIdx = token.lastIndexOf('.');
87
+ if (dotIdx >= 0) {
88
+ const obj = token.slice(0, dotIdx);
89
+ const partial = token.slice(dotIdx + 1);
90
+
91
+ // rpc.name completions
92
+ if (obj === 'rpc' || obj.endsWith('.rpc')) {
93
+ const rpcCompletions = meta.rpcNames
94
+ .filter(name => name.startsWith(partial))
95
+ .map(name => `${obj}.${name}(`);
96
+ return [rpcCompletions, token];
97
+ }
98
+
99
+ // Model method completions (e.g. Users.whe → Users.where()
100
+ const isModel = meta.tables.some(t => t.name === obj);
101
+ if (isModel) {
102
+ const matches = MODEL_METHODS
103
+ .filter(m => m.startsWith(partial))
104
+ .map(m => `${obj}.${m}`);
105
+ return [matches, token];
106
+ }
107
+
108
+ // Chain method completions after closing paren/bracket
109
+ // e.g. `).ord` or generic `something.meth`
110
+ const matches = CHAIN_METHODS
111
+ .filter(m => m.startsWith(partial))
112
+ .map(m => `${obj}.${m}`);
113
+ if (matches.length > 0) return [matches, token];
114
+ }
115
+
116
+ // 3. Bare word — scope variables
117
+ if (token.length > 0) {
118
+ const matches = meta.scope
119
+ .filter(s => s.startsWith(token))
120
+ .sort();
121
+ return [matches, token];
122
+ }
123
+
124
+ return [[], ''];
125
+ }
126
+
127
+ // ─── Readline Completer Factory ─────────────────────────────────────────────
128
+
129
+ /**
130
+ * Create a readline-compatible completer function.
131
+ *
132
+ * The `getMeta` callback lazily fetches and caches console metadata.
133
+ * If meta isn't available yet, completions degrade gracefully (dot commands only).
134
+ */
135
+ export function createCompleter(
136
+ getMeta: () => Promise<ConsoleMeta | null>,
137
+ ): (line: string, callback: (err: null, result: [string[], string]) => void) => void {
138
+ let cachedMeta: ConsoleMeta | null = null;
139
+ let metaFetched = false;
140
+
141
+ return (line: string, callback: (err: null, result: [string[], string]) => void) => {
142
+ if (!metaFetched) {
143
+ metaFetched = true;
144
+ getMeta().then(meta => {
145
+ cachedMeta = meta;
146
+ callback(null, getCompletions(line, cachedMeta));
147
+ }).catch(() => {
148
+ callback(null, getCompletions(line, null));
149
+ });
150
+ } else {
151
+ callback(null, getCompletions(line, cachedMeta));
152
+ }
153
+ };
154
+ }