@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
@@ -1,8 +1,15 @@
1
- import { homedir } from 'os';
2
- import { join } from 'path';
3
- import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from 'fs';
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(homedir(), '.dinox', 'daemon');
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 savePid(pid, port) {
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
- writeFileSync(getPidFilePath(), String(pid));
19
- writeFileSync(getPortFilePath(), String(port));
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 pid = parseInt(readFileSync(getPidFilePath(), 'utf-8').trim());
24
- const port = parseInt(readFileSync(getPortFilePath(), 'utf-8').trim());
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
- for (const path of [getPidFilePath(), getPortFilePath()]) {
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
+ }
@@ -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
  }
@@ -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 { connectPowerSync, waitForIdleDataFlow } from '../powersync/runtime.js';
5
- import { searchNotes, getNoteDetails } from '../commands/notes/repo.js';
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 { searchTodoTasks } from '../commands/todo/repo.js';
8
- import { savePid, removePid } from './pid.js';
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 config = resolveConfig(await loadConfig());
41
- const { db } = await connectPowerSync({ config, offline: false, timeoutMs: config.sync.timeoutMs });
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
- setInterval(async () => {
44
- if (this.db) {
45
- await waitForIdleDataFlow(this.db, 5000).catch(() => { });
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 { id: req.id, ok: true, result: { uptime: Date.now() - this.startTime, version: getPackageVersion() } };
58
- case 'note.search':
59
- const notes = await searchNotes(this.db, params.query || '', {
60
- includeDeleted: params.includeDeleted || false,
61
- tagsExpression: params.tags,
62
- createdAtFrom: params.from,
63
- createdAtTo: params.to,
64
- });
65
- return { id: req.id, ok: true, result: notes };
66
- case 'note.get':
67
- const noteDetails = await getNoteDetails(this.db, [params.id]);
68
- return { id: req.id, ok: true, result: noteDetails[0] || null };
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
- const todos = await searchTodoTasks(this.db, params.query || '', {
83
- includeDeleted: false,
84
- taskStatus: params.status || 'all',
85
- taskTagFilter: params.tags || [],
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.message };
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
- await new Promise((resolve, reject) => {
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
- reject(new Error('Server not initialized'));
124
- return;
238
+ throw error;
125
239
  }
126
- this.server.once('error', reject);
127
- this.server.listen(port, '127.0.0.1', () => resolve());
128
- });
129
- savePid(process.pid, port);
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.server) {
133
- await new Promise((resolve) => this.server?.close(() => resolve()));
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.db) {
136
- await this.db.close().catch(() => { });
286
+ if (this.ownsArtifacts) {
287
+ removePid(this.instanceId, { allowMissingOwner: true });
288
+ this.ownsArtifacts = false;
137
289
  }
138
- removePid();
139
290
  }
140
291
  }
@@ -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;
@@ -1 +1,2 @@
1
+ /** Kept only for old scripts that pass --port; the daemon transport is socket-based. */
1
2
  export const DEFAULT_DAEMON_PORT = 45678;