@seedvault/cli 0.3.0 → 0.4.1

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.
Files changed (2) hide show
  1. package/dist/sv.js +141 -84
  2. package/package.json +6 -1
package/dist/sv.js CHANGED
@@ -176,6 +176,10 @@ function createClient(serverUrl, token) {
176
176
  return res;
177
177
  }
178
178
  return {
179
+ async me() {
180
+ const res = await request("GET", "/v1/me");
181
+ return res.json();
182
+ },
179
183
  async signup(name, invite) {
180
184
  const body = { name };
181
185
  if (invite)
@@ -196,22 +200,48 @@ function createClient(serverUrl, token) {
196
200
  return res.json();
197
201
  },
198
202
  async putFile(username, path, content) {
199
- const res = await request("PUT", `/v1/contributors/${username}/files/${encodePath(path)}`, {
203
+ const res = await request("PUT", `/v1/files/${username}/${encodePath(path)}`, {
200
204
  body: content,
201
205
  contentType: "text/markdown"
202
206
  });
203
207
  return res.json();
204
208
  },
205
209
  async deleteFile(username, path) {
206
- await request("DELETE", `/v1/contributors/${username}/files/${encodePath(path)}`);
210
+ await request("DELETE", `/v1/files/${username}/${encodePath(path)}`);
207
211
  },
208
212
  async listFiles(username, prefix) {
209
- const qs = prefix ? `?prefix=${encodeURIComponent(prefix)}` : "";
210
- const res = await request("GET", `/v1/contributors/${username}/files${qs}`);
211
- return res.json();
213
+ const fullPrefix = prefix ? `${username}/${prefix}` : `${username}/`;
214
+ const qs = `?prefix=${encodeURIComponent(fullPrefix)}`;
215
+ const res = await request("GET", `/v1/files${qs}`);
216
+ const data = await res.json();
217
+ return {
218
+ files: data.files.map((f) => ({
219
+ ...f,
220
+ path: f.path.startsWith(`${username}/`) ? f.path.slice(username.length + 1) : f.path
221
+ }))
222
+ };
212
223
  },
213
224
  async getFile(username, path) {
214
- const res = await request("GET", `/v1/contributors/${username}/files/${encodePath(path)}`);
225
+ const fullPath = `${username}/${path}`;
226
+ const res = await request("POST", "/v1/sh", {
227
+ body: JSON.stringify({ cmd: `cat "${fullPath}"` }),
228
+ contentType: "application/json"
229
+ });
230
+ const exitCode = parseInt(res.headers.get("X-Exit-Code") || "0", 10);
231
+ if (exitCode !== 0) {
232
+ const stderr = decodeURIComponent(res.headers.get("X-Stderr") || "");
233
+ if (stderr.includes("No such file or directory")) {
234
+ throw new ApiError(404, "File not found");
235
+ }
236
+ throw new ApiError(500, stderr || `cat exited with code ${exitCode}`);
237
+ }
238
+ return res.text();
239
+ },
240
+ async sh(cmd) {
241
+ const res = await request("POST", "/v1/sh", {
242
+ body: JSON.stringify({ cmd }),
243
+ contentType: "application/json"
244
+ });
215
245
  return res.text();
216
246
  },
217
247
  async health() {
@@ -231,15 +261,12 @@ async function init(args) {
231
261
  }
232
262
  if (flags.server && flags.token) {
233
263
  const client = createClient(flags.server, flags.token);
264
+ let username;
234
265
  try {
235
- await client.health();
266
+ const me = await client.me();
267
+ username = me.username;
236
268
  } catch {
237
- console.error(`Could not reach server at ${flags.server}`);
238
- process.exit(1);
239
- }
240
- const username = flags["username"] || "";
241
- if (!username) {
242
- console.error("When using --token, also pass --username");
269
+ console.error(`Could not authenticate with server at ${flags.server}`);
243
270
  process.exit(1);
244
271
  }
245
272
  const config = {
@@ -303,12 +330,20 @@ async function init(args) {
303
330
  }
304
331
  const hasToken = await rl.question("Do you already have a token? (y/N): ");
305
332
  if (hasToken.toLowerCase() === "y") {
306
- const token = await rl.question("Token: ");
307
- const username = await rl.question("Username: ");
308
- const config = { server, token: token.trim(), username: username.trim(), collections: [] };
333
+ const token = (await rl.question("Token: ")).trim();
334
+ const authedClient = createClient(server, token);
335
+ let username;
336
+ try {
337
+ const me = await authedClient.me();
338
+ username = me.username;
339
+ } catch {
340
+ console.error(" Token is invalid or server rejected it.");
341
+ process.exit(1);
342
+ }
343
+ const config = { server, token, username, collections: [] };
309
344
  saveConfig(config);
310
345
  console.log(`
311
- Seedvault configured.`);
346
+ Seedvault configured as '${username}'`);
312
347
  } else {
313
348
  const name = await rl.question("Username (e.g. your-name-notes): ");
314
349
  const invite = await rl.question("Invite code (leave blank if first user): ");
@@ -333,6 +368,7 @@ Next steps:`);
333
368
  } finally {
334
369
  rl.close();
335
370
  }
371
+ process.exit(0);
336
372
  }
337
373
  function parseFlags(args) {
338
374
  const flags = {};
@@ -2126,6 +2162,8 @@ class RetryQueue {
2126
2162
  }
2127
2163
 
2128
2164
  // src/daemon/syncer.ts
2165
+ var SYNC_CONCURRENCY = 10;
2166
+
2129
2167
  class Syncer {
2130
2168
  client;
2131
2169
  username;
@@ -2167,6 +2205,7 @@ class Syncer {
2167
2205
  }
2168
2206
  const localFiles = await walkMd(collection.path);
2169
2207
  const localServerPaths = new Set;
2208
+ const toUpload = [];
2170
2209
  for (const localFile of localFiles) {
2171
2210
  const relPath = toPosixPath(relative5(collection.path, localFile.path));
2172
2211
  const serverPath = `${collection.name}/${relPath}`;
@@ -2181,22 +2220,24 @@ class Syncer {
2181
2220
  }
2182
2221
  }
2183
2222
  const content = await readFile(localFile.path, "utf-8");
2223
+ toUpload.push({ serverPath, content });
2224
+ }
2225
+ await pooled(toUpload, SYNC_CONCURRENCY, async (item) => {
2184
2226
  try {
2185
- await this.client.putFile(this.username, serverPath, content);
2227
+ await this.client.putFile(this.username, item.serverPath, item.content);
2186
2228
  uploaded++;
2187
2229
  } catch {
2188
2230
  this.queue.enqueue({
2189
2231
  type: "put",
2190
2232
  username: this.username,
2191
- serverPath,
2192
- content,
2233
+ serverPath: item.serverPath,
2234
+ content: item.content,
2193
2235
  queuedAt: new Date().toISOString()
2194
2236
  });
2195
2237
  }
2196
- }
2197
- for (const f of serverFiles) {
2198
- if (localServerPaths.has(f.path))
2199
- continue;
2238
+ });
2239
+ const toDelete = serverFiles.filter((f) => !localServerPaths.has(f.path));
2240
+ await pooled(toDelete, SYNC_CONCURRENCY, async (f) => {
2200
2241
  try {
2201
2242
  await this.client.deleteFile(this.username, f.path);
2202
2243
  deleted++;
@@ -2209,7 +2250,7 @@ class Syncer {
2209
2250
  queuedAt: new Date().toISOString()
2210
2251
  });
2211
2252
  }
2212
- }
2253
+ });
2213
2254
  this.log(` '${collection.name}': ${uploaded} uploaded, ${skipped} up-to-date, ${deleted} deleted`);
2214
2255
  } catch (e) {
2215
2256
  this.log(` '${collection.name}': sync failed (${e.message})`);
@@ -2222,7 +2263,7 @@ class Syncer {
2222
2263
  this.log(`Removing '${collection.name}' files from server...`);
2223
2264
  try {
2224
2265
  const { files: serverFiles } = await this.client.listFiles(this.username, collection.name + "/");
2225
- for (const f of serverFiles) {
2266
+ await pooled(serverFiles, SYNC_CONCURRENCY, async (f) => {
2226
2267
  try {
2227
2268
  await this.client.deleteFile(this.username, f.path);
2228
2269
  deleted++;
@@ -2236,7 +2277,7 @@ class Syncer {
2236
2277
  });
2237
2278
  queued++;
2238
2279
  }
2239
- }
2280
+ });
2240
2281
  this.log(` '${collection.name}': ${deleted} deleted, ${queued} queued`);
2241
2282
  } catch (e) {
2242
2283
  this.log(` '${collection.name}': remove failed (${e.message})`);
@@ -2280,6 +2321,16 @@ async function walkMd(dir) {
2280
2321
  await walkDirRecursive(dir, results);
2281
2322
  return results;
2282
2323
  }
2324
+ async function pooled(items, concurrency, fn) {
2325
+ let i = 0;
2326
+ async function worker() {
2327
+ while (i < items.length) {
2328
+ const idx = i++;
2329
+ await fn(items[idx]);
2330
+ }
2331
+ }
2332
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
2333
+ }
2283
2334
  async function walkDirRecursive(dir, results) {
2284
2335
  const entries = await readdir3(dir, { withFileTypes: true });
2285
2336
  for (const entry of entries) {
@@ -2646,7 +2697,7 @@ async function startForeground() {
2646
2697
  if (removedOverlappingCollections.length > 0) {
2647
2698
  config = normalizedConfig;
2648
2699
  }
2649
- const client = createClient(config.server, config.token);
2700
+ let client = createClient(config.server, config.token);
2650
2701
  try {
2651
2702
  await client.health();
2652
2703
  } catch {
@@ -2680,7 +2731,7 @@ async function startForeground() {
2680
2731
  log(` Collections: ${config.collections.map((f) => f.name).join(", ")}`);
2681
2732
  }
2682
2733
  writeFileSync2(getPidPath(), String(process.pid));
2683
- const syncer = new Syncer({
2734
+ let syncer = new Syncer({
2684
2735
  client,
2685
2736
  username: config.username,
2686
2737
  collections: config.collections,
@@ -2696,11 +2747,6 @@ async function startForeground() {
2696
2747
  log("Will continue watching for changes...");
2697
2748
  }
2698
2749
  }
2699
- const onWatcherEvent = (event) => {
2700
- syncer.handleEvent(event).catch((e) => {
2701
- log(`Error handling ${event.type} for ${event.serverPath}: ${e.message}`);
2702
- });
2703
- };
2704
2750
  let watcher = null;
2705
2751
  const rebuildWatcher = async (collections2) => {
2706
2752
  if (watcher) {
@@ -2711,13 +2757,17 @@ async function startForeground() {
2711
2757
  log("No collections configured. Daemon idle.");
2712
2758
  return;
2713
2759
  }
2714
- watcher = createWatcher(collections2, onWatcherEvent);
2760
+ watcher = createWatcher(collections2, (event) => {
2761
+ syncer.handleEvent(event).catch((e) => {
2762
+ log(`Error handling ${event.type} for ${event.serverPath}: ${e.message}`);
2763
+ });
2764
+ });
2715
2765
  log(`Watching ${collections2.length} collection(s): ${collections2.map((f) => f.name).join(", ")}`);
2716
2766
  };
2717
2767
  await rebuildWatcher(config.collections);
2718
- let reloadingCollections = false;
2768
+ let reloading = false;
2719
2769
  const pollTimer = setInterval(() => {
2720
- if (reloadingCollections)
2770
+ if (reloading)
2721
2771
  return;
2722
2772
  let nextConfig;
2723
2773
  try {
@@ -2728,26 +2778,61 @@ async function startForeground() {
2728
2778
  }
2729
2779
  ({ config: normalizedConfig, removedOverlappingCollections } = normalizeConfigCollections(nextConfig));
2730
2780
  maybeLogOverlapWarning(removedOverlappingCollections);
2731
- reloadingCollections = true;
2781
+ const coreChanged = normalizedConfig.server !== config.server || normalizedConfig.token !== config.token || normalizedConfig.username !== config.username;
2782
+ if (!coreChanged) {
2783
+ const { nextConfig: reconciledConfig, added, removed } = reconcileCollections(config, normalizedConfig);
2784
+ if (added.length === 0 && removed.length === 0)
2785
+ return;
2786
+ reloading = true;
2787
+ (async () => {
2788
+ try {
2789
+ log(`Collections changed: +${added.map((c) => c.name).join(", ") || "none"}, -${removed.map((c) => c.name).join(", ") || "none"}`);
2790
+ config = reconciledConfig;
2791
+ syncer.setCollections(reconciledConfig.collections);
2792
+ await rebuildWatcher(reconciledConfig.collections);
2793
+ for (const collection of removed) {
2794
+ await syncer.purgeCollection(collection);
2795
+ }
2796
+ for (const collection of added) {
2797
+ await syncer.syncCollection(collection);
2798
+ }
2799
+ } catch (e) {
2800
+ log(`Failed to reload collections: ${e.message}`);
2801
+ } finally {
2802
+ reloading = false;
2803
+ }
2804
+ })();
2805
+ return;
2806
+ }
2807
+ reloading = true;
2732
2808
  (async () => {
2733
2809
  try {
2734
- const { nextConfig: reconciledConfig, added, removed } = reconcileCollections(config, normalizedConfig);
2735
- if (added.length === 0 && removed.length === 0)
2736
- return;
2737
- log(`Collections changed: +${added.map((c) => c.name).join(", ") || "none"}, -${removed.map((c) => c.name).join(", ") || "none"}`);
2738
- config = reconciledConfig;
2739
- syncer.setCollections(reconciledConfig.collections);
2740
- await rebuildWatcher(reconciledConfig.collections);
2741
- for (const collection of removed) {
2742
- await syncer.purgeCollection(collection);
2743
- }
2744
- for (const collection of added) {
2745
- await syncer.syncCollection(collection);
2810
+ log("Config changed, reinitializing...");
2811
+ if (config.server !== normalizedConfig.server)
2812
+ log(` Server: ${config.server} -> ${normalizedConfig.server}`);
2813
+ if (config.username !== normalizedConfig.username)
2814
+ log(` Username: ${config.username} -> ${normalizedConfig.username}`);
2815
+ if (config.token !== normalizedConfig.token)
2816
+ log(` Token: updated`);
2817
+ syncer.stop();
2818
+ client = createClient(normalizedConfig.server, normalizedConfig.token);
2819
+ config = normalizedConfig;
2820
+ syncer = new Syncer({
2821
+ client,
2822
+ username: config.username,
2823
+ collections: config.collections,
2824
+ onLog: log
2825
+ });
2826
+ await rebuildWatcher(config.collections);
2827
+ if (config.collections.length > 0) {
2828
+ log("Running sync after reinitialize...");
2829
+ const { uploaded, skipped, deleted } = await syncer.initialSync();
2830
+ log(`Sync complete: ${uploaded} uploaded, ${skipped} skipped, ${deleted} deleted`);
2746
2831
  }
2747
2832
  } catch (e) {
2748
- log(`Failed to reload collections from config: ${e.message}`);
2833
+ log(`Failed to reinitialize: ${e.message}`);
2749
2834
  } finally {
2750
- reloadingCollections = false;
2835
+ reloading = false;
2751
2836
  }
2752
2837
  })();
2753
2838
  }, 1500);
@@ -2849,27 +2934,8 @@ async function status() {
2849
2934
  async function ls(args) {
2850
2935
  const config = loadConfig();
2851
2936
  const client = createClient(config.server, config.token);
2852
- const prefix = args[0] || undefined;
2853
- const { files } = await client.listFiles(config.username, prefix);
2854
- if (files.length === 0) {
2855
- console.log(prefix ? `No files matching '${prefix}'.` : "No files in your contributor.");
2856
- return;
2857
- }
2858
- const maxPath = Math.max(...files.map((f) => f.path.length));
2859
- for (const f of files) {
2860
- const size = formatSize(f.size);
2861
- const date = new Date(f.modifiedAt).toLocaleString();
2862
- console.log(` ${f.path.padEnd(maxPath + 2)} ${size.padStart(8)} ${date}`);
2863
- }
2864
- console.log(`
2865
- ${files.length} file(s)`);
2866
- }
2867
- function formatSize(bytes) {
2868
- if (bytes < 1024)
2869
- return `${bytes} B`;
2870
- if (bytes < 1024 * 1024)
2871
- return `${(bytes / 1024).toFixed(1)} KB`;
2872
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2937
+ const output = await client.sh(`ls ${args.join(" ")}`);
2938
+ process.stdout.write(output);
2873
2939
  }
2874
2940
 
2875
2941
  // src/commands/cat.ts
@@ -2878,19 +2944,10 @@ async function cat(args) {
2878
2944
  console.error("Usage: sv cat <path>");
2879
2945
  process.exit(1);
2880
2946
  }
2881
- const filePath = args[0];
2882
2947
  const config = loadConfig();
2883
2948
  const client = createClient(config.server, config.token);
2884
- try {
2885
- const content = await client.getFile(config.username, filePath);
2886
- process.stdout.write(content);
2887
- } catch (e) {
2888
- if (e instanceof ApiError && e.status === 404) {
2889
- console.error(`File not found: ${filePath}`);
2890
- process.exit(1);
2891
- }
2892
- throw e;
2893
- }
2949
+ const output = await client.sh(`cat ${args.join(" ")}`);
2950
+ process.stdout.write(output);
2894
2951
  }
2895
2952
 
2896
2953
  // src/commands/contributors.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seedvault/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sv": "bin/sv.mjs"
@@ -15,6 +15,11 @@
15
15
  "dist",
16
16
  "bin"
17
17
  ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/collaborator-ai/seedvault.git",
21
+ "directory": "cli"
22
+ },
18
23
  "dependencies": {
19
24
  "chokidar": "^4"
20
25
  },