@seedvault/cli 0.1.0 → 0.1.2
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/bin/sv +10 -0
- package/dist/sv.js +2705 -0
- package/package.json +9 -3
- package/src/client.ts +0 -164
- package/src/commands/add.ts +0 -52
- package/src/commands/cat.ts +0 -29
- package/src/commands/collections.ts +0 -24
- package/src/commands/contributors.ts +0 -28
- package/src/commands/init.ts +0 -153
- package/src/commands/invite.ts +0 -26
- package/src/commands/ls.ts +0 -37
- package/src/commands/remove.ts +0 -25
- package/src/commands/start.ts +0 -258
- package/src/commands/status.ts +0 -63
- package/src/commands/stop.ts +0 -51
- package/src/config.ts +0 -182
- package/src/daemon/queue.ts +0 -107
- package/src/daemon/syncer.ts +0 -254
- package/src/daemon/watcher.ts +0 -71
- package/src/index.ts +0 -93
- package/tsconfig.json +0 -19
package/src/commands/start.ts
DELETED
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
import { writeFileSync, unlinkSync } from "fs";
|
|
2
|
-
import type { FSWatcher } from "chokidar";
|
|
3
|
-
import {
|
|
4
|
-
loadConfig,
|
|
5
|
-
getPidPath,
|
|
6
|
-
getConfigDir,
|
|
7
|
-
normalizeConfigCollections,
|
|
8
|
-
type CollectionConfig,
|
|
9
|
-
type Config,
|
|
10
|
-
} from "../config.js";
|
|
11
|
-
import { createClient } from "../client.js";
|
|
12
|
-
import { createWatcher, type FileEvent } from "../daemon/watcher.js";
|
|
13
|
-
import { Syncer } from "../daemon/syncer.js";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* sv start [-d]
|
|
17
|
-
*
|
|
18
|
-
* Foreground (default): runs the daemon in the current terminal.
|
|
19
|
-
* -d: detaches as a background process.
|
|
20
|
-
*/
|
|
21
|
-
export async function start(args: string[]): Promise<void> {
|
|
22
|
-
const daemonize = args.includes("-d") || args.includes("--daemon");
|
|
23
|
-
|
|
24
|
-
if (daemonize) {
|
|
25
|
-
return startBackground();
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return startForeground();
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Run daemon in the foreground */
|
|
32
|
-
async function startForeground(): Promise<void> {
|
|
33
|
-
let config = loadConfig();
|
|
34
|
-
let { config: normalizedConfig, removedOverlappingCollections } = normalizeConfigCollections(config);
|
|
35
|
-
if (removedOverlappingCollections.length > 0) {
|
|
36
|
-
config = normalizedConfig;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const client = createClient(config.server, config.token);
|
|
40
|
-
|
|
41
|
-
// Verify server reachable
|
|
42
|
-
try {
|
|
43
|
-
await client.health();
|
|
44
|
-
} catch {
|
|
45
|
-
console.error(`Cannot reach server at ${config.server}`);
|
|
46
|
-
process.exit(1);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const log = (msg: string) => {
|
|
50
|
-
const ts = new Date().toISOString().slice(11, 19);
|
|
51
|
-
console.log(`[${ts}] ${msg}`);
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
let lastOverlapWarning = "";
|
|
55
|
-
const maybeLogOverlapWarning = (removed: CollectionConfig[]) => {
|
|
56
|
-
const summary = removed
|
|
57
|
-
.map((c) => `${c.name} (${c.path})`)
|
|
58
|
-
.sort()
|
|
59
|
-
.join(", ");
|
|
60
|
-
if (!summary) {
|
|
61
|
-
lastOverlapWarning = "";
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (summary === lastOverlapWarning) return;
|
|
65
|
-
lastOverlapWarning = summary;
|
|
66
|
-
log(`Ignoring overlapping collections in config: ${summary}`);
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
maybeLogOverlapWarning(removedOverlappingCollections);
|
|
70
|
-
|
|
71
|
-
log("Seedvault daemon starting...");
|
|
72
|
-
log(` Server: ${config.server}`);
|
|
73
|
-
log(` Contributor: ${config.contributorId}`);
|
|
74
|
-
if (config.collections.length === 0) {
|
|
75
|
-
log(" Collections: none");
|
|
76
|
-
log(" Waiting for collections to be added...");
|
|
77
|
-
} else {
|
|
78
|
-
log(` Collections: ${config.collections.map((f) => f.name).join(", ")}`);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Write PID file (useful for sv status / sv stop even in foreground)
|
|
82
|
-
writeFileSync(getPidPath(), String(process.pid));
|
|
83
|
-
|
|
84
|
-
const syncer = new Syncer({
|
|
85
|
-
client,
|
|
86
|
-
contributorId: config.contributorId,
|
|
87
|
-
collections: config.collections,
|
|
88
|
-
onLog: log,
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
if (config.collections.length > 0) {
|
|
92
|
-
// Initial sync
|
|
93
|
-
log("Running initial sync...");
|
|
94
|
-
try {
|
|
95
|
-
const { uploaded, skipped, deleted } = await syncer.initialSync();
|
|
96
|
-
log(`Initial sync complete: ${uploaded} uploaded, ${skipped} skipped, ${deleted} deleted`);
|
|
97
|
-
} catch (e: unknown) {
|
|
98
|
-
log(`Initial sync failed: ${(e as Error).message}`);
|
|
99
|
-
log("Will continue watching for changes...");
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const onWatcherEvent = (event: FileEvent) => {
|
|
104
|
-
syncer.handleEvent(event).catch((e) => {
|
|
105
|
-
log(`Error handling ${event.type} for ${event.serverPath}: ${(e as Error).message}`);
|
|
106
|
-
});
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
let watcher: FSWatcher | null = null;
|
|
110
|
-
const rebuildWatcher = async (collections: CollectionConfig[]): Promise<void> => {
|
|
111
|
-
if (watcher) {
|
|
112
|
-
await watcher.close();
|
|
113
|
-
watcher = null;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (collections.length === 0) {
|
|
117
|
-
log("No collections configured. Daemon idle.");
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
watcher = createWatcher(collections, onWatcherEvent);
|
|
122
|
-
log(`Watching ${collections.length} collection(s): ${collections.map((f) => f.name).join(", ")}`);
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
await rebuildWatcher(config.collections);
|
|
126
|
-
|
|
127
|
-
let reloadingCollections = false;
|
|
128
|
-
const pollTimer = setInterval(() => {
|
|
129
|
-
if (reloadingCollections) return;
|
|
130
|
-
let nextConfig: Config;
|
|
131
|
-
try {
|
|
132
|
-
nextConfig = loadConfig();
|
|
133
|
-
} catch (e: unknown) {
|
|
134
|
-
log(`Failed to read config: ${(e as Error).message}`);
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
({ config: normalizedConfig, removedOverlappingCollections } = normalizeConfigCollections(nextConfig));
|
|
139
|
-
maybeLogOverlapWarning(removedOverlappingCollections);
|
|
140
|
-
|
|
141
|
-
reloadingCollections = true;
|
|
142
|
-
|
|
143
|
-
void (async () => {
|
|
144
|
-
try {
|
|
145
|
-
const { nextConfig: reconciledConfig, added, removed } = reconcileCollections(config, normalizedConfig);
|
|
146
|
-
if (added.length === 0 && removed.length === 0) return;
|
|
147
|
-
|
|
148
|
-
log(
|
|
149
|
-
`Collections changed: +${added.map((c) => c.name).join(", ") || "none"}, -${removed
|
|
150
|
-
.map((c) => c.name)
|
|
151
|
-
.join(", ") || "none"}`
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
config = reconciledConfig;
|
|
155
|
-
syncer.setCollections(reconciledConfig.collections);
|
|
156
|
-
await rebuildWatcher(reconciledConfig.collections);
|
|
157
|
-
|
|
158
|
-
for (const collection of removed) {
|
|
159
|
-
await syncer.purgeCollection(collection);
|
|
160
|
-
}
|
|
161
|
-
for (const collection of added) {
|
|
162
|
-
await syncer.syncCollection(collection);
|
|
163
|
-
}
|
|
164
|
-
} catch (e: unknown) {
|
|
165
|
-
log(`Failed to reload collections from config: ${(e as Error).message}`);
|
|
166
|
-
} finally {
|
|
167
|
-
reloadingCollections = false;
|
|
168
|
-
}
|
|
169
|
-
})();
|
|
170
|
-
}, 1500);
|
|
171
|
-
|
|
172
|
-
log("Daemon running. Press Ctrl+C to stop.");
|
|
173
|
-
|
|
174
|
-
// Handle graceful shutdown
|
|
175
|
-
const shutdown = () => {
|
|
176
|
-
log("Shutting down...");
|
|
177
|
-
clearInterval(pollTimer);
|
|
178
|
-
if (watcher) void watcher.close();
|
|
179
|
-
syncer.stop();
|
|
180
|
-
|
|
181
|
-
// Clean up PID file
|
|
182
|
-
try {
|
|
183
|
-
unlinkSync(getPidPath());
|
|
184
|
-
} catch {}
|
|
185
|
-
|
|
186
|
-
process.exit(0);
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
process.on("SIGINT", shutdown);
|
|
190
|
-
process.on("SIGTERM", shutdown);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Spawn a detached background daemon */
|
|
194
|
-
async function startBackground(): Promise<void> {
|
|
195
|
-
loadConfig();
|
|
196
|
-
|
|
197
|
-
// Spawn a detached child running "sv start" (foreground, no -d)
|
|
198
|
-
// We find our entry point by going up from commands/ to index.ts
|
|
199
|
-
const entryPoint = import.meta.dir + "/../index.ts";
|
|
200
|
-
const logPath = getConfigDir() + "/daemon.log";
|
|
201
|
-
|
|
202
|
-
const child = Bun.spawn({
|
|
203
|
-
cmd: ["bun", "run", entryPoint, "start"],
|
|
204
|
-
stdin: "ignore",
|
|
205
|
-
stdout: Bun.file(logPath),
|
|
206
|
-
stderr: Bun.file(logPath),
|
|
207
|
-
env: { ...process.env },
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
const pid = child.pid;
|
|
211
|
-
writeFileSync(getPidPath(), String(pid));
|
|
212
|
-
|
|
213
|
-
console.log(`Daemon started in background (PID ${pid}).`);
|
|
214
|
-
console.log(` Log: ${logPath}`);
|
|
215
|
-
console.log(` Run 'sv status' to check, 'sv stop' to stop.`);
|
|
216
|
-
|
|
217
|
-
// Detach: don't wait for child
|
|
218
|
-
child.unref();
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function keyByName(collections: CollectionConfig[]): Map<string, CollectionConfig> {
|
|
222
|
-
const map = new Map<string, CollectionConfig>();
|
|
223
|
-
for (const collection of collections) {
|
|
224
|
-
map.set(collection.name, collection);
|
|
225
|
-
}
|
|
226
|
-
return map;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function reconcileCollections(
|
|
230
|
-
prev: Config,
|
|
231
|
-
next: Config
|
|
232
|
-
): { nextConfig: Config; added: CollectionConfig[]; removed: CollectionConfig[] } {
|
|
233
|
-
const prevByName = keyByName(prev.collections);
|
|
234
|
-
const nextByName = keyByName(next.collections);
|
|
235
|
-
|
|
236
|
-
const added: CollectionConfig[] = [];
|
|
237
|
-
const removed: CollectionConfig[] = [];
|
|
238
|
-
|
|
239
|
-
for (const [name, prevCollection] of prevByName) {
|
|
240
|
-
const nextCollection = nextByName.get(name);
|
|
241
|
-
if (!nextCollection) {
|
|
242
|
-
removed.push(prevCollection);
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
if (nextCollection.path !== prevCollection.path) {
|
|
246
|
-
removed.push(prevCollection);
|
|
247
|
-
added.push(nextCollection);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
for (const [name, nextCollection] of nextByName) {
|
|
252
|
-
if (!prevByName.has(name)) {
|
|
253
|
-
added.push(nextCollection);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return { nextConfig: next, added, removed };
|
|
258
|
-
}
|
package/src/commands/status.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from "fs";
|
|
2
|
-
import { loadConfig, configExists, getPidPath } from "../config.js";
|
|
3
|
-
import { createClient } from "../client.js";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* sv status
|
|
7
|
-
*
|
|
8
|
-
* Show the current config, daemon status, and server connectivity.
|
|
9
|
-
*/
|
|
10
|
-
export async function status(): Promise<void> {
|
|
11
|
-
if (!configExists()) {
|
|
12
|
-
console.log("Not configured. Run 'sv init' first.");
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const config = loadConfig();
|
|
17
|
-
|
|
18
|
-
console.log("Seedvault Status\n");
|
|
19
|
-
|
|
20
|
-
// Config
|
|
21
|
-
console.log(` Server: ${config.server}`);
|
|
22
|
-
console.log(` Contributor: ${config.contributorId}`);
|
|
23
|
-
|
|
24
|
-
// Daemon
|
|
25
|
-
const pidPath = getPidPath();
|
|
26
|
-
if (existsSync(pidPath)) {
|
|
27
|
-
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
28
|
-
let alive = false;
|
|
29
|
-
try {
|
|
30
|
-
process.kill(pid, 0);
|
|
31
|
-
alive = true;
|
|
32
|
-
} catch {}
|
|
33
|
-
|
|
34
|
-
if (alive) {
|
|
35
|
-
console.log(` Daemon: running (PID ${pid})`);
|
|
36
|
-
} else {
|
|
37
|
-
console.log(` Daemon: not running (stale PID file)`);
|
|
38
|
-
}
|
|
39
|
-
} else {
|
|
40
|
-
console.log(" Daemon: not running");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Collections
|
|
44
|
-
if (config.collections.length === 0) {
|
|
45
|
-
console.log(" Collections: none configured");
|
|
46
|
-
} else {
|
|
47
|
-
console.log(` Collections: ${config.collections.length}`);
|
|
48
|
-
for (const f of config.collections) {
|
|
49
|
-
console.log(` - ${f.name} -> ${f.path}`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Server connectivity
|
|
54
|
-
const client = createClient(config.server, config.token);
|
|
55
|
-
try {
|
|
56
|
-
await client.health();
|
|
57
|
-
console.log(" Server: reachable");
|
|
58
|
-
} catch {
|
|
59
|
-
console.log(" Server: unreachable");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
console.log();
|
|
63
|
-
}
|
package/src/commands/stop.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { readFileSync, unlinkSync, existsSync } from "fs";
|
|
2
|
-
import { getPidPath } from "../config.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* sv stop
|
|
6
|
-
*
|
|
7
|
-
* Stop the running daemon by sending SIGTERM to the PID.
|
|
8
|
-
*/
|
|
9
|
-
export async function stop(): Promise<void> {
|
|
10
|
-
const pidPath = getPidPath();
|
|
11
|
-
|
|
12
|
-
if (!existsSync(pidPath)) {
|
|
13
|
-
console.log("No daemon is running (no PID file found).");
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
|
|
18
|
-
|
|
19
|
-
if (isNaN(pid)) {
|
|
20
|
-
console.error("Invalid PID file. Removing it.");
|
|
21
|
-
unlinkSync(pidPath);
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Check if process is alive
|
|
26
|
-
try {
|
|
27
|
-
process.kill(pid, 0); // signal 0 = check existence
|
|
28
|
-
} catch {
|
|
29
|
-
console.log(`Daemon (PID ${pid}) is not running. Cleaning up PID file.`);
|
|
30
|
-
unlinkSync(pidPath);
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Send SIGTERM
|
|
35
|
-
try {
|
|
36
|
-
process.kill(pid, "SIGTERM");
|
|
37
|
-
console.log(`Sent SIGTERM to daemon (PID ${pid}).`);
|
|
38
|
-
|
|
39
|
-
// Wait briefly and verify it stopped
|
|
40
|
-
await Bun.sleep(500);
|
|
41
|
-
try {
|
|
42
|
-
process.kill(pid, 0);
|
|
43
|
-
console.log("Daemon still running. Send SIGKILL with: kill -9 " + pid);
|
|
44
|
-
} catch {
|
|
45
|
-
console.log("Daemon stopped.");
|
|
46
|
-
try { unlinkSync(pidPath); } catch {}
|
|
47
|
-
}
|
|
48
|
-
} catch (e: unknown) {
|
|
49
|
-
console.error(`Failed to stop daemon: ${(e as Error).message}`);
|
|
50
|
-
}
|
|
51
|
-
}
|
package/src/config.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import { join, resolve, relative, isAbsolute } from "path";
|
|
2
|
-
import { homedir } from "os";
|
|
3
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
4
|
-
|
|
5
|
-
// --- Types ---
|
|
6
|
-
|
|
7
|
-
export interface CollectionConfig {
|
|
8
|
-
path: string;
|
|
9
|
-
name: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface Config {
|
|
13
|
-
server: string;
|
|
14
|
-
token: string;
|
|
15
|
-
contributorId: string;
|
|
16
|
-
collections: CollectionConfig[];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface AddCollectionResult {
|
|
20
|
-
config: Config;
|
|
21
|
-
removedChildCollections: CollectionConfig[];
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface NormalizeCollectionsResult {
|
|
25
|
-
config: Config;
|
|
26
|
-
removedOverlappingCollections: CollectionConfig[];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// --- Paths ---
|
|
30
|
-
|
|
31
|
-
const CONFIG_DIR = join(homedir(), ".config", "seedvault");
|
|
32
|
-
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
33
|
-
const PID_PATH = join(CONFIG_DIR, "daemon.pid");
|
|
34
|
-
|
|
35
|
-
export function getConfigDir(): string {
|
|
36
|
-
return CONFIG_DIR;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function getConfigPath(): string {
|
|
40
|
-
return CONFIG_PATH;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function getPidPath(): string {
|
|
44
|
-
return PID_PATH;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// --- Config CRUD ---
|
|
48
|
-
|
|
49
|
-
function ensureConfigDir(): void {
|
|
50
|
-
if (!existsSync(CONFIG_DIR)) {
|
|
51
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function configExists(): boolean {
|
|
56
|
-
return existsSync(CONFIG_PATH);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function loadConfig(): Config {
|
|
60
|
-
if (!existsSync(CONFIG_PATH)) {
|
|
61
|
-
throw new Error(
|
|
62
|
-
`No config found. Run 'sv init' first.\n Expected: ${CONFIG_PATH}`
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
66
|
-
return JSON.parse(raw) as Config;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function saveConfig(config: Config): void {
|
|
70
|
-
ensureConfigDir();
|
|
71
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// --- Collection management ---
|
|
75
|
-
|
|
76
|
-
function isChildPath(parentPath: string, maybeChildPath: string): boolean {
|
|
77
|
-
const rel = relative(parentPath, maybeChildPath);
|
|
78
|
-
if (rel === "" || rel === ".") return false;
|
|
79
|
-
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function pruneOverlappingCollections(collections: CollectionConfig[]): {
|
|
83
|
-
collections: CollectionConfig[];
|
|
84
|
-
removedOverlappingCollections: CollectionConfig[];
|
|
85
|
-
} {
|
|
86
|
-
const removedOverlappingCollections: CollectionConfig[] = [];
|
|
87
|
-
|
|
88
|
-
// Keep the first occurrence of an identical path.
|
|
89
|
-
const seenPaths = new Set<string>();
|
|
90
|
-
const deduped: CollectionConfig[] = [];
|
|
91
|
-
for (const collection of collections) {
|
|
92
|
-
if (seenPaths.has(collection.path)) {
|
|
93
|
-
removedOverlappingCollections.push(collection);
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
seenPaths.add(collection.path);
|
|
97
|
-
deduped.push(collection);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const kept: CollectionConfig[] = [];
|
|
101
|
-
for (const candidate of deduped) {
|
|
102
|
-
const hasParent = deduped.some(
|
|
103
|
-
(other) => other.path !== candidate.path && isChildPath(other.path, candidate.path)
|
|
104
|
-
);
|
|
105
|
-
if (hasParent) {
|
|
106
|
-
removedOverlappingCollections.push(candidate);
|
|
107
|
-
} else {
|
|
108
|
-
kept.push(candidate);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return { collections: kept, removedOverlappingCollections };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function normalizeConfigCollections(config: Config): NormalizeCollectionsResult {
|
|
116
|
-
const { collections, removedOverlappingCollections } = pruneOverlappingCollections(config.collections);
|
|
117
|
-
if (removedOverlappingCollections.length === 0) {
|
|
118
|
-
return { config, removedOverlappingCollections: [] };
|
|
119
|
-
}
|
|
120
|
-
return {
|
|
121
|
-
config: {
|
|
122
|
-
...config,
|
|
123
|
-
collections,
|
|
124
|
-
},
|
|
125
|
-
removedOverlappingCollections,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function addCollection(config: Config, collectionPath: string, name: string): AddCollectionResult {
|
|
130
|
-
// Resolve to absolute path
|
|
131
|
-
const resolved = collectionPath.startsWith("~")
|
|
132
|
-
? resolve(homedir(), collectionPath.slice(2)) // skip ~/
|
|
133
|
-
: resolve(collectionPath);
|
|
134
|
-
|
|
135
|
-
// Check for duplicate path
|
|
136
|
-
if (config.collections.some((f) => f.path === resolved)) {
|
|
137
|
-
throw new Error(`Collection path '${resolved}' is already configured.`);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Reject adding a nested child under an existing parent collection.
|
|
141
|
-
const parentConflict = config.collections.find((f) => isChildPath(f.path, resolved));
|
|
142
|
-
if (parentConflict) {
|
|
143
|
-
throw new Error(
|
|
144
|
-
`Cannot add '${resolved}' because it is inside existing collection '${parentConflict.name}' (${parentConflict.path}).`
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// If adding a parent path, remove existing child collections first.
|
|
149
|
-
const removedChildCollections = config.collections.filter((f) => isChildPath(resolved, f.path));
|
|
150
|
-
const retainedCollections = config.collections.filter((f) => !isChildPath(resolved, f.path));
|
|
151
|
-
|
|
152
|
-
// Check for duplicate name against retained (non-overlapping) collections.
|
|
153
|
-
if (retainedCollections.some((f) => f.name === name)) {
|
|
154
|
-
throw new Error(`A collection named '${name}' already exists. Use --name to pick a different name.`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
config: {
|
|
159
|
-
...config,
|
|
160
|
-
collections: [...retainedCollections, { path: resolved, name }],
|
|
161
|
-
},
|
|
162
|
-
removedChildCollections,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function removeCollection(config: Config, name: string): Config {
|
|
167
|
-
const filtered = config.collections.filter((f) => f.name !== name);
|
|
168
|
-
if (filtered.length === config.collections.length) {
|
|
169
|
-
throw new Error(`No collection named '${name}' found.`);
|
|
170
|
-
}
|
|
171
|
-
return { ...config, collections: filtered };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Derive a name from a collection path (its basename) */
|
|
175
|
-
export function defaultCollectionName(collectionPath: string): string {
|
|
176
|
-
const abs = collectionPath.startsWith("~")
|
|
177
|
-
? join(homedir(), collectionPath.slice(1))
|
|
178
|
-
: collectionPath;
|
|
179
|
-
const base = abs.split("/").filter(Boolean).pop();
|
|
180
|
-
if (!base) throw new Error(`Cannot derive name from path: ${collectionPath}`);
|
|
181
|
-
return base;
|
|
182
|
-
}
|
package/src/daemon/queue.ts
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import type { SeedvaultClient } from "../client.js";
|
|
2
|
-
import { ApiError } from "../client.js";
|
|
3
|
-
|
|
4
|
-
// --- Types ---
|
|
5
|
-
|
|
6
|
-
export interface QueuedOperation {
|
|
7
|
-
type: "put" | "delete";
|
|
8
|
-
contributorId: string;
|
|
9
|
-
serverPath: string;
|
|
10
|
-
/** For put operations, the file content. Null for deletes. */
|
|
11
|
-
content: string | null;
|
|
12
|
-
/** Timestamp when the operation was queued */
|
|
13
|
-
queuedAt: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// --- Queue ---
|
|
17
|
-
|
|
18
|
-
const MIN_BACKOFF = 1000; // 1s
|
|
19
|
-
const MAX_BACKOFF = 60000; // 60s
|
|
20
|
-
|
|
21
|
-
export class RetryQueue {
|
|
22
|
-
private items: QueuedOperation[] = [];
|
|
23
|
-
private flushing = false;
|
|
24
|
-
private backoff = MIN_BACKOFF;
|
|
25
|
-
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
26
|
-
private client: SeedvaultClient;
|
|
27
|
-
private onStatus: (msg: string) => void;
|
|
28
|
-
|
|
29
|
-
constructor(client: SeedvaultClient, onStatus: (msg: string) => void = () => {}) {
|
|
30
|
-
this.client = client;
|
|
31
|
-
this.onStatus = onStatus;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Enqueue an operation. If online, flushes immediately. */
|
|
35
|
-
enqueue(op: QueuedOperation): void {
|
|
36
|
-
this.items.push(op);
|
|
37
|
-
this.scheduleFlush(0);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Number of pending operations */
|
|
41
|
-
get pending(): number {
|
|
42
|
-
return this.items.length;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Stop the queue (cancel pending flush) */
|
|
46
|
-
stop(): void {
|
|
47
|
-
if (this.flushTimer) {
|
|
48
|
-
clearTimeout(this.flushTimer);
|
|
49
|
-
this.flushTimer = null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// --- Internal ---
|
|
54
|
-
|
|
55
|
-
private scheduleFlush(delayMs: number): void {
|
|
56
|
-
if (this.flushing || this.flushTimer) return;
|
|
57
|
-
if (this.items.length === 0) return;
|
|
58
|
-
|
|
59
|
-
this.flushTimer = setTimeout(() => {
|
|
60
|
-
this.flushTimer = null;
|
|
61
|
-
this.flush();
|
|
62
|
-
}, delayMs);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
private async flush(): Promise<void> {
|
|
66
|
-
if (this.flushing || this.items.length === 0) return;
|
|
67
|
-
this.flushing = true;
|
|
68
|
-
|
|
69
|
-
while (this.items.length > 0) {
|
|
70
|
-
const op = this.items[0];
|
|
71
|
-
try {
|
|
72
|
-
if (op.type === "put" && op.content !== null) {
|
|
73
|
-
await this.client.putFile(op.contributorId, op.serverPath, op.content);
|
|
74
|
-
} else if (op.type === "delete") {
|
|
75
|
-
await this.client.deleteFile(op.contributorId, op.serverPath);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Success — remove from queue and reset backoff
|
|
79
|
-
this.items.shift();
|
|
80
|
-
this.backoff = MIN_BACKOFF;
|
|
81
|
-
} catch (e: unknown) {
|
|
82
|
-
// API errors (4xx) mean the server is reachable but the op is invalid —
|
|
83
|
-
// drop the op and continue flushing.
|
|
84
|
-
if (e instanceof ApiError && e.status >= 400 && e.status < 500) {
|
|
85
|
-
this.onStatus(`Dropping failed op: ${op.type} ${op.serverPath} (${e.status})`);
|
|
86
|
-
this.items.shift();
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Network error — stop flushing, schedule retry
|
|
91
|
-
const errMsg = e instanceof Error ? e.message : String(e);
|
|
92
|
-
this.onStatus(
|
|
93
|
-
`Server unreachable (${errMsg}), ${this.items.length} op(s) queued. Retry in ${this.backoff / 1000}s.`
|
|
94
|
-
);
|
|
95
|
-
this.flushing = false;
|
|
96
|
-
this.scheduleFlush(this.backoff);
|
|
97
|
-
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF);
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
this.flushing = false;
|
|
103
|
-
if (this.items.length === 0) {
|
|
104
|
-
this.onStatus("Queue flushed — all synced.");
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|