docdex 0.2.22 → 0.2.24

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.
@@ -3,6 +3,7 @@
3
3
 
4
4
  const fs = require("node:fs");
5
5
  const net = require("node:net");
6
+ const http = require("node:http");
6
7
  const os = require("node:os");
7
8
  const path = require("node:path");
8
9
  const readline = require("node:readline");
@@ -12,8 +13,11 @@ const { spawn, spawnSync } = require("node:child_process");
12
13
  const { detectPlatformKey, UnsupportedPlatformError } = require("./platform");
13
14
 
14
15
  const DEFAULT_HOST = "127.0.0.1";
15
- const DEFAULT_PORT_PRIMARY = 3000;
16
- const DEFAULT_PORT_FALLBACK = 3210;
16
+ const DEFAULT_DAEMON_PORT = 28491;
17
+ const DAEMON_HEALTH_TIMEOUT_MS = 8000;
18
+ const DAEMON_HEALTH_REQUEST_TIMEOUT_MS = 1000;
19
+ const DAEMON_HEALTH_POLL_INTERVAL_MS = 200;
20
+ const DAEMON_HEALTH_PATH = "/healthz";
17
21
  const STARTUP_FAILURE_MARKER = "startup_registration_failed.json";
18
22
  const DEFAULT_OLLAMA_MODEL = "nomic-embed-text";
19
23
  const DEFAULT_OLLAMA_CHAT_MODEL = "phi3.5:3.8b";
@@ -39,6 +43,17 @@ function setupPendingPath() {
39
43
  return path.join(stateDir(), SETUP_PENDING_MARKER);
40
44
  }
41
45
 
46
+ function daemonLockPaths() {
47
+ const root = path.join(os.homedir(), ".docdex");
48
+ const paths = [];
49
+ if (process.env.DOCDEX_DAEMON_LOCK_PATH) {
50
+ paths.push(process.env.DOCDEX_DAEMON_LOCK_PATH);
51
+ }
52
+ paths.push(path.join(root, "locks", "daemon.lock"));
53
+ paths.push(path.join(root, "daemon.lock"));
54
+ return Array.from(new Set(paths.filter(Boolean)));
55
+ }
56
+
42
57
  function configUrlForPort(port) {
43
58
  return `http://localhost:${port}/sse`;
44
59
  }
@@ -59,22 +74,51 @@ function isPortAvailable(port, host) {
59
74
  });
60
75
  }
61
76
 
