@everystack/cli 0.2.26 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -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, 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(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
- console.log(' Type expressions to evaluate. Ctrl+C or .exit to quit.\n');
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: `everystack:${stage}> `,
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
- rl.on('line', async (line) => {
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) { rl.prompt(); return; }
44
- if (trimmed === '.exit') { rl.close(); return; }
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
- { expression: trimmed },
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
  }
@@ -294,6 +294,19 @@ export async function discoverConfig(
294
294
  );
295
295
  }
296
296
 
297
+ // Media bucket fallback: if not found via naming convention (custom/pre-existing bucket),
298
+ // try reading from .sst/outputs.json where sst.config.ts return values land.
299
+ let mediaBucket = bucketsResult.mediaBucket;
300
+ if (!mediaBucket) {
301
+ try {
302
+ const raw = await fs.readFile(path.resolve('.sst', 'outputs.json'), 'utf8');
303
+ const outputs = JSON.parse(raw);
304
+ if (outputs.mediaBucket) mediaBucket = outputs.mediaBucket;
305
+ } catch {
306
+ // outputs.json not available — non-fatal
307
+ }
308
+ }
309
+
297
310
  return {
298
311
  baseUrl: lambdaResult.routerUrl || '',
299
312
  region,
@@ -302,7 +315,7 @@ export async function discoverConfig(
302
315
  workerFunctionName: workerFn,
303
316
  updatesBucket: bucketsResult.updatesBucket,
304
317
  clientBundlesBucket: bucketsResult.clientBundlesBucket,
305
- mediaBucket: bucketsResult.mediaBucket,
318
+ mediaBucket,
306
319
  kvsArn: cfResult.kvsArn,
307
320
  distributionId,
308
321
  };
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', '.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
+ }