@dinoxx/dinox-cli 1.0.1 → 1.0.3

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.
@@ -0,0 +1,599 @@
1
+ import { DinoxError } from '../../utils/errors.js';
2
+ const SEARCH_FIELDS = new Set([
3
+ 'id',
4
+ 'content_md',
5
+ 'summary',
6
+ 'tags',
7
+ 'zettel_boxes',
8
+ 'created_at',
9
+ 'type',
10
+ ]);
11
+ const FORBIDDEN_SQL_KEYWORDS = [
12
+ 'insert',
13
+ 'update',
14
+ 'delete',
15
+ 'drop',
16
+ 'alter',
17
+ 'create',
18
+ 'replace',
19
+ 'truncate',
20
+ 'attach',
21
+ 'detach',
22
+ 'pragma',
23
+ 'vacuum',
24
+ 'begin',
25
+ 'commit',
26
+ 'rollback',
27
+ ];
28
+ const SCALAR_FIELD_COLUMNS = {
29
+ id: 'id',
30
+ content_md: 'content_md',
31
+ summary: 'summary',
32
+ created_at: 'created_at',
33
+ type: 'type',
34
+ };
35
+ export async function buildNoteSearchSqlFilter(options) {
36
+ const expression = options.expression?.trim() ?? '';
37
+ if (!expression) {
38
+ return null;
39
+ }
40
+ try {
41
+ assertReadOnlySearchExpression(expression);
42
+ const ast = parseSearchSqlExpression(expression);
43
+ const zettelBoxIdByName = await buildZettelBoxIdLookup(ast, options.resolveZettelBoxNames);
44
+ const params = [];
45
+ const sql = compileNode(ast, options.noteAlias, params, zettelBoxIdByName);
46
+ return {
47
+ sql: `(${sql})`,
48
+ params,
49
+ };
50
+ }
51
+ catch (error) {
52
+ if (error instanceof DinoxError) {
53
+ throw error;
54
+ }
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ throw new DinoxError(`Invalid --sql expression: ${message}`);
57
+ }
58
+ }
59
+ function assertReadOnlySearchExpression(expression) {
60
+ const unquoted = maskQuotedSegments(expression).toLowerCase();
61
+ if (unquoted.includes(';')) {
62
+ throw new DinoxError('Invalid --sql expression: only read-only WHERE-style conditions are allowed (semicolon is not permitted)');
63
+ }
64
+ if (unquoted.includes('--') || unquoted.includes('/*') || unquoted.includes('*/')) {
65
+ throw new DinoxError('Invalid --sql expression: SQL comments are not allowed; only read-only WHERE-style conditions are supported');
66
+ }
67
+ for (const keyword of FORBIDDEN_SQL_KEYWORDS) {
68
+ const pattern = new RegExp(`\\b${keyword}\\b`, 'i');
69
+ if (pattern.test(unquoted)) {
70
+ throw new DinoxError(`Invalid --sql expression: keyword '${keyword.toUpperCase()}' is not allowed (read-only search only)`);
71
+ }
72
+ }
73
+ }
74
+ function maskQuotedSegments(expression) {
75
+ let result = '';
76
+ let quote = null;
77
+ for (let i = 0; i < expression.length; i += 1) {
78
+ const ch = expression[i];
79
+ if (!quote) {
80
+ if (ch === '"' || ch === "'") {
81
+ quote = ch;
82
+ result += ' ';
83
+ }
84
+ else {
85
+ result += ch;
86
+ }
87
+ continue;
88
+ }
89
+ result += ' ';
90
+ if (ch === '\\') {
91
+ if (i + 1 < expression.length) {
92
+ result += ' ';
93
+ i += 1;
94
+ }
95
+ continue;
96
+ }
97
+ if (ch !== quote) {
98
+ continue;
99
+ }
100
+ if (expression[i + 1] === quote) {
101
+ result += ' ';
102
+ i += 1;
103
+ continue;
104
+ }
105
+ quote = null;
106
+ }
107
+ return result;
108
+ }
109
+ function parseSearchSqlExpression(expression) {
110
+ const tokens = tokenizeSearchSqlExpression(expression);
111
+ if (tokens.length === 0) {
112
+ throw new Error('Expression is empty');
113
+ }
114
+ let index = 0;
115
+ const peek = () => tokens[index] ?? null;
116
+ const consume = (expected) => {
117
+ const token = peek();
118
+ if (!token) {
119
+ throw new Error('Unexpected end of expression');
120
+ }
121
+ if (expected && token.type !== expected) {
122
+ throw new Error(`Expected ${expected} at position ${token.position + 1}`);
123
+ }
124
+ index += 1;
125
+ return token;
126
+ };
127
+ const match = (type) => {
128
+ const token = peek();
129
+ if (!token || token.type !== type) {
130
+ return false;
131
+ }
132
+ index += 1;
133
+ return true;
134
+ };
135
+ const parseValue = () => {
136
+ const token = peek();
137
+ if (!token) {
138
+ throw new Error('Unexpected end of expression while reading a value');
139
+ }
140
+ if (token.type !== 'WORD' && token.type !== 'STRING') {
141
+ throw new Error(`Expected value at position ${token.position + 1}`);
142
+ }
143
+ consume(token.type);
144
+ const value = token.value.trim();
145
+ if (!value) {
146
+ throw new Error(`Empty value at position ${token.position + 1}`);
147
+ }
148
+ return value;
149
+ };
150
+ const parseValueList = () => {
151
+ const open = consume('LPAREN');
152
+ const values = [];
153
+ while (true) {
154
+ values.push(parseValue());
155
+ if (match('COMMA')) {
156
+ continue;
157
+ }
158
+ if (match('RPAREN')) {
159
+ break;
160
+ }
161
+ const next = peek();
162
+ const nextPos = next ? next.position + 1 : open.position + 1;
163
+ throw new Error(`Expected ',' or ')' at position ${nextPos}`);
164
+ }
165
+ if (values.length === 0) {
166
+ throw new Error(`IN list cannot be empty at position ${open.position + 1}`);
167
+ }
168
+ return values;
169
+ };
170
+ const parseCondition = () => {
171
+ const fieldToken = peek();
172
+ if (!fieldToken) {
173
+ throw new Error('Unexpected end of expression');
174
+ }
175
+ if (fieldToken.type !== 'WORD') {
176
+ throw new Error(`Expected field name at position ${fieldToken.position + 1}`);
177
+ }
178
+ consume('WORD');
179
+ const field = normalizeSearchField(fieldToken.value);
180
+ if (!field) {
181
+ throw new Error(`Unsupported field '${fieldToken.value}' at position ${fieldToken.position + 1}`);
182
+ }
183
+ if (match('NOT')) {
184
+ if (match('IN')) {
185
+ return {
186
+ kind: 'in',
187
+ field,
188
+ not: true,
189
+ values: parseValueList(),
190
+ };
191
+ }
192
+ if (match('LIKE')) {
193
+ return {
194
+ kind: 'comparison',
195
+ field,
196
+ operator: 'NOT LIKE',
197
+ value: parseValue(),
198
+ };
199
+ }
200
+ const token = peek();
201
+ const pos = token ? token.position + 1 : fieldToken.position + 1;
202
+ throw new Error(`Expected IN or LIKE after NOT at position ${pos}`);
203
+ }
204
+ if (match('IN')) {
205
+ return {
206
+ kind: 'in',
207
+ field,
208
+ not: false,
209
+ values: parseValueList(),
210
+ };
211
+ }
212
+ if (match('LIKE')) {
213
+ return {
214
+ kind: 'comparison',
215
+ field,
216
+ operator: 'LIKE',
217
+ value: parseValue(),
218
+ };
219
+ }
220
+ const operatorToken = peek();
221
+ if (!operatorToken || operatorToken.type !== 'OP') {
222
+ const pos = operatorToken ? operatorToken.position + 1 : fieldToken.position + 1;
223
+ throw new Error(`Expected comparison operator at position ${pos}`);
224
+ }
225
+ consume('OP');
226
+ return {
227
+ kind: 'comparison',
228
+ field,
229
+ operator: normalizeComparisonOperator(operatorToken.value, operatorToken.position),
230
+ value: parseValue(),
231
+ };
232
+ };
233
+ const parsePrimary = () => {
234
+ const token = peek();
235
+ if (!token) {
236
+ throw new Error('Unexpected end of expression');
237
+ }
238
+ if (token.type === 'LPAREN') {
239
+ consume('LPAREN');
240
+ const node = parseOr();
241
+ const close = peek();
242
+ if (!close || close.type !== 'RPAREN') {
243
+ throw new Error(`Missing ')' for '(' at position ${token.position + 1}`);
244
+ }
245
+ consume('RPAREN');
246
+ return node;
247
+ }
248
+ return parseCondition();
249
+ };
250
+ const parseUnary = () => {
251
+ if (match('NOT')) {
252
+ return { kind: 'not', child: parseUnary() };
253
+ }
254
+ return parsePrimary();
255
+ };
256
+ const parseAnd = () => {
257
+ let node = parseUnary();
258
+ while (match('AND')) {
259
+ node = { kind: 'and', left: node, right: parseUnary() };
260
+ }
261
+ return node;
262
+ };
263
+ const parseOr = () => {
264
+ let node = parseAnd();
265
+ while (match('OR')) {
266
+ node = { kind: 'or', left: node, right: parseAnd() };
267
+ }
268
+ return node;
269
+ };
270
+ const ast = parseOr();
271
+ const extra = peek();
272
+ if (extra) {
273
+ throw new Error(`Unexpected token '${extra.value}' at position ${extra.position + 1}`);
274
+ }
275
+ return ast;
276
+ }
277
+ function tokenizeSearchSqlExpression(expression) {
278
+ const tokens = [];
279
+ let i = 0;
280
+ while (i < expression.length) {
281
+ const ch = expression[i];
282
+ if (/\s/.test(ch)) {
283
+ i += 1;
284
+ continue;
285
+ }
286
+ if (ch === '(') {
287
+ tokens.push({ type: 'LPAREN', value: ch, position: i });
288
+ i += 1;
289
+ continue;
290
+ }
291
+ if (ch === ')') {
292
+ tokens.push({ type: 'RPAREN', value: ch, position: i });
293
+ i += 1;
294
+ continue;
295
+ }
296
+ if (ch === ',') {
297
+ tokens.push({ type: 'COMMA', value: ch, position: i });
298
+ i += 1;
299
+ continue;
300
+ }
301
+ if (ch === '\'' || ch === '"') {
302
+ const quoted = readQuotedValue(expression, i);
303
+ tokens.push({ type: 'STRING', value: quoted.value, position: i });
304
+ i = quoted.next;
305
+ continue;
306
+ }
307
+ const op = readOperator(expression, i);
308
+ if (op) {
309
+ tokens.push({ type: 'OP', value: op.value, position: i });
310
+ i = op.next;
311
+ continue;
312
+ }
313
+ let j = i;
314
+ while (j < expression.length) {
315
+ const next = expression[j];
316
+ if (/\s/.test(next) || next === '(' || next === ')' || next === ',' || /[<>=!]/.test(next)) {
317
+ break;
318
+ }
319
+ j += 1;
320
+ }
321
+ const raw = expression.slice(i, j);
322
+ if (!raw) {
323
+ throw new Error(`Unsupported token '${ch}' at position ${i + 1}`);
324
+ }
325
+ const upper = raw.toUpperCase();
326
+ if (upper === 'AND' || upper === 'OR' || upper === 'NOT' || upper === 'IN' || upper === 'LIKE') {
327
+ tokens.push({ type: upper, value: raw, position: i });
328
+ }
329
+ else {
330
+ tokens.push({ type: 'WORD', value: raw, position: i });
331
+ }
332
+ i = j;
333
+ }
334
+ return tokens;
335
+ }
336
+ function readOperator(expression, start) {
337
+ const twoChars = expression.slice(start, start + 2);
338
+ if (twoChars === '<=' || twoChars === '>=' || twoChars === '<>' || twoChars === '!=') {
339
+ return {
340
+ value: twoChars,
341
+ next: start + 2,
342
+ };
343
+ }
344
+ const oneChar = expression[start];
345
+ if (oneChar === '=' || oneChar === '<' || oneChar === '>') {
346
+ return {
347
+ value: oneChar,
348
+ next: start + 1,
349
+ };
350
+ }
351
+ return null;
352
+ }
353
+ function readQuotedValue(expression, start) {
354
+ const quote = expression[start];
355
+ let i = start + 1;
356
+ let value = '';
357
+ while (i < expression.length) {
358
+ const ch = expression[i];
359
+ if (ch === '\\') {
360
+ if (i + 1 >= expression.length) {
361
+ throw new Error(`Invalid escape at position ${i + 1}`);
362
+ }
363
+ value += expression[i + 1];
364
+ i += 2;
365
+ continue;
366
+ }
367
+ if (ch === quote) {
368
+ if (expression[i + 1] === quote) {
369
+ value += quote;
370
+ i += 2;
371
+ continue;
372
+ }
373
+ return {
374
+ value,
375
+ next: i + 1,
376
+ };
377
+ }
378
+ value += ch;
379
+ i += 1;
380
+ }
381
+ throw new Error(`Unclosed quote at position ${start + 1}`);
382
+ }
383
+ function normalizeSearchField(value) {
384
+ const normalized = value.trim().toLowerCase();
385
+ if (SEARCH_FIELDS.has(normalized)) {
386
+ return normalized;
387
+ }
388
+ return null;
389
+ }
390
+ function normalizeComparisonOperator(raw, position) {
391
+ if (raw === '=') {
392
+ return '=';
393
+ }
394
+ if (raw === '!=' || raw === '<>') {
395
+ return '!=';
396
+ }
397
+ if (raw === '>') {
398
+ return '>';
399
+ }
400
+ if (raw === '>=') {
401
+ return '>=';
402
+ }
403
+ if (raw === '<') {
404
+ return '<';
405
+ }
406
+ if (raw === '<=') {
407
+ return '<=';
408
+ }
409
+ throw new Error(`Unsupported operator '${raw}' at position ${position + 1}`);
410
+ }
411
+ function normalizeLookupKey(value) {
412
+ return value.trim().toLowerCase();
413
+ }
414
+ async function buildZettelBoxIdLookup(ast, resolveZettelBoxNames) {
415
+ const rawValues = collectZettelBoxValues(ast)
416
+ .map((value) => value.trim())
417
+ .filter((value) => value.length > 0);
418
+ if (rawValues.length === 0) {
419
+ return new Map();
420
+ }
421
+ const uniqueNames = [];
422
+ const seen = new Set();
423
+ for (const raw of rawValues) {
424
+ const key = normalizeLookupKey(raw);
425
+ if (seen.has(key)) {
426
+ continue;
427
+ }
428
+ seen.add(key);
429
+ uniqueNames.push(raw);
430
+ }
431
+ const ids = await resolveZettelBoxNames(uniqueNames);
432
+ if (ids.length !== uniqueNames.length) {
433
+ throw new DinoxError('Failed to resolve zettel box names in --sql expression');
434
+ }
435
+ const lookup = new Map();
436
+ for (let i = 0; i < uniqueNames.length; i += 1) {
437
+ lookup.set(normalizeLookupKey(uniqueNames[i]), ids[i]);
438
+ }
439
+ return lookup;
440
+ }
441
+ function collectZettelBoxValues(node) {
442
+ if (node.kind === 'comparison') {
443
+ if (node.field === 'zettel_boxes') {
444
+ return [node.value];
445
+ }
446
+ return [];
447
+ }
448
+ if (node.kind === 'in') {
449
+ if (node.field === 'zettel_boxes') {
450
+ return node.values;
451
+ }
452
+ return [];
453
+ }
454
+ if (node.kind === 'not') {
455
+ return collectZettelBoxValues(node.child);
456
+ }
457
+ return [...collectZettelBoxValues(node.left), ...collectZettelBoxValues(node.right)];
458
+ }
459
+ function compileNode(node, noteAlias, params, zettelBoxIdByName) {
460
+ if (node.kind === 'comparison') {
461
+ return compileComparison(node, noteAlias, params, zettelBoxIdByName);
462
+ }
463
+ if (node.kind === 'in') {
464
+ return compileInCondition(node, noteAlias, params, zettelBoxIdByName);
465
+ }
466
+ if (node.kind === 'not') {
467
+ return `NOT (${compileNode(node.child, noteAlias, params, zettelBoxIdByName)})`;
468
+ }
469
+ if (node.kind === 'and') {
470
+ return `(${compileNode(node.left, noteAlias, params, zettelBoxIdByName)}) AND (${compileNode(node.right, noteAlias, params, zettelBoxIdByName)})`;
471
+ }
472
+ return `(${compileNode(node.left, noteAlias, params, zettelBoxIdByName)}) OR (${compileNode(node.right, noteAlias, params, zettelBoxIdByName)})`;
473
+ }
474
+ function compileComparison(node, noteAlias, params, zettelBoxIdByName) {
475
+ if (node.field === 'tags') {
476
+ if (node.operator !== '=' && node.operator !== '!=') {
477
+ throw new Error(`Operator '${node.operator}' is not supported for field 'tags'`);
478
+ }
479
+ const value = normalizeLookupKey(node.value);
480
+ if (!value) {
481
+ throw new Error('tags condition value cannot be empty');
482
+ }
483
+ params.push(value);
484
+ const existsSql = `
485
+ EXISTS (
486
+ SELECT 1
487
+ FROM json_each(
488
+ CASE
489
+ WHEN json_valid(COALESCE(${noteAlias}.tags, '')) THEN ${noteAlias}.tags
490
+ ELSE '[]'
491
+ END
492
+ ) tag_item
493
+ WHERE LOWER(TRIM(CAST(tag_item.value AS TEXT))) = ?
494
+ )
495
+ `;
496
+ return node.operator === '!=' ? `NOT (${existsSql})` : existsSql;
497
+ }
498
+ if (node.field === 'zettel_boxes') {
499
+ if (node.operator !== '=' && node.operator !== '!=') {
500
+ throw new Error(`Operator '${node.operator}' is not supported for field 'zettel_boxes'`);
501
+ }
502
+ const resolvedId = zettelBoxIdByName.get(normalizeLookupKey(node.value));
503
+ if (!resolvedId) {
504
+ throw new DinoxError(`Unknown zettel box name in --sql expression: ${node.value}`);
505
+ }
506
+ params.push(resolvedId);
507
+ const existsSql = `
508
+ EXISTS (
509
+ SELECT 1
510
+ FROM json_each(
511
+ CASE
512
+ WHEN json_valid(COALESCE(${noteAlias}.zettel_boxes, '')) THEN ${noteAlias}.zettel_boxes
513
+ ELSE '[]'
514
+ END
515
+ ) box_item
516
+ WHERE TRIM(CAST(box_item.value AS TEXT)) = ?
517
+ )
518
+ `;
519
+ return node.operator === '!=' ? `NOT (${existsSql})` : existsSql;
520
+ }
521
+ const column = `${noteAlias}.${SCALAR_FIELD_COLUMNS[node.field]}`;
522
+ params.push(node.value);
523
+ if (node.operator === 'LIKE' || node.operator === 'NOT LIKE') {
524
+ return `(${column} IS NOT NULL AND ${column} ${node.operator} ?)`;
525
+ }
526
+ return `(${column} ${node.operator} ?)`;
527
+ }
528
+ function compileInCondition(node, noteAlias, params, zettelBoxIdByName) {
529
+ if (node.values.length === 0) {
530
+ throw new Error('IN list cannot be empty');
531
+ }
532
+ if (node.field === 'tags') {
533
+ const normalized = uniqueNonEmptyValues(node.values.map((value) => normalizeLookupKey(value)));
534
+ if (normalized.length === 0) {
535
+ throw new Error('tags IN list cannot be empty');
536
+ }
537
+ const placeholders = normalized.map(() => '?').join(', ');
538
+ params.push(...normalized);
539
+ const existsSql = `
540
+ EXISTS (
541
+ SELECT 1
542
+ FROM json_each(
543
+ CASE
544
+ WHEN json_valid(COALESCE(${noteAlias}.tags, '')) THEN ${noteAlias}.tags
545
+ ELSE '[]'
546
+ END
547
+ ) tag_item
548
+ WHERE LOWER(TRIM(CAST(tag_item.value AS TEXT))) IN (${placeholders})
549
+ )
550
+ `;
551
+ return node.not ? `NOT (${existsSql})` : existsSql;
552
+ }
553
+ if (node.field === 'zettel_boxes') {
554
+ const ids = uniqueNonEmptyValues(node.values
555
+ .map((value) => zettelBoxIdByName.get(normalizeLookupKey(value)) ?? '')
556
+ .filter((value) => value.length > 0));
557
+ if (ids.length === 0) {
558
+ throw new Error('zettel_boxes IN list cannot be empty');
559
+ }
560
+ const placeholders = ids.map(() => '?').join(', ');
561
+ params.push(...ids);
562
+ const existsSql = `
563
+ EXISTS (
564
+ SELECT 1
565
+ FROM json_each(
566
+ CASE
567
+ WHEN json_valid(COALESCE(${noteAlias}.zettel_boxes, '')) THEN ${noteAlias}.zettel_boxes
568
+ ELSE '[]'
569
+ END
570
+ ) box_item
571
+ WHERE TRIM(CAST(box_item.value AS TEXT)) IN (${placeholders})
572
+ )
573
+ `;
574
+ return node.not ? `NOT (${existsSql})` : existsSql;
575
+ }
576
+ const values = uniqueNonEmptyValues(node.values.map((value) => value.trim()));
577
+ if (values.length === 0) {
578
+ throw new Error('IN list cannot be empty');
579
+ }
580
+ const column = `${noteAlias}.${SCALAR_FIELD_COLUMNS[node.field]}`;
581
+ const placeholders = values.map(() => '?').join(', ');
582
+ params.push(...values);
583
+ if (node.not) {
584
+ return `(${column} NOT IN (${placeholders}))`;
585
+ }
586
+ return `(${column} IN (${placeholders}))`;
587
+ }
588
+ function uniqueNonEmptyValues(values) {
589
+ const result = [];
590
+ const seen = new Set();
591
+ for (const value of values) {
592
+ if (!value || seen.has(value)) {
593
+ continue;
594
+ }
595
+ seen.add(value);
596
+ result.push(value);
597
+ }
598
+ return result;
599
+ }
@@ -4,7 +4,8 @@ import { DinoxError } from '../../utils/errors.js';
4
4
  import { printYaml } from '../../utils/output.js';
