@lifeaitools/clauth 1.30.26 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,339 +1,339 @@
1
- // cli/webdav-service.js
2
- // WebDAV child-process supervisor — rclone serve webdav
3
- //
4
- // ISOLATION: This module has zero coupling to existing fs_* tool handlers.
5
- // Rollback: set webdav.enabled=false in the config block (or remove it)
6
- // + delete this file + remove the two-line import in serve.js.
7
- //
8
- // Design: lazy-start. The rclone child spawns on the first fs_dav_setup call.
9
- // Subsequent calls return the running child's status without restarting.
10
- // On child crash: exponential-backoff restart loop (2s → 5s → 15s → 60s).
11
-
12
- import { spawn, execSync } from "child_process";
13
- import { writeFileSync, readFileSync, unlinkSync, mkdirSync, readdirSync } from "fs";
14
- import { tmpdir, homedir } from "os";
15
- import { join } from "path";
16
- import crypto from "crypto";
17
- import * as api from "./api.js";
18
- import { deriveToken } from "./fingerprint.js";
19
-
20
- const WEBDAV_PORT = 8620;
21
- const WEBDAV_USER = "claude";
22
- const CRED_NAME = "webdav-claude";
23
- const RESTART_DELAYS = [2000, 5000, 15000, 60000];
24
-
25
- const APPDATA = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
26
- const CONFIG_PATH = join(APPDATA, "clauth", "webdav-config.json");
27
-
28
- const DEFAULT_CONFIG = {
29
- enabled: true,
30
- port: WEBDAV_PORT,
31
- credential: CRED_NAME,
32
- upstreams: [],
33
- };
34
-
35
- // ── Config persistence ──────────────────────────────────────────
36
-
37
- export function loadConfig() {
38
- try {
39
- const raw = readFileSync(CONFIG_PATH, "utf8");
40
- const cfg = JSON.parse(raw);
41
- if (!Array.isArray(cfg.upstreams)) cfg.upstreams = [];
42
- return { ...DEFAULT_CONFIG, ...cfg };
43
- } catch {
44
- return { ...DEFAULT_CONFIG };
45
- }
46
- }
47
-
48
- export function saveConfig(cfg) {
49
- const dir = join(APPDATA, "clauth");
50
- mkdirSync(dir, { recursive: true });
51
- writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf8");
52
- _fsCfgCache = null;
53
- }
54
-
55
- export function addMount(name, mountPath) {
56
- const cfg = loadConfig();
57
- if (cfg.upstreams.some(u => u.name === name)) return { error: `Mount '${name}' already exists` };
58
- cfg.upstreams.push({ name, path: mountPath });
59
- saveConfig(cfg);
60
- return { ok: true, upstreams: cfg.upstreams };
61
- }
62
-
63
- export function removeMount(name) {
64
- const cfg = loadConfig();
65
- const before = cfg.upstreams.length;
66
- cfg.upstreams = cfg.upstreams.filter(u => u.name !== name);
67
- if (cfg.upstreams.length === before) return { error: `Mount '${name}' not found` };
68
- saveConfig(cfg);
69
- return { ok: true, upstreams: cfg.upstreams };
70
- }
71
-
72
- export function updateMount(name, updates) {
73
- const cfg = loadConfig();
74
- const mount = cfg.upstreams.find(u => u.name === name);
75
- if (!mount) return { error: `Mount '${name}' not found` };
76
- if (updates.credential !== undefined) cfg.credential = updates.credential;
77
- saveConfig(cfg);
78
- return { ok: true, upstreams: cfg.upstreams };
79
- }
80
-
81
- let _fsCfgCache = null;
82
-
83
- // ── Module state ────────────────────────────────────────────────
84
- let _child = null;
85
- let _configPath = null;
86
- let _status = "stopped"; // stopped | starting | running | error
87
- let _error = null;
88
- let _restartCount = 0;
89
- let _startedAt = null;
90
- let _vaultFn = null; // () => { password, machineHash }
91
- let _cfg = null;
92
-
93
- // ── Public API ───────────────────────────────────────────────────
94
-
95
- /**
96
- * Ensure the rclone WebDAV child is running.
97
- * Idempotent — safe to call multiple times.
98
- * Returns { ok: true } or { error: string }.
99
- */
100
- export async function ensureRunning(vaultFn, config) {
101
- if (vaultFn) _vaultFn = vaultFn;
102
- if (config) _cfg = config;
103
- if (!_cfg) _cfg = loadConfig();
104
-
105
- if (_status === "running" && _child) return { ok: true };
106
- if (_status === "starting") return { ok: true, message: "still starting" };
107
-
108
- return _startChild();
109
- }
110
-
111
- /** Current supervisor status — safe to call at any time. */
112
- export function getStatus() {
113
- return {
114
- status: _status,
115
- port: WEBDAV_PORT,
116
- url: _status === "running" ? `http://127.0.0.1:${WEBDAV_PORT}` : null,
117
- error: _error || null,
118
- restart_count: _restartCount,
119
- started_at: _startedAt || null,
120
- };
121
- }
122
-
123
- /** Kill the child and clean up the temp config file. */
124
- export function shutdown() {
125
- if (_child) {
126
- _child.removeAllListeners();
127
- _child.kill("SIGTERM");
128
- _child = null;
129
- }
130
- _cleanupConfig();
131
- _status = "stopped";
132
- }
133
-
134
- /**
135
- * rclone-compatible AES-CTR "obscure" — same algorithm as `rclone obscure`.
136
- * Used by fs_dav_setup to embed a non-plaintext credential in the mount script.
137
- * The result can be passed as RCLONE_CONFIG_xxx_PASS.
138
- */
139
- export function rcloneObscure(password) {
140
- // Key from rclone source: lib/obscure/obscure.go cryptKey
141
- const key = Buffer.from([
142
- 0x9c, 0x93, 0x5b, 0x48, 0x73, 0x0a, 0x55, 0x4d,
143
- 0x6b, 0xfd, 0x7c, 0x63, 0xc8, 0x86, 0xd9, 0x2d,
144
- 0x02, 0x01, 0x99, 0x87, 0x54, 0xd3, 0x9b, 0xeb,
145
- 0x40, 0x87, 0x3f, 0xa1, 0x3f, 0x7a, 0xbe, 0x5e,
146
- ]);
147
- const iv = crypto.randomBytes(16);
148
- const cipher = crypto.createCipheriv("aes-256-ctr", key, iv);
149
- const ct = Buffer.concat([cipher.update(Buffer.from(password, "utf8")), cipher.final()]);
150
- return Buffer.concat([iv, ct]).toString("base64url");
151
- }
152
-
153
- // ── Internal ─────────────────────────────────────────────────────
154
-
155
- async function _startChild() {
156
- _status = "starting";
157
- _error = null;
158
-
159
- if (!_cfg?.upstreams?.length) {
160
- _status = "error";
161
- _error = "No mount points configured — add them in the clauth dashboard";
162
- return { error: _error };
163
- }
164
-
165
- // 1. Resolve credential from vault
166
- let pass;
167
- try {
168
- const vault = _vaultFn?.();
169
- if (!vault?.password) {
170
- _status = "error";
171
- _error = "Vault is locked — unlock clauth first";
172
- return { error: _error };
173
- }
174
- const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
175
- const credName = _cfg.credential || CRED_NAME;
176
- const result = await api.retrieve(vault.password, vault.machineHash, token, timestamp, credName);
177
- if (!result.value) {
178
- _status = "error";
179
- _error = `Credential '${credName}' not found — register it in the vault first`;
180
- return { error: _error };
181
- }
182
- pass = result.value;
183
- } catch (err) {
184
- _status = "error";
185
- _error = `Credential fetch failed: ${err.message}`;
186
- return { error: _error };
187
- }
188
-
189
- // 2. Locate rclone
190
- const rclonePath = _findRclone();
191
- if (!rclonePath) {
192
- _status = "error";
193
- _error = "rclone not found — install with: winget install Rclone.Rclone";
194
- return { error: _error };
195
- }
196
-
197
- // 3. Write rclone config — combine backend with quoted entries for paths with spaces.
198
- // Per rclone docs: "dir=remote:path with space" (quote the whole entry).
199
- const mounts = _cfg.upstreams || [];
200
- const combineUpstreams = mounts
201
- .map(u => `"${u.name}=${u.path.replace(/\\/g, "/")}"`)
202
- .join(" ");
203
- const configContent = `[srv]\ntype = combine\nupstreams = ${combineUpstreams}\n`;
204
- _cleanupConfig();
205
- _configPath = join(tmpdir(), `.rclone-webdav-${process.pid}.conf`);
206
- try {
207
- writeFileSync(_configPath, configContent, { mode: 0o600 });
208
- } catch (err) {
209
- _status = "error";
210
- _error = `Config write failed: ${err.message}`;
211
- return { error: _error };
212
- }
213
-
214
- // 4. Spawn rclone
215
- return new Promise(resolve => {
216
- let settled = false;
217
- const settle = (val) => { if (!settled) { settled = true; resolve(val); } };
218
-
219
- try {
220
- _child = spawn(rclonePath, [
221
- "serve", "webdav", "srv:",
222
- "--config", _configPath,
223
- "--addr", `127.0.0.1:${WEBDAV_PORT}`,
224
- "--user", WEBDAV_USER,
225
- "--pass", pass,
226
- "--no-modtime",
227
- "--vfs-cache-mode", "full",
228
- "--vfs-write-back", "0s",
229
- "--ignore-size",
230
- "--cache-dir", join(process.env.TEMP || tmpdir(), "rclone-dav-cache"),
231
- "--transfers", "4",
232
- ], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
233
-
234
- } catch (spawnErr) {
235
- _child = null;
236
- _status = "error";
237
- _error = spawnErr.message;
238
- _cleanupConfig();
239
- settle({ error: spawnErr.message });
240
- return;
241
- }
242
-
243
- _child.stdout.on("data", () => {});
244
-
245
- _child.stderr.on("data", (chunk) => {
246
- const line = chunk.toString().trim();
247
- if (line.includes("Serving WebDAV") || line.includes("Listening on")) {
248
- _status = "running";
249
- _startedAt = new Date().toISOString();
250
- settle({ ok: true });
251
- }
252
- // Surface errors but don't stop on every stderr line — rclone logs normally to stderr
253
- if (/\berror\b/i.test(line) || /\bfatal\b/i.test(line)) {
254
- _error = line.slice(0, 300);
255
- }
256
- });
257
-
258
- _child.on("error", (err) => {
259
- _child = null;
260
- _status = "error";
261
- _error = err.message;
262
- _cleanupConfig();
263
- settle({ error: err.message });
264
- _scheduleRestart();
265
- });
266
-
267
- _child.on("exit", (code, signal) => {
268
- _child = null;
269
- if (signal === "SIGTERM" || signal === "SIGKILL" || code === 0) {
270
- _status = "stopped";
271
- _cleanupConfig();
272
- return;
273
- }
274
- _status = "error";
275
- _error = `rclone exited unexpectedly (code=${code} signal=${signal})`;
276
- _cleanupConfig();
277
- settle({ error: _error });
278
- _scheduleRestart();
279
- });
280
-
281
- // Optimistic fallback: if rclone doesn't print "Serving WebDAV" within 2s, assume running
282
- setTimeout(() => {
283
- if (_status === "starting") {
284
- _status = "running";
285
- _startedAt = _startedAt || new Date().toISOString();
286
- }
287
- settle({ ok: true });
288
- }, 2000);
289
- });
290
- }
291
-
292
- function _scheduleRestart() {
293
- const delay = RESTART_DELAYS[Math.min(_restartCount, RESTART_DELAYS.length - 1)];
294
- _restartCount++;
295
- setTimeout(() => {
296
- if (_status === "stopped") return; // intentionally stopped, don't restart
297
- _startChild();
298
- }, delay);
299
- }
300
-
301
- function _cleanupConfig() {
302
- if (_configPath) {
303
- try { unlinkSync(_configPath); } catch {}
304
- _configPath = null;
305
- }
306
- }
307
-
308
- function _findRclone() {
309
- const home = homedir();
310
- const wingetBase = process.env.LOCALAPPDATA
311
- ? join(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Packages")
312
- : null;
313
- const execOpts = { stdio: "pipe", timeout: 4000, windowsHide: true };
314
-
315
- const candidates = [
316
- "rclone",
317
- "C:\\Program Files\\rclone\\rclone.exe",
318
- join(home, "scoop", "apps", "rclone", "current", "rclone.exe"),
319
- "C:\\ProgramData\\scoop\\apps\\rclone\\current\\rclone.exe",
320
- ];
321
-
322
- // Winget installs into a version-stamped subdirectory — glob for it
323
- if (wingetBase) {
324
- const wingetPkg = join(wingetBase, "Rclone.Rclone_Microsoft.Winget.Source_8wekyb3d8bbwe");
325
- try {
326
- for (const d of readdirSync(wingetPkg)) {
327
- if (d.startsWith("rclone-")) candidates.push(join(wingetPkg, d, "rclone.exe"));
328
- }
329
- } catch {}
330
- }
331
-
332
- for (const c of candidates) {
333
- try {
334
- execSync(`"${c}" version`, execOpts);
335
- return c;
336
- } catch {}
337
- }
338
- return null;
339
- }
1
+ // cli/webdav-service.js
2
+ // WebDAV child-process supervisor — rclone serve webdav
3
+ //
4
+ // ISOLATION: This module has zero coupling to existing fs_* tool handlers.
5
+ // Rollback: set webdav.enabled=false in the config block (or remove it)
6
+ // + delete this file + remove the two-line import in serve.js.
7
+ //
8
+ // Design: lazy-start. The rclone child spawns on the first fs_dav_setup call.
9
+ // Subsequent calls return the running child's status without restarting.
10
+ // On child crash: exponential-backoff restart loop (2s → 5s → 15s → 60s).
11
+
12
+ import { spawn, execSync } from "child_process";
13
+ import { writeFileSync, readFileSync, unlinkSync, mkdirSync, readdirSync } from "fs";
14
+ import { tmpdir, homedir } from "os";
15
+ import { join } from "path";
16
+ import crypto from "crypto";
17
+ import * as api from "./api.js";
18
+ import { deriveToken } from "./fingerprint.js";
19
+
20
+ const WEBDAV_PORT = 8620;
21
+ const WEBDAV_USER = "claude";
22
+ const CRED_NAME = "webdav-claude";
23
+ const RESTART_DELAYS = [2000, 5000, 15000, 60000];
24
+
25
+ const APPDATA = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
26
+ const CONFIG_PATH = join(APPDATA, "clauth", "webdav-config.json");
27
+
28
+ const DEFAULT_CONFIG = {
29
+ enabled: true,
30
+ port: WEBDAV_PORT,
31
+ credential: CRED_NAME,
32
+ upstreams: [],
33
+ };
34
+
35
+ // ── Config persistence ──────────────────────────────────────────
36
+
37
+ export function loadConfig() {
38
+ try {
39
+ const raw = readFileSync(CONFIG_PATH, "utf8");
40
+ const cfg = JSON.parse(raw);
41
+ if (!Array.isArray(cfg.upstreams)) cfg.upstreams = [];
42
+ return { ...DEFAULT_CONFIG, ...cfg };
43
+ } catch {
44
+ return { ...DEFAULT_CONFIG };
45
+ }
46
+ }
47
+
48
+ export function saveConfig(cfg) {
49
+ const dir = join(APPDATA, "clauth");
50
+ mkdirSync(dir, { recursive: true });
51
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf8");
52
+ _fsCfgCache = null;
53
+ }
54
+
55
+ export function addMount(name, mountPath) {
56
+ const cfg = loadConfig();
57
+ if (cfg.upstreams.some(u => u.name === name)) return { error: `Mount '${name}' already exists` };
58
+ cfg.upstreams.push({ name, path: mountPath });
59
+ saveConfig(cfg);
60
+ return { ok: true, upstreams: cfg.upstreams };
61
+ }
62
+
63
+ export function removeMount(name) {
64
+ const cfg = loadConfig();
65
+ const before = cfg.upstreams.length;
66
+ cfg.upstreams = cfg.upstreams.filter(u => u.name !== name);
67
+ if (cfg.upstreams.length === before) return { error: `Mount '${name}' not found` };
68
+ saveConfig(cfg);
69
+ return { ok: true, upstreams: cfg.upstreams };
70
+ }
71
+
72
+ export function updateMount(name, updates) {
73
+ const cfg = loadConfig();
74
+ const mount = cfg.upstreams.find(u => u.name === name);
75
+ if (!mount) return { error: `Mount '${name}' not found` };
76
+ if (updates.credential !== undefined) cfg.credential = updates.credential;
77
+ saveConfig(cfg);
78
+ return { ok: true, upstreams: cfg.upstreams };
79
+ }
80
+
81
+ let _fsCfgCache = null;
82
+
83
+ // ── Module state ────────────────────────────────────────────────
84
+ let _child = null;
85
+ let _configPath = null;
86
+ let _status = "stopped"; // stopped | starting | running | error
87
+ let _error = null;
88
+ let _restartCount = 0;
89
+ let _startedAt = null;
90
+ let _vaultFn = null; // () => { password, machineHash }
91
+ let _cfg = null;
92
+
93
+ // ── Public API ───────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Ensure the rclone WebDAV child is running.
97
+ * Idempotent — safe to call multiple times.
98
+ * Returns { ok: true } or { error: string }.
99
+ */
100
+ export async function ensureRunning(vaultFn, config) {
101
+ if (vaultFn) _vaultFn = vaultFn;
102
+ if (config) _cfg = config;
103
+ if (!_cfg) _cfg = loadConfig();
104
+
105
+ if (_status === "running" && _child) return { ok: true };
106
+ if (_status === "starting") return { ok: true, message: "still starting" };
107
+
108
+ return _startChild();
109
+ }
110
+
111
+ /** Current supervisor status — safe to call at any time. */
112
+ export function getStatus() {
113
+ return {
114
+ status: _status,
115
+ port: WEBDAV_PORT,
116
+ url: _status === "running" ? `http://127.0.0.1:${WEBDAV_PORT}` : null,
117
+ error: _error || null,
118
+ restart_count: _restartCount,
119
+ started_at: _startedAt || null,
120
+ };
121
+ }
122
+
123
+ /** Kill the child and clean up the temp config file. */
124
+ export function shutdown() {
125
+ if (_child) {
126
+ _child.removeAllListeners();
127
+ _child.kill("SIGTERM");
128
+ _child = null;
129
+ }
130
+ _cleanupConfig();
131
+ _status = "stopped";
132
+ }
133
+
134
+ /**
135
+ * rclone-compatible AES-CTR "obscure" — same algorithm as `rclone obscure`.
136
+ * Used by fs_dav_setup to embed a non-plaintext credential in the mount script.
137
+ * The result can be passed as RCLONE_CONFIG_xxx_PASS.
138
+ */
139
+ export function rcloneObscure(password) {
140
+ // Key from rclone source: lib/obscure/obscure.go cryptKey
141
+ const key = Buffer.from([
142
+ 0x9c, 0x93, 0x5b, 0x48, 0x73, 0x0a, 0x55, 0x4d,
143
+ 0x6b, 0xfd, 0x7c, 0x63, 0xc8, 0x86, 0xd9, 0x2d,
144
+ 0x02, 0x01, 0x99, 0x87, 0x54, 0xd3, 0x9b, 0xeb,
145
+ 0x40, 0x87, 0x3f, 0xa1, 0x3f, 0x7a, 0xbe, 0x5e,
146
+ ]);
147
+ const iv = crypto.randomBytes(16);
148
+ const cipher = crypto.createCipheriv("aes-256-ctr", key, iv);
149
+ const ct = Buffer.concat([cipher.update(Buffer.from(password, "utf8")), cipher.final()]);
150
+ return Buffer.concat([iv, ct]).toString("base64url");
151
+ }
152
+
153
+ // ── Internal ─────────────────────────────────────────────────────
154
+
155
+ async function _startChild() {
156
+ _status = "starting";
157
+ _error = null;
158
+
159
+ if (!_cfg?.upstreams?.length) {
160
+ _status = "error";
161
+ _error = "No mount points configured — add them in the clauth dashboard";
162
+ return { error: _error };
163
+ }
164
+
165
+ // 1. Resolve credential from vault
166
+ let pass;
167
+ try {
168
+ const vault = _vaultFn?.();
169
+ if (!vault?.password) {
170
+ _status = "error";
171
+ _error = "Vault is locked — unlock clauth first";
172
+ return { error: _error };
173
+ }
174
+ const { token, timestamp } = deriveToken(vault.password, vault.machineHash);
175
+ const credName = _cfg.credential || CRED_NAME;
176
+ const result = await api.retrieve(vault.password, vault.machineHash, token, timestamp, credName);
177
+ if (!result.value) {
178
+ _status = "error";
179
+ _error = `Credential '${credName}' not found — register it in the vault first`;
180
+ return { error: _error };
181
+ }
182
+ pass = result.value;
183
+ } catch (err) {
184
+ _status = "error";
185
+ _error = `Credential fetch failed: ${err.message}`;
186
+ return { error: _error };
187
+ }
188
+
189
+ // 2. Locate rclone
190
+ const rclonePath = _findRclone();
191
+ if (!rclonePath) {
192
+ _status = "error";
193
+ _error = "rclone not found — install with: winget install Rclone.Rclone";
194
+ return { error: _error };
195
+ }
196
+
197
+ // 3. Write rclone config — combine backend with quoted entries for paths with spaces.
198
+ // Per rclone docs: "dir=remote:path with space" (quote the whole entry).
199
+ const mounts = _cfg.upstreams || [];
200
+ const combineUpstreams = mounts
201
+ .map(u => `"${u.name}=${u.path.replace(/\\/g, "/")}"`)
202
+ .join(" ");
203
+ const configContent = `[srv]\ntype = combine\nupstreams = ${combineUpstreams}\n`;
204
+ _cleanupConfig();
205
+ _configPath = join(tmpdir(), `.rclone-webdav-${process.pid}.conf`);
206
+ try {
207
+ writeFileSync(_configPath, configContent, { mode: 0o600 });
208
+ } catch (err) {
209
+ _status = "error";
210
+ _error = `Config write failed: ${err.message}`;
211
+ return { error: _error };
212
+ }
213
+
214
+ // 4. Spawn rclone
215
+ return new Promise(resolve => {
216
+ let settled = false;
217
+ const settle = (val) => { if (!settled) { settled = true; resolve(val); } };
218
+
219
+ try {
220
+ _child = spawn(rclonePath, [
221
+ "serve", "webdav", "srv:",
222
+ "--config", _configPath,
223
+ "--addr", `127.0.0.1:${WEBDAV_PORT}`,
224
+ "--user", WEBDAV_USER,
225
+ "--pass", pass,
226
+ "--no-modtime",
227
+ "--vfs-cache-mode", "full",
228
+ "--vfs-write-back", "0s",
229
+ "--ignore-size",
230
+ "--cache-dir", join(process.env.TEMP || tmpdir(), "rclone-dav-cache"),
231
+ "--transfers", "4",
232
+ ], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
233
+
234
+ } catch (spawnErr) {
235
+ _child = null;
236
+ _status = "error";
237
+ _error = spawnErr.message;
238
+ _cleanupConfig();
239
+ settle({ error: spawnErr.message });
240
+ return;
241
+ }
242
+
243
+ _child.stdout.on("data", () => {});
244
+
245
+ _child.stderr.on("data", (chunk) => {
246
+ const line = chunk.toString().trim();
247
+ if (line.includes("Serving WebDAV") || line.includes("Listening on")) {
248
+ _status = "running";
249
+ _startedAt = new Date().toISOString();
250
+ settle({ ok: true });
251
+ }
252
+ // Surface errors but don't stop on every stderr line — rclone logs normally to stderr
253
+ if (/\berror\b/i.test(line) || /\bfatal\b/i.test(line)) {
254
+ _error = line.slice(0, 300);
255
+ }
256
+ });
257
+
258
+ _child.on("error", (err) => {
259
+ _child = null;
260
+ _status = "error";
261
+ _error = err.message;
262
+ _cleanupConfig();
263
+ settle({ error: err.message });
264
+ _scheduleRestart();
265
+ });
266
+
267
+ _child.on("exit", (code, signal) => {
268
+ _child = null;
269
+ if (signal === "SIGTERM" || signal === "SIGKILL" || code === 0) {
270
+ _status = "stopped";
271
+ _cleanupConfig();
272
+ return;
273
+ }
274
+ _status = "error";
275
+ _error = `rclone exited unexpectedly (code=${code} signal=${signal})`;
276
+ _cleanupConfig();
277
+ settle({ error: _error });
278
+ _scheduleRestart();
279
+ });
280
+
281
+ // Optimistic fallback: if rclone doesn't print "Serving WebDAV" within 2s, assume running
282
+ setTimeout(() => {
283
+ if (_status === "starting") {
284
+ _status = "running";
285
+ _startedAt = _startedAt || new Date().toISOString();
286
+ }
287
+ settle({ ok: true });
288
+ }, 2000);
289
+ });
290
+ }
291
+
292
+ function _scheduleRestart() {
293
+ const delay = RESTART_DELAYS[Math.min(_restartCount, RESTART_DELAYS.length - 1)];
294
+ _restartCount++;
295
+ setTimeout(() => {
296
+ if (_status === "stopped") return; // intentionally stopped, don't restart
297
+ _startChild();
298
+ }, delay);
299
+ }
300
+
301
+ function _cleanupConfig() {
302
+ if (_configPath) {
303
+ try { unlinkSync(_configPath); } catch {}
304
+ _configPath = null;
305
+ }
306
+ }
307
+
308
+ function _findRclone() {
309
+ const home = homedir();
310
+ const wingetBase = process.env.LOCALAPPDATA
311
+ ? join(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Packages")
312
+ : null;
313
+ const execOpts = { stdio: "pipe", timeout: 4000, windowsHide: true };
314
+
315
+ const candidates = [
316
+ "rclone",
317
+ "C:\\Program Files\\rclone\\rclone.exe",
318
+ join(home, "scoop", "apps", "rclone", "current", "rclone.exe"),
319
+ "C:\\ProgramData\\scoop\\apps\\rclone\\current\\rclone.exe",
320
+ ];
321
+
322
+ // Winget installs into a version-stamped subdirectory — glob for it
323
+ if (wingetBase) {
324
+ const wingetPkg = join(wingetBase, "Rclone.Rclone_Microsoft.Winget.Source_8wekyb3d8bbwe");
325
+ try {
326
+ for (const d of readdirSync(wingetPkg)) {
327
+ if (d.startsWith("rclone-")) candidates.push(join(wingetPkg, d, "rclone.exe"));
328
+ }
329
+ } catch {}
330
+ }
331
+
332
+ for (const c of candidates) {
333
+ try {
334
+ execSync(`"${c}" version`, execOpts);
335
+ return c;
336
+ } catch {}
337
+ }
338
+ return null;
339
+ }