@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.
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- }