5
5
  import { connectPowerSync, waitForIdleDataFlow } from '../../powersync/runtime.js';
6
6
  import { syncNoteTokenIndex } from '../../powersync/tokenIndex.js';
7
- import { listPrompts } from './repo.js';
7
+ import { resolveAuthIdentity } from '../../auth/userInfo.js';
8
+ import { addPrompt, listPrompts } from './repo.js';
8
9
  function parseSyncTimeoutMs(value) {
9
10
  if (typeof value !== 'string') {
10
11
  return undefined;
@@ -45,6 +46,58 @@ export function registerPromptCommands(program) {
45
46
  .option('--offline', 'skip connect/sync and only use local cache')
46
47
  .option('--sync-timeout <ms>', 'override sync/connect timeout (milliseconds)')
47
48
  .action(runPromptListCommand);
49
+ prompt
50
+ .command('add')
51
+ .description('Create a new prompt in c_cmd')
52
+ .option('--name <string>', 'prompt name')
53
+ .option('--cmd <string>', 'prompt command/prompt text')
54
+ .option('--json', 'output machine-readable YAML')
55
+ .option('--offline', 'skip connect/sync and only use local cache')
56
+ .option('--sync-timeout <ms>', 'override sync/connect timeout (milliseconds)')
57
+ .action(async (options, command) => {
58
+ const globals = command.optsWithGlobals?.() ?? {};
59
+ const offline = Boolean(options.offline ?? globals.offline);
60
+ const jsonOutput = Boolean(options.json ?? globals.json);
61
+ const name = typeof options.name === 'string' ? options.name.trim() : '';
62
+ const cmd = typeof options.cmd === 'string' ? options.cmd.trim() : '';
63
+ if (!name) {
64
+ throw new DinoxError('Prompt name is required. Use `dino prompt add --name <name> --cmd <cmd>`.');
65
+ }
66
+ if (!cmd) {
67
+ throw new DinoxError('Prompt cmd is required. Use `dino prompt add --name <name> --cmd <cmd>`.');
68
+ }
69
+ const rawConfig = await loadConfig();
70
+ const config = resolveConfig(rawConfig);
71
+ const timeoutMs = parseSyncTimeoutMs(options.syncTimeout ?? globals.syncTimeout) ?? config.sync.timeoutMs;
72
+ const identity = await resolveAuthIdentity(rawConfig, config);
73
+ const { db, stale } = await connectPowerSync({ config, offline, timeoutMs });
74
+ try {
75
+ await syncBeforePromptCommand(db, offline, timeoutMs);
76
+ const result = await addPrompt(db, {
77
+ name,
78
+ cmd,
79
+ userId: identity.userId,
80
+ });
81
+ if (!offline && !config.powersync.uploadBaseUrl) {
82
+ console.log('warning: upload disabled (powersync.uploadBaseUrl is unset); changes are local-only for now');
83
+ }
84
+ if (!offline) {
85
+ await waitForIdleDataFlow(db, Math.min(timeoutMs, 5_000));
86
+ }
87
+ const payload = { ok: true, stale: offline ? true : stale, ...result };
88
+ if (jsonOutput) {
89
+ printYaml(payload);
90
+ return;
91
+ }
92
+ console.log(result.id);
93
+ if (stale && !offline) {
94
+ console.log('warning: sync timed out; results may be stale');
95
+ }
96
+ }
97
+ finally {
98
+ await db.close().catch(() => undefined);
99
+ }
100
+ });
48
101
  prompt.action((_options, command) => {
49
102
  command.outputHelp();
50
103
  });
@@ -3,4 +3,16 @@ export type PromptListItem = {
3
3
  name: string;
4
4
  cmd: string;
5
5
  };
6
+ export type AddPromptInput = {
7
+ name: string;
8
+ cmd: string;
9
+ userId: string;
10
+ };
11
+ export type AddPromptResult = {
12
+ id: string;
13
+ name: string;
14
+ cmd: string;
15
+ restored: boolean;
16
+ };
6
17
  export declare function listPrompts(db: AbstractPowerSyncDatabase): Promise<PromptListItem[]>;
18
+ export declare function addPrompt(db: AbstractPowerSyncDatabase, input: AddPromptInput): Promise<AddPromptResult>;