@dinoxx/dinox-cli 1.0.22 → 1.1.1

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.
Files changed (147) hide show
  1. package/README.md +33 -10
  2. package/dist/auth/userInfo.d.ts +2 -0
  3. package/dist/auth/userInfo.js +55 -14
  4. package/dist/cli.js +36 -2
  5. package/dist/commands/auth/index.d.ts +35 -0
  6. package/dist/commands/auth/index.js +382 -83
  7. package/dist/commands/boxes/index.js +2 -6
  8. package/dist/commands/boxes/model.d.ts +3 -0
  9. package/dist/commands/boxes/model.js +16 -1
  10. package/dist/commands/boxes/operations.js +18 -8
  11. package/dist/commands/boxes/repo.js +13 -11
  12. package/dist/commands/boxes/store.js +1 -1
  13. package/dist/commands/config/index.js +7 -5
  14. package/dist/commands/descriptor/register.js +9 -0
  15. package/dist/commands/doctor.d.ts +123 -0
  16. package/dist/commands/doctor.js +539 -0
  17. package/dist/commands/graph/index.d.ts +1 -1
  18. package/dist/commands/graph/index.js +35 -21
  19. package/dist/commands/graph/repo.js +92 -17
  20. package/dist/commands/meta/index.js +4 -4
  21. package/dist/commands/meta/repo.js +4 -3
  22. package/dist/commands/notes/actions.d.ts +1 -1
  23. package/dist/commands/notes/actions.js +121 -78
  24. package/dist/commands/notes/bulk.d.ts +1 -0
  25. package/dist/commands/notes/bulk.js +23 -17
  26. package/dist/commands/notes/contentEngine.js +69 -11
  27. package/dist/commands/notes/contentPatch.d.ts +4 -1
  28. package/dist/commands/notes/contentPatch.js +105 -41
  29. package/dist/commands/notes/contentPatchCommand.js +28 -11
  30. package/dist/commands/notes/descriptors.js +50 -20
  31. package/dist/commands/notes/dryRun.d.ts +1 -0
  32. package/dist/commands/notes/dryRun.js +4 -7
  33. package/dist/commands/notes/export.d.ts +6 -0
  34. package/dist/commands/notes/export.js +26 -6
  35. package/dist/commands/notes/metadataMutation.d.ts +1 -0
  36. package/dist/commands/notes/metadataMutation.js +14 -3
  37. package/dist/commands/notes/repo.d.ts +46 -4
  38. package/dist/commands/notes/repo.js +113 -28
  39. package/dist/commands/notes/runtime.d.ts +2 -1
  40. package/dist/commands/notes/runtime.js +17 -1
  41. package/dist/commands/notes/searchSql.js +13 -6
  42. package/dist/commands/notes/writeReceipt.d.ts +20 -0
  43. package/dist/commands/notes/writeReceipt.js +82 -0
  44. package/dist/commands/prompt/index.js +3 -3
  45. package/dist/commands/prompt/repo.d.ts +4 -1
  46. package/dist/commands/prompt/repo.js +17 -7
  47. package/dist/commands/runtime.d.ts +14 -2
  48. package/dist/commands/runtime.js +89 -19
  49. package/dist/commands/schema/renderSkills.js +5 -2
  50. package/dist/commands/schema/spec.js +4 -0
  51. package/dist/commands/storage/index.js +8 -2
  52. package/dist/commands/storage/repo.d.ts +4 -0
  53. package/dist/commands/storage/repo.js +356 -92
  54. package/dist/commands/sync.d.ts +23 -1
  55. package/dist/commands/sync.js +105 -60
  56. package/dist/commands/tags/content.js +9 -0
  57. package/dist/commands/tags/index.js +2 -6
  58. package/dist/commands/tags/operations.js +15 -10
  59. package/dist/commands/tags/repo.d.ts +2 -0
  60. package/dist/commands/tags/repo.js +18 -5
  61. package/dist/commands/tags/store.js +1 -1
  62. package/dist/commands/todo/contentModel.d.ts +4 -3
  63. package/dist/commands/todo/contentModel.js +78 -28
  64. package/dist/commands/todo/dryRun.d.ts +1 -0
  65. package/dist/commands/todo/dryRun.js +17 -13
  66. package/dist/commands/todo/index.d.ts +6 -0
  67. package/dist/commands/todo/index.js +89 -123
  68. package/dist/commands/todo/repo.d.ts +36 -1
  69. package/dist/commands/todo/repo.js +146 -37
  70. package/dist/commands/todo/taskIdentity.d.ts +2 -0
  71. package/dist/commands/todo/taskIdentity.js +64 -0
  72. package/dist/commands/todo/taskModel.js +27 -17
  73. package/dist/commands/update.d.ts +13 -1
  74. package/dist/commands/update.js +135 -23
  75. package/dist/commands/writeDryRun.d.ts +3 -2
  76. package/dist/commands/writeDryRun.js +4 -3
  77. package/dist/commands/writeDurability.d.ts +5 -0
  78. package/dist/commands/writeDurability.js +22 -0
  79. package/dist/config/paths.js +6 -1
  80. package/dist/config/resolve.d.ts +2 -0
  81. package/dist/config/resolve.js +23 -9
  82. package/dist/config/store.d.ts +3 -0
  83. package/dist/config/store.js +61 -14
  84. package/dist/config/types.d.ts +4 -0
  85. package/dist/daemon/client.d.ts +26 -1
  86. package/dist/daemon/client.js +236 -12
  87. package/dist/daemon/ensure.js +74 -12
  88. package/dist/daemon/index.d.ts +42 -0
  89. package/dist/daemon/index.js +202 -52
  90. package/dist/daemon/lifecycle.d.ts +44 -0
  91. package/dist/daemon/lifecycle.js +118 -0
  92. package/dist/daemon/pid.d.ts +19 -6
  93. package/dist/daemon/pid.js +147 -13
  94. package/dist/daemon/route.d.ts +14 -0
  95. package/dist/daemon/route.js +40 -0
  96. package/dist/daemon/server.d.ts +11 -0
  97. package/dist/daemon/server.js +201 -50
  98. package/dist/daemon/types.d.ts +29 -1
  99. package/dist/daemon/types.js +1 -0
  100. package/dist/dinox.js +25 -1
  101. package/dist/powersync/connector.js +31 -16
  102. package/dist/powersync/exclusiveRuntime.d.ts +20 -0
  103. package/dist/powersync/exclusiveRuntime.js +48 -0
  104. package/dist/powersync/runtime.d.ts +25 -4
  105. package/dist/powersync/runtime.js +255 -35
  106. package/dist/powersync/syncGate.d.ts +4 -1
  107. package/dist/powersync/syncGate.js +11 -2
  108. package/dist/powersync/syncPass.d.ts +2 -0
  109. package/dist/powersync/syncPass.js +15 -4
  110. package/dist/powersync/tokenIndex.d.ts +13 -0
  111. package/dist/powersync/tokenIndex.js +216 -37
  112. package/dist/powersync/uploader.js +107 -52
  113. package/dist/utils/abort.js +0 -4
  114. package/dist/utils/asyncRelease.d.ts +2 -0
  115. package/dist/utils/asyncRelease.js +25 -0
  116. package/dist/utils/bidlinkCanonicalization.js +3 -0
  117. package/dist/utils/dryRun.d.ts +1 -1
  118. package/dist/utils/errors.d.ts +15 -1
  119. package/dist/utils/errors.js +223 -5
  120. package/dist/utils/output.d.ts +1 -0
  121. package/dist/utils/output.js +51 -15
  122. package/dist/utils/redact.js +1 -1
  123. package/dist/utils/sqliteLease.d.ts +8 -0
  124. package/dist/utils/sqliteLease.js +59 -0
  125. package/dist/utils/syncTimeout.d.ts +7 -1
  126. package/dist/utils/syncTimeout.js +8 -7
  127. package/dist/utils/terminalSanitization.js +6 -2
  128. package/dist/utils/tiptapMarkdown.js +49 -3
  129. package/dist/utils/tiptapMarkdownSyntax.d.ts +9 -0
  130. package/dist/utils/tiptapMarkdownSyntax.js +249 -0
  131. package/dist/utils/tokenize.js +24 -2
  132. package/dist/utils/updateNotice.js +60 -12
  133. package/package.json +14 -10
  134. package/dist/commands/mcp/auth.d.ts +0 -4
  135. package/dist/commands/mcp/auth.js +0 -75
  136. package/dist/commands/mcp/index.d.ts +0 -2
  137. package/dist/commands/mcp/index.js +0 -86
  138. package/dist/commands/mcp/noteContentTools.d.ts +0 -222
  139. package/dist/commands/mcp/noteContentTools.js +0 -390
  140. package/dist/commands/mcp/server.d.ts +0 -34
  141. package/dist/commands/mcp/server.js +0 -424
  142. package/dist/commands/mcp/tools.d.ts +0 -2
  143. package/dist/commands/mcp/tools.js +0 -32
  144. package/dist/commands/mcp/types.d.ts +0 -39
  145. package/dist/commands/mcp/types.js +0 -1
  146. package/dist/utils/noteContentEngine.d.ts +0 -333
  147. package/dist/utils/noteContentEngine.js +0 -740
