@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.
- package/README.md +33 -10
- 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 +89 -19
- 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.d.ts +23 -1
- package/dist/commands/sync.js +105 -60
- 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 +44 -0
- package/dist/daemon/lifecycle.js +118 -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 +20 -0
- package/dist/powersync/exclusiveRuntime.js +48 -0
- package/dist/powersync/runtime.d.ts +25 -4
- package/dist/powersync/runtime.js +255 -35
- package/dist/powersync/syncGate.d.ts +4 -1
- package/dist/powersync/syncGate.js +11 -2
- package/dist/powersync/syncPass.d.ts +2 -0
- package/dist/powersync/syncPass.js +15 -4
- package/dist/powersync/tokenIndex.d.ts +13 -0
- package/dist/powersync/tokenIndex.js +216 -37
- 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 +14 -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
package/dist/daemon/pid.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
6
|
+
import { getDataDir } from '../config/paths.js';
|
|
7
|
+
import { acquireExclusiveSqliteLease } from '../utils/sqliteLease.js';
|
|
8
|
+
const DAEMON_START_LOCK_TIMEOUT_MS = 10_000;
|
|
9
|
+
const LEGACY_DAEMON_UPTIME_TOLERANCE_MS = 60_000;
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
4
11
|
export function getDaemonDir() {
|
|
5
|
-
return join(
|
|
12
|
+
return join(getDataDir(), 'daemon');
|
|
6
13
|
}
|
|
7
14
|
export function getPidFilePath() {
|
|
8
15
|
return join(getDaemonDir(), 'daemon.pid');
|
|
@@ -10,32 +17,91 @@ export function getPidFilePath() {
|
|
|
10
17
|
export function getPortFilePath() {
|
|
11
18
|
return join(getDaemonDir(), 'daemon.port');
|
|
12
19
|
}
|
|
13
|
-
export function
|
|
20
|
+
export function getSocketFilePath() {
|
|
21
|
+
return join(getDaemonDir(), 'd.sock');
|
|
22
|
+
}
|
|
23
|
+
export function getDaemonSocketPath() {
|
|
24
|
+
if (process.platform === 'win32') {
|
|
25
|
+
const hash = crypto.createHash('sha256').update(getDataDir(), 'utf8').digest('hex').slice(0, 16);
|
|
26
|
+
return `\\\\.\\pipe\\dinox-cli-${hash}`;
|
|
27
|
+
}
|
|
28
|
+
return getSocketFilePath();
|
|
29
|
+
}
|
|
30
|
+
export function getTokenFilePath() {
|
|
31
|
+
return join(getDaemonDir(), 'daemon.token');
|
|
32
|
+
}
|
|
33
|
+
export function getStartLockFilePath() {
|
|
34
|
+
return join(getDaemonDir(), 'daemon.start.lock');
|
|
35
|
+
}
|
|
36
|
+
export function ensureDaemonDir() {
|
|
14
37
|
const dir = getDaemonDir();
|
|
15
38
|
if (!existsSync(dir)) {
|
|
16
|
-
mkdirSync(dir, { recursive: true });
|
|
39
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
chmodSync(dir, 0o700);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Best effort: older filesystems may not support POSIX mode changes.
|
|
17
46
|
}
|
|
18
|
-
|
|
19
|
-
|
|
47
|
+
return dir;
|
|
48
|
+
}
|
|
49
|
+
export function savePid(pid, port, instanceId) {
|
|
50
|
+
ensureDaemonDir();
|
|
51
|
+
writeFileSync(getPidFilePath(), JSON.stringify({
|
|
52
|
+
pid,
|
|
53
|
+
port,
|
|
54
|
+
instanceId,
|
|
55
|
+
startedAt: new Date().toISOString(),
|
|
56
|
+
}), { mode: 0o600 });
|
|
57
|
+
writeFileSync(getPortFilePath(), String(port), { mode: 0o600 });
|
|
20
58
|
}
|
|
21
59
|
export function readPid() {
|
|
22
60
|
try {
|
|
23
|
-
const
|
|
24
|
-
|
|
61
|
+
const raw = readFileSync(getPidFilePath(), 'utf-8').trim();
|
|
62
|
+
let pid;
|
|
63
|
+
let port;
|
|
64
|
+
let instanceId = null;
|
|
65
|
+
let startedAt = null;
|
|
66
|
+
if (raw.startsWith('{')) {
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
68
|
+
pid = Number(parsed.pid);
|
|
69
|
+
port = Number(parsed.port);
|
|
70
|
+
instanceId = typeof parsed.instanceId === 'string' && parsed.instanceId.trim()
|
|
71
|
+
? parsed.instanceId
|
|
72
|
+
: null;
|
|
73
|
+
startedAt = typeof parsed.startedAt === 'string' && parsed.startedAt.trim()
|
|
74
|
+
? parsed.startedAt
|
|
75
|
+
: null;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
pid = parseInt(raw, 10);
|
|
79
|
+
port = parseInt(readFileSync(getPortFilePath(), 'utf-8').trim(), 10);
|
|
80
|
+
}
|
|
25
81
|
if (!Number.isFinite(pid) || pid <= 0) {
|
|
26
82
|
return null;
|
|
27
83
|
}
|
|
28
84
|
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
29
85
|
return null;
|
|
30
86
|
}
|
|
31
|
-
return { pid, port };
|
|
87
|
+
return { pid, port, instanceId, startedAt };
|
|
32
88
|
}
|
|
33
89
|
catch {
|
|
34
90
|
return null;
|
|
35
91
|
}
|
|
36
92
|
}
|
|
37
|
-
export function removePid() {
|
|
38
|
-
|
|
93
|
+
export function removePid(expectedInstanceId, options = {}) {
|
|
94
|
+
const current = readPid();
|
|
95
|
+
if (options.expectedPid !== undefined && current?.pid !== options.expectedPid) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (expectedInstanceId && !current && !options.allowMissingOwner) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
if (expectedInstanceId && current && current.instanceId !== expectedInstanceId) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
for (const path of [getPidFilePath(), getPortFilePath(), getTokenFilePath(), getSocketFilePath()]) {
|
|
39
105
|
try {
|
|
40
106
|
unlinkSync(path);
|
|
41
107
|
}
|
|
@@ -43,6 +109,7 @@ export function removePid() {
|
|
|
43
109
|
// Ignore missing files/stale paths.
|
|
44
110
|
}
|
|
45
111
|
}
|
|
112
|
+
return true;
|
|
46
113
|
}
|
|
47
114
|
export function isProcessRunning(pid) {
|
|
48
115
|
try {
|
|
@@ -53,3 +120,70 @@ export function isProcessRunning(pid) {
|
|
|
53
120
|
return false;
|
|
54
121
|
}
|
|
55
122
|
}
|
|
123
|
+
function parseProcessElapsedMs(value) {
|
|
124
|
+
const trimmed = value.trim();
|
|
125
|
+
const match = trimmed.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
|
|
126
|
+
if (!match) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const days = Number(match[1] ?? 0);
|
|
130
|
+
const hours = Number(match[2] ?? 0);
|
|
131
|
+
const minutes = Number(match[3] ?? 0);
|
|
132
|
+
const seconds = Number(match[4] ?? 0);
|
|
133
|
+
if (![days, hours, minutes, seconds].every(Number.isFinite)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
|
|
137
|
+
}
|
|
138
|
+
function commandLooksLikeLegacyDaemon(command) {
|
|
139
|
+
return /(?:^|[\\/\s])dino(?:x)?(?:\.[cm]?[jt]s)?["']?\s+daemon\s+start(?:\s|$)/i.test(command)
|
|
140
|
+
&& /(?:^|\s)--no-detach(?:\s|$)/.test(command);
|
|
141
|
+
}
|
|
142
|
+
export async function isLegacyDaemonProcess(pid, daemonUptimeMs) {
|
|
143
|
+
if (!Number.isInteger(pid) || pid <= 0 || !Number.isFinite(daemonUptimeMs) || daemonUptimeMs < 0) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
if (process.platform === 'win32') {
|
|
148
|
+
const command = [
|
|
149
|
+
'$p = Get-CimInstance Win32_Process -Filter "ProcessId = ' + pid + '";',
|
|
150
|
+
'if ($null -ne $p) {',
|
|
151
|
+
' [Console]::WriteLine($p.CreationDate.ToUniversalTime().ToString("o"));',
|
|
152
|
+
' [Console]::Write($p.CommandLine);',
|
|
153
|
+
'}',
|
|
154
|
+
].join(' ');
|
|
155
|
+
const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { encoding: 'utf8', timeout: 2_000, maxBuffer: 64 * 1024 });
|
|
156
|
+
const newline = stdout.indexOf('\n');
|
|
157
|
+
if (newline < 0) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
const createdAt = Date.parse(stdout.slice(0, newline).trim());
|
|
161
|
+
const processUptimeMs = Date.now() - createdAt;
|
|
162
|
+
return Number.isFinite(processUptimeMs)
|
|
163
|
+
&& Math.abs(processUptimeMs - daemonUptimeMs) <= LEGACY_DAEMON_UPTIME_TOLERANCE_MS
|
|
164
|
+
&& commandLooksLikeLegacyDaemon(stdout.slice(newline + 1).trim());
|
|
165
|
+
}
|
|
166
|
+
const { stdout } = await execFileAsync('ps', ['-ww', '-p', String(pid), '-o', 'etime=', '-o', 'command='], { encoding: 'utf8', timeout: 2_000, maxBuffer: 64 * 1024 });
|
|
167
|
+
const match = stdout.trim().match(/^(\S+)\s+([\s\S]+)$/);
|
|
168
|
+
if (!match) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const processUptimeMs = parseProcessElapsedMs(match[1]);
|
|
172
|
+
return processUptimeMs !== null
|
|
173
|
+
&& Math.abs(processUptimeMs - daemonUptimeMs) <= LEGACY_DAEMON_UPTIME_TOLERANCE_MS
|
|
174
|
+
&& commandLooksLikeLegacyDaemon(match[2]);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export async function acquireDaemonStartLock(timeoutMs = DAEMON_START_LOCK_TIMEOUT_MS) {
|
|
181
|
+
ensureDaemonDir();
|
|
182
|
+
return acquireExclusiveSqliteLease({
|
|
183
|
+
lockPath: getStartLockFilePath(),
|
|
184
|
+
timeoutMs,
|
|
185
|
+
maxWaitMs: timeoutMs,
|
|
186
|
+
pollIntervalMs: 50,
|
|
187
|
+
createBusyError: () => new Error('Timed out waiting for daemon start lock'),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default online reads use the long-lived daemon as the canonical DB owner. The
|
|
3
|
+
* process-local path is only for explicit modes that require opening the cache here:
|
|
4
|
+
* - `--offline`: must read the local cache directly without network sync.
|
|
5
|
+
* - `--require-sync`: must run the in-process sync gate before reading.
|
|
6
|
+
* The local runtime coordinates with a same-database daemon through the shared
|
|
7
|
+
* lifecycle lock before opening SQLite.
|
|
8
|
+
*/
|
|
9
|
+
export declare function shouldUseDaemon(globals: Record<string, unknown>): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Route a read through the daemon when allowed, otherwise run it locally. `daemonRun`
|
|
12
|
+
* receives a thin sender bound to the daemon; `localRun` opens its own db/sync path.
|
|
13
|
+
*/
|
|
14
|
+
export declare function routeRead<T>(globals: Record<string, unknown>, daemonMethod: string, daemonParams: unknown, localRun: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { sendDaemonRequest } from './client.js';
|
|
2
|
+
import { ensureDaemonRunning } from './ensure.js';
|
|
3
|
+
import { DinoxError } from '../utils/errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* Default online reads use the long-lived daemon as the canonical DB owner. The
|
|
6
|
+
* process-local path is only for explicit modes that require opening the cache here:
|
|
7
|
+
* - `--offline`: must read the local cache directly without network sync.
|
|
8
|
+
* - `--require-sync`: must run the in-process sync gate before reading.
|
|
9
|
+
* The local runtime coordinates with a same-database daemon through the shared
|
|
10
|
+
* lifecycle lock before opening SQLite.
|
|
11
|
+
*/
|
|
12
|
+
export function shouldUseDaemon(globals) {
|
|
13
|
+
return !globals.offline && !globals.requireSync;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Route a read through the daemon when allowed, otherwise run it locally. `daemonRun`
|
|
17
|
+
* receives a thin sender bound to the daemon; `localRun` opens its own db/sync path.
|
|
18
|
+
*/
|
|
19
|
+
export async function routeRead(globals, daemonMethod, daemonParams, localRun) {
|
|
20
|
+
if (!shouldUseDaemon(globals)) {
|
|
21
|
+
return localRun();
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
await ensureDaemonRunning();
|
|
25
|
+
return (await sendDaemonRequest(daemonMethod, daemonParams));
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
throw new DinoxError(`Daemon execution failed: ${message}`, {
|
|
30
|
+
code: 'DAEMON_UNAVAILABLE',
|
|
31
|
+
cause: error,
|
|
32
|
+
suggestion: 'Run `dino doctor --fix --format json` to repair local daemon state, or retry with `--offline` to use the local cache directly.',
|
|
33
|
+
suggestedAction: {
|
|
34
|
+
command: 'dino doctor --fix --format json',
|
|
35
|
+
reason: 'Default online reads require the daemon-owned database runtime.',
|
|
36
|
+
},
|
|
37
|
+
recoverable: true,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/daemon/server.d.ts
CHANGED
|
@@ -3,8 +3,19 @@ export declare class DaemonServer {
|
|
|
3
3
|
private db;
|
|
4
4
|
private server;
|
|
5
5
|
private startTime;
|
|
6
|
+
private readonly instanceId;
|
|
7
|
+
private readonly version;
|
|
8
|
+
private userId;
|
|
9
|
+
private tokenFingerprint;
|
|
10
|
+
private runtimeConfigFingerprint;
|
|
11
|
+
private port;
|
|
12
|
+
private ownsArtifacts;
|
|
13
|
+
private releaseOwner;
|
|
14
|
+
private stopPromise;
|
|
6
15
|
initialize(): Promise<void>;
|
|
7
16
|
handleRequest(req: DaemonRequest): Promise<DaemonResponse>;
|
|
17
|
+
private isCacheStale;
|
|
8
18
|
start(port: number): Promise<void>;
|
|
9
19
|
stop(): Promise<void>;
|
|
20
|
+
private stopInternal;
|
|
10
21
|
}
|
package/dist/daemon/server.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import { createServer } from 'http';
|
|
3
|
+
import { chmodSync, existsSync } from 'node:fs';
|
|
2
4
|
import { loadConfig } from '../config/store.js';
|
|
3
|
-
import { resolveConfig } from '../config/resolve.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { fingerprintDaemonRuntimeConfig, resolveConfig } from '../config/resolve.js';
|
|
6
|
+
import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
|
|
7
|
+
import { closePowerSyncAndReleaseOwner, connectPowerSync, getStatusSnapshot, } from '../powersync/runtime.js';
|
|
8
|
+
import { executeNoteSearch } from '../commands/notes/repo.js';
|
|
6
9
|
import { getBacklinks, getGraphStats, getOutlinks, getRelatedNotes } from '../commands/graph/repo.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
10
|
+
import { executeTodoSearch } from '../commands/todo/repo.js';
|
|
11
|
+
import { ensureDaemonDir, getDaemonSocketPath, isProcessRunning, readPid, removePid, savePid, } from './pid.js';
|
|
9
12
|
import { getPackageVersion } from '../utils/version.js';
|
|
13
|
+
import { syncNoteTokenIndex } from '../powersync/tokenIndex.js';
|
|
14
|
+
import { buildStructuredErrorPayload, DinoxError } from '../utils/errors.js';
|
|
15
|
+
import { getDaemonInfo } from './client.js';
|
|
10
16
|
const MAX_DAEMON_REQUEST_BYTES = 1 * 1024 * 1024;
|
|
17
|
+
const DAEMON_PROCESS_VERSION = getPackageVersion();
|
|
11
18
|
async function readJsonBody(req) {
|
|
12
19
|
return new Promise((resolve, reject) => {
|
|
13
20
|
const chunks = [];
|
|
@@ -32,19 +39,52 @@ async function readJsonBody(req) {
|
|
|
32
39
|
req.on('error', reject);
|
|
33
40
|
});
|
|
34
41
|
}
|
|
42
|
+
function isJsonRequest(req) {
|
|
43
|
+
const contentType = req.headers['content-type'];
|
|
44
|
+
if (typeof contentType !== 'string') {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return contentType.split(';', 1)[0]?.trim().toLowerCase() === 'application/json';
|
|
48
|
+
}
|
|
49
|
+
function validateLocalRequest(req) {
|
|
50
|
+
if (req.headers.origin) {
|
|
51
|
+
return { ok: false, status: 403, message: 'Browser-origin daemon requests are not allowed' };
|
|
52
|
+
}
|
|
53
|
+
if (!isJsonRequest(req)) {
|
|
54
|
+
return { ok: false, status: 415, message: 'Daemon requests must use application/json' };
|
|
55
|
+
}
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
|
35
58
|
export class DaemonServer {
|
|
36
59
|
db = null;
|
|
37
60
|
server = null;
|
|
38
61
|
startTime = Date.now();
|
|
62
|
+
instanceId = crypto.randomUUID();
|
|
63
|
+
version = DAEMON_PROCESS_VERSION;
|
|
64
|
+
userId = null;
|
|
65
|
+
tokenFingerprint = null;
|
|
66
|
+
runtimeConfigFingerprint = null;
|
|
67
|
+
port = null;
|
|
68
|
+
ownsArtifacts = false;
|
|
69
|
+
releaseOwner = null;
|
|
70
|
+
stopPromise = null;
|
|
39
71
|
async initialize() {
|
|
40
|
-
const
|
|
41
|
-
const {
|
|
72
|
+
const baseConfig = resolveConfig(await loadConfig());
|
|
73
|
+
const { config } = await hydrateResolvedAuthIdentity(baseConfig, { timeoutMs: baseConfig.sync.timeoutMs });
|
|
74
|
+
this.userId = config.auth.userId ?? null;
|
|
75
|
+
this.tokenFingerprint = config.auth.tokenFingerprint ?? null;
|
|
76
|
+
this.runtimeConfigFingerprint = fingerprintDaemonRuntimeConfig(config);
|
|
77
|
+
const { db, releaseOwner } = await connectPowerSync({
|
|
78
|
+
config,
|
|
79
|
+
offline: false,
|
|
80
|
+
timeoutMs: config.sync.timeoutMs,
|
|
81
|
+
waitForInitialSync: false,
|
|
82
|
+
expectedTokenFingerprint: config.auth.tokenFingerprint ?? null,
|
|
83
|
+
});
|
|
42
84
|
this.db = db;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
}, 30000);
|
|
85
|
+
this.releaseOwner = releaseOwner;
|
|
86
|
+
// PowerSync keeps the local cache in sync continuously while connected,
|
|
87
|
+
// so no background polling loop is needed here.
|
|
48
88
|
}
|
|
49
89
|
async handleRequest(req) {
|
|
50
90
|
try {
|
|
@@ -54,50 +94,92 @@ export class DaemonServer {
|
|
|
54
94
|
const { method, params } = req;
|
|
55
95
|
switch (method) {
|
|
56
96
|
case 'ping':
|
|
57
|
-
return {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
97
|
+
return {
|
|
98
|
+
id: req.id,
|
|
99
|
+
ok: true,
|
|
100
|
+
result: {
|
|
101
|
+
pid: process.pid,
|
|
102
|
+
instanceId: this.instanceId,
|
|
103
|
+
uptime: Date.now() - this.startTime,
|
|
104
|
+
version: this.version,
|
|
105
|
+
transport: 'socket',
|
|
106
|
+
socketPath: getDaemonSocketPath(),
|
|
107
|
+
userId: this.userId,
|
|
108
|
+
tokenFingerprint: this.tokenFingerprint,
|
|
109
|
+
runtimeConfigFingerprint: this.runtimeConfigFingerprint,
|
|
110
|
+
authenticated: Boolean(this.userId && this.tokenFingerprint),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
case 'shutdown': {
|
|
114
|
+
const shutdownParams = params;
|
|
115
|
+
if (shutdownParams?.expectedInstanceId !== this.instanceId) {
|
|
116
|
+
throw new DinoxError('Daemon instance changed before shutdown was received.', {
|
|
117
|
+
code: 'DAEMON_INSTANCE_MISMATCH',
|
|
118
|
+
recoverable: true,
|
|
119
|
+
details: {
|
|
120
|
+
expectedInstanceId: shutdownParams?.expectedInstanceId ?? null,
|
|
121
|
+
actualInstanceId: this.instanceId,
|
|
122
|
+
},
|
|
123
|
+
suggestion: 'Ping the daemon again before retrying shutdown.',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
void this.stop().catch(() => undefined);
|
|
128
|
+
}, 10);
|
|
129
|
+
timer.unref?.();
|
|
130
|
+
return { id: req.id, ok: true, result: { stopping: true, instanceId: this.instanceId } };
|
|
131
|
+
}
|
|
132
|
+
case 'note.search': {
|
|
133
|
+
// Same executor as the local `dino note search` path — no option/result drift.
|
|
134
|
+
await syncNoteTokenIndex(this.db);
|
|
135
|
+
const result = await executeNoteSearch(this.db, params);
|
|
136
|
+
return { id: req.id, ok: true, result: { ...result, stale: this.isCacheStale() } };
|
|
137
|
+
}
|
|
69
138
|
case 'graph.backlinks':
|
|
70
139
|
const backlinks = await getBacklinks(this.db, params.id);
|
|
71
|
-
return { id: req.id, ok: true, result: backlinks };
|
|
140
|
+
return { id: req.id, ok: true, result: { ...backlinks, stale: this.isCacheStale() } };
|
|
72
141
|
case 'graph.outlinks':
|
|
73
142
|
const outlinks = await getOutlinks(this.db, params.id);
|
|
74
|
-
return { id: req.id, ok: true, result: outlinks };
|
|
143
|
+
return { id: req.id, ok: true, result: { ...outlinks, stale: this.isCacheStale() } };
|
|
75
144
|
case 'graph.related':
|
|
76
145
|
const related = await getRelatedNotes(this.db, params.id, params.depth || 2);
|
|
77
|
-
return { id: req.id, ok: true, result: related };
|
|
146
|
+
return { id: req.id, ok: true, result: { ...related, stale: this.isCacheStale() } };
|
|
78
147
|
case 'graph.stats':
|
|
79
148
|
const graphStats = await getGraphStats(this.db);
|
|
80
|
-
return { id: req.id, ok: true, result: graphStats };
|
|
81
|
-
case 'todo.search':
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
limit: params.limit || 50,
|
|
87
|
-
scanLimit: params.scanLimit || 200,
|
|
88
|
-
});
|
|
89
|
-
return { id: req.id, ok: true, result: todos };
|
|
149
|
+
return { id: req.id, ok: true, result: { ...graphStats, stale: this.isCacheStale() } };
|
|
150
|
+
case 'todo.search': {
|
|
151
|
+
// Same executor as the local `dino todo search` path — no option/result drift.
|
|
152
|
+
const result = await executeTodoSearch(this.db, params);
|
|
153
|
+
return { id: req.id, ok: true, result: { ...result, stale: this.isCacheStale() } };
|
|
154
|
+
}
|
|
90
155
|
default:
|
|
91
156
|
throw new Error(`Unknown method: ${method}`);
|
|
92
157
|
}
|
|
93
158
|
}
|
|
94
159
|
catch (error) {
|
|
95
|
-
return { id: req.id, ok: false, error: error
|
|
160
|
+
return { id: req.id, ok: false, error: buildStructuredErrorPayload(error, false) };
|
|
96
161
|
}
|
|
97
162
|
}
|
|
163
|
+
isCacheStale() {
|
|
164
|
+
if (!this.db) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
const status = getStatusSnapshot(this.db);
|
|
168
|
+
return (!status.connected ||
|
|
169
|
+
!status.hasSynced ||
|
|
170
|
+
status.dataFlowStatus.downloading ||
|
|
171
|
+
Boolean(status.dataFlowStatus.downloadError));
|
|
172
|
+
}
|
|
98
173
|
async start(port) {
|
|
174
|
+
this.port = port;
|
|
99
175
|
this.server = createServer(async (req, res) => {
|
|
100
176
|
if (req.method === 'POST') {
|
|
177
|
+
const validation = validateLocalRequest(req);
|
|
178
|
+
if (!validation.ok) {
|
|
179
|
+
res.writeHead(validation.status, { 'Content-Type': 'application/json' });
|
|
180
|
+
res.end(JSON.stringify({ ok: false, error: validation.message }));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
101
183
|
try {
|
|
102
184
|
const daemonReq = await readJsonBody(req);
|
|
103
185
|
const daemonRes = await this.handleRequest(daemonReq);
|
|
@@ -118,23 +200,92 @@ export class DaemonServer {
|
|
|
118
200
|
});
|
|
119
201
|
this.server.headersTimeout = 5_000;
|
|
120
202
|
this.server.requestTimeout = 10_000;
|
|
121
|
-
|
|
203
|
+
try {
|
|
204
|
+
ensureDaemonDir();
|
|
205
|
+
const socketPath = getDaemonSocketPath();
|
|
206
|
+
if (process.platform !== 'win32' && existsSync(socketPath)) {
|
|
207
|
+
const existing = await getDaemonInfo();
|
|
208
|
+
if (existing) {
|
|
209
|
+
throw new Error(`A daemon is already listening on ${socketPath}`);
|
|
210
|
+
}
|
|
211
|
+
const pidInfo = readPid();
|
|
212
|
+
if (!pidInfo?.instanceId || isProcessRunning(pidInfo.pid)) {
|
|
213
|
+
throw new Error(`Daemon socket ${socketPath} exists but ownership cannot be proven stale`);
|
|
214
|
+
}
|
|
215
|
+
removePid();
|
|
216
|
+
}
|
|
217
|
+
await new Promise((resolve, reject) => {
|
|
218
|
+
if (!this.server) {
|
|
219
|
+
reject(new Error('Server not initialized'));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.server.once('error', reject);
|
|
223
|
+
this.server.listen(socketPath, () => resolve());
|
|
224
|
+
});
|
|
225
|
+
if (process.platform !== 'win32') {
|
|
226
|
+
try {
|
|
227
|
+
chmodSync(socketPath, 0o600);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Best effort: not every filesystem honors socket chmod.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
savePid(process.pid, port, this.instanceId);
|
|
234
|
+
this.ownsArtifacts = true;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
122
237
|
if (!this.server) {
|
|
123
|
-
|
|
124
|
-
return;
|
|
238
|
+
throw error;
|
|
125
239
|
}
|
|
126
|
-
this.server.
|
|
127
|
-
this.server
|
|
128
|
-
|
|
129
|
-
|
|
240
|
+
await new Promise((resolve) => this.server?.close(() => resolve())).catch(() => undefined);
|
|
241
|
+
this.server = null;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
130
244
|
}
|
|
131
245
|
async stop() {
|
|
132
|
-
if (this.
|
|
133
|
-
|
|
246
|
+
if (this.stopPromise) {
|
|
247
|
+
return this.stopPromise;
|
|
248
|
+
}
|
|
249
|
+
const attempt = this.stopInternal();
|
|
250
|
+
this.stopPromise = attempt;
|
|
251
|
+
try {
|
|
252
|
+
await attempt;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
if (this.stopPromise === attempt) {
|
|
256
|
+
this.stopPromise = null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async stopInternal() {
|
|
261
|
+
const db = this.db;
|
|
262
|
+
const releaseOwner = this.releaseOwner;
|
|
263
|
+
if (db) {
|
|
264
|
+
if (releaseOwner) {
|
|
265
|
+
await closePowerSyncAndReleaseOwner(db, releaseOwner);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
await db.close();
|
|
269
|
+
}
|
|
270
|
+
if (this.db === db) {
|
|
271
|
+
this.db = null;
|
|
272
|
+
}
|
|
273
|
+
if (this.releaseOwner === releaseOwner) {
|
|
274
|
+
this.releaseOwner = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const server = this.server;
|
|
278
|
+
if (server) {
|
|
279
|
+
await new Promise((resolve, reject) => {
|
|
280
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
281
|
+
});
|
|
282
|
+
if (this.server === server) {
|
|
283
|
+
this.server = null;
|
|
284
|
+
}
|
|
134
285
|
}
|
|
135
|
-
if (this.
|
|
136
|
-
|
|
286
|
+
if (this.ownsArtifacts) {
|
|
287
|
+
removePid(this.instanceId, { allowMissingOwner: true });
|
|
288
|
+
this.ownsArtifacts = false;
|
|
137
289
|
}
|
|
138
|
-
removePid();
|
|
139
290
|
}
|
|
140
291
|
}
|
package/dist/daemon/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StructuredErrorPayload } from '../utils/errors.js';
|
|
1
2
|
export type DaemonRequest = {
|
|
2
3
|
id: string;
|
|
3
4
|
method: string;
|
|
@@ -7,13 +8,40 @@ export type DaemonResponse = {
|
|
|
7
8
|
id: string;
|
|
8
9
|
ok: boolean;
|
|
9
10
|
result?: any;
|
|
10
|
-
error?: string;
|
|
11
|
+
error?: string | StructuredErrorPayload;
|
|
12
|
+
};
|
|
13
|
+
export type DaemonShutdownParams = {
|
|
14
|
+
expectedInstanceId: string;
|
|
15
|
+
};
|
|
16
|
+
export type DaemonShutdownResult = {
|
|
17
|
+
stopping: true;
|
|
18
|
+
instanceId: string;
|
|
11
19
|
};
|
|
12
20
|
export type DaemonStatus = {
|
|
13
21
|
running: boolean;
|
|
14
22
|
pid?: number;
|
|
15
23
|
port?: number;
|
|
24
|
+
socketPath?: string;
|
|
25
|
+
transport?: 'socket';
|
|
16
26
|
uptime?: number;
|
|
17
27
|
version?: string;
|
|
18
28
|
};
|
|
29
|
+
/** Handshake payload returned by the daemon `ping` method. */
|
|
30
|
+
export type DaemonPingResult = {
|
|
31
|
+
pid: number;
|
|
32
|
+
instanceId: string;
|
|
33
|
+
uptime: number;
|
|
34
|
+
version: string;
|
|
35
|
+
transport: 'socket';
|
|
36
|
+
socketPath: string;
|
|
37
|
+
/** Identity the daemon connected with; null when running unauthenticated. */
|
|
38
|
+
userId: string | null;
|
|
39
|
+
/** Non-secret fingerprint of the exact authorization token used by this daemon. */
|
|
40
|
+
tokenFingerprint: string | null;
|
|
41
|
+
/** Non-secret fingerprint of endpoints, upload paths, and sync runtime settings. */
|
|
42
|
+
runtimeConfigFingerprint: string | null;
|
|
43
|
+
/** True only for daemons using the current authenticated local protocol. */
|
|
44
|
+
authenticated: boolean;
|
|
45
|
+
};
|
|
46
|
+
/** Kept only for old scripts that pass --port; the daemon transport is socket-based. */
|
|
19
47
|
export declare const DEFAULT_DAEMON_PORT = 45678;
|
package/dist/daemon/types.js
CHANGED