@everystack/cli 0.2.27 → 0.2.28
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/package.json +1 -1
- package/src/cli/commands/console.ts +312 -11
- 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/package.json
CHANGED
|
@@ -1,25 +1,148 @@
|
|
|
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(email, password) 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
|
+
' .user Show current auth context',
|
|
82
|
+
' .logout Clear auth context',
|
|
83
|
+
' .clear Clear the terminal',
|
|
84
|
+
' .exit Exit the console',
|
|
85
|
+
'',
|
|
86
|
+
' Auth: login(email, password), asUser(id), logout()',
|
|
87
|
+
].join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface ConsoleMeta {
|
|
91
|
+
tables: Array<{
|
|
92
|
+
name: string;
|
|
93
|
+
dbName: string;
|
|
94
|
+
columns: Array<{
|
|
95
|
+
name: string;
|
|
96
|
+
property: string;
|
|
97
|
+
type: string;
|
|
98
|
+
notNull: boolean;
|
|
99
|
+
hasDefault: boolean;
|
|
100
|
+
primaryKey: boolean;
|
|
101
|
+
}>;
|
|
102
|
+
}>;
|
|
103
|
+
rpcNames: string[];
|
|
104
|
+
scope: string[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── History ─────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** Load history from disk, truncating to HISTORY_MAX lines. */
|
|
110
|
+
function loadHistory(): string[] {
|
|
111
|
+
try {
|
|
112
|
+
const content = fs.readFileSync(HISTORY_FILE, 'utf8');
|
|
113
|
+
const lines = content.split('\n').filter(Boolean);
|
|
114
|
+
if (lines.length > HISTORY_MAX) {
|
|
115
|
+
const trimmed = lines.slice(-HISTORY_MAX);
|
|
116
|
+
fs.writeFileSync(HISTORY_FILE, trimmed.join('\n') + '\n');
|
|
117
|
+
return trimmed;
|
|
118
|
+
}
|
|
119
|
+
return lines;
|
|
120
|
+
} catch {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Append a single expression to the history file. */
|
|
126
|
+
function appendHistory(expression: string): void {
|
|
127
|
+
try {
|
|
128
|
+
fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
|
129
|
+
fs.appendFileSync(HISTORY_FILE, expression + '\n');
|
|
130
|
+
} catch {
|
|
131
|
+
// Non-critical — don't let history IO break the REPL
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Console Command ─────────────────────────────────────────────────────────
|
|
14
136
|
|
|
15
137
|
export async function consoleCommand(flags: Record<string, string>): Promise<void> {
|
|
16
138
|
const stage = flags.stage;
|
|
139
|
+
const sandbox = flags.sandbox !== undefined;
|
|
17
140
|
if (!stage) {
|
|
18
141
|
fail('--stage is required. Example: everystack console --stage dev');
|
|
19
142
|
process.exit(1);
|
|
20
143
|
}
|
|
21
144
|
|
|
22
|
-
let config;
|
|
145
|
+
let config: CliConfig;
|
|
23
146
|
try {
|
|
24
147
|
config = await resolveConfig(stage);
|
|
25
148
|
} catch (err: any) {
|
|
@@ -28,33 +151,211 @@ export async function consoleCommand(flags: Record<string, string>): Promise<voi
|
|
|
28
151
|
}
|
|
29
152
|
|
|
30
153
|
success(`Connected to ${stage} (${config.region}) — ${config.apiFunctionName}`);
|
|
31
|
-
|
|
154
|
+
if (sandbox) {
|
|
155
|
+
info('Sandbox mode: all changes will be rolled back after each expression.');
|
|
156
|
+
}
|
|
157
|
+
console.log(' Type expressions to evaluate. .help for commands. Ctrl+C or .exit to quit.\n');
|
|
158
|
+
|
|
159
|
+
const continuationPrompt = '... ';
|
|
160
|
+
const history = loadHistory();
|
|
161
|
+
|
|
162
|
+
const buffer: string[] = [];
|
|
163
|
+
let cachedMeta: ConsoleMeta | null = null;
|
|
164
|
+
let currentUser: Record<string, unknown> | null = null;
|
|
165
|
+
|
|
166
|
+
/** Build prompt string based on auth and sandbox state. */
|
|
167
|
+
function getPrompt(): string {
|
|
168
|
+
const suffix = sandbox ? '(sandbox)> ' : '> ';
|
|
169
|
+
if (currentUser) {
|
|
170
|
+
const label = (currentUser.email as string)?.split('@')[0]
|
|
171
|
+
?? (currentUser.sub as string)?.slice(0, 8)
|
|
172
|
+
?? '?';
|
|
173
|
+
return `everystack:${stage}[${label}]${suffix}`;
|
|
174
|
+
}
|
|
175
|
+
return `everystack:${stage}${suffix}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Fetch and cache console metadata from Lambda. */
|
|
179
|
+
async function getMeta(): Promise<ConsoleMeta> {
|
|
180
|
+
if (cachedMeta) return cachedMeta;
|
|
181
|
+
cachedMeta = await invokeAction(
|
|
182
|
+
config.region,
|
|
183
|
+
config.apiFunctionName,
|
|
184
|
+
'console:meta',
|
|
185
|
+
{},
|
|
186
|
+
) as ConsoleMeta;
|
|
187
|
+
return cachedMeta;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const completer = createCompleter(async () => {
|
|
191
|
+
try { return await getMeta(); } catch { return null; }
|
|
192
|
+
});
|
|
32
193
|
|
|
33
194
|
const rl = readline.createInterface({
|
|
34
195
|
input: process.stdin,
|
|
35
196
|
output: process.stdout,
|
|
36
|
-
prompt:
|
|
37
|
-
|
|
197
|
+
prompt: getPrompt(),
|
|
198
|
+
history,
|
|
199
|
+
historySize: HISTORY_MAX,
|
|
200
|
+
completer,
|
|
201
|
+
} as readline.ReadLineOptions);
|
|
38
202
|
|
|
39
203
|
rl.prompt();
|
|
40
204
|
|
|
41
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Handle a dot command. Returns true if the line was a dot command
|
|
207
|
+
* (even if it was unrecognized), false if it should be treated as code.
|
|
208
|
+
*/
|
|
209
|
+
async function handleDotCommand(line: string): Promise<boolean> {
|
|
42
210
|
const trimmed = line.trim();
|
|
43
|
-
if (!trimmed)
|
|
44
|
-
|
|
211
|
+
if (!trimmed.startsWith('.') || trimmed.startsWith('..')) return false;
|
|
212
|
+
|
|
213
|
+
const parts = trimmed.split(/\s+/);
|
|
214
|
+
const cmd = parts[0].toLowerCase();
|
|
215
|
+
const arg = parts[1];
|
|
216
|
+
|
|
217
|
+
switch (cmd) {
|
|
218
|
+
case '.exit':
|
|
219
|
+
rl.close();
|
|
220
|
+
return true;
|
|
221
|
+
|
|
222
|
+
case '.clear':
|
|
223
|
+
process.stdout.write('\x1B[2J\x1B[0f');
|
|
224
|
+
rl.prompt();
|
|
225
|
+
return true;
|
|
226
|
+
|
|
227
|
+
case '.help':
|
|
228
|
+
console.log(formatHelp());
|
|
229
|
+
rl.prompt();
|
|
230
|
+
return true;
|
|
231
|
+
|
|
232
|
+
case '.tables': {
|
|
233
|
+
try {
|
|
234
|
+
const meta = await getMeta();
|
|
235
|
+
console.log(formatTables(meta));
|
|
236
|
+
} catch (err: any) {
|
|
237
|
+
fail(err.message);
|
|
238
|
+
}
|
|
239
|
+
rl.prompt();
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case '.columns': {
|
|
244
|
+
if (!arg) {
|
|
245
|
+
fail('Usage: .columns <TableName>');
|
|
246
|
+
rl.prompt();
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const meta = await getMeta();
|
|
251
|
+
console.log(formatColumns(meta, arg));
|
|
252
|
+
} catch (err: any) {
|
|
253
|
+
fail(err.message);
|
|
254
|
+
}
|
|
255
|
+
rl.prompt();
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
case '.rpc': {
|
|
260
|
+
try {
|
|
261
|
+
const meta = await getMeta();
|
|
262
|
+
console.log(formatRpc(meta));
|
|
263
|
+
} catch (err: any) {
|
|
264
|
+
fail(err.message);
|
|
265
|
+
}
|
|
266
|
+
rl.prompt();
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
case '.scope': {
|
|
271
|
+
try {
|
|
272
|
+
const meta = await getMeta();
|
|
273
|
+
console.log(formatScope(meta));
|
|
274
|
+
} catch (err: any) {
|
|
275
|
+
fail(err.message);
|
|
276
|
+
}
|
|
277
|
+
rl.prompt();
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case '.user':
|
|
282
|
+
console.log(formatUser(currentUser));
|
|
283
|
+
rl.prompt();
|
|
284
|
+
return true;
|
|
285
|
+
|
|
286
|
+
case '.logout':
|
|
287
|
+
currentUser = null;
|
|
288
|
+
rl.setPrompt(getPrompt());
|
|
289
|
+
info('Logged out');
|
|
290
|
+
rl.prompt();
|
|
291
|
+
return true;
|
|
292
|
+
|
|
293
|
+
default:
|
|
294
|
+
if (trimmed.startsWith('.')) {
|
|
295
|
+
fail(`Unknown command: ${cmd}. Type .help for available commands.`);
|
|
296
|
+
rl.prompt();
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
rl.on('line', async (line) => {
|
|
304
|
+
// Empty line: if buffer is empty just re-prompt, otherwise keep it (preserves formatting)
|
|
305
|
+
if (!line.trim() && buffer.length === 0) {
|
|
306
|
+
rl.prompt();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Dot commands only when not in multiline mode
|
|
311
|
+
if (buffer.length === 0 && line.trim().startsWith('.')) {
|
|
312
|
+
const handled = await handleDotCommand(line);
|
|
313
|
+
if (handled) return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
buffer.push(line);
|
|
317
|
+
const joined = buffer.join('\n');
|
|
318
|
+
|
|
319
|
+
if (!isComplete(joined)) {
|
|
320
|
+
rl.setPrompt(continuationPrompt);
|
|
321
|
+
rl.prompt();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Expression is complete — send it
|
|
326
|
+
const expression = joined.trim();
|
|
327
|
+
buffer.length = 0;
|
|
328
|
+
rl.setPrompt(getPrompt());
|
|
329
|
+
|
|
330
|
+
if (!expression) {
|
|
331
|
+
rl.prompt();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
45
334
|
|
|
46
335
|
try {
|
|
336
|
+
const payload: Record<string, unknown> = { expression };
|
|
337
|
+
if (currentUser) payload.user = currentUser;
|
|
338
|
+
if (sandbox) payload.sandbox = true;
|
|
339
|
+
|
|
47
340
|
const result: any = await invokeAction(
|
|
48
341
|
config.region,
|
|
49
342
|
config.apiFunctionName,
|
|
50
343
|
'console',
|
|
51
|
-
|
|
344
|
+
payload,
|
|
52
345
|
);
|
|
346
|
+
|
|
347
|
+
// Handle auth side-channel (login/asUser/logout signals)
|
|
348
|
+
if (result?._auth !== undefined) {
|
|
349
|
+
currentUser = result._auth.user;
|
|
350
|
+
rl.setPrompt(getPrompt());
|
|
351
|
+
}
|
|
352
|
+
|
|
53
353
|
if (result?.error) {
|
|
54
354
|
fail(result.error);
|
|
55
355
|
} else {
|
|
56
356
|
console.log(formatResult(result?.result));
|
|
57
357
|
}
|
|
358
|
+
appendHistory(expression);
|
|
58
359
|
} catch (err: any) {
|
|
59
360
|
fail(err.message);
|
|
60
361
|
}
|
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', '.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
|
+
}
|