@@ -0,0 +1,539 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
3
+ import { getPowerSyncDbPath } from '../config/paths.js';
4
+ import { loadConfig } from '../config/store.js';
5
+ import { fingerprintDaemonRuntimeConfig, resolveConfig } from '../config/resolve.js';
6
+ import { getDaemonInfo, isDaemonFresh, shutdownDaemonForExclusiveAccess } from '../daemon/client.js';
7
+ import { restartDaemonLocked } from '../daemon/index.js';
8
+ import { acquireDaemonStartLock, getDaemonSocketPath, isProcessRunning, readPid, } from '../daemon/pid.js';
9
+ import { DEFAULT_DAEMON_PORT } from '../daemon/types.js';
10
+ import { closePowerSyncAndReleaseOwner, connectPowerSync, getStatusSnapshot, waitForUploadQueueEmpty, } from '../powersync/runtime.js';
11
+ import { inspectNoteTokenIndex, rebuildNoteTokenIndex, } from '../powersync/tokenIndex.js';
12
+ import { getPackageVersion } from '../utils/version.js';
13
+ import { parseSyncTimeoutMs } from '../utils/syncTimeout.js';
14
+ import { getRequestedStructuredOutputFormat, printHumanReadableLine, printHumanReadableWarning, printSuccess, } from '../utils/output.js';
15
+ import { registerStandaloneCommand } from './descriptor/register.js';
16
+ import { FORMAT_OPTIONS, OFFLINE_OPTIONS, SYNC_TIMEOUT_OPTIONS } from './schema/globalOptions.js';
17
+ const DEFAULT_DOCTOR_TIMEOUT_MS = 10_000;
18
+ const doctorPreparationDependencies = {
19
+ loadConfig,
20
+ resolveConfig,
21
+ hydrateAuthIdentity: hydrateResolvedAuthIdentity,
22
+ acquireDaemonStartLock,
23
+ captureDaemonSnapshot,
24
+ shutdownDaemon: shutdownDaemonForExclusiveAccess,
25
+ connectPowerSync,
26
+ restartDaemon: restartDaemonLocked,
27
+ };
28
+ function errorMessage(error) {
29
+ return error instanceof Error ? error.message : String(error);
30
+ }
31
+ function issue(issues, condition, message) {
32
+ if (condition) {
33
+ issues.push(message);
34
+ }
35
+ }
36
+ function doctorTimeoutMs(globals, config) {
37
+ return parseSyncTimeoutMs(globals.syncTimeout) ?? Math.min(config.sync.timeoutMs, DEFAULT_DOCTOR_TIMEOUT_MS);
38
+ }
39
+ export async function prepareDoctorRuntime(context, overrides = {}) {
40
+ const dependencies = { ...doctorPreparationDependencies, ...overrides };
41
+ const releaseDaemonStartLock = await dependencies.acquireDaemonStartLock(parseSyncTimeoutMs(context.globals.syncTimeout) ?? DEFAULT_DOCTOR_TIMEOUT_MS);
42
+ try {
43
+ const rawConfig = await dependencies.loadConfig();
44
+ const baseConfig = dependencies.resolveConfig(rawConfig);
45
+ const offline = Boolean(context.globals.offline);
46
+ const timeoutMs = doctorTimeoutMs(context.globals, baseConfig);
47
+ let config = baseConfig;
48
+ let authError = null;
49
+ let syncError = null;
50
+ if (!offline && baseConfig.auth.authorization) {
51
+ try {
52
+ const hydrated = await dependencies.hydrateAuthIdentity(baseConfig, { timeoutMs });
53
+ config = hydrated.config;
54
+ }
55
+ catch (error) {
56
+ authError = errorMessage(error);
57
+ }
58
+ }
59
+ const daemonBefore = await dependencies.captureDaemonSnapshot();
60
+ const daemonDbPath = daemonBefore.info ? getPowerSyncDbPath(daemonBefore.info.userId) : null;
61
+ const targetDbPath = getPowerSyncDbPath(config.auth.userId);
62
+ const daemonWasStopped = Boolean(daemonBefore.info
63
+ && daemonDbPath === targetDbPath
64
+ && await dependencies.shutdownDaemon(Math.min(timeoutMs, 10_000), daemonBefore.info.instanceId));
65
+ try {
66
+ if (!offline && config.auth.authorization && !authError) {
67
+ try {
68
+ const connected = await dependencies.connectPowerSync({
69
+ config,
70
+ offline: false,
71
+ timeoutMs,
72
+ expectedTokenFingerprint: config.auth.tokenFingerprint ?? null,
73
+ });
74
+ return {
75
+ db: connected.db,
76
+ dbPath: connected.dbPath,
77
+ config,
78
+ authError,
79
+ syncError,
80
+ offline,
81
+ timeoutMs,
82
+ releaseOwner: connected.releaseOwner,
83
+ daemonBefore,
84
+ daemonWasStopped,
85
+ releaseDaemonStartLock,
86
+ daemonStartLockReleased: false,
87
+ released: false,
88
+ };
89
+ }
90
+ catch (error) {
91
+ syncError = errorMessage(error);
92
+ }
93
+ }
94
+ const opened = await dependencies.connectPowerSync({
95
+ config,
96
+ offline: true,
97
+ timeoutMs,
98
+ expectedTokenFingerprint: config.auth.tokenFingerprint ?? null,
99
+ });
100
+ return {
101
+ db: opened.db,
102
+ dbPath: opened.dbPath,
103
+ config,
104
+ authError,
105
+ syncError,
106
+ offline,
107
+ timeoutMs,
108
+ releaseOwner: opened.releaseOwner,
109
+ daemonBefore,
110
+ daemonWasStopped,
111
+ releaseDaemonStartLock,
112
+ daemonStartLockReleased: false,
113
+ released: false,
114
+ };
115
+ }
116
+ catch (error) {
117
+ await restoreStoppedDoctorDaemon(config, daemonBefore, daemonWasStopped, dependencies.restartDaemon);
118
+ throw error;
119
+ }
120
+ }
121
+ catch (error) {
122
+ await releaseDaemonStartLock().catch((releaseError) => {
123
+ printHumanReadableWarning(`warning: failed to release daemon lifecycle lock: ${errorMessage(releaseError)}`);
124
+ });
125
+ throw error;
126
+ }
127
+ }
128
+ async function readUploadQueueCount(db) {
129
+ try {
130
+ const stats = await db.getUploadQueueStats(false);
131
+ return stats.count;
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ async function inspectDbIntegrity(db, dbPath) {
138
+ try {
139
+ const rows = await db.getAll('PRAGMA integrity_check', []);
140
+ const messages = rows
141
+ .flatMap((row) => Object.values(row))
142
+ .map((value) => String(value ?? '').trim())
143
+ .filter(Boolean);
144
+ const ok = messages.length === 1 && messages[0]?.toLowerCase() === 'ok';
145
+ return {
146
+ status: ok ? 'ok' : 'error',
147
+ path: dbPath,
148
+ integrity: ok ? 'ok' : 'error',
149
+ ...(ok ? {} : { message: messages.join('; ') || 'integrity_check returned no result' }),
150
+ };
151
+ }
152
+ catch (error) {
153
+ return {
154
+ status: 'error',
155
+ path: dbPath,
156
+ integrity: 'unknown',
157
+ message: errorMessage(error),
158
+ };
159
+ }
160
+ }
161
+ async function captureDaemonSnapshot() {
162
+ return {
163
+ info: await getDaemonInfo(),
164
+ pidInfo: readPid(),
165
+ socketExists: process.platform !== 'win32' && existsSync(getDaemonSocketPath()),
166
+ };
167
+ }
168
+ function inspectDaemon(config, snapshot) {
169
+ const { info, pidInfo, socketExists } = snapshot;
170
+ const version = getPackageVersion();
171
+ const expectedUserId = config.auth.userId ?? null;
172
+ const running = Boolean(info);
173
+ const unreachable = !info && Boolean((pidInfo && isProcessRunning(pidInfo.pid))
174
+ || socketExists);
175
+ const versionMatch = info ? info.version === version : null;
176
+ const accountMatch = info ? info.userId === expectedUserId : null;
177
+ const tokenMatch = info
178
+ ? info.tokenFingerprint === (config.auth.tokenFingerprint ?? null)
179
+ : null;
180
+ const runtimeConfigFingerprint = fingerprintDaemonRuntimeConfig(config);
181
+ const runtimeConfigMatch = info
182
+ ? info.runtimeConfigFingerprint === runtimeConfigFingerprint
183
+ : null;
184
+ const fresh = info
185
+ ? isDaemonFresh(info, version, expectedUserId, config.auth.tokenFingerprint ?? null, runtimeConfigFingerprint)
186
+ : false;
187
+ const pidFileMatches = Boolean(info
188
+ && pidInfo
189
+ && pidInfo.pid === info.pid
190
+ && pidInfo.instanceId === info.instanceId);
191
+ return {
192
+ status: unreachable ? 'error' : !info || fresh ? 'ok' : 'warn',
193
+ running,
194
+ pid: info?.pid || (pidInfo && isProcessRunning(pidInfo.pid) ? pidInfo.pid : null),
195
+ port: pidFileMatches ? pidInfo?.port ?? null : null,
196
+ transport: 'socket',
197
+ socket_path: info?.socketPath ?? (socketExists ? getDaemonSocketPath() : null),
198
+ version: info?.version ?? null,
199
+ user_id: info?.userId ?? null,
200
+ authenticated: info?.authenticated ?? null,
201
+ version_match: versionMatch,
202
+ account_match: accountMatch,
203
+ token_match: tokenMatch,
204
+ runtime_config_match: runtimeConfigMatch,
205
+ ...(unreachable ? { error: 'Daemon artifacts exist, but the private socket is not responding.' } : {}),
206
+ };
207
+ }
208
+ async function restoreStoppedDoctorDaemon(config, snapshot, daemonWasStopped, restart = restartDaemonLocked) {
209
+ if (!daemonWasStopped || inspectDaemon(config, snapshot).status !== 'ok') {
210
+ return;
211
+ }
212
+ const port = snapshot.pidInfo?.port ?? DEFAULT_DAEMON_PORT;
213
+ await restart(port, { quiet: true }).catch((error) => {
214
+ printHumanReadableWarning(`warning: failed to restore daemon after doctor command: ${errorMessage(error)}`);
215
+ });
216
+ }
217
+ function inspectAuth(runtime) {
218
+ const hasToken = Boolean(runtime.config.auth.authorization);
219
+ if (!hasToken) {
220
+ return {
221
+ status: 'error',
222
+ logged_in: false,
223
+ verified: false,
224
+ user_id: null,
225
+ username: null,
226
+ email: null,
227
+ };
228
+ }
229
+ if (runtime.authError) {
230
+ return {
231
+ status: 'error',
232
+ logged_in: true,
233
+ verified: false,
234
+ user_id: runtime.config.auth.userId ?? null,
235
+ username: runtime.config.auth.username ?? null,
236
+ email: runtime.config.auth.email ?? null,
237
+ error: runtime.authError,
238
+ };
239
+ }
240
+ return {
241
+ status: runtime.config.auth.userId ? 'ok' : 'warn',
242
+ logged_in: true,
243
+ verified: !runtime.offline,
244
+ user_id: runtime.config.auth.userId ?? null,
245
+ username: runtime.config.auth.username ?? null,
246
+ email: runtime.config.auth.email ?? null,
247
+ };
248
+ }
249
+ async function inspectSync(runtime) {
250
+ const snapshot = getStatusSnapshot(runtime.db);
251
+ const uploadQueue = await readUploadQueueCount(runtime.db);
252
+ const uploadError = snapshot.dataFlowStatus.uploadError;
253
+ const downloadError = snapshot.dataFlowStatus.downloadError;
254
+ const result = {
255
+ status: 'ok',
256
+ connected: snapshot.connected,
257
+ connecting: snapshot.connecting,
258
+ has_synced: snapshot.hasSynced,
259
+ last_synced_at: snapshot.lastSyncedAt,
260
+ upload_queue: uploadQueue,
261
+ downloading: snapshot.dataFlowStatus.downloading,
262
+ uploading: snapshot.dataFlowStatus.uploading,
263
+ ...(downloadError ? { download_error: downloadError } : {}),
264
+ ...(uploadError ? { upload_error: uploadError } : {}),
265
+ ...(runtime.syncError ? { error: runtime.syncError } : {}),
266
+ };
267
+ result.status = doctorSyncStatus(runtime, result);
268
+ return result;
269
+ }
270
+ function doctorSyncStatus(runtime, sync) {
271
+ if (runtime.syncError || sync.error || sync.upload_error || sync.download_error) {
272
+ return 'error';
273
+ }
274
+ if (runtime.offline || !runtime.config.auth.authorization) {
275
+ return 'skipped';
276
+ }
277
+ if (!sync.connected || !sync.has_synced || sync.upload_queue === null || sync.upload_queue > 0) {
278
+ return 'warn';
279
+ }
280
+ return 'ok';
281
+ }
282
+ function refreshDoctorSyncSnapshot(runtime, sync) {
283
+ const snapshot = getStatusSnapshot(runtime.db);
284
+ sync.connected = snapshot.connected;
285
+ sync.connecting = snapshot.connecting;
286
+ sync.has_synced = snapshot.hasSynced;
287
+ sync.last_synced_at = snapshot.lastSyncedAt;
288
+ sync.downloading = snapshot.dataFlowStatus.downloading;
289
+ sync.uploading = snapshot.dataFlowStatus.uploading;
290
+ if (snapshot.dataFlowStatus.downloadError) {
291
+ sync.download_error = snapshot.dataFlowStatus.downloadError;
292
+ }
293
+ else {
294
+ delete sync.download_error;
295
+ }
296
+ if (snapshot.dataFlowStatus.uploadError) {
297
+ sync.upload_error = snapshot.dataFlowStatus.uploadError;
298
+ }
299
+ else {
300
+ delete sync.upload_error;
301
+ }
302
+ }
303
+ function summarizeIndex(inspection) {
304
+ return {
305
+ ...inspection,
306
+ status: inspection.drift === 0 && inspection.ftsAvailable ? 'ok' : 'warn',
307
+ };
308
+ }
309
+ export async function applyDatabaseFixes(runtime, payload) {
310
+ if (payload.sync.upload_queue && payload.sync.upload_queue > 0 && !runtime.offline && !runtime.syncError) {
311
+ const result = await waitForUploadQueueEmpty(runtime.db, runtime.timeoutMs);
312
+ const fixStatus = result.ok
313
+ ? 'ok'
314
+ : result.uploadError || result.queueError
315
+ ? 'error'
316
+ : 'warn';
317
+ payload.fixes.push({
318
+ target: 'upload_queue',
319
+ status: fixStatus,
320
+ message: result.ok
321
+ ? 'Upload queue drained.'
322
+ : result.uploadError
323
+ ? `Upload failed while waiting for the queue: ${result.uploadError.message}. No queued mutations were discarded.`
324
+ : result.queueError
325
+ ? `Upload queue could not be verified: ${result.queueError.message}. No queued mutations were discarded.`
326
+ : `Upload queue still has ${result.pendingCount ?? 'an unknown number of'} pending mutation(s). No queued mutations were discarded.`,
327
+ });
328
+ payload.sync.upload_queue = result.pendingCount;
329
+ refreshDoctorSyncSnapshot(runtime, payload.sync);
330
+ if (result.uploadError) {
331
+ payload.sync.upload_error = result.uploadError;
332
+ }
333
+ payload.sync.status = doctorSyncStatus(runtime, payload.sync);
334
+ }
335
+ if (payload.index.drift > 0 || !payload.index.ftsAvailable) {
336
+ await rebuildNoteTokenIndex(runtime.db);
337
+ const rebuilt = summarizeIndex(await inspectNoteTokenIndex(runtime.db));
338
+ payload.index = rebuilt;
339
+ payload.fixes.push({
340
+ target: 'index',
341
+ status: rebuilt.status,
342
+ message: rebuilt.drift === 0 ? 'Rebuilt note FTS index.' : `Rebuilt note FTS index; drift remains ${rebuilt.drift}.`,
343
+ });
344
+ }
345
+ }
346
+ function daemonNeedsRepair(daemon) {
347
+ return !daemon.running
348
+ || daemon.version_match === false
349
+ || daemon.account_match === false
350
+ || daemon.token_match === false
351
+ || daemon.authenticated === false
352
+ || daemon.status === 'error';
353
+ }
354
+ export async function releaseDoctorRuntime(runtime) {
355
+ if (runtime.released) {
356
+ return;
357
+ }
358
+ await closePowerSyncAndReleaseOwner(runtime.db, runtime.releaseOwner);
359
+ runtime.released = true;
360
+ }
361
+ export async function releaseDoctorDaemonStartLock(runtime) {
362
+ if (runtime.daemonStartLockReleased) {
363
+ return;
364
+ }
365
+ try {
366
+ await runtime.releaseDaemonStartLock();
367
+ runtime.daemonStartLockReleased = true;
368
+ }
369
+ catch (error) {
370
+ printHumanReadableWarning(`warning: failed to release daemon lifecycle lock: ${errorMessage(error)}`);
371
+ }
372
+ }
373
+ export async function applyDoctorDaemonLifecycle(runtime, payload, fixRequested, dependencies = {
374
+ restart: restartDaemonLocked,
375
+ capture: captureDaemonSnapshot,
376
+ }) {
377
+ if (!runtime.released) {
378
+ throw new Error('Doctor daemon lifecycle cannot run while the PowerSync owner lease is held.');
379
+ }
380
+ const needsRepair = daemonNeedsRepair(payload.daemon);
381
+ const repairRequested = Boolean(fixRequested && runtime.config.auth.userId && needsRepair);
382
+ const restoreRequired = Boolean(runtime.daemonWasStopped && payload.daemon.status === 'ok');
383
+ if (!repairRequested && !restoreRequired) {
384
+ if (runtime.daemonWasStopped) {
385
+ payload.daemon = {
386
+ ...payload.daemon,
387
+ running: false,
388
+ pid: null,
389
+ port: null,
390
+ socket_path: null,
391
+ };
392
+ }
393
+ return;
394
+ }
395
+ try {
396
+ const port = runtime.daemonBefore.pidInfo?.port ?? DEFAULT_DAEMON_PORT;
397
+ await dependencies.restart(port, { quiet: true });
398
+ payload.daemon = inspectDaemon(runtime.config, await dependencies.capture());
399
+ if (repairRequested) {
400
+ payload.fixes.push({
401
+ target: 'daemon',
402
+ status: payload.daemon.status,
403
+ message: payload.daemon.running ? 'Restarted daemon.' : 'Daemon restart did not leave a running daemon.',
404
+ });
405
+ }
406
+ }
407
+ catch (error) {
408
+ const message = errorMessage(error);
409
+ payload.daemon = {
410
+ ...payload.daemon,
411
+ status: 'error',
412
+ running: false,
413
+ pid: null,
414
+ port: null,
415
+ socket_path: null,
416
+ error: message,
417
+ };
418
+ if (repairRequested) {
419
+ payload.fixes.push({
420
+ target: 'daemon',
421
+ status: 'error',
422
+ message,
423
+ });
424
+ }
425
+ }
426
+ }
427
+ export function finalizeDoctorPayload(payload) {
428
+ const issues = payload.issues;
429
+ issue(issues, payload.auth.status === 'error', payload.auth.error ?? 'auth is not configured');
430
+ issue(issues, payload.auth.status === 'warn', 'auth token is present but user identity is not verified');
431
+ issue(issues, payload.sync.status === 'error', payload.sync.error ?? payload.sync.upload_error?.message ?? payload.sync.download_error?.message ?? 'sync error');
432
+ issue(issues, payload.sync.status === 'warn', !payload.sync.connected
433
+ ? 'PowerSync is not connected'
434
+ : !payload.sync.has_synced
435
+ ? 'PowerSync has not completed a sync'
436
+ : payload.sync.upload_queue === null
437
+ ? 'upload queue status is unavailable'
438
+ : `upload queue has ${payload.sync.upload_queue} pending mutation(s)`);
439
+ issue(issues, !payload.index.ftsAvailable, 'note FTS index table is missing');
440
+ issue(issues, payload.index.ftsAvailable && payload.index.drift > 0, `note FTS index drift is ${payload.index.drift}`);
441
+ issue(issues, payload.daemon.status !== 'ok', payload.daemon.error ?? 'daemon version/account/token does not match current CLI');
442
+ issue(issues, payload.db.status !== 'ok', payload.db.message ?? 'database integrity check failed');
443
+ for (const fix of payload.fixes) {
444
+ issue(issues, fix.status === 'error', `${fix.target} fix failed: ${fix.message}`);
445
+ }
446
+ payload.healthy = issues.length === 0;
447
+ payload.fixed = payload.fixes.some((fix) => fix.status === 'ok');
448
+ return payload;
449
+ }
450
+ function printHumanDoctor(payload) {
451
+ printHumanReadableLine(`healthy: ${payload.healthy ? 'yes' : 'no'}`);
452
+ printHumanReadableLine(`auth: ${payload.auth.status}${payload.auth.user_id ? ` (${payload.auth.user_id})` : ''}`);
453
+ printHumanReadableLine(`sync: ${payload.sync.status} connected=${payload.sync.connected} last_synced_at=${payload.sync.last_synced_at ?? '(none)'} upload_queue=${payload.sync.upload_queue ?? 'unknown'}`);
454
+ printHumanReadableLine(`index: ${payload.index.status} notes=${payload.index.notes} fts_rows=${payload.index.ftsRows} drift=${payload.index.drift}`);
455
+ printHumanReadableLine(`daemon: ${payload.daemon.status} running=${payload.daemon.running} transport=${payload.daemon.transport} socket=${payload.daemon.socket_path ?? '(none)'} version_match=${payload.daemon.version_match ?? 'unknown'} account_match=${payload.daemon.account_match ?? 'unknown'} token_match=${payload.daemon.token_match ?? 'unknown'} runtime_config_match=${payload.daemon.runtime_config_match ?? 'unknown'}`);
456
+ printHumanReadableLine(`db: ${payload.db.status} integrity=${payload.db.integrity} path=${payload.db.path}`);
457
+ if (payload.fixes.length > 0) {
458
+ printHumanReadableLine('fixes:');
459
+ for (const fix of payload.fixes) {
460
+ printHumanReadableLine(`- ${fix.target}: ${fix.status} - ${fix.message}`);
461
+ }
462
+ }
463
+ if (payload.issues.length > 0) {
464
+ printHumanReadableLine('issues:');
465
+ for (const entry of payload.issues) {
466
+ printHumanReadableLine(`- ${entry}`);
467
+ }
468
+ }
469
+ }
470
+ async function runDoctor(context) {
471
+ const runtime = await prepareDoctorRuntime(context);
472
+ try {
473
+ let payload;
474
+ let inspectionError;
475
+ try {
476
+ payload = {
477
+ ok: true,
478
+ healthy: false,
479
+ fixed: false,
480
+ auth: inspectAuth(runtime),
481
+ sync: await inspectSync(runtime),
482
+ index: summarizeIndex(await inspectNoteTokenIndex(runtime.db)),
483
+ daemon: inspectDaemon(runtime.config, runtime.daemonBefore),
484
+ db: await inspectDbIntegrity(runtime.db, runtime.dbPath),
485
+ fixes: [],
486
+ issues: [],
487
+ };
488
+ if (Boolean(context.options.fix)) {
489
+ await applyDatabaseFixes(runtime, payload);
490
+ }
491
+ }
492
+ catch (error) {
493
+ inspectionError = error;
494
+ }
495
+ finally {
496
+ await releaseDoctorRuntime(runtime);
497
+ }
498
+ if (inspectionError) {
499
+ await restoreStoppedDoctorDaemon(runtime.config, runtime.daemonBefore, runtime.daemonWasStopped);
500
+ throw inspectionError;
501
+ }
502
+ if (!payload) {
503
+ throw new Error('Doctor did not produce a health report.');
504
+ }
505
+ await applyDoctorDaemonLifecycle(runtime, payload, Boolean(context.options.fix));
506
+ const finalized = finalizeDoctorPayload(payload);
507
+ if (getRequestedStructuredOutputFormat()) {
508
+ printSuccess(finalized);
509
+ return;
510
+ }
511
+ printHumanDoctor(finalized);
512
+ }
513
+ finally {
514
+ await releaseDoctorDaemonStartLock(runtime);
515
+ }
516
+ }
517
+ export const doctorCommandDescriptor = {
518
+ name: 'doctor',
519
+ description: 'Check Dinox CLI health across auth, sync, local indexes, daemon, and database integrity',
520
+ options: [
521
+ { flags: '--fix', description: 'Safely repair local indexes, drain upload queue, and restart stale daemon', type: 'boolean' },
522
+ ],
523
+ schema: {
524
+ path: 'doctor',
525
+ risk: 'write',
526
+ auth: 'optional',
527
+ globalOptions: [...FORMAT_OPTIONS, ...OFFLINE_OPTIONS, ...SYNC_TIMEOUT_OPTIONS],
528
+ output: {
529
+ type: 'structured',
530
+ summary: 'Health report for auth, sync, indexes, daemon, and local database',
531
+ fields: ['healthy', 'fixed', 'auth', 'sync', 'index', 'daemon', 'db', 'fixes', 'issues'],
532
+ },
533
+ examples: ['dino doctor --format json', 'dino doctor --fix --format json'],
534
+ },
535
+ action: runDoctor,
536
+ };
537
+ export function registerDoctorCommand(program) {
538
+ registerStandaloneCommand(program, doctorCommandDescriptor);
539
+ }
@@ -1,4 +1,4 @@
1
- import { Command } from 'commander';
1
+ import type { Command } from 'commander';
2
2
  import type { CommandGroupDescriptor } from '../descriptor/types.js';
3
3
  export declare const graphCommandGroup: CommandGroupDescriptor;
4
4
  export declare function registerGraphCommands(program: Command): void;
@@ -4,7 +4,7 @@ import { registerCommandGroup } from '../descriptor/register.js';
4
4
  import { FORMAT_OPTIONS, OFFLINE_OPTIONS, REQUIRE_SYNC_OPTIONS, SYNC_TIMEOUT_OPTIONS } from '../schema/globalOptions.js';
5
5
  import { DinoxError } from '../../utils/errors.js';
6
6
  import { printSuccess } from '../../utils/output.js';
7
- import { syncBeforeCommand, withCommandRuntime } from '../runtime.js';
7
+ import { commandStale, syncBeforeCommand, withCommandRuntime } from '../runtime.js';
8
8
  import { getBacklinks, getGraphStats, getOutlinks, getRelatedNotes } from './repo.js';
9
9
  const GRAPH_GLOBAL_OPTIONS = [...FORMAT_OPTIONS, ...OFFLINE_OPTIONS, ...REQUIRE_SYNC_OPTIONS, ...SYNC_TIMEOUT_OPTIONS];
10
10
  function parseDepth(value) {
@@ -24,28 +24,42 @@ function parseDepth(value) {
24
24
  }
25
25
  return Math.trunc(parsed);
26
26
  }
27
- async function withOfflineDb(command, fn) {
28
- const globals = command.optsWithGlobals?.() ?? {};
29
- if (!Boolean(globals.offline)) {
30
- throw new DinoxError('withOfflineDb called without --offline');
31
- }
32
- return withCommandRuntime(command, async ({ db }) => fn(db));
33
- }
34
27
  async function runGraphCommand(context, onlineAction, offlineAction) {
35
28
  const globals = context.globals;
36
- if (Boolean(globals.offline)) {
37
- printSuccess(await withOfflineDb(context.command, offlineAction));
38
- return;
39
- }
40
- if (Boolean(globals.requireSync)) {
29
+ if (Boolean(globals.offline) || Boolean(globals.requireSync)) {
41
30
  await withCommandRuntime(context, async (runtime) => {
42
- await syncBeforeCommand(runtime, { syncTokenIndex: true });
43
- printSuccess(await offlineAction(runtime.db));
31
+ await syncBeforeCommand(runtime);
32
+ printSuccess({
33
+ ...(await offlineAction(runtime.db)),
34
+ stale: commandStale(runtime),
35
+ });
44
36
  });
45
37
  return;
46
38
  }
47
- await ensureDaemonRunning();
48
- printSuccess(await onlineAction());
39
+ try {
40
+ await ensureDaemonRunning();
41
+ const result = await onlineAction();
42
+ const record = result && typeof result === 'object' && !Array.isArray(result)
43
+ ? result
44
+ : {};
45
+ printSuccess({
46
+ ...record,
47
+ stale: typeof record.stale === 'boolean' ? record.stale : true,
48
+ });
49
+ }
50
+ catch (error) {
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ throw new DinoxError(`Daemon execution failed: ${message}`, {
53
+ code: 'DAEMON_UNAVAILABLE',
54
+ cause: error,
55
+ suggestion: 'Run `dino doctor --fix --format json` to repair local daemon state, or retry with `--offline` to use the local cache directly.',
56
+ suggestedAction: {
57
+ command: 'dino doctor --fix --format json',
58
+ reason: 'Default online graph reads require the daemon-owned database runtime.',
59
+ },
60
+ recoverable: true,
61
+ });
62
+ }
49
63
  }
50
64
  const graphBacklinksCommand = {
51
65
  name: 'backlinks',
@@ -59,7 +73,7 @@ const graphBacklinksCommand = {
59
73
  output: {
60
74
  type: 'structured',
61
75
  summary: 'Backlink graph payload',
62
- fields: ['note_id', 'backlinks'],
76
+ fields: ['note_id', 'backlinks', 'count', 'stale'],
63
77
  },
64
78
  examples: ['dino graph backlinks <id> --format json'],
65
79
  },
@@ -83,7 +97,7 @@ const graphOutlinksCommand = {
83
97
  output: {
84
98
  type: 'structured',
85
99
  summary: 'Outlink graph payload',
86
- fields: ['note_id', 'outlinks'],
100
+ fields: ['note_id', 'outlinks', 'count', 'stale'],
87
101
  },
88
102
  examples: ['dino graph outlinks <id> --format json'],
89
103
  },
@@ -108,7 +122,7 @@ const graphRelatedCommand = {
108
122
  output: {
109
123
  type: 'structured',
110
124
  summary: 'Related-notes graph payload',
111
- fields: ['note_id', 'depth', 'related_notes'],
125
+ fields: ['note_id', 'depth', 'related', 'count', 'stale'],
112
126
  },
113
127
  examples: ['dino graph related <id> --depth 2 --format json'],
114
128
  },
@@ -133,7 +147,7 @@ const graphStatsCommand = {
133
147
  type: 'structured',
134
148
  summary: 'Graph statistics payload',
135
149
  entity: 'graph_stats',
136
- fields: ['notes_count', 'links_count', 'connected_notes_count', 'isolated_notes_count'],
150
+ fields: ['notes_count', 'links_count', 'connected_notes_count', 'isolated_notes_count', 'stale'],
137
151
  },
138
152
  examples: ['dino graph stats --format json'],
139
153
  },