@seedvault/cli 0.2.1 → 0.4.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.
Files changed (2) hide show
  1. package/dist/sv.js +149 -86
  2. package/package.json +1 -1
package/dist/sv.js CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
 
4
+ // src/index.ts
5
+ import { readFileSync as readFileSync2 } from "fs";
6
+ import { resolve as resolve6 } from "path";
7
+
4
8
  // src/commands/init.ts
5
9
  import * as readline from "readline/promises";
6
10
  import { stdout } from "process";
@@ -172,6 +176,10 @@ function createClient(serverUrl, token) {
172
176
  return res;
173
177
  }
174
178
  return {
179
+ async me() {
180
+ const res = await request("GET", "/v1/me");
181
+ return res.json();
182
+ },
175
183
  async signup(name, invite) {
176
184
  const body = { name };
177
185
  if (invite)
@@ -191,23 +199,23 @@ function createClient(serverUrl, token) {
191
199
  const res = await request("GET", "/v1/contributors");
192
200
  return res.json();
193
201
  },
194
- async putFile(contributorId, path, content) {
195
- const res = await request("PUT", `/v1/contributors/${contributorId}/files/${encodePath(path)}`, {
202
+ async putFile(username, path, content) {
203
+ const res = await request("PUT", `/v1/contributors/${username}/files/${encodePath(path)}`, {
196
204
  body: content,
197
205
  contentType: "text/markdown"
198
206
  });
199
207
  return res.json();
200
208
  },
201
- async deleteFile(contributorId, path) {
202
- await request("DELETE", `/v1/contributors/${contributorId}/files/${encodePath(path)}`);
209
+ async deleteFile(username, path) {
210
+ await request("DELETE", `/v1/contributors/${username}/files/${encodePath(path)}`);
203
211
  },
204
- async listFiles(contributorId, prefix) {
212
+ async listFiles(username, prefix) {
205
213
  const qs = prefix ? `?prefix=${encodeURIComponent(prefix)}` : "";
206
- const res = await request("GET", `/v1/contributors/${contributorId}/files${qs}`);
214
+ const res = await request("GET", `/v1/contributors/${username}/files${qs}`);
207
215
  return res.json();
208
216
  },
209
- async getFile(contributorId, path) {
210
- const res = await request("GET", `/v1/contributors/${contributorId}/files/${encodePath(path)}`);
217
+ async getFile(username, path) {
218
+ const res = await request("GET", `/v1/contributors/${username}/files/${encodePath(path)}`);
211
219
  return res.text();
212
220
  },
213
221
  async health() {
@@ -227,27 +235,24 @@ async function init(args) {
227
235
  }
228
236
  if (flags.server && flags.token) {
229
237
  const client = createClient(flags.server, flags.token);
238
+ let username;
230
239
  try {
231
- await client.health();
240
+ const me = await client.me();
241
+ username = me.username;
232
242
  } catch {
233
- console.error(`Could not reach server at ${flags.server}`);
234
- process.exit(1);
235
- }
236
- const contributorId = flags["contributor-id"] || "";
237
- if (!contributorId) {
238
- console.error("When using --token, also pass --contributor-id");
243
+ console.error(`Could not authenticate with server at ${flags.server}`);
239
244
  process.exit(1);
240
245
  }
241
246
  const config = {
242
247
  server: flags.server,
243
248
  token: flags.token,
244
- contributorId,
249
+ username,
245
250
  collections: []
246
251
  };
247
252
  saveConfig(config);
248
253
  console.log("Seedvault configured.");
249
254
  console.log(` Server: ${config.server}`);
250
- console.log(` Contributor ID: ${config.contributorId}`);
255
+ console.log(` Username: ${config.username}`);
251
256
  return;
252
257
  }
253
258
  if (flags.server && flags.name) {
@@ -262,13 +267,13 @@ async function init(args) {
262
267
  const config = {
263
268
  server: flags.server,
264
269
  token: result.token,
265
- contributorId: result.contributor.id,
270
+ username: result.contributor.username,
266
271
  collections: []
267
272
  };
268
273
  saveConfig(config);
269
274
  console.log("Signed up and configured.");
270
275
  console.log(` Server: ${config.server}`);
271
- console.log(` Contributor: ${result.contributor.name} (${result.contributor.id})`);
276
+ console.log(` Username: ${result.contributor.username}`);
272
277
  console.log(` Token: ${result.token}`);
273
278
  return;
274
279
  }
@@ -299,26 +304,33 @@ async function init(args) {
299
304
  }
300
305
  const hasToken = await rl.question("Do you already have a token? (y/N): ");
301
306
  if (hasToken.toLowerCase() === "y") {
302
- const token = await rl.question("Token: ");
303
- const contributorId = await rl.question("Contributor ID: ");
304
- const config = { server, token: token.trim(), contributorId: contributorId.trim(), collections: [] };
307
+ const token = (await rl.question("Token: ")).trim();
308
+ const authedClient = createClient(server, token);
309
+ let username;
310
+ try {
311
+ const me = await authedClient.me();
312
+ username = me.username;
313
+ } catch {
314
+ console.error(" Token is invalid or server rejected it.");
315
+ process.exit(1);
316
+ }
317
+ const config = { server, token, username, collections: [] };
305
318
  saveConfig(config);
306
319
  console.log(`
307
- Seedvault configured.`);
320
+ Seedvault configured as '${username}'`);
308
321
  } else {
309
- const name = await rl.question("Contributor name (e.g. your-name-notes): ");
322
+ const name = await rl.question("Username (e.g. your-name-notes): ");
310
323
  const invite = await rl.question("Invite code (leave blank if first user): ");
311
324
  const result = await client.signup(name.trim(), invite.trim() || undefined);
312
325
  const config = {
313
326
  server,
314
327
  token: result.token,
315
- contributorId: result.contributor.id,
328
+ username: result.contributor.username,
316
329
  collections: []
317
330
  };
318
331
  saveConfig(config);
319
332
  console.log(`
320
- Signed up as '${result.contributor.name}'.`);
321
- console.log(` Contributor ID: ${result.contributor.id}`);
333
+ Signed up as '${result.contributor.username}'.`);
322
334
  console.log(` Token: ${result.token}`);
323
335
  console.log(`
324
336
  Save your token \u2014 it won't be shown again.`);
@@ -330,6 +342,7 @@ Next steps:`);
330
342
  } finally {
331
343
  rl.close();
332
344
  }
345
+ process.exit(0);
333
346
  }
334
347
  function parseFlags(args) {
335
348
  const flags = {};
@@ -2095,9 +2108,9 @@ class RetryQueue {
2095
2108
  const op = this.items[0];
2096
2109
  try {
2097
2110
  if (op.type === "put" && op.content !== null) {
2098
- await this.client.putFile(op.contributorId, op.serverPath, op.content);
2111
+ await this.client.putFile(op.username, op.serverPath, op.content);
2099
2112
  } else if (op.type === "delete") {
2100
- await this.client.deleteFile(op.contributorId, op.serverPath);
2113
+ await this.client.deleteFile(op.username, op.serverPath);
2101
2114
  }
2102
2115
  this.items.shift();
2103
2116
  this.backoff = MIN_BACKOFF;
@@ -2123,15 +2136,17 @@ class RetryQueue {
2123
2136
  }
2124
2137
 
2125
2138
  // src/daemon/syncer.ts
2139
+ var SYNC_CONCURRENCY = 10;
2140
+
2126
2141
  class Syncer {
2127
2142
  client;
2128
- contributorId;
2143
+ username;
2129
2144
  collections;
2130
2145
  queue;
2131
2146
  log;
2132
2147
  constructor(opts) {
2133
2148
  this.client = opts.client;
2134
- this.contributorId = opts.contributorId;
2149
+ this.username = opts.username;
2135
2150
  this.collections = opts.collections;
2136
2151
  this.log = opts.onLog;
2137
2152
  this.queue = new RetryQueue(opts.client, opts.onLog);
@@ -2157,13 +2172,14 @@ class Syncer {
2157
2172
  let deleted = 0;
2158
2173
  this.log(`Syncing '${collection.name}' (${collection.path})...`);
2159
2174
  try {
2160
- const { files: serverFiles } = await this.client.listFiles(this.contributorId, collection.name + "/");
2175
+ const { files: serverFiles } = await this.client.listFiles(this.username, collection.name + "/");
2161
2176
  const serverMap = new Map;
2162
2177
  for (const f of serverFiles) {
2163
2178
  serverMap.set(f.path, f.modifiedAt);
2164
2179
  }
2165
2180
  const localFiles = await walkMd(collection.path);
2166
2181
  const localServerPaths = new Set;
2182
+ const toUpload = [];
2167
2183
  for (const localFile of localFiles) {
2168
2184
  const relPath = toPosixPath(relative5(collection.path, localFile.path));
2169
2185
  const serverPath = `${collection.name}/${relPath}`;
@@ -2178,35 +2194,37 @@ class Syncer {
2178
2194
  }
2179
2195
  }
2180
2196
  const content = await readFile(localFile.path, "utf-8");
2197
+ toUpload.push({ serverPath, content });
2198
+ }
2199
+ await pooled(toUpload, SYNC_CONCURRENCY, async (item) => {
2181
2200
  try {
2182
- await this.client.putFile(this.contributorId, serverPath, content);
2201
+ await this.client.putFile(this.username, item.serverPath, item.content);
2183
2202
  uploaded++;
2184
2203
  } catch {
2185
2204
  this.queue.enqueue({
2186
2205
  type: "put",
2187
- contributorId: this.contributorId,
2188
- serverPath,
2189
- content,
2206
+ username: this.username,
2207
+ serverPath: item.serverPath,
2208
+ content: item.content,
2190
2209
  queuedAt: new Date().toISOString()
2191
2210
  });
2192
2211
  }
2193
- }
2194
- for (const f of serverFiles) {
2195
- if (localServerPaths.has(f.path))
2196
- continue;
2212
+ });
2213
+ const toDelete = serverFiles.filter((f) => !localServerPaths.has(f.path));
2214
+ await pooled(toDelete, SYNC_CONCURRENCY, async (f) => {
2197
2215
  try {
2198
- await this.client.deleteFile(this.contributorId, f.path);
2216
+ await this.client.deleteFile(this.username, f.path);
2199
2217
  deleted++;
2200
2218
  } catch {
2201
2219
  this.queue.enqueue({
2202
2220
  type: "delete",
2203
- contributorId: this.contributorId,
2221
+ username: this.username,
2204
2222
  serverPath: f.path,
2205
2223
  content: null,
2206
2224
  queuedAt: new Date().toISOString()
2207
2225
  });
2208
2226
  }
2209
- }
2227
+ });
2210
2228
  this.log(` '${collection.name}': ${uploaded} uploaded, ${skipped} up-to-date, ${deleted} deleted`);
2211
2229
  } catch (e) {
2212
2230
  this.log(` '${collection.name}': sync failed (${e.message})`);
@@ -2218,22 +2236,22 @@ class Syncer {
2218
2236
  let queued = 0;
2219
2237
  this.log(`Removing '${collection.name}' files from server...`);
2220
2238
  try {
2221
- const { files: serverFiles } = await this.client.listFiles(this.contributorId, collection.name + "/");
2222
- for (const f of serverFiles) {
2239
+ const { files: serverFiles } = await this.client.listFiles(this.username, collection.name + "/");
2240
+ await pooled(serverFiles, SYNC_CONCURRENCY, async (f) => {
2223
2241
  try {
2224
- await this.client.deleteFile(this.contributorId, f.path);
2242
+ await this.client.deleteFile(this.username, f.path);
2225
2243
  deleted++;
2226
2244
  } catch {
2227
2245
  this.queue.enqueue({
2228
2246
  type: "delete",
2229
- contributorId: this.contributorId,
2247
+ username: this.username,
2230
2248
  serverPath: f.path,
2231
2249
  content: null,
2232
2250
  queuedAt: new Date().toISOString()
2233
2251
  });
2234
2252
  queued++;
2235
2253
  }
2236
- }
2254
+ });
2237
2255
  this.log(` '${collection.name}': ${deleted} deleted, ${queued} queued`);
2238
2256
  } catch (e) {
2239
2257
  this.log(` '${collection.name}': remove failed (${e.message})`);
@@ -2246,7 +2264,7 @@ class Syncer {
2246
2264
  this.log(`PUT ${event.serverPath} (${content.length} bytes)`);
2247
2265
  this.queue.enqueue({
2248
2266
  type: "put",
2249
- contributorId: this.contributorId,
2267
+ username: this.username,
2250
2268
  serverPath: event.serverPath,
2251
2269
  content,
2252
2270
  queuedAt: new Date().toISOString()
@@ -2255,7 +2273,7 @@ class Syncer {
2255
2273
  this.log(`DELETE ${event.serverPath}`);
2256
2274
  this.queue.enqueue({
2257
2275
  type: "delete",
2258
- contributorId: this.contributorId,
2276
+ username: this.username,
2259
2277
  serverPath: event.serverPath,
2260
2278
  content: null,
2261
2279
  queuedAt: new Date().toISOString()
@@ -2277,6 +2295,16 @@ async function walkMd(dir) {
2277
2295
  await walkDirRecursive(dir, results);
2278
2296
  return results;
2279
2297
  }
2298
+ async function pooled(items, concurrency, fn) {
2299
+ let i = 0;
2300
+ async function worker() {
2301
+ while (i < items.length) {
2302
+ const idx = i++;
2303
+ await fn(items[idx]);
2304
+ }
2305
+ }
2306
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
2307
+ }
2280
2308
  async function walkDirRecursive(dir, results) {
2281
2309
  const entries = await readdir3(dir, { withFileTypes: true });
2282
2310
  for (const entry of entries) {
@@ -2643,7 +2671,7 @@ async function startForeground() {
2643
2671
  if (removedOverlappingCollections.length > 0) {
2644
2672
  config = normalizedConfig;
2645
2673
  }
2646
- const client = createClient(config.server, config.token);
2674
+ let client = createClient(config.server, config.token);
2647
2675
  try {
2648
2676
  await client.health();
2649
2677
  } catch {
@@ -2669,7 +2697,7 @@ async function startForeground() {
2669
2697
  maybeLogOverlapWarning(removedOverlappingCollections);
2670
2698
  log("Seedvault daemon starting...");
2671
2699
  log(` Server: ${config.server}`);
2672
- log(` Contributor: ${config.contributorId}`);
2700
+ log(` Contributor: ${config.username}`);
2673
2701
  if (config.collections.length === 0) {
2674
2702
  log(" Collections: none");
2675
2703
  log(" Waiting for collections to be added...");
@@ -2677,9 +2705,9 @@ async function startForeground() {
2677
2705
  log(` Collections: ${config.collections.map((f) => f.name).join(", ")}`);
2678
2706
  }
2679
2707
  writeFileSync2(getPidPath(), String(process.pid));
2680
- const syncer = new Syncer({
2708
+ let syncer = new Syncer({
2681
2709
  client,
2682
- contributorId: config.contributorId,
2710
+ username: config.username,
2683
2711
  collections: config.collections,
2684
2712
  onLog: log
2685
2713
  });
@@ -2693,11 +2721,6 @@ async function startForeground() {
2693
2721
  log("Will continue watching for changes...");
2694
2722
  }
2695
2723
  }
2696
- const onWatcherEvent = (event) => {
2697
- syncer.handleEvent(event).catch((e) => {
2698
- log(`Error handling ${event.type} for ${event.serverPath}: ${e.message}`);
2699
- });
2700
- };
2701
2724
  let watcher = null;
2702
2725
  const rebuildWatcher = async (collections2) => {
2703
2726
  if (watcher) {
@@ -2708,13 +2731,17 @@ async function startForeground() {
2708
2731
  log("No collections configured. Daemon idle.");
2709
2732
  return;
2710
2733
  }
2711
- watcher = createWatcher(collections2, onWatcherEvent);
2734
+ watcher = createWatcher(collections2, (event) => {
2735
+ syncer.handleEvent(event).catch((e) => {
2736
+ log(`Error handling ${event.type} for ${event.serverPath}: ${e.message}`);
2737
+ });
2738
+ });
2712
2739
  log(`Watching ${collections2.length} collection(s): ${collections2.map((f) => f.name).join(", ")}`);
2713
2740
  };
2714
2741
  await rebuildWatcher(config.collections);
2715
- let reloadingCollections = false;
2742
+ let reloading = false;
2716
2743
  const pollTimer = setInterval(() => {
2717
- if (reloadingCollections)
2744
+ if (reloading)
2718
2745
  return;
2719
2746
  let nextConfig;
2720
2747
  try {
@@ -2725,26 +2752,61 @@ async function startForeground() {
2725
2752
  }
2726
2753
  ({ config: normalizedConfig, removedOverlappingCollections } = normalizeConfigCollections(nextConfig));
2727
2754
  maybeLogOverlapWarning(removedOverlappingCollections);
2728
- reloadingCollections = true;
2755
+ const coreChanged = normalizedConfig.server !== config.server || normalizedConfig.token !== config.token || normalizedConfig.username !== config.username;
2756
+ if (!coreChanged) {
2757
+ const { nextConfig: reconciledConfig, added, removed } = reconcileCollections(config, normalizedConfig);
2758
+ if (added.length === 0 && removed.length === 0)
2759
+ return;
2760
+ reloading = true;
2761
+ (async () => {
2762
+ try {
2763
+ log(`Collections changed: +${added.map((c) => c.name).join(", ") || "none"}, -${removed.map((c) => c.name).join(", ") || "none"}`);
2764
+ config = reconciledConfig;
2765
+ syncer.setCollections(reconciledConfig.collections);
2766
+ await rebuildWatcher(reconciledConfig.collections);
2767
+ for (const collection of removed) {
2768
+ await syncer.purgeCollection(collection);
2769
+ }
2770
+ for (const collection of added) {
2771
+ await syncer.syncCollection(collection);
2772
+ }
2773
+ } catch (e) {
2774
+ log(`Failed to reload collections: ${e.message}`);
2775
+ } finally {
2776
+ reloading = false;
2777
+ }
2778
+ })();
2779
+ return;
2780
+ }
2781
+ reloading = true;
2729
2782
  (async () => {
2730
2783
  try {
2731
- const { nextConfig: reconciledConfig, added, removed } = reconcileCollections(config, normalizedConfig);
2732
- if (added.length === 0 && removed.length === 0)
2733
- return;
2734
- log(`Collections changed: +${added.map((c) => c.name).join(", ") || "none"}, -${removed.map((c) => c.name).join(", ") || "none"}`);
2735
- config = reconciledConfig;
2736
- syncer.setCollections(reconciledConfig.collections);
2737
- await rebuildWatcher(reconciledConfig.collections);
2738
- for (const collection of removed) {
2739
- await syncer.purgeCollection(collection);
2740
- }
2741
- for (const collection of added) {
2742
- await syncer.syncCollection(collection);
2784
+ log("Config changed, reinitializing...");
2785
+ if (config.server !== normalizedConfig.server)
2786
+ log(` Server: ${config.server} -> ${normalizedConfig.server}`);
2787
+ if (config.username !== normalizedConfig.username)
2788
+ log(` Username: ${config.username} -> ${normalizedConfig.username}`);
2789
+ if (config.token !== normalizedConfig.token)
2790
+ log(` Token: updated`);
2791
+ syncer.stop();
2792
+ client = createClient(normalizedConfig.server, normalizedConfig.token);
2793
+ config = normalizedConfig;
2794
+ syncer = new Syncer({
2795
+ client,
2796
+ username: config.username,
2797
+ collections: config.collections,
2798
+ onLog: log
2799
+ });
2800
+ await rebuildWatcher(config.collections);
2801
+ if (config.collections.length > 0) {
2802
+ log("Running sync after reinitialize...");
2803
+ const { uploaded, skipped, deleted } = await syncer.initialSync();
2804
+ log(`Sync complete: ${uploaded} uploaded, ${skipped} skipped, ${deleted} deleted`);
2743
2805
  }
2744
2806
  } catch (e) {
2745
- log(`Failed to reload collections from config: ${e.message}`);
2807
+ log(`Failed to reinitialize: ${e.message}`);
2746
2808
  } finally {
2747
- reloadingCollections = false;
2809
+ reloading = false;
2748
2810
  }
2749
2811
  })();
2750
2812
  }, 1500);
@@ -2809,7 +2871,7 @@ async function status() {
2809
2871
  console.log(`Seedvault Status
2810
2872
  `);
2811
2873
  console.log(` Server: ${config.server}`);
2812
- console.log(` Contributor: ${config.contributorId}`);
2874
+ console.log(` Contributor: ${config.username}`);
2813
2875
  try {
2814
2876
  const platform = detectPlatform();
2815
2877
  const serviceName = platform === "macos" ? "launchd" : platform === "linux" ? "systemd" : "Task Scheduler";
@@ -2847,7 +2909,7 @@ async function ls(args) {
2847
2909
  const config = loadConfig();
2848
2910
  const client = createClient(config.server, config.token);
2849
2911
  const prefix = args[0] || undefined;
2850
- const { files } = await client.listFiles(config.contributorId, prefix);
2912
+ const { files } = await client.listFiles(config.username, prefix);
2851
2913
  if (files.length === 0) {
2852
2914
  console.log(prefix ? `No files matching '${prefix}'.` : "No files in your contributor.");
2853
2915
  return;
@@ -2879,7 +2941,7 @@ async function cat(args) {
2879
2941
  const config = loadConfig();
2880
2942
  const client = createClient(config.server, config.token);
2881
2943
  try {
2882
- const content = await client.getFile(config.contributorId, filePath);
2944
+ const content = await client.getFile(config.username, filePath);
2883
2945
  process.stdout.write(content);
2884
2946
  } catch (e) {
2885
2947
  if (e instanceof ApiError && e.status === 404) {
@@ -2902,9 +2964,8 @@ async function contributors() {
2902
2964
  console.log(`Contributors:
2903
2965
  `);
2904
2966
  for (const contributor of contributors2) {
2905
- const you = contributor.id === config.contributorId ? " (you)" : "";
2906
- console.log(` ${contributor.name}${you}`);
2907
- console.log(` ID: ${contributor.id}`);
2967
+ const you = contributor.username === config.username ? " (you)" : "";
2968
+ console.log(` ${contributor.username}${you}`);
2908
2969
  console.log(` Created: ${new Date(contributor.createdAt).toLocaleString()}`);
2909
2970
  console.log();
2910
2971
  }
@@ -2938,8 +2999,8 @@ Usage: sv <command> [options]
2938
2999
 
2939
3000
  Setup:
2940
3001
  init Interactive first-time setup
2941
- init --server URL --token T Non-interactive (existing token)
2942
- init --server URL --name N Non-interactive (signup)
3002
+ init --server URL --token T --username U Non-interactive (existing token)
3003
+ init --server URL --name N Non-interactive (signup)
2943
3004
 
2944
3005
  Collections:
2945
3006
  add <path> [--name N] Add a collection path
@@ -2967,7 +3028,9 @@ async function main() {
2967
3028
  return;
2968
3029
  }
2969
3030
  if (cmd === "--version" || cmd === "-v") {
2970
- console.log("0.2.1");
3031
+ const pkgPath = resolve6(import.meta.dirname, "..", "package.json");
3032
+ const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
3033
+ console.log(pkg.version);
2971
3034
  return;
2972
3035
  }
2973
3036
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seedvault/cli",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sv": "bin/sv.mjs"