@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 +34 -0
- package/package.json +1 -1
- package/src/cli/aws.ts +45 -0
- package/src/cli/commands/console.ts +396 -11
- package/src/cli/commands/update.ts +51 -6
- package/src/cli/index.ts +1 -1
- package/src/cli/utils/bracket-counter.ts +124 -0
- package/src/cli/utils/completer.ts +154 -0
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,
|
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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)
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
|
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>
|
|
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
|
+
}
|