62
- async function pickAvailablePort(host, preferred) {
63
- for (const port of preferred) {
64
- if (await isPortAvailable(port, host)) return port;
65
- }
66
- return new Promise((resolve, reject) => {
67
- const server = net.createServer();
68
- server.unref();
69
- server.once("error", reject);
70
- server.once("listening", () => {
71
- const addr = server.address();
72
- server.close(() => resolve(addr.port));
77
+ function sleep(ms) {
78
+ return new Promise((resolve) => setTimeout(resolve, ms));
79
+ }
80
+
81
+ function checkDaemonHealth({ host, port, timeoutMs = DAEMON_HEALTH_REQUEST_TIMEOUT_MS }) {
82
+ return new Promise((resolve) => {
83
+ const req = http.request(
84
+ {
85
+ host,
86
+ port,
87
+ path: DAEMON_HEALTH_PATH,
88
+ method: "GET",
89
+ timeout: timeoutMs
90
+ },
91
+ (res) => {
92
+ let body = "";
93
+ res.setEncoding("utf8");
94
+ res.on("data", (chunk) => {
95
+ body += chunk;
96
+ });
97
+ res.on("end", () => {
98
+ resolve(res.statusCode === 200 && body.trim() === "ok");
99
+ });
100
+ }
101
+ );
102
+ req.on("timeout", () => {
103
+ req.destroy();
104
+ resolve(false);
73
105
  });
74
- server.listen(0, host);
106
+ req.on("error", () => resolve(false));
107
+ req.end();
75
108
  });
76
109
  }
77
110
 
111
+ async function waitForDaemonHealthy({ host, port, timeoutMs = DAEMON_HEALTH_TIMEOUT_MS }) {
112
+ const deadline = Date.now() + timeoutMs;
113
+ while (Date.now() < deadline) {
114
+ if (await checkDaemonHealth({ host, port })) {
115
+ return true;
116
+ }
117
+ await sleep(DAEMON_HEALTH_POLL_INTERVAL_MS);
118
+ }
119
+ return false;
120
+ }
121
+
78
122
  function parseServerBind(contents) {
79
123
  let inServer = false;
80
124
  const lines = contents.split(/\r?\n/);
@@ -91,6 +135,125 @@ function parseServerBind(contents) {
91
135
  return null;
92
136
  }
93
137
 
138
+ function stopDaemonService({ logger } = {}) {
139
+ if (process.platform === "darwin") {
140
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
141
+ const domain = uid != null ? `gui/${uid}` : null;
142
+ const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", "com.docdex.daemon.plist");
143
+ const bootoutByLabel = domain
144
+ ? spawnSync("launchctl", ["bootout", domain, "com.docdex.daemon"])
145
+ : spawnSync("launchctl", ["bootout", "com.docdex.daemon"]);
146
+ const bootoutByPath = domain
147
+ ? spawnSync("launchctl", ["bootout", domain, plistPath])
148
+ : spawnSync("launchctl", ["bootout", plistPath]);
149
+ const fallback = spawnSync("launchctl", ["unload", "-w", plistPath]);
150
+ spawnSync("launchctl", ["remove", "com.docdex.daemon"]);
151
+ if (bootoutByLabel.status === 0 || bootoutByPath.status === 0 || fallback.status === 0) {
152
+ return true;
153
+ }
154
+ logger?.warn?.(
155
+ `[docdex] launchctl stop failed: ${bootoutByLabel.stderr || bootoutByPath.stderr || fallback.stderr || "unknown error"}`
156
+ );
157
+ return false;
158
+ }
159
+ if (process.platform === "linux") {
160
+ const stop = spawnSync("systemctl", ["--user", "stop", "docdexd.service"]);
161
+ if (stop.status === 0) return true;
162
+ logger?.warn?.(`[docdex] systemd stop failed: ${stop.stderr || "unknown error"}`);
163
+ return false;
164
+ }
165
+ if (process.platform === "win32") {
166
+ const stop = spawnSync("schtasks", ["/End", "/TN", "Docdex Daemon"]);
167
+ if (stop.status === 0) return true;
168
+ logger?.warn?.(`[docdex] schtasks stop failed: ${stop.stderr || "unknown error"}`);
169
+ return false;
170
+ }
171
+ return false;
172
+ }
173
+
174
+ function startDaemonService({ logger } = {}) {
175
+ if (process.platform === "darwin") {
176
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
177
+ const domain = uid != null ? `gui/${uid}` : null;
178
+ const kickstart = domain
179
+ ? spawnSync("launchctl", ["kickstart", "-k", `${domain}/com.docdex.daemon`])
180
+ : spawnSync("launchctl", ["kickstart", "-k", "com.docdex.daemon"]);
181
+ if (kickstart.status === 0) return true;
182
+ const start = spawnSync("launchctl", ["start", "com.docdex.daemon"]);
183
+ if (start.status === 0) return true;
184
+ logger?.warn?.(`[docdex] launchctl start failed: ${kickstart.stderr || start.stderr || "unknown error"}`);
185
+ return false;
186
+ }
187
+ if (process.platform === "linux") {
188
+ const start = spawnSync("systemctl", ["--user", "restart", "docdexd.service"]);
189
+ if (start.status === 0) return true;
190
+ logger?.warn?.(`[docdex] systemd start failed: ${start.stderr || "unknown error"}`);
191
+ return false;
192
+ }
193
+ if (process.platform === "win32") {
194
+ const run = spawnSync("schtasks", ["/Run", "/TN", "Docdex Daemon"]);
195
+ if (run.status === 0) return true;
196
+ logger?.warn?.(`[docdex] schtasks run failed: ${run.stderr || "unknown error"}`);
197
+ return false;
198
+ }
199
+ return false;
200
+ }
201
+
202
+ function stopDaemonByName({ logger } = {}) {
203
+ if (process.platform === "win32") {
204
+ const result = spawnSync("taskkill", ["/IM", "docdexd.exe", "/T", "/F"]);
205
+ if (result?.error?.code === "ENOENT") return false;
206
+ return true;
207
+ }
208
+ const result = spawnSync("pkill", ["-TERM", "-x", "docdexd"]);
209
+ if (result?.error?.code === "ENOENT") {
210
+ spawnSync("killall", ["-TERM", "docdexd"]);
211
+ return false;
212
+ }
213
+ spawnSync("pkill", ["-TERM", "-f", "docdexd"]);
214
+ return true;
215
+ }
216
+
217
+ function clearDaemonLocks() {
218
+ let removed = false;
219
+ for (const lockPath of daemonLockPaths()) {
220
+ if (!lockPath || !fs.existsSync(lockPath)) continue;
221
+ try {
222
+ const resolved = path.resolve(lockPath);
223
+ const home = path.resolve(os.homedir());
224
+ if (!resolved.startsWith(home + path.sep)) continue;
225
+ fs.unlinkSync(lockPath);
226
+ removed = true;
227
+ } catch {
228
+ continue;
229
+ }
230
+ }
231
+ return removed;
232
+ }
233
+
234
+ function stopDaemonFromLock({ logger } = {}) {
235
+ let stopped = false;
236
+ for (const lockPath of daemonLockPaths()) {
237
+ if (!lockPath || !fs.existsSync(lockPath)) continue;
238
+ try {
239
+ const raw = fs.readFileSync(lockPath, "utf8");
240
+ if (!raw.trim()) continue;
241
+ const payload = JSON.parse(raw);
242
+ const pid = Number(payload?.pid);
243
+ if (!Number.isFinite(pid) || pid <= 0) continue;
244
+ try {
245
+ process.kill(pid, "SIGTERM");
246
+ stopped = true;
247
+ } catch (err) {
248
+ logger?.warn?.(`[docdex] failed to stop daemon pid ${pid}: ${err?.message || err}`);
249
+ }
250
+ } catch {
251
+ continue;
252
+ }
253
+ }
254
+ return stopped;
255
+ }
256
+
94
257
  function upsertServerConfig(contents, httpBindAddr) {
95
258
  const lines = contents.split(/\r?\n/);
96
259
  const output = [];
@@ -400,7 +563,7 @@ function upsertClaudeInstructions(pathname, instructions) {
400
563
  return true;
401
564
  }
402
565
 
403
- function upsertContinueInstructions(pathname, instructions) {
566
+ function upsertContinueJsonInstructions(pathname, instructions) {
404
567
  const { value } = readJson(pathname);
405
568
  if (typeof value !== "object" || value == null || Array.isArray(value)) return false;
406
569
  const merged = mergeInstructionText(value.systemMessage, instructions);
@@ -410,6 +573,198 @@ function upsertContinueInstructions(pathname, instructions) {
410
573
  return true;
411
574
  }
412
575
 
576
+ function countLeadingWhitespace(line) {
577
+ const match = line.match(/^\s*/);
578
+ return match ? match[0].length : 0;
579
+ }
580
+
581
+ function isYamlTopLevelKey(line, baseIndent) {
582
+ const indent = countLeadingWhitespace(line);
583
+ if (indent > baseIndent) return false;
584
+ const trimmed = line.trimStart();
585
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-")) return false;
586
+ return trimmed.includes(":");
587
+ }
588
+
589
+ function hasYamlContent(lines) {
590
+ return lines.some((line) => {
591
+ const trimmed = line.trim();
592
+ return trimmed && !trimmed.startsWith("#");
593
+ });
594
+ }
595
+
596
+ function splitInlineYamlList(value) {
597
+ const trimmed = String(value || "").trim();
598
+ if (trimmed === "[]") return [];
599
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
600
+ const inner = trimmed.slice(1, -1);
601
+ const items = [];
602
+ let current = "";
603
+ let inSingle = false;
604
+ let inDouble = false;
605
+ let escaped = false;
606
+ for (const ch of inner) {
607
+ if (escaped) {
608
+ current += ch;
609
+ escaped = false;
610
+ continue;
611
+ }
612
+ if (ch === "\\\\") {
613
+ escaped = true;
614
+ current += ch;
615
+ continue;
616
+ }
617
+ if (ch === "'" && !inDouble) {
618
+ inSingle = !inSingle;
619
+ current += ch;
620
+ continue;
621
+ }
622
+ if (ch === '"' && !inSingle) {
623
+ inDouble = !inDouble;
624
+ current += ch;
625
+ continue;
626
+ }
627
+ if (ch === "," && !inSingle && !inDouble) {
628
+ const next = current.trim();
629
+ if (next) items.push(next);
630
+ current = "";
631
+ continue;
632
+ }
633
+ current += ch;
634
+ }
635
+ const next = current.trim();
636
+ if (next) items.push(next);
637
+ return items;
638
+ }
639
+
640
+ function inlineRulesToItems(value, itemIndent) {
641
+ const trimmed = String(value || "").trim();
642
+ if (!trimmed) return [];
643
+ const prefix = " ".repeat(itemIndent);
644
+ const split = splitInlineYamlList(trimmed);
645
+ if (split) {
646
+ return split.map((item) => [`${prefix}- ${item}`]).filter((item) => item[0].trim() !== `${prefix}-`);
647
+ }
648
+ return [[`${prefix}- ${trimmed}`]];
649
+ }
650
+
651
+ function buildYamlRuleBlock(itemIndent, instructions) {
652
+ const prefix = " ".repeat(itemIndent);
653
+ const contentPrefix = " ".repeat(itemIndent + 2);
654
+ const lines = [`${prefix}- |`];
655
+ for (const line of String(instructions).split(/\r?\n/)) {
656
+ lines.push(`${contentPrefix}${line}`);
657
+ }
658
+ return lines;
659
+ }
660
+
661
+ function rewriteContinueYamlRules(source, instructions, addDocdex) {
662
+ const lines = String(source || "").split(/\r?\n/);
663
+ const ruleLineRe = /^(\s*)rules\s*:(.*)$/;
664
+ let rulesIndex = -1;
665
+ let rulesIndent = 0;
666
+ let rulesInline = "";
667
+ for (let i = 0; i < lines.length; i += 1) {
668
+ const match = lines[i].match(ruleLineRe);
669
+ if (!match) continue;
670
+ rulesIndex = i;
671
+ rulesIndent = match[1]?.length || 0;
672
+ rulesInline = (match[2] || "").trim();
673
+ if (rulesInline.includes("#")) {
674
+ rulesInline = rulesInline.split("#")[0].trim();
675
+ }
676
+ if (rulesInline.startsWith("#")) rulesInline = "";
677
+ break;
678
+ }
679
+
680
+ if (rulesIndex === -1) {
681
+ if (!addDocdex) return null;
682
+ const trimmed = String(source || "").trimEnd();
683
+ const docdexBlock = buildYamlRuleBlock(2, instructions);
684
+ const prefix = trimmed ? `${trimmed}\n\n` : "";
685
+ return `${prefix}rules:\n${docdexBlock.join("\n")}`;
686
+ }
687
+
688
+ let endIndex = lines.length;
689
+ for (let i = rulesIndex + 1; i < lines.length; i += 1) {
690
+ if (isYamlTopLevelKey(lines[i], rulesIndent)) {
691
+ endIndex = i;
692
+ break;
693
+ }
694
+ }
695
+ const blockLines = lines.slice(rulesIndex + 1, endIndex);
696
+ const preLines = [];
697
+ const items = [];
698
+ let currentItem = [];
699
+ let itemIndent = null;
700
+ let startedItems = false;
701
+
702
+ for (const line of blockLines) {
703
+ const trimmed = line.trimStart();
704
+ const indent = countLeadingWhitespace(line);
705
+ const isItem = trimmed.startsWith("-") && indent > rulesIndent;
706
+ if (isItem) {
707
+ if (itemIndent == null) itemIndent = indent;
708
+ if (indent === itemIndent) {
709
+ if (startedItems && currentItem.length) {
710
+ items.push(currentItem);
711
+ currentItem = [];
712
+ }
713
+ startedItems = true;
714
+ }
715
+ currentItem.push(line);
716
+ continue;
717
+ }
718
+ if (startedItems) {
719
+ currentItem.push(line);
720
+ } else {
721
+ preLines.push(line);
722
+ }
723
+ }
724
+ if (currentItem.length) items.push(currentItem);
725
+
726
+ const inferredIndent = itemIndent == null ? rulesIndent + 2 : itemIndent;
727
+ if (!items.length && rulesInline) {
728
+ items.push(...inlineRulesToItems(rulesInline, inferredIndent));
729
+ rulesInline = "";
730
+ }
731
+
732
+ const keptItems = items.filter((item) => {
733
+ const text = item.join("\n");
734
+ return !(text.includes(DOCDEX_INFO_START_PREFIX) && text.includes(DOCDEX_INFO_END));
735
+ });
736
+
737
+ if (addDocdex) {
738
+ keptItems.push(buildYamlRuleBlock(inferredIndent, instructions));
739
+ }
740
+
741
+ const removeRulesBlock =
742
+ !addDocdex && !keptItems.length && !hasYamlContent(preLines) && !rulesInline;
743
+
744
+ const output = [];
745
+ output.push(...lines.slice(0, rulesIndex));
746
+ if (!removeRulesBlock) {
747
+ output.push(`${" ".repeat(rulesIndent)}rules:`);
748
+ output.push(...preLines);
749
+ for (const item of keptItems) {
750
+ output.push(...item);
751
+ }
752
+ }
753
+ output.push(...lines.slice(endIndex));
754
+ const next = output.join("\n");
755
+ return next === source ? null : next;
756
+ }
757
+
758
+ function upsertContinueYamlRules(pathname, instructions) {
759
+ if (!fs.existsSync(pathname)) return false;
760
+ const normalized = normalizeInstructionText(instructions);
761
+ if (!normalized) return false;
762
+ const current = fs.readFileSync(pathname, "utf8");
763
+ const updated = rewriteContinueYamlRules(current, normalized, true);
764
+ if (!updated) return false;
765
+ return writeTextFile(pathname, updated);
766
+ }
767
+
413
768
  function upsertZedInstructions(pathname, instructions) {
414
769
  const { value } = readJson(pathname);
415
770
  if (typeof value !== "object" || value == null || Array.isArray(value)) return false;
@@ -423,12 +778,56 @@ function upsertZedInstructions(pathname, instructions) {
423
778
  return true;
424
779
  }
425
780
 
426
- function upsertVsCodeInstructions(pathname, instructionsPath) {
781
+ function upsertVsCodeInstructionKey(value, key, instructions) {
782
+ const existing = typeof value[key] === "string" ? value[key] : "";
783
+ const merged = mergeInstructionText(existing, instructions);
784
+ if (!merged || merged === existing) return false;
785
+ value[key] = merged;
786
+ return true;
787
+ }
788
+
789
+ function upsertVsCodeInstructionLocations(value, instructionsDir) {
790
+ const key = "chat.instructionsFilesLocations";
791
+ const location = String(instructionsDir);
792
+ if (value[key] && typeof value[key] === "object" && !Array.isArray(value[key])) {
793
+ if (value[key][location] === true) return false;
794
+ value[key][location] = true;
795
+ return true;
796
+ }
797
+ if (Array.isArray(value[key])) {
798
+ if (value[key].some((entry) => entry === location)) return false;
799
+ value[key].push(location);
800
+ return true;
801
+ }
802
+ if (typeof value[key] === "string") {
803
+ if (value[key] === location) return false;
804
+ value[key] = [value[key], location];
805
+ return true;
806
+ }
807
+ value[key] = { [location]: true };
808
+ return true;
809
+ }
810
+
811
+ function upsertVsCodeInstructions(pathname, instructions, instructionsDir) {
427
812
  const { value } = readJson(pathname);
428
813
  if (typeof value !== "object" || value == null || Array.isArray(value)) return false;
429
- const key = "copilot.chat.codeGeneration.instructions";
430
- if (value[key] === instructionsPath) return false;
431
- value[key] = instructionsPath;
814
+ const normalized = normalizeInstructionText(instructions);
815
+ if (!normalized) return false;
816
+ let updated = false;
817
+ if (upsertVsCodeInstructionKey(value, "github.copilot.chat.codeGeneration.instructions", instructions)) {
818
+ updated = true;
819
+ }
820
+ if (upsertVsCodeInstructionKey(value, "copilot.chat.codeGeneration.instructions", instructions)) {
821
+ updated = true;
822
+ }
823
+ if (value["github.copilot.chat.codeGeneration.useInstructionFiles"] !== true) {
824
+ value["github.copilot.chat.codeGeneration.useInstructionFiles"] = true;
825
+ updated = true;
826
+ }
827
+ if (upsertVsCodeInstructionLocations(value, instructionsDir)) {
828
+ updated = true;
829
+ }
830
+ if (!updated) return false;
432
831
  writeJson(pathname, value);
433
832
  return true;
434
833
  }
@@ -758,6 +1157,12 @@ function clientInstructionPaths() {
758
1157
  const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
759
1158
  const userProfile = process.env.USERPROFILE || home;
760
1159
  const vscodeGlobalInstructions = path.join(home, ".vscode", "global_instructions.md");
1160
+ const vscodeInstructionsDir = path.join(home, ".vscode", "instructions");
1161
+ const vscodeInstructionsFile = path.join(vscodeInstructionsDir, "docdex.md");
1162
+ const continueRoot = path.join(userProfile, ".continue");
1163
+ const continueJson = path.join(continueRoot, "config.json");
1164
+ const continueYaml = path.join(continueRoot, "config.yaml");
1165
+ const continueYml = path.join(continueRoot, "config.yml");
761
1166
  const windsurfGlobalRules = path.join(userProfile, ".codeium", "windsurf", "memories", "global_rules.md");
762
1167
  const rooRules = path.join(home, ".roo", "rules", "docdex.md");
763
1168
  const pearaiAgent = path.join(home, ".config", "pearai", "agent.md");
@@ -769,10 +1174,14 @@ function clientInstructionPaths() {
769
1174
  case "win32":
770
1175
  return {
771
1176
  claude: path.join(appData, "Claude", "claude_desktop_config.json"),
772
- continue: path.join(userProfile, ".continue", "config.json"),
1177
+ continue: continueJson,
1178
+ continueYaml,
1179
+ continueYml,
773
1180
  zed: path.join(appData, "Zed", "settings.json"),
774
1181
  vscodeSettings: path.join(appData, "Code", "User", "settings.json"),
775
1182
  vscodeGlobalInstructions,
1183
+ vscodeInstructionsDir,
1184
+ vscodeInstructionsFile,
776
1185
  windsurfGlobalRules,
777
1186
  rooRules,
778
1187
  pearaiAgent,
@@ -784,10 +1193,14 @@ function clientInstructionPaths() {
784
1193
  case "darwin":
785
1194
  return {
786
1195
  claude: path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
787
- continue: path.join(home, ".continue", "config.json"),
1196
+ continue: continueJson,
1197
+ continueYaml,
1198
+ continueYml,
788
1199
  zed: path.join(home, ".config", "zed", "settings.json"),
789
1200
  vscodeSettings: path.join(home, "Library", "Application Support", "Code", "User", "settings.json"),
790
1201
  vscodeGlobalInstructions,
1202
+ vscodeInstructionsDir,
1203
+ vscodeInstructionsFile,
791
1204
  windsurfGlobalRules,
792
1205
  rooRules,
793
1206
  pearaiAgent,
@@ -799,10 +1212,14 @@ function clientInstructionPaths() {
799
1212
  default:
800
1213
  return {
801
1214
  claude: path.join(home, ".config", "Claude", "claude_desktop_config.json"),
802
- continue: path.join(home, ".continue", "config.json"),
1215
+ continue: continueJson,
1216
+ continueYaml,
1217
+ continueYml,
803
1218
  zed: path.join(home, ".config", "zed", "settings.json"),
804
1219
  vscodeSettings: path.join(home, ".config", "Code", "User", "settings.json"),
805
1220
  vscodeGlobalInstructions,
1221
+ vscodeInstructionsDir,
1222
+ vscodeInstructionsFile,
806
1223
  windsurfGlobalRules,
807
1224
  rooRules,
808
1225
  pearaiAgent,
@@ -826,6 +1243,41 @@ function resolveBinaryPath({ binaryPath } = {}) {
826
1243
  return null;
827
1244
  }
828
1245
 
1246
+ function isPathWithin(parent, candidate) {
1247
+ const base = path.resolve(parent);
1248
+ const target = path.resolve(candidate);
1249
+ if (base === target) return true;
1250
+ return target.startsWith(base + path.sep);
1251
+ }
1252
+
1253
+ function isMacProtectedPath(candidate) {
1254
+ if (process.platform !== "darwin") return false;
1255
+ const home = os.homedir();
1256
+ return ["Desktop", "Documents", "Downloads"].some((dir) => isPathWithin(path.join(home, dir), candidate));
1257
+ }
1258
+
1259
+ function ensureStartupBinary(binaryPath, { logger } = {}) {
1260
+ if (!binaryPath) return null;
1261
+ if (!isMacProtectedPath(binaryPath) && !isTempPath(binaryPath)) return binaryPath;
1262
+ const binDir = path.join(os.homedir(), ".docdex", "bin");
1263
+ const target = path.join(binDir, path.basename(binaryPath));
1264
+ if (fs.existsSync(target)) return target;
1265
+ try {
1266
+ fs.mkdirSync(binDir, { recursive: true });
1267
+ fs.copyFileSync(binaryPath, target);
1268
+ fs.chmodSync(target, 0o755);
1269
+ return target;
1270
+ } catch (err) {
1271
+ logger?.warn?.(`[docdex] failed to stage daemon binary for startup: ${err?.message || err}`);
1272
+ return binaryPath;
1273
+ }
1274
+ }
1275
+
1276
+ function resolveStartupBinaryPaths({ binaryPath, logger } = {}) {
1277
+ const resolvedBinary = ensureStartupBinary(binaryPath, { logger });
1278
+ return { binaryPath: resolvedBinary };
1279
+ }
1280
+
829
1281
  function applyAgentInstructions({ logger } = {}) {
830
1282
  const instructions = buildDocdexInstructionBlock(loadAgentInstructions());
831
1283
  if (!normalizeInstructionText(instructions)) return { ok: false, reason: "missing_instructions" };
@@ -847,8 +1299,15 @@ function applyAgentInstructions({ logger } = {}) {
847
1299
  upsertPromptFile(paths.vscodeGlobalInstructions, instructions, { prepend: true })
848
1300
  );
849
1301
  }
850
- if (paths.vscodeSettings && paths.vscodeGlobalInstructions) {
851
- safeApply("vscode-settings", () => upsertVsCodeInstructions(paths.vscodeSettings, paths.vscodeGlobalInstructions));
1302
+ if (paths.vscodeInstructionsFile) {
1303
+ safeApply("vscode-instructions-file", () =>
1304
+ upsertPromptFile(paths.vscodeInstructionsFile, instructions, { prepend: true })
1305
+ );
1306
+ }
1307
+ if (paths.vscodeSettings && paths.vscodeInstructionsDir) {
1308
+ safeApply("vscode-settings", () =>
1309
+ upsertVsCodeInstructions(paths.vscodeSettings, instructions, paths.vscodeInstructionsDir)
1310
+ );
852
1311
  }
853
1312
  if (paths.windsurfGlobalRules) {
854
1313
  safeApply("windsurf", () => upsertPromptFile(paths.windsurfGlobalRules, instructions, { prepend: true }));
@@ -862,18 +1321,25 @@ function applyAgentInstructions({ logger } = {}) {
862
1321
  if (paths.claude) {
863
1322
  safeApply("claude", () => upsertClaudeInstructions(paths.claude, instructions));
864
1323
  }
865
- if (paths.continue) {
866
- safeApply("continue", () => upsertContinueInstructions(paths.continue, instructions));
1324
+ const continueYamlExists =
1325
+ (paths.continueYaml && fs.existsSync(paths.continueYaml)) ||
1326
+ (paths.continueYml && fs.existsSync(paths.continueYml));
1327
+ if (continueYamlExists) {
1328
+ if (paths.continueYaml && fs.existsSync(paths.continueYaml)) {
1329
+ safeApply("continue-yaml", () => upsertContinueYamlRules(paths.continueYaml, instructions));
1330
+ }
1331
+ if (paths.continueYml && fs.existsSync(paths.continueYml)) {
1332
+ safeApply("continue-yml", () => upsertContinueYamlRules(paths.continueYml, instructions));
1333
+ }
1334
+ if (paths.continue && fs.existsSync(paths.continue)) {
1335
+ safeApply("continue-json", () => upsertContinueJsonInstructions(paths.continue, instructions));
1336
+ }
1337
+ } else if (paths.continue) {
1338
+ safeApply("continue-json", () => upsertContinueJsonInstructions(paths.continue, instructions));
867
1339
  }
868
1340
  if (paths.zed) {
869
1341
  safeApply("zed", () => upsertZedInstructions(paths.zed, instructions));
870
1342
  }
871
- if (paths.aiderConfig) {
872
- safeApply("aider", () => upsertYamlInstruction(paths.aiderConfig, "system-prompt", instructions));
873
- }
874
- if (paths.gooseConfig) {
875
- safeApply("goose", () => upsertYamlInstruction(paths.gooseConfig, "instructions", instructions));
876
- }
877
1343
  if (paths.openInterpreterConfig) {
878
1344
  safeApply("open-interpreter", () =>
879
1345
  upsertYamlInstruction(paths.openInterpreterConfig, "system_message", instructions)
@@ -886,14 +1352,6 @@ function applyAgentInstructions({ logger } = {}) {
886
1352
  return { ok: true, updated };
887
1353
  }
888
1354
 
889
- function resolveMcpBinaryPath(binaryPath) {
890
- if (!binaryPath) return null;
891
- const dir = path.dirname(binaryPath);
892
- const name = process.platform === "win32" ? "docdex-mcp-server.exe" : "docdex-mcp-server";
893
- const candidate = path.join(dir, name);
894
- return fs.existsSync(candidate) ? candidate : null;
895
- }
896
-
897
1355
  function ensureDaemonRoot() {
898
1356
  const root = daemonRootPath();
899
1357
  fs.mkdirSync(root, { recursive: true });
@@ -1464,30 +1922,36 @@ async function maybePromptOllamaModel({
1464
1922
  return { status: "skipped", reason: "invalid_selection" };
1465
1923
  }
1466
1924
 
1467
- function resolvePlaywrightFetcherPath() {
1468
- const candidate = path.join(__dirname, "playwright_fetch.js");
1469
- return fs.existsSync(candidate) ? candidate : null;
1925
+ function envBool(value) {
1926
+ if (!value) return false;
1927
+ const normalized = String(value).trim().toLowerCase();
1928
+ return ["1", "true", "t", "yes", "y", "on"].includes(normalized);
1470
1929
  }
1471
1930
 
1472
- function buildDaemonEnvPairs({ mcpBinaryPath } = {}) {
1473
- const pairs = [["DOCDEX_BROWSER_AUTO_INSTALL", "0"]];
1474
- if (mcpBinaryPath) pairs.push(["DOCDEX_MCP_SERVER_BIN", mcpBinaryPath]);
1475
- const fetcher = resolvePlaywrightFetcherPath();
1476
- if (fetcher) pairs.push(["DOCDEX_PLAYWRIGHT_FETCHER", fetcher]);
1477
- return pairs;
1931
+ function isTempPath(value, osModule = os) {
1932
+ if (!value) return false;
1933
+ const tmpdir = osModule.tmpdir();
1934
+ if (!tmpdir) return false;
1935
+ const resolvedValue = path.resolve(value);
1936
+ const resolvedTmp = path.resolve(tmpdir);
1937
+ return resolvedValue === resolvedTmp || resolvedValue.startsWith(resolvedTmp + path.sep);
1478
1938
  }
1479
1939
 
1480
- function buildDaemonEnv({ mcpBinaryPath } = {}) {
1481
- return Object.fromEntries(buildDaemonEnvPairs({ mcpBinaryPath }));
1940
+ function buildDaemonEnvPairs() {
1941
+ return [["DOCDEX_BROWSER_AUTO_INSTALL", "0"]];
1482
1942
  }
1483
1943
 
1484
- function registerStartup({ binaryPath, mcpBinaryPath, port, repoRoot, logger }) {
1944
+ function buildDaemonEnv() {
1945
+ return Object.fromEntries(buildDaemonEnvPairs());
1946
+ }
1947
+
1948
+ function registerStartup({ binaryPath, port, repoRoot, logger }) {
1485
1949
  if (!binaryPath) return { ok: false, reason: "missing_binary" };
1486
- const envPairs = buildDaemonEnvPairs({ mcpBinaryPath });
1950
+ stopDaemonService({ logger });
1951
+ const envPairs = buildDaemonEnvPairs();
1952
+ const workingDir = repoRoot ? path.resolve(repoRoot) : null;
1487
1953
  const args = [
1488
1954
  "daemon",
1489
- "--repo",
1490
- repoRoot,
1491
1955
  "--host",
1492
1956
  DEFAULT_HOST,
1493
1957
  "--port",
@@ -1520,6 +1984,9 @@ function registerStartup({ binaryPath, mcpBinaryPath, port, repoRoot, logger })
1520
1984
  ` <array>\n` +
1521
1985
  programArgs.map((arg) => ` <string>${arg}</string>\n`).join("") +
1522
1986
  ` </array>\n` +
1987
+ (workingDir
1988
+ ? ` <key>WorkingDirectory</key>\n` + ` <string>${workingDir}</string>\n`
1989
+ : "") +
1523
1990
  ` <key>RunAtLoad</key>\n` +
1524
1991
  ` <true/>\n` +
1525
1992
  ` <key>KeepAlive</key>\n` +
@@ -1555,6 +2022,7 @@ function registerStartup({ binaryPath, mcpBinaryPath, port, repoRoot, logger })
1555
2022
  "",
1556
2023
  "[Service]",
1557
2024
  `ExecStart=${binaryPath} ${args.join(" ")}`,
2025
+ workingDir ? `WorkingDirectory=${workingDir}` : "",
1558
2026
  ...envLines,
1559
2027
  "Restart=always",
1560
2028
  "RestartSec=2",
@@ -1575,8 +2043,9 @@ function registerStartup({ binaryPath, mcpBinaryPath, port, repoRoot, logger })
1575
2043
  const taskName = "Docdex Daemon";
1576
2044
  const joinedArgs = args.map((arg) => `"${arg}"`).join(" ");
1577
2045
  const envParts = envPairs.map(([key, value]) => `set "${key}=${value}"`);
2046
+ const cdCommand = workingDir ? `cd /d "${workingDir}"` : null;
1578
2047
  const taskArgs =
1579
- `"cmd.exe" /c "${envParts.join(" && ")} && \"${binaryPath}\" ${joinedArgs}"`;
2048
+ `"cmd.exe" /c "${envParts.join(" && ")}${cdCommand ? ` && ${cdCommand}` : ""} && \"${binaryPath}\" ${joinedArgs}"`;
1580
2049
  const create = spawnSync("schtasks", [
1581
2050
  "/Create",
1582
2051
  "/F",
@@ -1600,34 +2069,28 @@ function registerStartup({ binaryPath, mcpBinaryPath, port, repoRoot, logger })
1600
2069
  return { ok: false, reason: "unsupported_platform" };
1601
2070
  }
1602
2071
 
1603
- function startDaemonNow({ binaryPath, mcpBinaryPath, port, repoRoot }) {
1604
- if (!binaryPath) return false;
1605
- const extraEnv = buildDaemonEnv({ mcpBinaryPath });
1606
- const child = spawn(
2072
+ async function startDaemonWithHealthCheck({ binaryPath, port, host, logger }) {
2073
+ const startup = registerStartup({
1607
2074
  binaryPath,
1608
- [
1609
- "daemon",
1610
- "--repo",
1611
- repoRoot,
1612
- "--host",
1613
- DEFAULT_HOST,
1614
- "--port",
1615
- String(port),
1616
- "--log",
1617
- "warn",
1618
- "--secure-mode=false"
1619
- ],
1620
- {
1621
- stdio: "ignore",
1622
- detached: true,
1623
- env: {
1624
- ...process.env,
1625
- ...extraEnv
1626
- }
1627
- }
1628
- );
1629
- child.unref();
1630
- return true;
2075
+ port,
2076
+ repoRoot: daemonRootPath(),
2077
+ logger
2078
+ });
2079
+ if (!startup.ok) {
2080
+ logger?.warn?.(`[docdex] daemon service registration failed (${startup.reason || "unknown"}).`);
2081
+ return { ok: false, reason: "startup_failed" };
2082
+ }
2083
+ startDaemonService({ logger });
2084
+ const healthy = await waitForDaemonHealthy({ host, port });
2085
+ if (healthy) {
2086
+ return { ok: true, reason: "healthy" };
2087
+ }
2088
+ logger?.warn?.(`[docdex] daemon failed health check on ${host}:${port}`);
2089
+ stopDaemonService({ logger });
2090
+ stopDaemonFromLock({ logger });
2091
+ stopDaemonByName({ logger });
2092
+ clearDaemonLocks();
2093
+ return { ok: false, reason: "health_failed" };
1631
2094
  }
1632
2095
 
1633
2096
  function recordStartupFailure(details) {
@@ -1758,16 +2221,34 @@ async function runPostInstallSetup({ binaryPath, logger } = {}) {
1758
2221
  if (fs.existsSync(configPath)) {
1759
2222
  existingConfig = fs.readFileSync(configPath, "utf8");
1760
2223
  }
1761
- const configuredBind = existingConfig ? parseServerBind(existingConfig) : null;
1762
- let port;
1763
- if (process.env.DOCDEX_DAEMON_PORT) {
1764
- port = Number(process.env.DOCDEX_DAEMON_PORT);
1765
- } else if (configuredBind) {
1766
- const match = configuredBind.match(/:(\d+)$/);
1767
- port = match ? Number(match[1]) : null;
2224
+ const port = DEFAULT_DAEMON_PORT;
2225
+ const available = await isPortAvailable(port, DEFAULT_HOST);
2226
+ if (!available) {
2227
+ log.error?.(
2228
+ `[docdex] ${DEFAULT_HOST}:${port} is already in use; docdex requires a fixed port. Stop the process using this port and re-run the install.`
2229
+ );
2230
+ throw new Error(`docdex requires ${DEFAULT_HOST}:${port}, but the port is already in use`);
1768
2231
  }
1769
- if (!port || Number.isNaN(port)) {
1770
- port = await pickAvailablePort(DEFAULT_HOST, [DEFAULT_PORT_PRIMARY, DEFAULT_PORT_FALLBACK]);
2232
+
2233
+ const daemonRoot = ensureDaemonRoot();
2234
+ const resolvedBinary = resolveBinaryPath({ binaryPath });
2235
+ const startupBinaries = resolveStartupBinaryPaths({
2236
+ binaryPath: resolvedBinary,
2237
+ logger: log
2238
+ });
2239
+ stopDaemonService({ logger: log });
2240
+ stopDaemonFromLock({ logger: log });
2241
+ stopDaemonByName({ logger: log });
2242
+ clearDaemonLocks();
2243
+ const result = await startDaemonWithHealthCheck({
2244
+ binaryPath: startupBinaries.binaryPath,
2245
+ port,
2246
+ host: DEFAULT_HOST,
2247
+ logger: log
2248
+ });
2249
+ if (!result.ok) {
2250
+ log.warn?.(`[docdex] daemon failed to start on ${DEFAULT_HOST}:${port}.`);
2251
+ throw new Error("docdex daemon failed to start");
1771
2252
  }
1772
2253
 
1773
2254
  const httpBindAddr = `${DEFAULT_HOST}:${port}`;
@@ -1800,29 +2281,8 @@ async function runPostInstallSetup({ binaryPath, logger } = {}) {
1800
2281
  }
1801
2282
  upsertCodexConfig(paths.codex, codexUrl);
1802
2283
  applyAgentInstructions({ logger: log });
1803
-
1804
- const daemonRoot = ensureDaemonRoot();
1805
- const resolvedBinary = resolveBinaryPath({ binaryPath });
1806
- const resolvedMcpBinary = resolveMcpBinaryPath(resolvedBinary);
1807
- const startup = registerStartup({
1808
- binaryPath: resolvedBinary,
1809
- mcpBinaryPath: resolvedMcpBinary,
1810
- port,
1811
- repoRoot: daemonRoot,
1812
- logger: log
1813
- });
1814
- if (!startup.ok) {
1815
- if (!startupFailureReported()) {
1816
- log.warn?.("[docdex] startup registration failed; run the daemon manually:");
1817
- log.warn?.(`[docdex] ${resolvedBinary || "docdexd"} daemon --repo ${daemonRoot} --host ${DEFAULT_HOST} --port ${port}`);
1818
- recordStartupFailure({ reason: startup.reason, port, repoRoot: daemonRoot });
1819
- }
1820
- } else {
1821
- clearStartupFailure();
1822
- }
1823
-
1824
- startDaemonNow({ binaryPath: resolvedBinary, mcpBinaryPath: resolvedMcpBinary, port, repoRoot: daemonRoot });
1825
- const setupLaunch = launchSetupWizard({ binaryPath: resolvedBinary, logger: log });
2284
+ clearStartupFailure();
2285
+ const setupLaunch = launchSetupWizard({ binaryPath: startupBinaries.binaryPath, logger: log });
1826
2286
  if (!setupLaunch.ok && setupLaunch.reason !== "skipped") {
1827
2287
  log.warn?.("[docdex] setup wizard did not launch. Run `docdex setup`.");
1828
2288
  recordSetupPending({ reason: setupLaunch.reason, port, repoRoot: daemonRoot });
@@ -1837,7 +2297,6 @@ module.exports = {
1837
2297
  upsertMcpServerJson,
1838
2298
  upsertZedConfig,
1839
2299
  upsertCodexConfig,
1840
- pickAvailablePort,
1841
2300
  configUrlForPort,
1842
2301
  configStreamableUrlForPort,
1843
2302
  parseEnvBool,