@dinoxx/dinox-cli 1.0.22 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -9
- package/dist/auth/userInfo.d.ts +2 -0
- package/dist/auth/userInfo.js +55 -14
- package/dist/cli.js +36 -2
- package/dist/commands/auth/index.d.ts +35 -0
- package/dist/commands/auth/index.js +382 -83
- package/dist/commands/boxes/index.js +2 -6
- package/dist/commands/boxes/model.d.ts +3 -0
- package/dist/commands/boxes/model.js +16 -1
- package/dist/commands/boxes/operations.js +18 -8
- package/dist/commands/boxes/repo.js +13 -11
- package/dist/commands/boxes/store.js +1 -1
- package/dist/commands/config/index.js +7 -5
- package/dist/commands/descriptor/register.js +9 -0
- package/dist/commands/doctor.d.ts +123 -0
- package/dist/commands/doctor.js +539 -0
- package/dist/commands/graph/index.d.ts +1 -1
- package/dist/commands/graph/index.js +35 -21
- package/dist/commands/graph/repo.js +92 -17
- package/dist/commands/meta/index.js +4 -4
- package/dist/commands/meta/repo.js +4 -3
- package/dist/commands/notes/actions.d.ts +1 -1
- package/dist/commands/notes/actions.js +121 -78
- package/dist/commands/notes/bulk.d.ts +1 -0
- package/dist/commands/notes/bulk.js +23 -17
- package/dist/commands/notes/contentEngine.js +69 -11
- package/dist/commands/notes/contentPatch.d.ts +4 -1
- package/dist/commands/notes/contentPatch.js +105 -41
- package/dist/commands/notes/contentPatchCommand.js +28 -11
- package/dist/commands/notes/descriptors.js +50 -20
- package/dist/commands/notes/dryRun.d.ts +1 -0
- package/dist/commands/notes/dryRun.js +4 -7
- package/dist/commands/notes/export.d.ts +6 -0
- package/dist/commands/notes/export.js +26 -6
- package/dist/commands/notes/metadataMutation.d.ts +1 -0
- package/dist/commands/notes/metadataMutation.js +14 -3
- package/dist/commands/notes/repo.d.ts +46 -4
- package/dist/commands/notes/repo.js +113 -28
- package/dist/commands/notes/runtime.d.ts +2 -1
- package/dist/commands/notes/runtime.js +17 -1
- package/dist/commands/notes/searchSql.js +13 -6
- package/dist/commands/notes/writeReceipt.d.ts +20 -0
- package/dist/commands/notes/writeReceipt.js +82 -0
- package/dist/commands/prompt/index.js +3 -3
- package/dist/commands/prompt/repo.d.ts +4 -1
- package/dist/commands/prompt/repo.js +17 -7
- package/dist/commands/runtime.d.ts +14 -2
- package/dist/commands/runtime.js +74 -15
- package/dist/commands/schema/renderSkills.js +5 -2
- package/dist/commands/schema/spec.js +4 -0
- package/dist/commands/storage/index.js +8 -2
- package/dist/commands/storage/repo.d.ts +4 -0
- package/dist/commands/storage/repo.js +356 -92
- package/dist/commands/sync.js +16 -12
- package/dist/commands/tags/content.js +9 -0
- package/dist/commands/tags/index.js +2 -6
- package/dist/commands/tags/operations.js +15 -10
- package/dist/commands/tags/repo.d.ts +2 -0
- package/dist/commands/tags/repo.js +18 -5
- package/dist/commands/tags/store.js +1 -1
- package/dist/commands/todo/contentModel.d.ts +4 -3
- package/dist/commands/todo/contentModel.js +78 -28
- package/dist/commands/todo/dryRun.d.ts +1 -0
- package/dist/commands/todo/dryRun.js +17 -13
- package/dist/commands/todo/index.d.ts +6 -0
- package/dist/commands/todo/index.js +89 -123
- package/dist/commands/todo/repo.d.ts +36 -1
- package/dist/commands/todo/repo.js +146 -37
- package/dist/commands/todo/taskIdentity.d.ts +2 -0
- package/dist/commands/todo/taskIdentity.js +64 -0
- package/dist/commands/todo/taskModel.js +27 -17
- package/dist/commands/update.d.ts +13 -1
- package/dist/commands/update.js +135 -23
- package/dist/commands/writeDryRun.d.ts +3 -2
- package/dist/commands/writeDryRun.js +4 -3
- package/dist/commands/writeDurability.d.ts +5 -0
- package/dist/commands/writeDurability.js +22 -0
- package/dist/config/paths.js +6 -1
- package/dist/config/resolve.d.ts +2 -0
- package/dist/config/resolve.js +23 -9
- package/dist/config/store.d.ts +3 -0
- package/dist/config/store.js +61 -14
- package/dist/config/types.d.ts +4 -0
- package/dist/daemon/client.d.ts +26 -1
- package/dist/daemon/client.js +236 -12
- package/dist/daemon/ensure.js +74 -12
- package/dist/daemon/index.d.ts +42 -0
- package/dist/daemon/index.js +202 -52
- package/dist/daemon/lifecycle.d.ts +42 -0
- package/dist/daemon/lifecycle.js +115 -0
- package/dist/daemon/pid.d.ts +19 -6
- package/dist/daemon/pid.js +147 -13
- package/dist/daemon/route.d.ts +14 -0
- package/dist/daemon/route.js +40 -0
- package/dist/daemon/server.d.ts +11 -0
- package/dist/daemon/server.js +201 -50
- package/dist/daemon/types.d.ts +29 -1
- package/dist/daemon/types.js +1 -0
- package/dist/dinox.js +25 -1
- package/dist/powersync/connector.js +31 -16
- package/dist/powersync/exclusiveRuntime.d.ts +19 -0
- package/dist/powersync/exclusiveRuntime.js +42 -0
- package/dist/powersync/runtime.d.ts +24 -4
- package/dist/powersync/runtime.js +234 -36
- package/dist/powersync/syncGate.d.ts +3 -1
- package/dist/powersync/syncGate.js +7 -1
- package/dist/powersync/tokenIndex.d.ts +11 -0
- package/dist/powersync/tokenIndex.js +159 -22
- package/dist/powersync/uploader.js +107 -52
- package/dist/utils/abort.js +0 -4
- package/dist/utils/asyncRelease.d.ts +2 -0
- package/dist/utils/asyncRelease.js +25 -0
- package/dist/utils/bidlinkCanonicalization.js +3 -0
- package/dist/utils/dryRun.d.ts +1 -1
- package/dist/utils/errors.d.ts +15 -1
- package/dist/utils/errors.js +223 -5
- package/dist/utils/output.d.ts +1 -0
- package/dist/utils/output.js +51 -15
- package/dist/utils/redact.js +1 -1
- package/dist/utils/sqliteLease.d.ts +8 -0
- package/dist/utils/sqliteLease.js +59 -0
- package/dist/utils/syncTimeout.d.ts +7 -1
- package/dist/utils/syncTimeout.js +8 -7
- package/dist/utils/terminalSanitization.js +6 -2
- package/dist/utils/tiptapMarkdown.js +49 -3
- package/dist/utils/tiptapMarkdownSyntax.d.ts +9 -0
- package/dist/utils/tiptapMarkdownSyntax.js +249 -0
- package/dist/utils/tokenize.js +24 -2
- package/dist/utils/updateNotice.js +60 -12
- package/package.json +12 -10
- package/dist/commands/mcp/auth.d.ts +0 -4
- package/dist/commands/mcp/auth.js +0 -75
- package/dist/commands/mcp/index.d.ts +0 -2
- package/dist/commands/mcp/index.js +0 -86
- package/dist/commands/mcp/noteContentTools.d.ts +0 -222
- package/dist/commands/mcp/noteContentTools.js +0 -390
- package/dist/commands/mcp/server.d.ts +0 -34
- package/dist/commands/mcp/server.js +0 -424
- package/dist/commands/mcp/tools.d.ts +0 -2
- package/dist/commands/mcp/tools.js +0 -32
- package/dist/commands/mcp/types.d.ts +0 -39
- package/dist/commands/mcp/types.js +0 -1
- package/dist/utils/noteContentEngine.d.ts +0 -333
- 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
|
|
43
|
-
printSuccess(
|
|
31
|
+
await syncBeforeCommand(runtime);
|
|
32
|
+
printSuccess({
|
|
33
|
+
...(await offlineAction(runtime.db)),
|
|
34
|
+
stale: commandStale(runtime),
|
|
35
|
+
});
|
|
44
36
|
});
|
|
45
37
|
return;
|
|
46
38
|
}
|
|
47
|
-
|
|
48
|
-
|
|
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', '
|
|
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
|
},
|