@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,254 +0,0 @@
1
- import { readdir, stat, readFile } from "fs/promises";
2
- import { join, relative } from "path";
3
- import type { SeedvaultClient } from "../client.js";
4
- import type { CollectionConfig } from "../config.js";
5
- import type { FileEvent } from "./watcher.js";
6
- import { RetryQueue } from "./queue.js";
7
-
8
- export interface SyncerOptions {
9
- client: SeedvaultClient;
10
- contributorId: string;
11
- collections: CollectionConfig[];
12
- onLog: (msg: string) => void;
13
- }
14
-
15
- export class Syncer {
16
- private client: SeedvaultClient;
17
- private contributorId: string;
18
- private collections: CollectionConfig[];
19
- private queue: RetryQueue;
20
- private log: (msg: string) => void;
21
-
22
- constructor(opts: SyncerOptions) {
23
- this.client = opts.client;
24
- this.contributorId = opts.contributorId;
25
- this.collections = opts.collections;
26
- this.log = opts.onLog;
27
- this.queue = new RetryQueue(opts.client, opts.onLog);
28
- }
29
-
30
- /** Update the active collections set used for whole-collection syncs. */
31
- setCollections(collections: CollectionConfig[]): void {
32
- this.collections = [...collections];
33
- }
34
-
35
- /**
36
- * Initial sync: compare local files against what's on the server,
37
- * PUT anything that's newer or missing, and DELETE files that
38
- * exist on server but no longer exist locally.
39
- */
40
- async initialSync(): Promise<{ uploaded: number; skipped: number; deleted: number }> {
41
- let uploaded = 0;
42
- let skipped = 0;
43
- let deleted = 0;
44
-
45
- for (const collection of [...this.collections]) {
46
- const result = await this.syncCollection(collection);
47
- uploaded += result.uploaded;
48
- skipped += result.skipped;
49
- deleted += result.deleted;
50
- }
51
-
52
- return { uploaded, skipped, deleted };
53
- }
54
-
55
- /**
56
- * Sync a single collection.
57
- */
58
- async syncCollection(collection: CollectionConfig): Promise<{ uploaded: number; skipped: number; deleted: number }> {
59
- let uploaded = 0;
60
- let skipped = 0;
61
- let deleted = 0;
62
-
63
- this.log(`Syncing '${collection.name}' (${collection.path})...`);
64
-
65
- try {
66
- // Get server file listing for this collection's prefix
67
- const { files: serverFiles } = await this.client.listFiles(
68
- this.contributorId,
69
- collection.name + "/"
70
- );
71
-
72
- // Build a map of server files by path -> modifiedAt
73
- const serverMap = new Map<string, string>();
74
- for (const f of serverFiles) {
75
- serverMap.set(f.path, f.modifiedAt);
76
- }
77
-
78
- // Walk local directory for .md files
79
- const localFiles = await walkMd(collection.path);
80
- const localServerPaths = new Set<string>();
81
-
82
- for (const localFile of localFiles) {
83
- const relPath = toPosixPath(relative(collection.path, localFile.path));
84
- const serverPath = `${collection.name}/${relPath}`;
85
- localServerPaths.add(serverPath);
86
-
87
- const serverMod = serverMap.get(serverPath);
88
- if (serverMod) {
89
- // File exists on server — compare mtime
90
- const serverDate = new Date(serverMod).getTime();
91
- const localDate = localFile.mtimeMs;
92
- if (localDate <= serverDate) {
93
- skipped++;
94
- continue;
95
- }
96
- }
97
-
98
- // Upload
99
- const content = await readFile(localFile.path, "utf-8");
100
- try {
101
- await this.client.putFile(this.contributorId, serverPath, content);
102
- uploaded++;
103
- } catch {
104
- // If server unreachable, queue it
105
- this.queue.enqueue({
106
- type: "put",
107
- contributorId: this.contributorId,
108
- serverPath,
109
- content,
110
- queuedAt: new Date().toISOString(),
111
- });
112
- }
113
- }
114
-
115
- // Delete server files that no longer exist locally
116
- for (const f of serverFiles) {
117
- if (localServerPaths.has(f.path)) continue;
118
-
119
- try {
120
- await this.client.deleteFile(this.contributorId, f.path);
121
- deleted++;
122
- } catch {
123
- // If server unreachable, queue it
124
- this.queue.enqueue({
125
- type: "delete",
126
- contributorId: this.contributorId,
127
- serverPath: f.path,
128
- content: null,
129
- queuedAt: new Date().toISOString(),
130
- });
131
- }
132
- }
133
-
134
- this.log(
135
- ` '${collection.name}': ${uploaded} uploaded, ${skipped} up-to-date, ${deleted} deleted`
136
- );
137
- } catch (e: unknown) {
138
- this.log(` '${collection.name}': sync failed (${(e as Error).message})`);
139
- }
140
-
141
- return { uploaded, skipped, deleted };
142
- }
143
-
144
- /**
145
- * Remove all remote files under a collection prefix.
146
- * Used when a collection is removed from config while the daemon is running.
147
- */
148
- async purgeCollection(collection: CollectionConfig): Promise<{ deleted: number; queued: number }> {
149
- let deleted = 0;
150
- let queued = 0;
151
-
152
- this.log(`Removing '${collection.name}' files from server...`);
153
-
154
- try {
155
- const { files: serverFiles } = await this.client.listFiles(
156
- this.contributorId,
157
- collection.name + "/"
158
- );
159
-
160
- for (const f of serverFiles) {
161
- try {
162
- await this.client.deleteFile(this.contributorId, f.path);
163
- deleted++;
164
- } catch {
165
- this.queue.enqueue({
166
- type: "delete",
167
- contributorId: this.contributorId,
168
- serverPath: f.path,
169
- content: null,
170
- queuedAt: new Date().toISOString(),
171
- });
172
- queued++;
173
- }
174
- }
175
-
176
- this.log(` '${collection.name}': ${deleted} deleted, ${queued} queued`);
177
- } catch (e: unknown) {
178
- this.log(` '${collection.name}': remove failed (${(e as Error).message})`);
179
- }
180
-
181
- return { deleted, queued };
182
- }
183
-
184
- /**
185
- * Handle a file event from the watcher.
186
- */
187
- async handleEvent(event: FileEvent): Promise<void> {
188
- if (event.type === "add" || event.type === "change") {
189
- const content = await readFile(event.localPath, "utf-8");
190
- this.log(`PUT ${event.serverPath} (${content.length} bytes)`);
191
- this.queue.enqueue({
192
- type: "put",
193
- contributorId: this.contributorId,
194
- serverPath: event.serverPath,
195
- content,
196
- queuedAt: new Date().toISOString(),
197
- });
198
- } else if (event.type === "unlink") {
199
- this.log(`DELETE ${event.serverPath}`);
200
- this.queue.enqueue({
201
- type: "delete",
202
- contributorId: this.contributorId,
203
- serverPath: event.serverPath,
204
- content: null,
205
- queuedAt: new Date().toISOString(),
206
- });
207
- }
208
- }
209
-
210
- /** Stop retry timers. Pending ops remain in memory for process lifetime only. */
211
- stop(): void {
212
- this.queue.stop();
213
- }
214
-
215
- /** Number of pending queued operations */
216
- get pendingOps(): number {
217
- return this.queue.pending;
218
- }
219
- }
220
-
221
- // --- Helpers ---
222
-
223
- interface LocalFile {
224
- path: string;
225
- mtimeMs: number;
226
- }
227
-
228
- function toPosixPath(path: string): string {
229
- return path.split("\\").join("/");
230
- }
231
-
232
- async function walkMd(dir: string): Promise<LocalFile[]> {
233
- const results: LocalFile[] = [];
234
- await walkDirRecursive(dir, results);
235
- return results;
236
- }
237
-
238
- async function walkDirRecursive(dir: string, results: LocalFile[]): Promise<void> {
239
- const entries = await readdir(dir, { withFileTypes: true });
240
-
241
- for (const entry of entries) {
242
- // Skip hidden dirs and node_modules
243
- if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
244
-
245
- const full = join(dir, entry.name);
246
-
247
- if (entry.isDirectory()) {
248
- await walkDirRecursive(full, results);
249
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
250
- const s = await stat(full);
251
- results.push({ path: full, mtimeMs: s.mtimeMs });
252
- }
253
- }
254
- }
@@ -1,71 +0,0 @@
1
- import { watch, type FSWatcher } from "chokidar";
2
- import { relative } from "path";
3
- import type { CollectionConfig } from "../config.js";
4
-
5
- export type FileEvent =
6
- | { type: "add" | "change"; serverPath: string; localPath: string }
7
- | { type: "unlink"; serverPath: string; localPath: string };
8
-
9
- export type EventHandler = (event: FileEvent) => void;
10
-
11
- /**
12
- * Create a chokidar watcher for a set of configured collections.
13
- * Maps local file events to server-relative paths using collection names.
14
- */
15
- export function createWatcher(
16
- collections: CollectionConfig[],
17
- onEvent: EventHandler
18
- ): FSWatcher {
19
- // Build the paths to watch
20
- const paths = collections.map((f) => f.path);
21
-
22
- const watcher = watch(paths, {
23
- ignored: [
24
- /(^|[/\\])\./, // dotfiles / dotdirs (.git, .DS_Store, etc.)
25
- "**/node_modules/**",
26
- "**/*.tmp.*",
27
- ],
28
- persistent: true,
29
- ignoreInitial: true, // we handle initial sync separately
30
- awaitWriteFinish: {
31
- stabilityThreshold: 300,
32
- pollInterval: 100,
33
- },
34
- });
35
-
36
- // Build a lookup: absolute collection path -> collection name
37
- const collectionMap = new Map<string, string>();
38
- for (const f of collections) {
39
- collectionMap.set(f.path, f.name);
40
- }
41
-
42
- function toServerPath(localPath: string): string | null {
43
- // Only markdown files
44
- if (!localPath.endsWith(".md")) return null;
45
-
46
- for (const [collectionPath, name] of collectionMap) {
47
- if (localPath.startsWith(collectionPath + "/") || localPath === collectionPath) {
48
- const rel = relative(collectionPath, localPath);
49
- return `${name}/${rel}`;
50
- }
51
- }
52
- return null;
53
- }
54
-
55
- watcher.on("add", (path) => {
56
- const sp = toServerPath(path);
57
- if (sp) onEvent({ type: "add", serverPath: sp, localPath: path });
58
- });
59
-
60
- watcher.on("change", (path) => {
61
- const sp = toServerPath(path);
62
- if (sp) onEvent({ type: "change", serverPath: sp, localPath: path });
63
- });
64
-
65
- watcher.on("unlink", (path) => {
66
- const sp = toServerPath(path);
67
- if (sp) onEvent({ type: "unlink", serverPath: sp, localPath: path });
68
- });
69
-
70
- return watcher;
71
- }
package/src/index.ts DELETED
@@ -1,93 +0,0 @@
1
- #!/usr/bin/env bun
2
-
3
- import { init } from "./commands/init.js";
4
- import { add } from "./commands/add.js";
5
- import { remove } from "./commands/remove.js";
6
- import { collections } from "./commands/collections.js";
7
- import { start } from "./commands/start.js";
8
- import { stop } from "./commands/stop.js";
9
- import { status } from "./commands/status.js";
10
- import { ls } from "./commands/ls.js";
11
- import { cat } from "./commands/cat.js";
12
- import { contributors } from "./commands/contributors.js";
13
- import { invite } from "./commands/invite.js";
14
-
15
- const USAGE = `
16
- Seedvault CLI
17
-
18
- Usage: sv <command> [options]
19
-
20
- Setup:
21
- init Interactive first-time setup
22
- init --server URL --token T Non-interactive (existing token)
23
- init --server URL --name N Non-interactive (signup)
24
-
25
- Collections:
26
- add <path> [--name N] Add a collection path
27
- remove <name> Remove a collection by name
28
- collections List configured collections
29
-
30
- Daemon:
31
- start Start syncing (foreground)
32
- start -d Start syncing (background)
33
- stop Stop the daemon
34
- status Show sync status
35
-
36
- Files:
37
- ls [prefix] List files in your contributor
38
- cat <path> Read a file from the server
39
-
40
- Vault:
41
- contributors List all contributors
42
- invite Generate an invite code (operator only)
43
- `.trim();
44
-
45
- async function main(): Promise<void> {
46
- const [cmd, ...args] = process.argv.slice(2);
47
-
48
- if (!cmd || cmd === "--help" || cmd === "-h") {
49
- console.log(USAGE);
50
- return;
51
- }
52
-
53
- if (cmd === "--version" || cmd === "-v") {
54
- console.log("0.1.0");
55
- return;
56
- }
57
-
58
- try {
59
- switch (cmd) {
60
- case "init":
61
- return await init(args);
62
- case "add":
63
- return await add(args);
64
- case "remove":
65
- return await remove(args);
66
- case "collections":
67
- return await collections();
68
- case "start":
69
- return await start(args);
70
- case "stop":
71
- return await stop();
72
- case "status":
73
- return await status();
74
- case "ls":
75
- return await ls(args);
76
- case "cat":
77
- return await cat(args);
78
- case "contributors":
79
- return await contributors();
80
- case "invite":
81
- return await invite();
82
- default:
83
- console.error(`Unknown command: ${cmd}\n`);
84
- console.log(USAGE);
85
- process.exit(1);
86
- }
87
- } catch (e: unknown) {
88
- console.error(`Error: ${(e as Error).message}`);
89
- process.exit(1);
90
- }
91
- }
92
-
93
- main();
package/tsconfig.json DELETED
@@ -1,19 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ES2022",
5
- "moduleResolution": "bundler",
6
- "strict": true,
7
- "esModuleInterop": true,
8
- "skipLibCheck": true,
9
- "forceConsistentCasingInFileNames": true,
10
- "resolveJsonModule": true,
11
- "declaration": true,
12
- "sourceMap": true,
13
- "outDir": "./dist",
14
- "rootDir": "./src",
15
- "types": ["bun"]
16
- },
17
- "include": ["src/**/*"],
18
- "exclude": ["node_modules", "dist"]
19
- }