@wspc/cli 0.1.13 → 0.1.15

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/dist/cli.js CHANGED
@@ -1520,9 +1520,9 @@ function createConsistencyFetch(opts) {
1520
1520
  }
1521
1521
 
1522
1522
  // src/version.ts
1523
- var VERSION = "0.1.13";
1523
+ var VERSION = "0.1.15";
1524
1524
  var SPEC_SHA = "bec760cd";
1525
- var SPEC_FETCHED_AT = "2026-07-08T04:52:19.152Z";
1525
+ var SPEC_FETCHED_AT = "2026-07-13T04:21:22.596Z";
1526
1526
  var API_BASE = "https://api.wspc.ai";
1527
1527
 
1528
1528
  // src/index.ts
@@ -4851,6 +4851,7 @@ async function expectJsonResult(result) {
4851
4851
  if (result.data == null) throw new Error("empty response");
4852
4852
  return result.data;
4853
4853
  }
4854
+ var SYNC_CLIENT_HEADERS = { "x-wspc-client": "drive-sync" };
4854
4855
  function driveContentUrl(baseUrl, id) {
4855
4856
  const baseWithTrailingSlash = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
4856
4857
  return new URL(`drive/libraries/${encodeURIComponent(id)}/files/content`, baseWithTrailingSlash);
@@ -4878,6 +4879,7 @@ async function createDriveApi(opts = {}) {
4878
4879
  const result = await driveFileDelete({
4879
4880
  client: rawClient,
4880
4881
  path: { id },
4882
+ headers: SYNC_CLIENT_HEADERS,
4881
4883
  body: {
4882
4884
  path,
4883
4885
  expected_entry_version: expectedEntryVersion
@@ -4895,7 +4897,8 @@ async function createDriveApi(opts = {}) {
4895
4897
  method: "PUT",
4896
4898
  headers: {
4897
4899
  "content-type": "application/octet-stream",
4898
- "x-drive-content-sha256": sha256
4900
+ "x-drive-content-sha256": sha256,
4901
+ ...SYNC_CLIENT_HEADERS
4899
4902
  },
4900
4903
  body
4901
4904
  });
@@ -5155,9 +5158,44 @@ function driveBindCommand() {
5155
5158
  // src/handwritten/commands/drive/sync.ts
5156
5159
  import { Command as Command80 } from "commander";
5157
5160
  import { mkdir as mkdir3, readFile as readFile4, rm as rm3, writeFile as writeFile3 } from "fs/promises";
5158
- import { basename as basename3, dirname as dirname2, join as join5, posix as pathPosix2, resolve as resolve3 } from "path";
5161
+ import { basename as basename3, dirname as dirname2, join as join6, posix as pathPosix2, resolve as resolve3 } from "path";
5159
5162
  import { createHash as createHash3, randomUUID as randomUUID4 } from "crypto";
5160
5163
 
5164
+ // src/handwritten/commands/drive/debug-log.ts
5165
+ import { appendFileSync, mkdirSync, renameSync, statSync } from "fs";
5166
+ import { join as join3 } from "path";
5167
+ var noopDriveDebugLogger = { log() {
5168
+ } };
5169
+ var DEBUG_LOG_FILE = "debug.log";
5170
+ var MAX_DEBUG_LOG_BYTES = 10 * 1024 * 1024;
5171
+ function createDriveDebugLogger(root, options = {}) {
5172
+ const clock = options.clock ?? systemDriveClock;
5173
+ const maxBytes = options.maxBytes ?? MAX_DEBUG_LOG_BYTES;
5174
+ const dir = join3(root, DRIVE_DIR);
5175
+ const file = join3(dir, DEBUG_LOG_FILE);
5176
+ let warned = false;
5177
+ return {
5178
+ log(event, fields = {}) {
5179
+ try {
5180
+ mkdirSync(dir, { recursive: true });
5181
+ try {
5182
+ if (statSync(file).size > maxBytes) renameSync(file, `${file}.old`);
5183
+ } catch {
5184
+ }
5185
+ appendFileSync(file, `${JSON.stringify({ ts: driveIsoTimestamp(clock), event, ...fields })}
5186
+ `);
5187
+ } catch (error) {
5188
+ if (!warned) {
5189
+ warned = true;
5190
+ const message = error instanceof Error ? error.message : String(error);
5191
+ process.stderr.write(`wspc drive: debug log write failed: ${message}
5192
+ `);
5193
+ }
5194
+ }
5195
+ }
5196
+ };
5197
+ }
5198
+
5161
5199
  // src/handwritten/commands/drive/decision.ts
5162
5200
  function decideDriveAction(entry, local, remote) {
5163
5201
  if (!entry) return decideWithoutBase(local, remote);
@@ -5417,7 +5455,7 @@ function safeShortVersionId(versionId) {
5417
5455
  import { createHash } from "crypto";
5418
5456
  import { constants as fsConstants } from "fs";
5419
5457
  import { open as open2, readdir, lstat } from "fs/promises";
5420
- import { join as join3 } from "path";
5458
+ import { join as join4 } from "path";
5421
5459
  async function scanDriveFiles(root, options = {}) {
5422
5460
  const candidates = [];
5423
5461
  const files = {};
@@ -5443,7 +5481,7 @@ async function scanDriveFiles(root, options = {}) {
5443
5481
  await options.onPathError(nextDrivePath, error);
5444
5482
  continue;
5445
5483
  }
5446
- const nextPath = join3(currentPath, entry.name);
5484
+ const nextPath = join4(currentPath, entry.name);
5447
5485
  const stats = await lstat(nextPath);
5448
5486
  if (stats.isSymbolicLink()) {
5449
5487
  continue;
@@ -5536,7 +5574,7 @@ async function hashDriveFile(path) {
5536
5574
  // src/handwritten/commands/drive/local-mutations.ts
5537
5575
  import { createWriteStream as createWriteStream2 } from "fs";
5538
5576
  import { link, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm as rm2, unlink, writeFile as writeFile2 } from "fs/promises";
5539
- import { basename as basename2, dirname, join as join4 } from "path";
5577
+ import { basename as basename2, dirname, join as join5 } from "path";
5540
5578
  import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
5541
5579
  import { Readable as Readable2, Transform } from "stream";
5542
5580
  import { pipeline as pipeline2 } from "stream/promises";
@@ -5552,7 +5590,7 @@ async function assertLocalStillScanned(localPath, scanned) {
5552
5590
  async function writeMergedLocalFile(root, path, bytes, digest, scanned, onLocalMutation) {
5553
5591
  const target = resolveInsideRoot(root, path);
5554
5592
  await mkdir2(dirname(target), { recursive: true });
5555
- const tmp = join4(dirname(target), `.${basename2(target)}.wspc-merge-${randomUUID3()}.tmp`);
5593
+ const tmp = join5(dirname(target), `.${basename2(target)}.wspc-merge-${randomUUID3()}.tmp`);
5556
5594
  try {
5557
5595
  await writeFile2(tmp, bytes, { flag: "wx" });
5558
5596
  return await installMergedLocalFile(root, path, tmp, scanned, digest, bytes.byteLength, onLocalMutation);
@@ -5604,7 +5642,7 @@ async function installMergedLocalFile(root, path, tmp, scanned, mergedSha256, me
5604
5642
  }
5605
5643
  }
5606
5644
  async function restoreMergedLocalFile(target, backup, backupSha256, backupSizeBytes, mergedSha256, mergedSizeBytes) {
5607
- const quarantine = join4(dirname(target), `.${basename2(target)}.wspc-merge-restore-${randomUUID3()}.tmp`);
5645
+ const quarantine = join5(dirname(target), `.${basename2(target)}.wspc-merge-restore-${randomUUID3()}.tmp`);
5608
5646
  try {
5609
5647
  await rename2(target, quarantine);
5610
5648
  } catch (error) {
@@ -5634,7 +5672,7 @@ async function restoreMergedLocalFile(target, backup, backupSha256, backupSizeBy
5634
5672
  async function downloadRemote(root, libraryId, path, api, expectedSha256, entry, onLocalMutation) {
5635
5673
  const target = resolveInsideRoot(root, path);
5636
5674
  await mkdir2(dirname(target), { recursive: true });
5637
- const tmp = join4(dirname(target), `.${basename2(target)}.wspc-download-${randomUUID3()}.tmp`);
5675
+ const tmp = join5(dirname(target), `.${basename2(target)}.wspc-download-${randomUUID3()}.tmp`);
5638
5676
  try {
5639
5677
  const response = await api.downloadFile(libraryId, path);
5640
5678
  if (!response.body) {
@@ -5768,7 +5806,7 @@ async function localFileExists(path) {
5768
5806
  return digest !== void 0;
5769
5807
  }
5770
5808
  function localMutationBackupPath(target) {
5771
- return join4(dirname(target), `.${basename2(target)}.wspc-backup-${randomUUID3()}.tmp`);
5809
+ return join5(dirname(target), `.${basename2(target)}.wspc-backup-${randomUUID3()}.tmp`);
5772
5810
  }
5773
5811
  function expectedLocalBaseSha256(entry) {
5774
5812
  return entry?.last_local_sha256 ?? entry?.content_sha256;
@@ -5843,32 +5881,75 @@ function emptySummary() {
5843
5881
  paths: []
5844
5882
  };
5845
5883
  }
5846
- async function runDriveSyncOnce(root, api, clock = systemDriveClock) {
5884
+ function isActionableAction(action) {
5885
+ return action.type !== "unchanged" && action.type !== "state_only" && action.type !== "remove_state";
5886
+ }
5887
+ async function runDriveSyncOnce(root, api, clock = systemDriveClock, onProgress, debug = noopDriveDebugLogger) {
5847
5888
  return withDriveLock(root, async () => {
5848
5889
  let state = await readDriveState(root);
5849
5890
  const syncApi = api ?? await createDriveApi();
5850
5891
  const summary = emptySummary();
5851
5892
  const blockedPaths = /* @__PURE__ */ new Set();
5893
+ const scanStartedMs = Date.now();
5852
5894
  const localFiles = await scanDriveFiles(root, {
5853
5895
  onPathError: async (path, error) => {
5854
5896
  await recordPathError(summary, blockedPaths, path, error);
5855
5897
  }
5856
5898
  });
5899
+ const scanMs = Date.now() - scanStartedMs;
5900
+ const manifestStartedMs = Date.now();
5857
5901
  const remoteFiles = await fetchRemoteManifest(root, state, syncApi, summary, blockedPaths);
5902
+ const manifestMs = Date.now() - manifestStartedMs;
5858
5903
  const paths = Array.from(
5859
5904
  /* @__PURE__ */ new Set([...Object.keys(localFiles), ...Object.keys(remoteFiles), ...Object.keys(state.entries)])
5860
5905
  ).filter((path) => !blockedPaths.has(path)).sort((left, right) => left.localeCompare(right));
5906
+ const total = paths.filter(
5907
+ (path) => isActionableAction(decideDriveAction(state.entries[path], localFiles[path], remoteFiles[path]))
5908
+ ).length;
5909
+ let processed = 0;
5910
+ onProgress?.(processed, total);
5911
+ const processStartedMs = Date.now();
5861
5912
  for (const path of paths) {
5862
5913
  const remote = remoteFiles[path];
5863
- const action = decideDriveAction(state.entries[path], localFiles[path], remote);
5864
- const result = await processPath({ root, state, api: syncApi, path, action, remote, local: localFiles[path], summary, clock });
5914
+ const local = localFiles[path];
5915
+ const action = decideDriveAction(state.entries[path], local, remote);
5916
+ if (isActionableAction(action)) {
5917
+ debug.log("decision", decisionFields(path, action, state.entries[path], local, remote));
5918
+ }
5919
+ const previousConflict = state.conflicts[path];
5920
+ const result = await processPath({ root, state, api: syncApi, path, action, remote, local, summary, clock });
5865
5921
  state = result.state;
5922
+ const recordedConflict = state.conflicts[path];
5923
+ if (recordedConflict !== void 0 && recordedConflict !== previousConflict) {
5924
+ debug.log("conflict", {
5925
+ path,
5926
+ reason: recordedConflict.reason,
5927
+ ...recordedConflict.type === void 0 ? {} : { type: recordedConflict.type },
5928
+ ...recordedConflict.strategy === void 0 ? {} : { strategy: recordedConflict.strategy },
5929
+ ...recordedConflict.conflict_paths === void 0 ? {} : { conflict_paths: recordedConflict.conflict_paths }
5930
+ });
5931
+ }
5932
+ if (isActionableAction(action)) {
5933
+ processed += 1;
5934
+ onProgress?.(processed, total);
5935
+ }
5866
5936
  if (result.stop) break;
5867
5937
  }
5868
5938
  recordUnresolvedConflicts(summary, state);
5939
+ debug.log("sync_phases", { scan_ms: scanMs, manifest_ms: manifestMs, process_ms: Date.now() - processStartedMs });
5869
5940
  return summary;
5870
5941
  });
5871
5942
  }
5943
+ function decisionFields(path, action, entry, local, remote) {
5944
+ return {
5945
+ path,
5946
+ action: action.type,
5947
+ ..."reason" in action && action.reason !== void 0 ? { reason: action.reason } : {},
5948
+ ...entry === void 0 ? {} : { base_version_id: entry.current_version_id, base_entry_version: entry.entry_version, base_sha256: entry.content_sha256 },
5949
+ ...local === void 0 ? {} : { local_sha256: local.sha256, local_size_bytes: local.size_bytes },
5950
+ ...remote === void 0 ? {} : { remote_version_id: remote.current_version_id, remote_entry_version: remote.entry_version, remote_sha256: remote.content_sha256 }
5951
+ };
5952
+ }
5872
5953
  function driveSyncCommand(api) {
5873
5954
  const sync = new Command80("sync").description("Drive sync commands");
5874
5955
  sync.command("once").description("Run one Drive sync pass").argument("[path]", "local folder path", ".").action(async (path) => {
@@ -6182,7 +6263,7 @@ async function writeConflictCopy(root, path, side, versionId, bytes, clock) {
6182
6263
  const target = resolveInsideRoot(root, candidate);
6183
6264
  await mkdir3(dirname2(target), { recursive: true });
6184
6265
  for (; ; ) {
6185
- const tmp = join5(dirname2(target), `.${basename3(target)}.wspc-conflict-${randomUUID4()}.tmp`);
6266
+ const tmp = join6(dirname2(target), `.${basename3(target)}.wspc-conflict-${randomUUID4()}.tmp`);
6186
6267
  let tmpWritten = false;
6187
6268
  try {
6188
6269
  await writeFile3(tmp, bytes, { flag: "wx" });
@@ -6624,7 +6705,8 @@ function webSocketError(event) {
6624
6705
 
6625
6706
  // src/handwritten/commands/drive/watch.ts
6626
6707
  async function runDriveWatch(root, options = {}) {
6627
- const runSync = options.runSync ?? runDriveSyncOnce;
6708
+ const dbg = options.debugLogger ?? (options.debug ? createDriveDebugLogger(root) : noopDriveDebugLogger);
6709
+ const runSync = options.runSync ?? ((syncRoot, onProgress) => runDriveSyncOnce(syncRoot, void 0, void 0, onProgress, dbg));
6628
6710
  const debounceMs = options.debounceMs ?? 500;
6629
6711
  const remoteDebounceMs = options.remoteDebounceMs ?? 2e3;
6630
6712
  const emit = options.onEvent ?? ((event) => {
@@ -6635,6 +6717,7 @@ async function runDriveWatch(root, options = {}) {
6635
6717
  }
6636
6718
  render({ kind: "drive_watch", display: { shape: "object" } }, event);
6637
6719
  });
6720
+ let nextTrigger = "initial";
6638
6721
  let debounceTimer;
6639
6722
  let debounceDeadlineMs;
6640
6723
  let retryTimer;
@@ -6659,12 +6742,28 @@ async function runDriveWatch(root, options = {}) {
6659
6742
  rerunRequested = false;
6660
6743
  try {
6661
6744
  emit({ kind: "drive_sync_start" });
6662
- const summary = await runSync(root);
6745
+ dbg.log("sync_start", { trigger: nextTrigger });
6746
+ const startedAtMs = Date.now();
6747
+ const summary = await runSync(root, (processed, total) => {
6748
+ emit({ kind: "drive_sync_progress", processed, total });
6749
+ });
6663
6750
  emit({ kind: "drive_sync_once", ...summary });
6751
+ dbg.log("sync_end", {
6752
+ duration_ms: Date.now() - startedAtMs,
6753
+ uploaded: summary.uploaded,
6754
+ downloaded: summary.downloaded,
6755
+ deleted: summary.deleted,
6756
+ merged: summary.merged,
6757
+ unchanged: summary.unchanged,
6758
+ conflicts: summary.conflicts,
6759
+ errors: summary.errors
6760
+ });
6664
6761
  backoffMs = 1e3;
6665
6762
  } catch (error) {
6666
6763
  if (isAuthError(error) || isFatalWatchError(error) || !isRetryableWatchError(error)) throw error;
6667
6764
  emit({ kind: "drive_watch_retry", delay_ms: backoffMs, error: errorMessage2(error) });
6765
+ dbg.log("retry", { delay_ms: backoffMs, error: errorMessage2(error) });
6766
+ nextTrigger = "retry";
6668
6767
  if (stopped) return;
6669
6768
  await waitForManagedTimer(backoffMs);
6670
6769
  if (stopped) return;
@@ -6682,7 +6781,8 @@ async function runDriveWatch(root, options = {}) {
6682
6781
  debounceTimer = void 0;
6683
6782
  debounceDeadlineMs = void 0;
6684
6783
  }
6685
- function scheduleSync(delayMs) {
6784
+ function scheduleSync(delayMs, trigger) {
6785
+ nextTrigger = trigger;
6686
6786
  if (running) {
6687
6787
  rerunRequested = true;
6688
6788
  return;
@@ -6753,7 +6853,8 @@ async function runDriveWatch(root, options = {}) {
6753
6853
  source = options.source ?? createDefaultWatchSource(root);
6754
6854
  source.onChange((path) => {
6755
6855
  if (isDriveInternalPath(root, path)) return;
6756
- scheduleSync(debounceMs);
6856
+ dbg.log("fs_event", { path: relative2(root, path) });
6857
+ scheduleSync(debounceMs, "local");
6757
6858
  });
6758
6859
  emit({ kind: "drive_watch_started", root, library_id: state.library_id });
6759
6860
  realtimeSource?.start({
@@ -6762,11 +6863,12 @@ async function runDriveWatch(root, options = {}) {
6762
6863
  },
6763
6864
  onEvent(event) {
6764
6865
  emit(realtimeEvent(event));
6866
+ dbg.log("realtime_event", { ...event });
6765
6867
  if (event.immediate) {
6766
- scheduleSync(0);
6868
+ scheduleSync(0, "remote");
6767
6869
  return;
6768
6870
  }
6769
- scheduleSync(event.debounce_ms ?? remoteDebounceMs);
6871
+ scheduleSync(event.debounce_ms ?? remoteDebounceMs, "remote");
6770
6872
  },
6771
6873
  onReconnect(delayMs, error) {
6772
6874
  emit({ kind: "drive_realtime_reconnecting", delay_ms: delayMs, error });
@@ -6795,8 +6897,9 @@ async function runDriveWatch(root, options = {}) {
6795
6897
  }
6796
6898
  }
6797
6899
  function driveWatchCommand(options = {}) {
6798
- return new Command81("watch").description("Watch a bound Drive folder and sync local changes").argument("[path]", "local folder path", ".").action(async (path) => {
6799
- await runDriveWatch(resolve4(path), options);
6900
+ return new Command81("watch").description("Watch a bound Drive folder and sync local changes").argument("[path]", "local folder path", ".").option("--debug", "append debug NDJSON events to <folder>/.wspc-drive/debug.log").action(async (path, flags) => {
6901
+ const debug = options.debug ?? (flags.debug === true || process.env.WSPC_DRIVE_DEBUG === "1");
6902
+ await runDriveWatch(resolve4(path), { ...options, debug });
6800
6903
  });
6801
6904
  }
6802
6905
  function createDefaultWatchSource(root) {