@wspc/cli 0.1.2 → 0.1.3

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
@@ -1464,9 +1464,9 @@ function createConsistencyFetch(opts) {
1464
1464
  }
1465
1465
 
1466
1466
  // src/version.ts
1467
- var VERSION = "0.1.2";
1467
+ var VERSION = "0.1.3";
1468
1468
  var SPEC_SHA = "e0bbc1e3";
1469
- var SPEC_FETCHED_AT = "2026-06-21T13:29:36.026Z";
1469
+ var SPEC_FETCHED_AT = "2026-06-22T01:23:52.693Z";
1470
1470
  var API_BASE = "https://api.wspc.ai";
1471
1471
 
1472
1472
  // src/index.ts
@@ -1620,8 +1620,19 @@ async function loadAuthedFetch(opts = {}) {
1620
1620
  const { fetch: fetch2, baseUrl } = await loadClientParts(opts);
1621
1621
  return { fetch: fetch2, baseUrl };
1622
1622
  }
1623
+ async function loadRealtimeAuthHeaders(opts = {}) {
1624
+ const { baseUrl, authInterceptor } = await loadClientParts(opts);
1625
+ const verifyUrl = new URL(opts.verifyPath ?? "/auth/me", `${baseUrl.replace(/\/$/, "")}/`);
1626
+ const verifyResponse = await authInterceptor.execute(new Request(verifyUrl));
1627
+ if (!verifyResponse.ok) {
1628
+ throw new Error(`realtime auth failed: HTTP ${verifyResponse.status}`);
1629
+ }
1630
+ const request = await authInterceptor.onRequest(new Request(baseUrl));
1631
+ return { baseUrl, headers: request.headers };
1632
+ }
1623
1633
  async function loadSdkClientWithAuthedFetch(opts = {}) {
1624
- return loadClientParts(opts);
1634
+ const { _rawClient, fetch: fetch2, baseUrl } = await loadClientParts(opts);
1635
+ return { _rawClient, fetch: fetch2, baseUrl };
1625
1636
  }
1626
1637
  async function loadClientParts(opts = {}) {
1627
1638
  const store = opts.store ?? new ConfigStore();
@@ -1641,7 +1652,7 @@ async function loadClientParts(opts = {}) {
1641
1652
  fetch: authedFetch
1642
1653
  })
1643
1654
  );
1644
- return { _rawClient: rawClient, fetch: authedFetch, baseUrl: resolved.apiBase };
1655
+ return { _rawClient: rawClient, fetch: authedFetch, baseUrl: resolved.apiBase, authInterceptor: interceptor };
1645
1656
  }
1646
1657
 
1647
1658
  // src/handwritten/output/primitives.ts
@@ -1867,22 +1878,24 @@ function render(ctx, data) {
1867
1878
  renderPaginationFooter(data);
1868
1879
  }
1869
1880
  function renderPaginationFooter(data) {
1870
- if (data === null || typeof data !== "object") return;
1871
- const d = data;
1872
- if (typeof d.next_cursor === "string" && d.next_cursor.length > 0) {
1873
- process.stdout.write(dim(` \u2026 more results \u2014 re-run with --cursor ${d.next_cursor}`) + "\n");
1874
- }
1875
- const id = typeof d.id === "string" ? d.id : "<id>";
1876
- if (typeof d.children_next_cursor === "string" && d.children_next_cursor.length > 0) {
1881
+ if (!isRecord(data)) return;
1882
+ const nextCursor = getString(data, "next_cursor");
1883
+ if (nextCursor !== void 0 && nextCursor.length > 0) {
1884
+ process.stdout.write(dim(` \u2026 more results \u2014 re-run with --cursor ${nextCursor}`) + "\n");
1885
+ }
1886
+ const id = getString(data, "id") ?? "<id>";
1887
+ const childrenNextCursor = getString(data, "children_next_cursor");
1888
+ if (childrenNextCursor !== void 0 && childrenNextCursor.length > 0) {
1877
1889
  process.stdout.write(dim(` \u2026 more children \u2014 wspc todo ls --parent ${id}`) + "\n");
1878
1890
  }
1879
- if (typeof d.comments_next_cursor === "string" && d.comments_next_cursor.length > 0) {
1891
+ const commentsNextCursor = getString(data, "comments_next_cursor");
1892
+ if (commentsNextCursor !== void 0 && commentsNextCursor.length > 0) {
1880
1893
  process.stdout.write(dim(` \u2026 more comments \u2014 wspc todo comment ls ${id}`) + "\n");
1881
1894
  }
1882
1895
  }
1883
1896
  function drillDataPath(data, dataPath) {
1884
1897
  if (!dataPath) return data;
1885
- if (data === null || typeof data !== "object") return data;
1898
+ if (!isRecord(data)) return data;
1886
1899
  const value = data[dataPath];
1887
1900
  return value === void 0 ? data : value;
1888
1901
  }
@@ -1910,9 +1923,9 @@ function renderGeneric(data, hints) {
1910
1923
  }
1911
1924
  function detectShape(data) {
1912
1925
  if (Array.isArray(data)) return "list";
1913
- if (typeof data === "object" && data !== null) {
1926
+ if (isRecord(data)) {
1914
1927
  const keys = Object.keys(data);
1915
- const arrayKeys = keys.filter((k) => Array.isArray(data[k]));
1928
+ const arrayKeys = keys.filter((k) => getArray(data, k) !== void 0);
1916
1929
  if (arrayKeys.length === 1) return "list";
1917
1930
  return "object";
1918
1931
  }
@@ -1920,9 +1933,10 @@ function detectShape(data) {
1920
1933
  }
1921
1934
  function extractItems(data) {
1922
1935
  if (Array.isArray(data)) return data;
1923
- if (typeof data === "object" && data !== null) {
1924
- for (const v of Object.values(data)) {
1925
- if (Array.isArray(v)) return v;
1936
+ if (isRecord(data)) {
1937
+ for (const key of Object.keys(data)) {
1938
+ const value = getArray(data, key);
1939
+ if (value !== void 0) return value;
1926
1940
  }
1927
1941
  }
1928
1942
  return [];
@@ -1933,13 +1947,13 @@ function renderList(data, hints) {
1933
1947
  process.stdout.write(dim(" " + (hints?.emptyMessage ?? "no items")) + "\n");
1934
1948
  return;
1935
1949
  }
1936
- const first = items[0];
1950
+ const first = isRecord(items[0]) ? items[0] : {};
1937
1951
  const columns = pickColumns(first, hints?.columns);
1938
1952
  const format = hints?.format ?? {};
1939
1953
  const headers = columns.map((c) => c.toUpperCase());
1940
1954
  const rows = items.map(
1941
1955
  (item) => columns.map(
1942
- (col) => formatCell(item[col], format[col], hints?.enumColorMap?.[col])
1956
+ (col) => formatCell(isRecord(item) ? item[col] : void 0, format[col], hints?.enumColorMap?.[col])
1943
1957
  )
1944
1958
  );
1945
1959
  process.stdout.write(table(headers, rows));
@@ -1957,7 +1971,7 @@ function pickColumns(first, hint) {
1957
1971
  return ordered.slice(0, 5);
1958
1972
  }
1959
1973
  function renderObject(data, hints) {
1960
- if (typeof data !== "object" || data === null) {
1974
+ if (!isRecord(data)) {
1961
1975
  renderScalar(data);
1962
1976
  return;
1963
1977
  }
@@ -1966,7 +1980,7 @@ function renderObject(data, hints) {
1966
1980
  if (topKeys.length === 1) {
1967
1981
  const onlyKey = topKeys[0];
1968
1982
  const inner = obj[onlyKey];
1969
- if (inner && typeof inner === "object" && !Array.isArray(inner)) {
1983
+ if (isRecord(inner)) {
1970
1984
  obj = inner;
1971
1985
  }
1972
1986
  }
@@ -1977,7 +1991,10 @@ function renderObject(data, hints) {
1977
1991
  }
1978
1992
  const format = hints?.format ?? {};
1979
1993
  const arrayFields = hints?.fields ? [] : Object.keys(obj).filter(
1980
- (k) => Array.isArray(obj[k]) && obj[k].length > 0
1994
+ (k) => {
1995
+ const value = getArray(obj, k);
1996
+ return value !== void 0 && value.length > 0;
1997
+ }
1981
1998
  );
1982
1999
  const formatted = fields.map((f) => [
1983
2000
  f,
@@ -2006,7 +2023,8 @@ function renderObject(data, hints) {
2006
2023
  for (const f of arrayFields) {
2007
2024
  const uncapped = f === "children" || f === "comments";
2008
2025
  const max = uncapped ? Number.POSITIVE_INFINITY : ARRAY_FIELD_MAX_ITEMS;
2009
- renderArrayField(f, obj[f], labelWidth, max);
2026
+ const items = getArray(obj, f);
2027
+ if (items !== void 0) renderArrayField(f, items, labelWidth, max);
2010
2028
  }
2011
2029
  const hadAbove = inlineFinal.length > 0 || arrayFields.length > 0;
2012
2030
  blocks.forEach(([f, value], i) => {
@@ -2058,28 +2076,31 @@ function formatArrayItem(item) {
2058
2076
  return JSON.stringify(item);
2059
2077
  }
2060
2078
  function formatTodoLike(item) {
2061
- if (!item || typeof item !== "object" || Array.isArray(item)) return null;
2062
- const rec = item;
2063
- if (typeof rec.id !== "string" || typeof rec.title !== "string") return null;
2064
- const id = idShort(rec.id);
2065
- const status = typeof rec.status === "string" ? statusBadge(rec.status) : "";
2066
- return status ? `${id} ${status} ${rec.title}` : `${id} ${rec.title}`;
2079
+ if (!isRecord(item)) return null;
2080
+ const idValue = getString(item, "id");
2081
+ const title = getString(item, "title");
2082
+ if (idValue === void 0 || title === void 0) return null;
2083
+ const status = getString(item, "status");
2084
+ const id = idShort(idValue);
2085
+ const statusText = status === void 0 ? "" : statusBadge(status);
2086
+ return statusText ? `${id} ${statusText} ${title}` : `${id} ${title}`;
2067
2087
  }
2068
2088
  function formatCommentLike(item) {
2069
- if (!item || typeof item !== "object" || Array.isArray(item)) return null;
2070
- const rec = item;
2071
- if (typeof rec.id !== "string" || typeof rec.content !== "string") return null;
2072
- const id = idShort(rec.id);
2073
- const when = rec.created_at !== void 0 ? `${relativeTime(rec.created_at)} ` : "";
2074
- const snippet = truncate(rec.content, 60);
2089
+ if (!isRecord(item)) return null;
2090
+ const idValue = getString(item, "id");
2091
+ const content = getString(item, "content");
2092
+ if (idValue === void 0 || content === void 0) return null;
2093
+ const id = idShort(idValue);
2094
+ const when = item.created_at !== void 0 ? `${relativeTime(item.created_at)} ` : "";
2095
+ const snippet = truncate(content, 60);
2075
2096
  return `${id} ${when}${snippet}`;
2076
2097
  }
2077
2098
  function formatAttendeeLike(item) {
2078
- if (!item || typeof item !== "object" || Array.isArray(item)) return null;
2079
- const rec = item;
2080
- const email = typeof rec.email === "string" ? rec.email : null;
2099
+ if (!isRecord(item)) return null;
2100
+ const email = getString(item, "email");
2081
2101
  if (!email) return null;
2082
- const name = typeof rec.display_name === "string" && rec.display_name.length > 0 ? rec.display_name : null;
2102
+ const displayName = getString(item, "display_name");
2103
+ const name = displayName !== void 0 && displayName.length > 0 ? displayName : void 0;
2083
2104
  return name ? `${name} <${email}>` : `<${email}>`;
2084
2105
  }
2085
2106
  function renderScalar(data) {
@@ -2100,6 +2121,17 @@ function renderScalar(data) {
2100
2121
  function isScalar(v) {
2101
2122
  return v === null || typeof v !== "object" && typeof v !== "function";
2102
2123
  }
2124
+ function isRecord(value) {
2125
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2126
+ }
2127
+ function getString(record, key) {
2128
+ const value = record[key];
2129
+ return typeof value === "string" ? value : void 0;
2130
+ }
2131
+ function getArray(record, key) {
2132
+ const value = record[key];
2133
+ return Array.isArray(value) ? value : void 0;
2134
+ }
2103
2135
  function formatCell(value, fmt, colorMap, opts) {
2104
2136
  if (fmt !== "enum-badge" && (value === void 0 || value === null)) return dim("\u2014");
2105
2137
  switch (fmt) {
@@ -4595,6 +4627,29 @@ async function createDriveApi(opts = {}) {
4595
4627
  import { mkdir, open, readFile as readFile2, rename, rm, writeFile } from "fs/promises";
4596
4628
  import { randomUUID } from "crypto";
4597
4629
  import { join as join2 } from "path";
4630
+ import { DateTime as DateTime4 } from "luxon";
4631
+
4632
+ // src/handwritten/commands/drive/clock.ts
4633
+ import { DateTime as DateTime3 } from "luxon";
4634
+ var systemDriveClock = {
4635
+ now: () => DateTime3.utc()
4636
+ };
4637
+ function driveIsoTimestamp(clock = systemDriveClock) {
4638
+ const timestamp = clock.now().toISO();
4639
+ if (timestamp === null) {
4640
+ throw new Error("invalid drive clock timestamp");
4641
+ }
4642
+ return timestamp;
4643
+ }
4644
+ function driveConflictTimestamp(clock = systemDriveClock) {
4645
+ const timestamp = clock.now().toUTC();
4646
+ if (!timestamp.isValid) {
4647
+ throw new Error("invalid drive clock timestamp");
4648
+ }
4649
+ return timestamp.toFormat("yyyyLLdd'T'HHmmss'Z'");
4650
+ }
4651
+
4652
+ // src/handwritten/commands/drive/state.ts
4598
4653
  var DRIVE_DIR = ".wspc-drive";
4599
4654
  var STATE_FILE = "state.json";
4600
4655
  function statePath(root) {
@@ -4608,13 +4663,13 @@ async function readDriveState(root) {
4608
4663
  }
4609
4664
  return parsed;
4610
4665
  }
4611
- async function writeDriveState(root, state) {
4666
+ async function writeDriveState(root, state, clock) {
4612
4667
  await mkdir(join2(root, DRIVE_DIR), { recursive: true });
4613
4668
  const tmp = join2(root, DRIVE_DIR, `state.json.tmp-${process.pid}-${randomUUID()}`);
4614
4669
  const snapshot = JSON.stringify(
4615
4670
  {
4616
4671
  ...state,
4617
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
4672
+ updated_at: driveIsoTimestamp(clock)
4618
4673
  },
4619
4674
  null,
4620
4675
  2
@@ -4633,7 +4688,7 @@ async function writeDriveState(root, state) {
4633
4688
  await rm(tmp, { force: true });
4634
4689
  }
4635
4690
  }
4636
- async function initDriveState(root, libraryId) {
4691
+ async function initDriveState(root, libraryId, clock) {
4637
4692
  await mkdir(join2(root, DRIVE_DIR), { recursive: true });
4638
4693
  try {
4639
4694
  const existing = await readDriveState(root);
@@ -4644,7 +4699,7 @@ async function initDriveState(root, libraryId) {
4644
4699
  } catch (err) {
4645
4700
  if (err.code !== "ENOENT") throw err;
4646
4701
  }
4647
- const now = (/* @__PURE__ */ new Date()).toISOString();
4702
+ const now = driveIsoTimestamp(clock);
4648
4703
  const state = {
4649
4704
  schema_version: 1,
4650
4705
  library_id: libraryId,
@@ -4653,9 +4708,32 @@ async function initDriveState(root, libraryId) {
4653
4708
  entries: {},
4654
4709
  conflicts: {}
4655
4710
  };
4656
- await writeDriveState(root, state);
4711
+ await writeDriveState(root, state, clock);
4657
4712
  return state;
4658
4713
  }
4714
+ async function ensureDriveRealtimeState(root) {
4715
+ return withDriveLock(root, async () => {
4716
+ const state = await readDriveState(root);
4717
+ if (state.realtime?.client_id !== void 0) {
4718
+ return state;
4719
+ }
4720
+ const next = {
4721
+ ...state,
4722
+ realtime: {
4723
+ ...state.realtime,
4724
+ client_id: `drvcli_${randomUUID().replace(/-/g, "")}`
4725
+ }
4726
+ };
4727
+ await writeDriveState(root, next);
4728
+ return next;
4729
+ });
4730
+ }
4731
+ async function writeDriveRealtimeState(root, realtime) {
4732
+ await withDriveLock(root, async () => {
4733
+ const current = await readDriveState(root);
4734
+ await writeDriveState(root, { ...current, realtime });
4735
+ });
4736
+ }
4659
4737
  async function withDriveLock(root, fn) {
4660
4738
  await mkdir(join2(root, DRIVE_DIR), { recursive: true });
4661
4739
  const lockFile = join2(root, DRIVE_DIR, "sync.lock");
@@ -4674,18 +4752,28 @@ async function withDriveLock(root, fn) {
4674
4752
  });
4675
4753
  }
4676
4754
  }
4677
- function isRecord(value) {
4755
+ function isRecord2(value) {
4678
4756
  return typeof value === "object" && value !== null && !Array.isArray(value);
4679
4757
  }
4680
4758
  function isDriveStateEntry(value) {
4681
- return isRecord(value) && typeof value.entry_id === "string" && typeof value.entry_version === "number" && typeof value.size_bytes === "number" && typeof value.last_synced_at === "string" && value.status === "synced" && (value.current_version_id === void 0 || typeof value.current_version_id === "string") && (value.content_sha256 === void 0 || typeof value.content_sha256 === "string") && (value.last_local_sha256 === void 0 || typeof value.last_local_sha256 === "string");
4759
+ return isRecord2(value) && typeof value.entry_id === "string" && typeof value.entry_version === "number" && typeof value.size_bytes === "number" && typeof value.last_synced_at === "string" && value.status === "synced" && (value.current_version_id === void 0 || typeof value.current_version_id === "string") && (value.content_sha256 === void 0 || typeof value.content_sha256 === "string") && (value.last_local_sha256 === void 0 || typeof value.last_local_sha256 === "string");
4682
4760
  }
4683
4761
  function isDriveConflict(value) {
4684
- return isRecord(value) && typeof value.detected_at === "string" && typeof value.reason === "string" && (value.type === void 0 || value.type === "edit_edit" || value.type === "create_create" || value.type === "delete_edit" || value.type === "edit_delete") && (value.strategy === void 0 || value.strategy === "clean_merge" || value.strategy === "conflict_copy" || value.strategy === "record_only") && (value.base_version_id === void 0 || typeof value.base_version_id === "string") && (value.remote_entry_version === void 0 || typeof value.remote_entry_version === "number") && (value.remote_version_id === void 0 || typeof value.remote_version_id === "string") && (value.conflict_paths === void 0 || Array.isArray(value.conflict_paths) && value.conflict_paths.every((path) => typeof path === "string"));
4762
+ return isRecord2(value) && typeof value.detected_at === "string" && typeof value.reason === "string" && (value.type === void 0 || value.type === "edit_edit" || value.type === "create_create" || value.type === "delete_edit" || value.type === "edit_delete") && (value.strategy === void 0 || value.strategy === "clean_merge" || value.strategy === "conflict_copy" || value.strategy === "record_only") && (value.base_version_id === void 0 || typeof value.base_version_id === "string") && (value.remote_entry_version === void 0 || typeof value.remote_entry_version === "number") && (value.remote_version_id === void 0 || typeof value.remote_version_id === "string") && (value.conflict_paths === void 0 || Array.isArray(value.conflict_paths) && value.conflict_paths.every((path) => typeof path === "string"));
4763
+ }
4764
+ function isDriveRealtimeState(value) {
4765
+ const allowedKeys = /* @__PURE__ */ new Set(["client_id", "last_cursor", "last_connected_at", "last_event_at"]);
4766
+ return isRecord2(value) && Object.keys(value).every((key) => allowedKeys.has(key)) && (value.client_id === void 0 || typeof value.client_id === "string" && /^drvcli_[A-Za-z0-9_-]+$/.test(value.client_id)) && (value.last_cursor === void 0 || typeof value.last_cursor === "string") && isOptionalIsoTimestamp(value.last_connected_at) && isOptionalIsoTimestamp(value.last_event_at);
4767
+ }
4768
+ function isOptionalIsoTimestamp(value) {
4769
+ if (value === void 0) return true;
4770
+ if (typeof value !== "string") return false;
4771
+ if (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value)) return false;
4772
+ return DateTime4.fromISO(value, { setZone: true }).isValid;
4685
4773
  }
4686
4774
  function isValidDriveState(value) {
4687
- if (!isRecord(value)) return false;
4688
- if (value.schema_version !== 1 || typeof value.library_id !== "string" || typeof value.created_at !== "string" || typeof value.updated_at !== "string" || !isRecord(value.entries) || !isRecord(value.conflicts)) {
4775
+ if (!isRecord2(value)) return false;
4776
+ if (value.schema_version !== 1 || typeof value.library_id !== "string" || typeof value.created_at !== "string" || typeof value.updated_at !== "string" || !isRecord2(value.entries) || !isRecord2(value.conflicts) || value.realtime !== void 0 && !isDriveRealtimeState(value.realtime)) {
4689
4777
  return false;
4690
4778
  }
4691
4779
  for (const entry of Object.values(value.entries)) {
@@ -4729,12 +4817,9 @@ function driveBindCommand() {
4729
4817
 
4730
4818
  // src/handwritten/commands/drive/sync.ts
4731
4819
  import { Command as Command71 } from "commander";
4732
- import { createWriteStream as createWriteStream2 } from "fs";
4733
- import { link, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm as rm2, unlink, writeFile as writeFile2 } from "fs/promises";
4734
- import { basename as basename2, dirname, join as join4, posix as pathPosix2, resolve as resolve3 } from "path";
4735
- import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
4736
- import { Readable as Readable2, Transform } from "stream";
4737
- import { pipeline as pipeline2 } from "stream/promises";
4820
+ import { mkdir as mkdir3, readFile as readFile4, rm as rm3, writeFile as writeFile3 } from "fs/promises";
4821
+ import { basename as basename3, dirname as dirname2, join as join5, posix as pathPosix2, resolve as resolve3 } from "path";
4822
+ import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
4738
4823
 
4739
4824
  // src/handwritten/commands/drive/decision.ts
4740
4825
  function decideDriveAction(entry, local, remote) {
@@ -4806,6 +4891,96 @@ function getRemoteStatus(entry, remote) {
4806
4891
  return "changed";
4807
4892
  }
4808
4893
 
4894
+ // src/handwritten/commands/drive/path-policy.ts
4895
+ import { isAbsolute, relative, resolve as resolve2, sep } from "path";
4896
+ var UTF8_SEGMENT_LIMIT = 255;
4897
+ var UTF8_PATH_LIMIT = 1024;
4898
+ var CONTROL_CHARS = /[\0-\x1f\x7f]/;
4899
+ var WINDOWS_DRIVE_PREFIX = /^[a-zA-Z]:/;
4900
+ var UNC_PREFIX = /^\\\\/;
4901
+ var ABSOLUTE_POSIX_DOUBLE_SLASH = /^\/\//;
4902
+ function validateDrivePath(drivePath) {
4903
+ if (drivePath.length === 0) {
4904
+ throw new Error("invalid drive path: empty");
4905
+ }
4906
+ if (isAbsolute(drivePath) || ABSOLUTE_POSIX_DOUBLE_SLASH.test(drivePath)) {
4907
+ throw new Error(`invalid drive path: ${drivePath}`);
4908
+ }
4909
+ if (drivePath.includes("\\")) {
4910
+ throw new Error("invalid drive path: backslash");
4911
+ }
4912
+ if (WINDOWS_DRIVE_PREFIX.test(drivePath) || UNC_PREFIX.test(drivePath)) {
4913
+ throw new Error(`invalid drive path: ${drivePath}`);
4914
+ }
4915
+ if (CONTROL_CHARS.test(drivePath)) {
4916
+ throw new Error(`invalid drive path: control character`);
4917
+ }
4918
+ if (Buffer.byteLength(drivePath, "utf8") > UTF8_PATH_LIMIT) {
4919
+ throw new Error(`invalid drive path: exceeds ${UTF8_PATH_LIMIT} bytes`);
4920
+ }
4921
+ const segments = drivePath.split("/");
4922
+ if (segments.some((segment) => segment.length === 0)) {
4923
+ throw new Error("invalid drive path: empty segment");
4924
+ }
4925
+ if (segments.some((segment) => segment === "." || segment === "..")) {
4926
+ throw new Error("invalid drive path: relative segment");
4927
+ }
4928
+ if (segments.some((segment) => Buffer.byteLength(segment, "utf8") > UTF8_SEGMENT_LIMIT)) {
4929
+ throw new Error(`invalid drive path: segment exceeds ${UTF8_SEGMENT_LIMIT} bytes`);
4930
+ }
4931
+ return drivePath;
4932
+ }
4933
+ function resolveInsideRoot(root, drivePath) {
4934
+ const normalizedPath = validateDrivePath(drivePath);
4935
+ const absoluteRoot = resolve2(root);
4936
+ const absolutePath = resolve2(absoluteRoot, normalizedPath);
4937
+ const relativePath = relative(absoluteRoot, absolutePath);
4938
+ if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
4939
+ throw new Error(`drive path escapes root: ${drivePath}`);
4940
+ }
4941
+ return absolutePath;
4942
+ }
4943
+
4944
+ // src/handwritten/commands/drive/manifest.ts
4945
+ function normalizeRemoteManifest(root, entries) {
4946
+ const candidates = [];
4947
+ const remoteFiles = {};
4948
+ const pathErrors = [];
4949
+ for (const entry of entries) {
4950
+ try {
4951
+ validateDrivePath(entry.path);
4952
+ resolveInsideRoot(root, entry.path);
4953
+ candidates.push(entry);
4954
+ } catch (error) {
4955
+ pathErrors.push({ path: entry.path, error: error instanceof Error ? error : new Error(String(error)) });
4956
+ }
4957
+ }
4958
+ const byCaseFoldedPath = /* @__PURE__ */ new Map();
4959
+ for (const entry of candidates) {
4960
+ const folded = entry.path.toLowerCase();
4961
+ const group = byCaseFoldedPath.get(folded) ?? [];
4962
+ group.push(entry);
4963
+ byCaseFoldedPath.set(folded, group);
4964
+ }
4965
+ for (const group of byCaseFoldedPath.values()) {
4966
+ if (group.length > 1) {
4967
+ const hasExactDuplicate = new Set(group.map((entry2) => entry2.path)).size < group.length;
4968
+ const reason = hasExactDuplicate ? "REMOTE_PATH_DUPLICATE" : "REMOTE_PATH_CASE_CONFLICT";
4969
+ for (const entry2 of group.sort((left, right) => left.path.localeCompare(right.path))) {
4970
+ pathErrors.push({
4971
+ path: entry2.path,
4972
+ error: new Error(`${reason}: ${entry2.path}`),
4973
+ appendPathResult: true
4974
+ });
4975
+ }
4976
+ continue;
4977
+ }
4978
+ const [entry] = group;
4979
+ if (entry) remoteFiles[entry.path] = entry;
4980
+ }
4981
+ return { remoteFiles, pathErrors };
4982
+ }
4983
+
4809
4984
  // src/handwritten/commands/drive/merge.ts
4810
4985
  import { posix as pathPosix } from "path";
4811
4986
  import { TextDecoder } from "util";
@@ -4864,9 +5039,8 @@ function mergeText3(base, local, remote) {
4864
5039
  }
4865
5040
  return { clean: true, text: mergedLines.join(localNewline) };
4866
5041
  }
4867
- function conflictCopyPath(path, side, now, versionId) {
5042
+ function conflictCopyPath(path, side, timestamp, versionId) {
4868
5043
  const parsed = pathPosix.parse(path);
4869
- const timestamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
4870
5044
  const shortVersionId = safeShortVersionId(versionId);
4871
5045
  const fileName = `${parsed.name}.${side}-conflict-${timestamp}-${shortVersionId}${parsed.ext}`;
4872
5046
  if (parsed.dir === "") {
@@ -4902,56 +5076,6 @@ function safeShortVersionId(versionId) {
4902
5076
  return (safeVersionId.length > 0 ? safeVersionId : "unknown").slice(0, 8);
4903
5077
  }
4904
5078
 
4905
- // src/handwritten/commands/drive/path-policy.ts
4906
- import { isAbsolute, relative, resolve as resolve2, sep } from "path";
4907
- var UTF8_SEGMENT_LIMIT = 255;
4908
- var UTF8_PATH_LIMIT = 1024;
4909
- var CONTROL_CHARS = /[\0-\x1f\x7f]/;
4910
- var WINDOWS_DRIVE_PREFIX = /^[a-zA-Z]:/;
4911
- var UNC_PREFIX = /^\\\\/;
4912
- var ABSOLUTE_POSIX_DOUBLE_SLASH = /^\/\//;
4913
- function validateDrivePath(drivePath) {
4914
- if (drivePath.length === 0) {
4915
- throw new Error("invalid drive path: empty");
4916
- }
4917
- if (isAbsolute(drivePath) || ABSOLUTE_POSIX_DOUBLE_SLASH.test(drivePath)) {
4918
- throw new Error(`invalid drive path: ${drivePath}`);
4919
- }
4920
- if (drivePath.includes("\\")) {
4921
- throw new Error("invalid drive path: backslash");
4922
- }
4923
- if (WINDOWS_DRIVE_PREFIX.test(drivePath) || UNC_PREFIX.test(drivePath)) {
4924
- throw new Error(`invalid drive path: ${drivePath}`);
4925
- }
4926
- if (CONTROL_CHARS.test(drivePath)) {
4927
- throw new Error(`invalid drive path: control character`);
4928
- }
4929
- if (Buffer.byteLength(drivePath, "utf8") > UTF8_PATH_LIMIT) {
4930
- throw new Error(`invalid drive path: exceeds ${UTF8_PATH_LIMIT} bytes`);
4931
- }
4932
- const segments = drivePath.split("/");
4933
- if (segments.some((segment) => segment.length === 0)) {
4934
- throw new Error("invalid drive path: empty segment");
4935
- }
4936
- if (segments.some((segment) => segment === "." || segment === "..")) {
4937
- throw new Error("invalid drive path: relative segment");
4938
- }
4939
- if (segments.some((segment) => Buffer.byteLength(segment, "utf8") > UTF8_SEGMENT_LIMIT)) {
4940
- throw new Error(`invalid drive path: segment exceeds ${UTF8_SEGMENT_LIMIT} bytes`);
4941
- }
4942
- return drivePath;
4943
- }
4944
- function resolveInsideRoot(root, drivePath) {
4945
- const normalizedPath = validateDrivePath(drivePath);
4946
- const absoluteRoot = resolve2(root);
4947
- const absolutePath = resolve2(absoluteRoot, normalizedPath);
4948
- const relativePath = relative(absoluteRoot, absolutePath);
4949
- if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
4950
- throw new Error(`drive path escapes root: ${drivePath}`);
4951
- }
4952
- return absolutePath;
4953
- }
4954
-
4955
5079
  // src/handwritten/commands/drive/scanner.ts
4956
5080
  import { createHash } from "crypto";
4957
5081
  import { constants as fsConstants } from "fs";
@@ -5072,484 +5196,47 @@ async function hashDriveFile(path) {
5072
5196
  }
5073
5197
  }
5074
5198
 
5075
- // src/handwritten/commands/drive/sync.ts
5076
- function emptySummary() {
5077
- return {
5078
- uploaded: 0,
5079
- downloaded: 0,
5080
- deleted: 0,
5081
- unchanged: 0,
5082
- merged: 0,
5083
- conflicts: 0,
5084
- errors: 0,
5085
- conflict_paths: [],
5086
- paths: []
5087
- };
5088
- }
5089
- async function runDriveSyncOnce(root, api) {
5090
- return withDriveLock(root, async () => {
5091
- let state = await readDriveState(root);
5092
- const syncApi = api ?? await createDriveApi();
5093
- const summary = emptySummary();
5094
- const blockedPaths = /* @__PURE__ */ new Set();
5095
- const localFiles = await scanDriveFiles(root, {
5096
- onPathError: async (path, error) => {
5097
- await recordPathError(summary, blockedPaths, path, error);
5098
- }
5099
- });
5100
- const remoteFiles = await fetchRemoteManifest(root, state, syncApi, summary, blockedPaths);
5101
- const paths = Array.from(
5102
- /* @__PURE__ */ new Set([...Object.keys(localFiles), ...Object.keys(remoteFiles), ...Object.keys(state.entries)])
5103
- ).filter((path) => !blockedPaths.has(path)).sort((left, right) => left.localeCompare(right));
5104
- for (const path of paths) {
5105
- const remote = remoteFiles[path];
5106
- const action = decideDriveAction(state.entries[path], localFiles[path], remote);
5107
- const result = await processPath({ root, state, api: syncApi, path, action, remote, local: localFiles[path], summary });
5108
- state = result.state;
5109
- if (result.stop) break;
5110
- }
5111
- recordUnresolvedConflicts(summary, state);
5112
- return summary;
5199
+ // src/handwritten/commands/drive/local-mutations.ts
5200
+ import { createWriteStream as createWriteStream2 } from "fs";
5201
+ import { link, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm as rm2, unlink, writeFile as writeFile2 } from "fs/promises";
5202
+ import { basename as basename2, dirname, join as join4 } from "path";
5203
+ import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
5204
+ import { Readable as Readable2, Transform } from "stream";
5205
+ import { pipeline as pipeline2 } from "stream/promises";
5206
+ async function assertLocalStillScanned(localPath, scanned) {
5207
+ const snapshot = await hashDriveFile(localPath).catch((error) => {
5208
+ if (isNotFoundError(error)) return void 0;
5209
+ throw error;
5113
5210
  });
5211
+ if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
5212
+ throw new Error("local file changed after scan");
5213
+ }
5114
5214
  }
5115
- function driveSyncCommand(api) {
5116
- const sync = new Command71("sync").description("Drive sync commands");
5117
- sync.command("once").description("Run one Drive sync pass").argument("[path]", "local folder path", ".").action(async (path) => {
5118
- const summary = await runDriveSyncOnce(resolve3(path), api);
5119
- render({ kind: "drive_sync_once", display: { shape: "object" } }, summary);
5120
- if (summary.conflicts > 0 || summary.errors > 0) {
5121
- process.exitCode = 1;
5122
- }
5123
- });
5124
- return sync;
5215
+ async function writeMergedLocalFile(root, path, bytes, digest, scanned, onLocalMutation) {
5216
+ const target = resolveInsideRoot(root, path);
5217
+ await mkdir2(dirname(target), { recursive: true });
5218
+ const tmp = join4(dirname(target), `.${basename2(target)}.wspc-merge-${randomUUID2()}.tmp`);
5219
+ try {
5220
+ await writeFile2(tmp, bytes, { flag: "wx" });
5221
+ return await installMergedLocalFile(root, path, tmp, scanned, digest, bytes.byteLength, onLocalMutation);
5222
+ } finally {
5223
+ await rm2(tmp, { force: true }).catch(() => {
5224
+ });
5225
+ }
5125
5226
  }
5126
- async function fetchRemoteManifest(root, state, api, summary, blockedPaths) {
5127
- const candidates = [];
5128
- const remoteFiles = {};
5129
- let cursor;
5130
- do {
5131
- const page = await api.getManifest(state.library_id, cursor);
5132
- for (const entry of page.entries) {
5133
- try {
5134
- validateRemoteEntry(root, entry);
5135
- candidates.push(entry);
5136
- } catch (error) {
5137
- await recordPathError(summary, blockedPaths, entry.path, error);
5227
+ async function installMergedLocalFile(root, path, tmp, scanned, mergedSha256, mergedSizeBytes, onLocalMutation) {
5228
+ const target = resolveInsideRoot(root, path);
5229
+ const backup = localMutationBackupPath(target);
5230
+ let backupIsScannedLocal = false;
5231
+ try {
5232
+ try {
5233
+ await rename2(target, backup);
5234
+ onLocalMutation();
5235
+ } catch (error) {
5236
+ if (isNotFoundError(error)) {
5237
+ throw new Error("local file changed after scan");
5138
5238
  }
5139
- }
5140
- cursor = page.next_cursor ?? void 0;
5141
- } while (cursor !== void 0);
5142
- const byCaseFoldedPath = /* @__PURE__ */ new Map();
5143
- for (const entry of candidates) {
5144
- const folded = entry.path.toLowerCase();
5145
- const group = byCaseFoldedPath.get(folded) ?? [];
5146
- group.push(entry);
5147
- byCaseFoldedPath.set(folded, group);
5148
- }
5149
- for (const group of byCaseFoldedPath.values()) {
5150
- const exactPathCounts = /* @__PURE__ */ new Map();
5151
- for (const entry2 of group) {
5152
- exactPathCounts.set(entry2.path, (exactPathCounts.get(entry2.path) ?? 0) + 1);
5153
- }
5154
- if (group.length > 1) {
5155
- const hasExactDuplicate = Array.from(exactPathCounts.values()).some((count) => count > 1);
5156
- const reason = hasExactDuplicate ? "REMOTE_PATH_DUPLICATE" : "REMOTE_PATH_CASE_CONFLICT";
5157
- for (const entry2 of group.sort((left, right) => left.path.localeCompare(right.path))) {
5158
- await recordPathError(summary, blockedPaths, entry2.path, new Error(`${reason}: ${entry2.path}`), {
5159
- appendPathResult: true
5160
- });
5161
- }
5162
- continue;
5163
- }
5164
- const [entry] = group;
5165
- if (entry) remoteFiles[entry.path] = entry;
5166
- }
5167
- return remoteFiles;
5168
- }
5169
- function validateRemoteEntry(root, entry) {
5170
- validateDrivePath(entry.path);
5171
- resolveInsideRoot(root, entry.path);
5172
- }
5173
- async function processPath(args) {
5174
- const { root, state, api, path, action, remote, local, summary } = args;
5175
- summary.paths.push({ path, action: action.type });
5176
- let durableStateRequired = false;
5177
- try {
5178
- if (action.type === "upload_create" || action.type === "upload_update") {
5179
- const localPath = resolveInsideRoot(root, path);
5180
- const { body, digest: uploadDigest } = await readStableUploadBody(localPath, local);
5181
- const uploaded = await api.uploadFile(state.library_id, path, body, uploadDigest, action.expectedEntryVersion);
5182
- durableStateRequired = true;
5183
- const nextState = cloneDriveState(state);
5184
- nextState.entries[path] = stateEntryFromRemote(uploaded.entry, uploadDigest);
5185
- delete nextState.conflicts[path];
5186
- await commitDriveState(root, nextState);
5187
- summary.uploaded += 1;
5188
- return { state: nextState, stop: false };
5189
- }
5190
- if (action.type === "download") {
5191
- if (!remote) throw new Error("remote entry missing for download");
5192
- await assertLocalSafeForDownload(root, path, state.entries[path]);
5193
- const digest = await downloadRemote(root, state.library_id, path, api, remote.content_sha256, state.entries[path], () => {
5194
- durableStateRequired = true;
5195
- });
5196
- const nextState = cloneDriveState(state);
5197
- nextState.entries[path] = stateEntryFromRemote(remote, digest);
5198
- delete nextState.conflicts[path];
5199
- await commitDriveState(root, nextState);
5200
- summary.downloaded += 1;
5201
- return { state: nextState, stop: false };
5202
- }
5203
- if (action.type === "delete_remote") {
5204
- await assertLocalAbsentBeforeRemoteDelete(root, path);
5205
- await api.deleteFile(state.library_id, path, action.expectedEntryVersion);
5206
- durableStateRequired = true;
5207
- await assertLocalAbsentBeforeRemoteDelete(root, path);
5208
- const nextState = cloneDriveState(state);
5209
- delete nextState.entries[path];
5210
- delete nextState.conflicts[path];
5211
- await commitDriveState(root, nextState);
5212
- summary.deleted += 1;
5213
- return { state: nextState, stop: false };
5214
- }
5215
- if (action.type === "delete_local") {
5216
- await removeLocalIfStillBase(root, path, state.entries[path], () => {
5217
- durableStateRequired = true;
5218
- });
5219
- const nextState = cloneDriveState(state);
5220
- delete nextState.entries[path];
5221
- delete nextState.conflicts[path];
5222
- await commitDriveState(root, nextState);
5223
- summary.deleted += 1;
5224
- return { state: nextState, stop: false };
5225
- }
5226
- if (action.type === "state_only") {
5227
- if (!remote) throw new Error("remote entry missing for state update");
5228
- const nextState = cloneDriveState(state);
5229
- nextState.entries[path] = stateEntryFromRemote(remote, local?.sha256 ?? remote.content_sha256);
5230
- delete nextState.conflicts[path];
5231
- await commitDriveState(root, nextState);
5232
- summary.unchanged += 1;
5233
- return { state: nextState, stop: false };
5234
- }
5235
- if (action.type === "remove_state") {
5236
- const nextState = cloneDriveState(state);
5237
- delete nextState.entries[path];
5238
- delete nextState.conflicts[path];
5239
- await commitDriveState(root, nextState);
5240
- summary.unchanged += 1;
5241
- return { state: nextState, stop: false };
5242
- }
5243
- if (action.type === "conflict") {
5244
- if (action.reason === "local_and_remote_changed") {
5245
- try {
5246
- const mergedState = await tryResolveConflict({
5247
- root,
5248
- state,
5249
- api,
5250
- path,
5251
- remote,
5252
- local,
5253
- onLocalMutation: () => {
5254
- durableStateRequired = true;
5255
- }
5256
- });
5257
- if (mergedState) {
5258
- summary.merged += 1;
5259
- summary.paths[summary.paths.length - 1] = { path, action: "merged" };
5260
- return { state: mergedState, stop: false };
5261
- }
5262
- } catch (error) {
5263
- if (!isLocalChangedDuringMerge(error)) {
5264
- throw error;
5265
- }
5266
- const nextState2 = await recordTypedConflict(root, state, path, action.reason, remote, {
5267
- type: "edit_edit",
5268
- strategy: "record_only",
5269
- reason: "local_changed_during_merge"
5270
- });
5271
- summary.conflicts += 1;
5272
- return { state: nextState2, stop: false };
5273
- }
5274
- const conflictCopyState = await recordRemoteConflictCopy({
5275
- root,
5276
- state,
5277
- api,
5278
- path,
5279
- reason: action.reason,
5280
- type: "edit_edit",
5281
- remote
5282
- });
5283
- if (conflictCopyState) {
5284
- summary.conflicts += 1;
5285
- return { state: conflictCopyState, stop: false };
5286
- }
5287
- }
5288
- if (action.reason === "local_and_remote_without_base") {
5289
- const conflictCopyState = await recordRemoteConflictCopy({
5290
- root,
5291
- state,
5292
- api,
5293
- path,
5294
- reason: action.reason,
5295
- type: "create_create",
5296
- remote
5297
- });
5298
- if (conflictCopyState) {
5299
- summary.conflicts += 1;
5300
- return { state: conflictCopyState, stop: false };
5301
- }
5302
- }
5303
- if (action.reason === "local_changed_remote_deleted") {
5304
- const nextState2 = await recordTypedConflict(root, state, path, action.reason, remote, {
5305
- type: "edit_delete",
5306
- strategy: "record_only"
5307
- });
5308
- summary.conflicts += 1;
5309
- return { state: nextState2, stop: false };
5310
- }
5311
- if (action.reason === "remote_changed_before_delete") {
5312
- const conflictCopyState = await recordRemoteConflictCopy({
5313
- root,
5314
- state,
5315
- api,
5316
- path,
5317
- reason: action.reason,
5318
- type: "delete_edit",
5319
- remote
5320
- });
5321
- if (conflictCopyState) {
5322
- summary.conflicts += 1;
5323
- return { state: conflictCopyState, stop: false };
5324
- }
5325
- }
5326
- const nextState = await recordConflict(root, state, path, action.reason, remote);
5327
- summary.conflicts += 1;
5328
- return { state: nextState, stop: false };
5329
- }
5330
- summary.unchanged += 1;
5331
- } catch (error) {
5332
- if (isVersionConflict(error)) {
5333
- try {
5334
- const nextState = await recordConflict(root, state, path, "VERSION_CONFLICT", remote);
5335
- summary.conflicts += 1;
5336
- summary.paths[summary.paths.length - 1] = { path, action: "conflict" };
5337
- return { state: nextState, stop: false };
5338
- } catch (writeError) {
5339
- await recordPathError(summary, void 0, path, writeError);
5340
- return { state, stop: durableStateRequired };
5341
- }
5342
- }
5343
- await recordPathError(summary, void 0, path, error);
5344
- return { state, stop: durableStateRequired };
5345
- }
5346
- return { state, stop: false };
5347
- }
5348
- async function tryResolveConflict(args) {
5349
- const { root, state, api, path, remote, local, onLocalMutation } = args;
5350
- const entry = state.entries[path];
5351
- const baseVersionId = entry?.current_version_id;
5352
- const remoteVersionId = remote?.current_version_id;
5353
- if (!entry || !remote || !local || baseVersionId === void 0 || remoteVersionId === void 0) {
5354
- return void 0;
5355
- }
5356
- const localPath = resolveInsideRoot(root, path);
5357
- let baseBytes;
5358
- let remoteBytes;
5359
- try {
5360
- const [downloadedBaseBytes, downloadedRemoteBytes] = await Promise.all([
5361
- downloadBytes(api, state.library_id, path, baseVersionId),
5362
- downloadBytes(api, state.library_id, path, remoteVersionId)
5363
- ]);
5364
- baseBytes = downloadedBaseBytes;
5365
- remoteBytes = downloadedRemoteBytes;
5366
- } catch (error) {
5367
- if (isExpectedVersionDownloadMissing(error)) {
5368
- return void 0;
5369
- }
5370
- throw error;
5371
- }
5372
- const localBytes = await readFile3(localPath);
5373
- const baseText = classifyMergeText(path, baseBytes, void 0);
5374
- const localText = classifyMergeText(path, localBytes, void 0);
5375
- const remoteText = classifyMergeText(path, remoteBytes, void 0);
5376
- if (!baseText.mergeable || !localText.mergeable || !remoteText.mergeable) {
5377
- return void 0;
5378
- }
5379
- const merged = mergeText3(baseText.text, localText.text, remoteText.text);
5380
- if (!merged.clean) {
5381
- return void 0;
5382
- }
5383
- await assertLocalStillScanned(localPath, local);
5384
- const mergedBytes = new TextEncoder().encode(merged.text);
5385
- const mergedDigest = createHash2("sha256").update(mergedBytes).digest("hex");
5386
- const install = await writeMergedLocalFile(root, path, mergedBytes, mergedDigest, local, onLocalMutation);
5387
- let uploaded;
5388
- try {
5389
- uploaded = await api.uploadFile(state.library_id, path, mergedBytes, mergedDigest, remote.entry_version);
5390
- } catch (error) {
5391
- await install.restore();
5392
- throw error;
5393
- }
5394
- await install.finalize();
5395
- const nextState = cloneDriveState(state);
5396
- nextState.entries[path] = stateEntryFromRemote(uploaded.entry, mergedDigest);
5397
- delete nextState.conflicts[path];
5398
- await commitDriveState(root, nextState);
5399
- return nextState;
5400
- }
5401
- async function downloadBytes(api, libraryId, path, versionId) {
5402
- const response = await api.downloadFile(libraryId, path, versionId);
5403
- return new Uint8Array(await response.arrayBuffer());
5404
- }
5405
- async function recordRemoteConflictCopy(args) {
5406
- const { root, state, api, path, reason, type, remote } = args;
5407
- const entry = state.entries[path];
5408
- const remoteVersionId = remote?.current_version_id;
5409
- if (!remote || remoteVersionId === void 0) {
5410
- return void 0;
5411
- }
5412
- if (await canReuseConflictCopy(root, state.conflicts[path], remoteVersionId)) {
5413
- return state;
5414
- }
5415
- const remoteBytes = await downloadBytes(api, state.library_id, path, remoteVersionId);
5416
- const copyPath = await writeConflictCopy(root, path, "remote", remoteVersionId, remoteBytes);
5417
- const nextState = cloneDriveState(state);
5418
- nextState.conflicts[path] = {
5419
- detected_at: (/* @__PURE__ */ new Date()).toISOString(),
5420
- reason,
5421
- type,
5422
- strategy: "conflict_copy",
5423
- base_version_id: entry?.current_version_id,
5424
- remote_version_id: remoteVersionId,
5425
- remote_entry_version: remote.entry_version,
5426
- conflict_paths: [copyPath]
5427
- };
5428
- await commitDriveState(root, nextState);
5429
- return nextState;
5430
- }
5431
- async function canReuseConflictCopy(root, conflict2, remoteVersionId) {
5432
- if (conflict2?.strategy !== "conflict_copy" || conflict2.remote_version_id !== remoteVersionId || !Array.isArray(conflict2.conflict_paths) || conflict2.conflict_paths.length === 0) {
5433
- return false;
5434
- }
5435
- for (const conflictPath of conflict2.conflict_paths) {
5436
- try {
5437
- validateDrivePath(conflictPath);
5438
- if (!await localFileExists(resolveInsideRoot(root, conflictPath))) {
5439
- return false;
5440
- }
5441
- } catch {
5442
- return false;
5443
- }
5444
- }
5445
- return true;
5446
- }
5447
- async function writeConflictCopy(root, path, side, versionId, bytes) {
5448
- const baseCopyPath = conflictCopyPath(path, side, /* @__PURE__ */ new Date(), versionId);
5449
- for (let suffix = 1; ; suffix += 1) {
5450
- const candidate = conflictCopyPathWithSuffix(baseCopyPath, suffix);
5451
- validateDrivePath(candidate);
5452
- const target = resolveInsideRoot(root, candidate);
5453
- await mkdir2(dirname(target), { recursive: true });
5454
- for (; ; ) {
5455
- const tmp = join4(dirname(target), `.${basename2(target)}.wspc-conflict-${randomUUID2()}.tmp`);
5456
- let tmpWritten = false;
5457
- try {
5458
- await writeFile2(tmp, bytes, { flag: "wx" });
5459
- tmpWritten = true;
5460
- await installNoOverwrite(tmp, target);
5461
- return candidate;
5462
- } catch (error) {
5463
- if (tmpWritten) {
5464
- await rm2(tmp, { force: true }).catch(() => {
5465
- });
5466
- }
5467
- if (isAlreadyExistsError(error)) {
5468
- if (tmpWritten) break;
5469
- continue;
5470
- }
5471
- throw error;
5472
- }
5473
- }
5474
- }
5475
- }
5476
- function conflictCopyPathWithSuffix(path, suffix) {
5477
- if (suffix === 1) {
5478
- return path;
5479
- }
5480
- const parsed = pathPosix2.parse(path);
5481
- const fileName = `${parsed.name}-${suffix}${parsed.ext}`;
5482
- if (parsed.dir === "") {
5483
- return fileName;
5484
- }
5485
- return pathPosix2.join(parsed.dir, fileName);
5486
- }
5487
- function isExpectedVersionDownloadMissing(error) {
5488
- const structured = error;
5489
- if (structured?.status === 404 || structured?.status === 410) return true;
5490
- if (structured?.response?.status === 404 || structured?.response?.status === 410) return true;
5491
- if (structured?.code === "VERSION_NOT_FOUND" || structured?.code === "NOT_FOUND") return true;
5492
- return /\b(?:HTTP 40[410]|missing version|version not found|not found)\b/i.test(errorMessage(error));
5493
- }
5494
- async function assertLocalStillScanned(localPath, scanned) {
5495
- const snapshot = await hashDriveFile(localPath).catch((error) => {
5496
- if (isNotFoundError(error)) return void 0;
5497
- throw error;
5498
- });
5499
- if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
5500
- throw new Error("local file changed after scan");
5501
- }
5502
- }
5503
- function recordUnresolvedConflicts(summary, state) {
5504
- const newlyRecorded = new Set(summary.paths.filter((result) => result.action === "conflict").map((result) => result.path));
5505
- const reportedPaths = new Set(summary.paths.map((result) => result.path));
5506
- for (const path of Object.keys(state.conflicts).sort((left, right) => left.localeCompare(right))) {
5507
- const conflictPaths = state.conflicts[path]?.conflict_paths;
5508
- if (conflictPaths) {
5509
- summary.conflict_paths.push(...conflictPaths);
5510
- }
5511
- if (!newlyRecorded.has(path)) {
5512
- summary.conflicts += 1;
5513
- }
5514
- const existingResult = summary.paths.find((result) => result.path === path);
5515
- if (existingResult?.action === "unchanged") {
5516
- existingResult.action = "conflict";
5517
- if (conflictPaths) existingResult.conflict_paths = conflictPaths;
5518
- continue;
5519
- }
5520
- if (existingResult?.action === "conflict" && conflictPaths) {
5521
- existingResult.conflict_paths = conflictPaths;
5522
- }
5523
- if (!reportedPaths.has(path)) {
5524
- summary.paths.push({ path, action: "conflict", ...conflictPaths ? { conflict_paths: conflictPaths } : {} });
5525
- }
5526
- }
5527
- }
5528
- async function writeMergedLocalFile(root, path, bytes, digest, scanned, onLocalMutation) {
5529
- const target = resolveInsideRoot(root, path);
5530
- await mkdir2(dirname(target), { recursive: true });
5531
- const tmp = join4(dirname(target), `.${basename2(target)}.wspc-merge-${randomUUID2()}.tmp`);
5532
- try {
5533
- await writeFile2(tmp, bytes, { flag: "wx" });
5534
- return await installMergedLocalFile(root, path, tmp, scanned, digest, bytes.byteLength, onLocalMutation);
5535
- } finally {
5536
- await rm2(tmp, { force: true }).catch(() => {
5537
- });
5538
- }
5539
- }
5540
- async function installMergedLocalFile(root, path, tmp, scanned, mergedSha256, mergedSizeBytes, onLocalMutation) {
5541
- const target = resolveInsideRoot(root, path);
5542
- const backup = localMutationBackupPath(target);
5543
- let backupIsScannedLocal = false;
5544
- try {
5545
- try {
5546
- await rename2(target, backup);
5547
- onLocalMutation();
5548
- } catch (error) {
5549
- if (isNotFoundError(error)) {
5550
- throw new Error("local file changed after scan");
5551
- }
5552
- throw error;
5239
+ throw error;
5553
5240
  }
5554
5241
  const backupDigest = await hashDriveFile(backup);
5555
5242
  if (!backupDigest || backupDigest.sha256 !== scanned.sha256 || backupDigest.sizeBytes !== scanned.size_bytes) {
@@ -5639,166 +5326,590 @@ async function downloadRemote(root, libraryId, path, api, expectedSha256, entry,
5639
5326
  });
5640
5327
  }
5641
5328
  }
5642
- async function installDownloadedFile(root, path, tmp, entry, onLocalMutation) {
5643
- const target = resolveInsideRoot(root, path);
5644
- const backup = localMutationBackupPath(target);
5645
- const expectedSha256 = expectedLocalBaseSha256(entry);
5646
- let backupIsExpectedBase = false;
5329
+ async function installDownloadedFile(root, path, tmp, entry, onLocalMutation) {
5330
+ const target = resolveInsideRoot(root, path);
5331
+ const backup = localMutationBackupPath(target);
5332
+ const expectedSha256 = expectedLocalBaseSha256(entry);
5333
+ let backupIsExpectedBase = false;
5334
+ try {
5335
+ try {
5336
+ await rename2(target, backup);
5337
+ onLocalMutation();
5338
+ } catch (error) {
5339
+ if (!isNotFoundError(error)) throw error;
5340
+ await installNoOverwrite(tmp, target, onLocalMutation);
5341
+ return;
5342
+ }
5343
+ const backupDigest = await hashDriveFile(backup);
5344
+ if (!backupDigest) {
5345
+ await restoreBackupWhenPossible(backup, target);
5346
+ throw new Error("local file changed before download");
5347
+ }
5348
+ if (!expectedSha256 || backupDigest.sha256 !== expectedSha256) {
5349
+ await restoreBackupWhenPossible(backup, target);
5350
+ throw new Error("local file changed before download");
5351
+ }
5352
+ backupIsExpectedBase = true;
5353
+ try {
5354
+ await installNoOverwrite(tmp, target, onLocalMutation);
5355
+ } catch (error) {
5356
+ const restored = await restoreBackupWhenPossible(backup, target);
5357
+ if (!restored && backupIsExpectedBase) {
5358
+ await unlink(backup).catch(() => {
5359
+ });
5360
+ }
5361
+ throw error;
5362
+ }
5363
+ await unlink(backup);
5364
+ } catch (error) {
5365
+ if (!backupIsExpectedBase) {
5366
+ await restoreBackupWhenPossible(backup, target);
5367
+ }
5368
+ throw error;
5369
+ }
5370
+ }
5371
+ async function removeLocalIfStillBase(root, path, entry, onLocalMutation) {
5372
+ const target = resolveInsideRoot(root, path);
5373
+ const backup = localMutationBackupPath(target);
5374
+ const expectedSha256 = expectedLocalBaseSha256(entry);
5375
+ if (!expectedSha256) {
5376
+ throw new Error("local file has no sync base");
5377
+ }
5378
+ let backupIsExpectedBase = false;
5379
+ try {
5380
+ try {
5381
+ await rename2(target, backup);
5382
+ onLocalMutation();
5383
+ } catch (error) {
5384
+ if (isNotFoundError(error)) {
5385
+ throw new Error("local file changed before delete");
5386
+ }
5387
+ throw error;
5388
+ }
5389
+ const backupDigest = await hashDriveFile(backup);
5390
+ if (!backupDigest || backupDigest.sha256 !== expectedSha256) {
5391
+ await restoreBackupWhenPossible(backup, target);
5392
+ throw new Error("local file changed before delete");
5393
+ }
5394
+ backupIsExpectedBase = true;
5395
+ if (await localFileExists(target)) {
5396
+ await unlink(backup).catch(() => {
5397
+ });
5398
+ throw new Error("local file reappeared during delete");
5399
+ }
5400
+ await unlink(backup);
5401
+ if (await localFileExists(target)) {
5402
+ throw new Error("local file reappeared during delete");
5403
+ }
5404
+ } catch (error) {
5405
+ if (!backupIsExpectedBase) {
5406
+ await restoreBackupWhenPossible(backup, target);
5407
+ }
5408
+ throw error;
5409
+ }
5410
+ }
5411
+ async function installNoOverwrite(source, target, onLinked) {
5412
+ await link(source, target);
5413
+ onLinked?.();
5414
+ await unlink(source);
5415
+ }
5416
+ async function restoreBackupWhenPossible(backup, target) {
5417
+ try {
5418
+ await installNoOverwrite(backup, target);
5419
+ return true;
5420
+ } catch (error) {
5421
+ if (isAlreadyExistsError(error)) return false;
5422
+ if (isNotFoundError(error)) return true;
5423
+ return false;
5424
+ }
5425
+ }
5426
+ async function localFileExists(path) {
5427
+ const digest = await hashDriveFile(path).catch((error) => {
5428
+ if (isNotFoundError(error)) return void 0;
5429
+ throw error;
5430
+ });
5431
+ return digest !== void 0;
5432
+ }
5433
+ function localMutationBackupPath(target) {
5434
+ return join4(dirname(target), `.${basename2(target)}.wspc-backup-${randomUUID2()}.tmp`);
5435
+ }
5436
+ function expectedLocalBaseSha256(entry) {
5437
+ return entry?.last_local_sha256 ?? entry?.content_sha256;
5438
+ }
5439
+ async function readStableUploadBody(localPath, scanned) {
5440
+ if (!scanned) {
5441
+ throw new Error("local file missing from scan");
5442
+ }
5443
+ const snapshot = await hashDriveFile(localPath).catch((error) => {
5444
+ if (isNotFoundError(error)) return void 0;
5445
+ throw error;
5446
+ });
5447
+ if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
5448
+ throw new Error("local file changed after scan");
5449
+ }
5450
+ const body = await readFile3(localPath).catch((error) => {
5451
+ if (isNotFoundError(error)) return void 0;
5452
+ throw error;
5453
+ });
5454
+ if (!body) {
5455
+ throw new Error("local file changed after scan");
5456
+ }
5457
+ const uploadBytes = new Uint8Array(body.byteLength);
5458
+ uploadBytes.set(body);
5459
+ const digest = createHash2("sha256").update(uploadBytes).digest("hex");
5460
+ if (digest !== scanned.sha256 || uploadBytes.byteLength !== scanned.size_bytes) {
5461
+ throw new Error("local file changed after scan");
5462
+ }
5463
+ return { body: uploadBytes.buffer, digest };
5464
+ }
5465
+ async function assertLocalSafeForDownload(root, path, entry) {
5466
+ const target = resolveInsideRoot(root, path);
5467
+ const digest = await hashDriveFile(target).catch((error) => {
5468
+ if (isNotFoundError(error)) return void 0;
5469
+ throw error;
5470
+ });
5471
+ if (!digest) return;
5472
+ if (!entry?.last_local_sha256) {
5473
+ throw new Error("local file appeared before download");
5474
+ }
5475
+ if (digest.sha256 !== entry.last_local_sha256) {
5476
+ throw new Error("local file changed before download");
5477
+ }
5478
+ }
5479
+ async function assertLocalAbsentBeforeRemoteDelete(root, path) {
5480
+ const digest = await hashDriveFile(resolveInsideRoot(root, path)).catch((error) => {
5481
+ if (isNotFoundError(error)) return void 0;
5482
+ throw error;
5483
+ });
5484
+ if (digest) {
5485
+ throw new Error("local file appeared before remote delete");
5486
+ }
5487
+ }
5488
+ function isNotFoundError(error) {
5489
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
5490
+ }
5491
+ function isAlreadyExistsError(error) {
5492
+ return error instanceof Error && "code" in error && error.code === "EEXIST";
5493
+ }
5494
+
5495
+ // src/handwritten/commands/drive/sync.ts
5496
+ function emptySummary() {
5497
+ return {
5498
+ uploaded: 0,
5499
+ downloaded: 0,
5500
+ deleted: 0,
5501
+ unchanged: 0,
5502
+ merged: 0,
5503
+ conflicts: 0,
5504
+ errors: 0,
5505
+ conflict_paths: [],
5506
+ paths: []
5507
+ };
5508
+ }
5509
+ async function runDriveSyncOnce(root, api, clock = systemDriveClock) {
5510
+ return withDriveLock(root, async () => {
5511
+ let state = await readDriveState(root);
5512
+ const syncApi = api ?? await createDriveApi();
5513
+ const summary = emptySummary();
5514
+ const blockedPaths = /* @__PURE__ */ new Set();
5515
+ const localFiles = await scanDriveFiles(root, {
5516
+ onPathError: async (path, error) => {
5517
+ await recordPathError(summary, blockedPaths, path, error);
5518
+ }
5519
+ });
5520
+ const remoteFiles = await fetchRemoteManifest(root, state, syncApi, summary, blockedPaths);
5521
+ const paths = Array.from(
5522
+ /* @__PURE__ */ new Set([...Object.keys(localFiles), ...Object.keys(remoteFiles), ...Object.keys(state.entries)])
5523
+ ).filter((path) => !blockedPaths.has(path)).sort((left, right) => left.localeCompare(right));
5524
+ for (const path of paths) {
5525
+ const remote = remoteFiles[path];
5526
+ const action = decideDriveAction(state.entries[path], localFiles[path], remote);
5527
+ const result = await processPath({ root, state, api: syncApi, path, action, remote, local: localFiles[path], summary, clock });
5528
+ state = result.state;
5529
+ if (result.stop) break;
5530
+ }
5531
+ recordUnresolvedConflicts(summary, state);
5532
+ return summary;
5533
+ });
5534
+ }
5535
+ function driveSyncCommand(api) {
5536
+ const sync = new Command71("sync").description("Drive sync commands");
5537
+ sync.command("once").description("Run one Drive sync pass").argument("[path]", "local folder path", ".").action(async (path) => {
5538
+ const summary = await runDriveSyncOnce(resolve3(path), api);
5539
+ render({ kind: "drive_sync_once", display: { shape: "object" } }, summary);
5540
+ if (summary.conflicts > 0 || summary.errors > 0) {
5541
+ process.exitCode = 1;
5542
+ }
5543
+ });
5544
+ return sync;
5545
+ }
5546
+ async function fetchRemoteManifest(root, state, api, summary, blockedPaths) {
5547
+ const entries = [];
5548
+ let cursor;
5549
+ do {
5550
+ const page = await api.getManifest(state.library_id, cursor);
5551
+ entries.push(...page.entries);
5552
+ cursor = page.next_cursor ?? void 0;
5553
+ } while (cursor !== void 0);
5554
+ const normalized = normalizeRemoteManifest(root, entries);
5555
+ for (const pathError of normalized.pathErrors) {
5556
+ await recordPathError(summary, blockedPaths, pathError.path, pathError.error, {
5557
+ appendPathResult: pathError.appendPathResult
5558
+ });
5559
+ }
5560
+ return normalized.remoteFiles;
5561
+ }
5562
+ async function processPath(args) {
5563
+ const { root, state, api, path, action, remote, local, summary, clock } = args;
5564
+ summary.paths.push({ path, action: action.type });
5565
+ let durableStateRequired = false;
5647
5566
  try {
5648
- try {
5649
- await rename2(target, backup);
5650
- onLocalMutation();
5651
- } catch (error) {
5652
- if (!isNotFoundError(error)) throw error;
5653
- await installNoOverwrite(tmp, target, onLocalMutation);
5654
- return;
5567
+ if (action.type === "upload_create" || action.type === "upload_update") {
5568
+ const localPath = resolveInsideRoot(root, path);
5569
+ const { body, digest: uploadDigest } = await readStableUploadBody(localPath, local);
5570
+ const uploaded = await api.uploadFile(state.library_id, path, body, uploadDigest, action.expectedEntryVersion);
5571
+ durableStateRequired = true;
5572
+ const nextState = cloneDriveState(state);
5573
+ nextState.entries[path] = stateEntryFromRemote(uploaded.entry, uploadDigest, clock);
5574
+ delete nextState.conflicts[path];
5575
+ await writeDriveState(root, nextState, clock);
5576
+ summary.uploaded += 1;
5577
+ return { state: nextState, stop: false };
5655
5578
  }
5656
- const backupDigest = await hashDriveFile(backup);
5657
- if (!backupDigest) {
5658
- await restoreBackupWhenPossible(backup, target);
5659
- throw new Error("local file changed before download");
5579
+ if (action.type === "download") {
5580
+ if (!remote) throw new Error("remote entry missing for download");
5581
+ await assertLocalSafeForDownload(root, path, state.entries[path]);
5582
+ const digest = await downloadRemote(root, state.library_id, path, api, remote.content_sha256, state.entries[path], () => {
5583
+ durableStateRequired = true;
5584
+ });
5585
+ const nextState = cloneDriveState(state);
5586
+ nextState.entries[path] = stateEntryFromRemote(remote, digest, clock);
5587
+ delete nextState.conflicts[path];
5588
+ await writeDriveState(root, nextState, clock);
5589
+ summary.downloaded += 1;
5590
+ return { state: nextState, stop: false };
5660
5591
  }
5661
- if (!expectedSha256 || backupDigest.sha256 !== expectedSha256) {
5662
- await restoreBackupWhenPossible(backup, target);
5663
- throw new Error("local file changed before download");
5592
+ if (action.type === "delete_remote") {
5593
+ await assertLocalAbsentBeforeRemoteDelete(root, path);
5594
+ await api.deleteFile(state.library_id, path, action.expectedEntryVersion);
5595
+ durableStateRequired = true;
5596
+ await assertLocalAbsentBeforeRemoteDelete(root, path);
5597
+ const nextState = cloneDriveState(state);
5598
+ delete nextState.entries[path];
5599
+ delete nextState.conflicts[path];
5600
+ await writeDriveState(root, nextState, clock);
5601
+ summary.deleted += 1;
5602
+ return { state: nextState, stop: false };
5664
5603
  }
5665
- backupIsExpectedBase = true;
5666
- try {
5667
- await installNoOverwrite(tmp, target, onLocalMutation);
5668
- } catch (error) {
5669
- const restored = await restoreBackupWhenPossible(backup, target);
5670
- if (!restored && backupIsExpectedBase) {
5671
- await unlink(backup).catch(() => {
5604
+ if (action.type === "delete_local") {
5605
+ await removeLocalIfStillBase(root, path, state.entries[path], () => {
5606
+ durableStateRequired = true;
5607
+ });
5608
+ const nextState = cloneDriveState(state);
5609
+ delete nextState.entries[path];
5610
+ delete nextState.conflicts[path];
5611
+ await writeDriveState(root, nextState, clock);
5612
+ summary.deleted += 1;
5613
+ return { state: nextState, stop: false };
5614
+ }
5615
+ if (action.type === "state_only") {
5616
+ if (!remote) throw new Error("remote entry missing for state update");
5617
+ const nextState = cloneDriveState(state);
5618
+ nextState.entries[path] = stateEntryFromRemote(remote, local?.sha256 ?? remote.content_sha256, clock);
5619
+ delete nextState.conflicts[path];
5620
+ await writeDriveState(root, nextState, clock);
5621
+ summary.unchanged += 1;
5622
+ return { state: nextState, stop: false };
5623
+ }
5624
+ if (action.type === "remove_state") {
5625
+ const nextState = cloneDriveState(state);
5626
+ delete nextState.entries[path];
5627
+ delete nextState.conflicts[path];
5628
+ await writeDriveState(root, nextState, clock);
5629
+ summary.unchanged += 1;
5630
+ return { state: nextState, stop: false };
5631
+ }
5632
+ if (action.type === "conflict") {
5633
+ if (action.reason === "local_and_remote_changed") {
5634
+ try {
5635
+ const mergedState = await tryResolveConflict({
5636
+ root,
5637
+ state,
5638
+ api,
5639
+ path,
5640
+ remote,
5641
+ local,
5642
+ clock,
5643
+ onLocalMutation: () => {
5644
+ durableStateRequired = true;
5645
+ }
5646
+ });
5647
+ if (mergedState) {
5648
+ summary.merged += 1;
5649
+ summary.paths[summary.paths.length - 1] = { path, action: "merged" };
5650
+ return { state: mergedState, stop: false };
5651
+ }
5652
+ } catch (error) {
5653
+ if (!isLocalChangedDuringMerge(error)) {
5654
+ throw error;
5655
+ }
5656
+ const nextState2 = await recordTypedConflict(root, state, path, action.reason, remote, clock, {
5657
+ type: "edit_edit",
5658
+ strategy: "record_only",
5659
+ reason: "local_changed_during_merge"
5660
+ });
5661
+ summary.conflicts += 1;
5662
+ return { state: nextState2, stop: false };
5663
+ }
5664
+ const conflictCopyState = await recordRemoteConflictCopy({
5665
+ root,
5666
+ state,
5667
+ api,
5668
+ path,
5669
+ reason: action.reason,
5670
+ type: "edit_edit",
5671
+ remote,
5672
+ clock
5673
+ });
5674
+ if (conflictCopyState) {
5675
+ summary.conflicts += 1;
5676
+ return { state: conflictCopyState, stop: false };
5677
+ }
5678
+ }
5679
+ if (action.reason === "local_and_remote_without_base") {
5680
+ const conflictCopyState = await recordRemoteConflictCopy({
5681
+ root,
5682
+ state,
5683
+ api,
5684
+ path,
5685
+ reason: action.reason,
5686
+ type: "create_create",
5687
+ remote,
5688
+ clock
5672
5689
  });
5690
+ if (conflictCopyState) {
5691
+ summary.conflicts += 1;
5692
+ return { state: conflictCopyState, stop: false };
5693
+ }
5673
5694
  }
5674
- throw error;
5695
+ if (action.reason === "local_changed_remote_deleted") {
5696
+ const nextState2 = await recordTypedConflict(root, state, path, action.reason, remote, clock, {
5697
+ type: "edit_delete",
5698
+ strategy: "record_only"
5699
+ });
5700
+ summary.conflicts += 1;
5701
+ return { state: nextState2, stop: false };
5702
+ }
5703
+ if (action.reason === "remote_changed_before_delete") {
5704
+ const conflictCopyState = await recordRemoteConflictCopy({
5705
+ root,
5706
+ state,
5707
+ api,
5708
+ path,
5709
+ reason: action.reason,
5710
+ type: "delete_edit",
5711
+ remote,
5712
+ clock
5713
+ });
5714
+ if (conflictCopyState) {
5715
+ summary.conflicts += 1;
5716
+ return { state: conflictCopyState, stop: false };
5717
+ }
5718
+ }
5719
+ const nextState = await recordConflict(root, state, path, action.reason, remote, clock);
5720
+ summary.conflicts += 1;
5721
+ return { state: nextState, stop: false };
5675
5722
  }
5676
- await unlink(backup);
5723
+ summary.unchanged += 1;
5677
5724
  } catch (error) {
5678
- if (!backupIsExpectedBase) {
5679
- await restoreBackupWhenPossible(backup, target);
5725
+ if (isVersionConflict(error)) {
5726
+ try {
5727
+ const nextState = await recordConflict(root, state, path, "VERSION_CONFLICT", remote, clock);
5728
+ summary.conflicts += 1;
5729
+ summary.paths[summary.paths.length - 1] = { path, action: "conflict" };
5730
+ return { state: nextState, stop: false };
5731
+ } catch (writeError) {
5732
+ await recordPathError(summary, void 0, path, writeError);
5733
+ return { state, stop: durableStateRequired };
5734
+ }
5680
5735
  }
5681
- throw error;
5736
+ await recordPathError(summary, void 0, path, error);
5737
+ return { state, stop: durableStateRequired };
5682
5738
  }
5739
+ return { state, stop: false };
5683
5740
  }
5684
- async function removeLocalIfStillBase(root, path, entry, onLocalMutation) {
5685
- const target = resolveInsideRoot(root, path);
5686
- const backup = localMutationBackupPath(target);
5687
- const expectedSha256 = expectedLocalBaseSha256(entry);
5688
- if (!expectedSha256) {
5689
- throw new Error("local file has no sync base");
5741
+ async function tryResolveConflict(args) {
5742
+ const { root, state, api, path, remote, local, clock, onLocalMutation } = args;
5743
+ const entry = state.entries[path];
5744
+ const baseVersionId = entry?.current_version_id;
5745
+ const remoteVersionId = remote?.current_version_id;
5746
+ if (!entry || !remote || !local || baseVersionId === void 0 || remoteVersionId === void 0) {
5747
+ return void 0;
5690
5748
  }
5691
- let backupIsExpectedBase = false;
5749
+ const localPath = resolveInsideRoot(root, path);
5750
+ let baseBytes;
5751
+ let remoteBytes;
5692
5752
  try {
5693
- try {
5694
- await rename2(target, backup);
5695
- onLocalMutation();
5696
- } catch (error) {
5697
- if (isNotFoundError(error)) {
5698
- throw new Error("local file changed before delete");
5699
- }
5700
- throw error;
5701
- }
5702
- const backupDigest = await hashDriveFile(backup);
5703
- if (!backupDigest || backupDigest.sha256 !== expectedSha256) {
5704
- await restoreBackupWhenPossible(backup, target);
5705
- throw new Error("local file changed before delete");
5706
- }
5707
- backupIsExpectedBase = true;
5708
- if (await localFileExists(target)) {
5709
- await unlink(backup).catch(() => {
5710
- });
5711
- throw new Error("local file reappeared during delete");
5712
- }
5713
- await unlink(backup);
5714
- if (await localFileExists(target)) {
5715
- throw new Error("local file reappeared during delete");
5716
- }
5753
+ const [downloadedBaseBytes, downloadedRemoteBytes] = await Promise.all([
5754
+ downloadBytes(api, state.library_id, path, baseVersionId),
5755
+ downloadBytes(api, state.library_id, path, remoteVersionId)
5756
+ ]);
5757
+ baseBytes = downloadedBaseBytes;
5758
+ remoteBytes = downloadedRemoteBytes;
5717
5759
  } catch (error) {
5718
- if (!backupIsExpectedBase) {
5719
- await restoreBackupWhenPossible(backup, target);
5760
+ if (isExpectedVersionDownloadMissing(error)) {
5761
+ return void 0;
5720
5762
  }
5721
5763
  throw error;
5722
5764
  }
5723
- }
5724
- async function installNoOverwrite(source, target, onLinked) {
5725
- await link(source, target);
5726
- onLinked?.();
5727
- await unlink(source);
5728
- }
5729
- async function restoreBackupWhenPossible(backup, target) {
5765
+ const localBytes = await readFile4(localPath);
5766
+ const baseText = classifyMergeText(path, baseBytes, void 0);
5767
+ const localText = classifyMergeText(path, localBytes, void 0);
5768
+ const remoteText = classifyMergeText(path, remoteBytes, void 0);
5769
+ if (!baseText.mergeable || !localText.mergeable || !remoteText.mergeable) {
5770
+ return void 0;
5771
+ }
5772
+ const merged = mergeText3(baseText.text, localText.text, remoteText.text);
5773
+ if (!merged.clean) {
5774
+ return void 0;
5775
+ }
5776
+ await assertLocalStillScanned(localPath, local);
5777
+ const mergedBytes = new TextEncoder().encode(merged.text);
5778
+ const mergedDigest = createHash3("sha256").update(mergedBytes).digest("hex");
5779
+ const install = await writeMergedLocalFile(root, path, mergedBytes, mergedDigest, local, onLocalMutation);
5780
+ let uploaded;
5730
5781
  try {
5731
- await installNoOverwrite(backup, target);
5732
- return true;
5782
+ uploaded = await api.uploadFile(state.library_id, path, mergedBytes, mergedDigest, remote.entry_version);
5733
5783
  } catch (error) {
5734
- if (isAlreadyExistsError(error)) return false;
5735
- if (isNotFoundError(error)) return true;
5736
- return false;
5737
- }
5738
- }
5739
- async function localFileExists(path) {
5740
- const digest = await hashDriveFile(path).catch((error) => {
5741
- if (isNotFoundError(error)) return void 0;
5784
+ await install.restore();
5742
5785
  throw error;
5743
- });
5744
- return digest !== void 0;
5745
- }
5746
- function localMutationBackupPath(target) {
5747
- return join4(dirname(target), `.${basename2(target)}.wspc-backup-${randomUUID2()}.tmp`);
5786
+ }
5787
+ await install.finalize();
5788
+ const nextState = cloneDriveState(state);
5789
+ nextState.entries[path] = stateEntryFromRemote(uploaded.entry, mergedDigest, clock);
5790
+ delete nextState.conflicts[path];
5791
+ await writeDriveState(root, nextState, clock);
5792
+ return nextState;
5748
5793
  }
5749
- function expectedLocalBaseSha256(entry) {
5750
- return entry?.last_local_sha256 ?? entry?.content_sha256;
5794
+ async function downloadBytes(api, libraryId, path, versionId) {
5795
+ const response = await api.downloadFile(libraryId, path, versionId);
5796
+ return new Uint8Array(await response.arrayBuffer());
5751
5797
  }
5752
- async function readStableUploadBody(localPath, scanned) {
5753
- if (!scanned) {
5754
- throw new Error("local file missing from scan");
5798
+ async function recordRemoteConflictCopy(args) {
5799
+ const { root, state, api, path, reason, type, remote, clock } = args;
5800
+ const entry = state.entries[path];
5801
+ const remoteVersionId = remote?.current_version_id;
5802
+ if (!remote || remoteVersionId === void 0) {
5803
+ return void 0;
5755
5804
  }
5756
- const snapshot = await hashDriveFile(localPath).catch((error) => {
5757
- if (isNotFoundError(error)) return void 0;
5758
- throw error;
5759
- });
5760
- if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
5761
- throw new Error("local file changed after scan");
5805
+ if (await canReuseConflictCopy(root, state.conflicts[path], remoteVersionId)) {
5806
+ return state;
5762
5807
  }
5763
- const body = await readFile3(localPath).catch((error) => {
5764
- if (isNotFoundError(error)) return void 0;
5765
- throw error;
5766
- });
5767
- if (!body) {
5768
- throw new Error("local file changed after scan");
5808
+ const remoteBytes = await downloadBytes(api, state.library_id, path, remoteVersionId);
5809
+ const copyPath = await writeConflictCopy(root, path, "remote", remoteVersionId, remoteBytes, clock);
5810
+ const nextState = cloneDriveState(state);
5811
+ nextState.conflicts[path] = {
5812
+ detected_at: driveIsoTimestamp(clock),
5813
+ reason,
5814
+ type,
5815
+ strategy: "conflict_copy",
5816
+ base_version_id: entry?.current_version_id,
5817
+ remote_version_id: remoteVersionId,
5818
+ remote_entry_version: remote.entry_version,
5819
+ conflict_paths: [copyPath]
5820
+ };
5821
+ await writeDriveState(root, nextState, clock);
5822
+ return nextState;
5823
+ }
5824
+ async function canReuseConflictCopy(root, conflict2, remoteVersionId) {
5825
+ if (conflict2?.strategy !== "conflict_copy" || conflict2.remote_version_id !== remoteVersionId || !Array.isArray(conflict2.conflict_paths) || conflict2.conflict_paths.length === 0) {
5826
+ return false;
5769
5827
  }
5770
- const uploadBytes = new Uint8Array(body.byteLength);
5771
- uploadBytes.set(body);
5772
- const digest = createHash2("sha256").update(uploadBytes).digest("hex");
5773
- if (digest !== scanned.sha256 || uploadBytes.byteLength !== scanned.size_bytes) {
5774
- throw new Error("local file changed after scan");
5828
+ for (const conflictPath of conflict2.conflict_paths) {
5829
+ try {
5830
+ validateDrivePath(conflictPath);
5831
+ if (!await localFileExists(resolveInsideRoot(root, conflictPath))) {
5832
+ return false;
5833
+ }
5834
+ } catch {
5835
+ return false;
5836
+ }
5775
5837
  }
5776
- return { body: uploadBytes.buffer, digest };
5838
+ return true;
5777
5839
  }
5778
- async function assertLocalSafeForDownload(root, path, entry) {
5779
- const target = resolveInsideRoot(root, path);
5780
- const digest = await hashDriveFile(target).catch((error) => {
5781
- if (isNotFoundError(error)) return void 0;
5782
- throw error;
5783
- });
5784
- if (!digest) return;
5785
- if (!entry?.last_local_sha256) {
5786
- throw new Error("local file appeared before download");
5840
+ async function writeConflictCopy(root, path, side, versionId, bytes, clock) {
5841
+ const baseCopyPath = conflictCopyPath(path, side, driveConflictTimestamp(clock), versionId);
5842
+ for (let suffix = 1; ; suffix += 1) {
5843
+ const candidate = conflictCopyPathWithSuffix(baseCopyPath, suffix);
5844
+ validateDrivePath(candidate);
5845
+ const target = resolveInsideRoot(root, candidate);
5846
+ await mkdir3(dirname2(target), { recursive: true });
5847
+ for (; ; ) {
5848
+ const tmp = join5(dirname2(target), `.${basename3(target)}.wspc-conflict-${randomUUID3()}.tmp`);
5849
+ let tmpWritten = false;
5850
+ try {
5851
+ await writeFile3(tmp, bytes, { flag: "wx" });
5852
+ tmpWritten = true;
5853
+ await installNoOverwrite(tmp, target);
5854
+ return candidate;
5855
+ } catch (error) {
5856
+ if (tmpWritten) {
5857
+ await rm3(tmp, { force: true }).catch(() => {
5858
+ });
5859
+ }
5860
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") {
5861
+ if (tmpWritten) break;
5862
+ continue;
5863
+ }
5864
+ throw error;
5865
+ }
5866
+ }
5787
5867
  }
5788
- if (digest.sha256 !== entry.last_local_sha256) {
5789
- throw new Error("local file changed before download");
5868
+ }
5869
+ function conflictCopyPathWithSuffix(path, suffix) {
5870
+ if (suffix === 1) {
5871
+ return path;
5872
+ }
5873
+ const parsed = pathPosix2.parse(path);
5874
+ const fileName = `${parsed.name}-${suffix}${parsed.ext}`;
5875
+ if (parsed.dir === "") {
5876
+ return fileName;
5790
5877
  }
5878
+ return pathPosix2.join(parsed.dir, fileName);
5791
5879
  }
5792
- async function assertLocalAbsentBeforeRemoteDelete(root, path) {
5793
- const digest = await hashDriveFile(resolveInsideRoot(root, path)).catch((error) => {
5794
- if (isNotFoundError(error)) return void 0;
5795
- throw error;
5796
- });
5797
- if (digest) {
5798
- throw new Error("local file appeared before remote delete");
5880
+ function isExpectedVersionDownloadMissing(error) {
5881
+ const structured = error;
5882
+ if (structured?.status === 404 || structured?.status === 410) return true;
5883
+ if (structured?.response?.status === 404 || structured?.response?.status === 410) return true;
5884
+ if (structured?.code === "VERSION_NOT_FOUND" || structured?.code === "NOT_FOUND") return true;
5885
+ return /\b(?:HTTP 40[410]|missing version|version not found|not found)\b/i.test(errorMessage(error));
5886
+ }
5887
+ function recordUnresolvedConflicts(summary, state) {
5888
+ const newlyRecorded = new Set(summary.paths.filter((result) => result.action === "conflict").map((result) => result.path));
5889
+ const reportedPaths = new Set(summary.paths.map((result) => result.path));
5890
+ for (const path of Object.keys(state.conflicts).sort((left, right) => left.localeCompare(right))) {
5891
+ const conflictPaths = state.conflicts[path]?.conflict_paths;
5892
+ if (conflictPaths) {
5893
+ summary.conflict_paths.push(...conflictPaths);
5894
+ }
5895
+ if (!newlyRecorded.has(path)) {
5896
+ summary.conflicts += 1;
5897
+ }
5898
+ const existingResult = summary.paths.find((result) => result.path === path);
5899
+ if (existingResult?.action === "unchanged") {
5900
+ existingResult.action = "conflict";
5901
+ if (conflictPaths) existingResult.conflict_paths = conflictPaths;
5902
+ continue;
5903
+ }
5904
+ if (existingResult?.action === "conflict" && conflictPaths) {
5905
+ existingResult.conflict_paths = conflictPaths;
5906
+ }
5907
+ if (!reportedPaths.has(path)) {
5908
+ summary.paths.push({ path, action: "conflict", ...conflictPaths ? { conflict_paths: conflictPaths } : {} });
5909
+ }
5799
5910
  }
5800
5911
  }
5801
- function stateEntryFromRemote(remote, localSha256) {
5912
+ function stateEntryFromRemote(remote, localSha256, clock) {
5802
5913
  return {
5803
5914
  entry_id: remote.id,
5804
5915
  entry_version: remote.entry_version,
@@ -5806,13 +5917,10 @@ function stateEntryFromRemote(remote, localSha256) {
5806
5917
  content_sha256: remote.content_sha256,
5807
5918
  size_bytes: remote.size_bytes,
5808
5919
  last_local_sha256: localSha256,
5809
- last_synced_at: (/* @__PURE__ */ new Date()).toISOString(),
5920
+ last_synced_at: driveIsoTimestamp(clock),
5810
5921
  status: "synced"
5811
5922
  };
5812
5923
  }
5813
- async function commitDriveState(root, nextState) {
5814
- await writeDriveState(root, nextState);
5815
- }
5816
5924
  function cloneDriveState(state) {
5817
5925
  return {
5818
5926
  ...state,
@@ -5820,21 +5928,21 @@ function cloneDriveState(state) {
5820
5928
  conflicts: { ...state.conflicts }
5821
5929
  };
5822
5930
  }
5823
- async function recordConflict(root, state, path, reason, remote) {
5931
+ async function recordConflict(root, state, path, reason, remote, clock) {
5824
5932
  const nextState = cloneDriveState(state);
5825
- nextState.conflicts[path] = conflict(reason, remote);
5826
- await commitDriveState(root, nextState);
5933
+ nextState.conflicts[path] = conflict(reason, remote, clock);
5934
+ await writeDriveState(root, nextState, clock);
5827
5935
  return nextState;
5828
5936
  }
5829
- async function recordTypedConflict(root, state, path, reason, remote, metadata) {
5937
+ async function recordTypedConflict(root, state, path, reason, remote, clock, metadata) {
5830
5938
  const nextState = cloneDriveState(state);
5831
5939
  nextState.conflicts[path] = {
5832
- ...conflict(metadata.reason ?? reason, remote),
5940
+ ...conflict(metadata.reason ?? reason, remote, clock),
5833
5941
  type: metadata.type,
5834
5942
  strategy: metadata.strategy,
5835
5943
  base_version_id: state.entries[path]?.current_version_id
5836
5944
  };
5837
- await commitDriveState(root, nextState);
5945
+ await writeDriveState(root, nextState, clock);
5838
5946
  return nextState;
5839
5947
  }
5840
5948
  async function recordPathError(summary, blockedPaths, path, error, options = {}) {
@@ -5848,9 +5956,9 @@ async function recordPathError(summary, blockedPaths, path, error, options = {})
5848
5956
  void errorMessage(error);
5849
5957
  summary.errors += 1;
5850
5958
  }
5851
- function conflict(reason, remote) {
5959
+ function conflict(reason, remote, clock) {
5852
5960
  return {
5853
- detected_at: (/* @__PURE__ */ new Date()).toISOString(),
5961
+ detected_at: driveIsoTimestamp(clock),
5854
5962
  reason,
5855
5963
  remote_entry_version: remote?.entry_version,
5856
5964
  remote_version_id: remote?.current_version_id
@@ -5876,22 +5984,268 @@ function containsVersionConflict(value) {
5876
5984
  return false;
5877
5985
  }
5878
5986
  }
5879
- function isNotFoundError(error) {
5880
- return error instanceof Error && "code" in error && error.code === "ENOENT";
5881
- }
5882
- function isAlreadyExistsError(error) {
5883
- return error instanceof Error && "code" in error && error.code === "EEXIST";
5884
- }
5885
5987
 
5886
5988
  // src/handwritten/commands/drive/watch.ts
5887
5989
  import { Command as Command72 } from "commander";
5888
5990
  import chokidar from "chokidar";
5889
5991
  import { relative as relative2, resolve as resolve4 } from "path";
5992
+
5993
+ // src/handwritten/commands/drive/realtime.ts
5994
+ function createDriveRealtimeSource(args) {
5995
+ const connect = args.connect ?? nativeWebSocketConnector;
5996
+ const clock = args.clock ?? systemDriveClock;
5997
+ const scheduleTimeout = args.setTimeout ?? setTimeout;
5998
+ const cancelTimeout = args.clearTimeout ?? clearTimeout;
5999
+ let currentRealtime = { ...args.realtime };
6000
+ let handlers;
6001
+ let activeSocket;
6002
+ let reconnectTimer;
6003
+ let reconnectDelayMs = 1e3;
6004
+ let stopped = false;
6005
+ let authFailed = false;
6006
+ let connectionId = 0;
6007
+ function clearReconnectTimer() {
6008
+ if (reconnectTimer === void 0) return;
6009
+ cancelTimeout(reconnectTimer);
6010
+ reconnectTimer = void 0;
6011
+ }
6012
+ async function persist(next) {
6013
+ currentRealtime = next;
6014
+ await args.writeRealtimeState(next);
6015
+ }
6016
+ async function persistBestEffort(next) {
6017
+ try {
6018
+ await persist(next);
6019
+ } catch (error) {
6020
+ handlers?.onWarning?.(redactedRealtimeError(error));
6021
+ }
6022
+ }
6023
+ function connectNow() {
6024
+ if (stopped || authFailed) return;
6025
+ clearReconnectTimer();
6026
+ const id = ++connectionId;
6027
+ const url = buildDriveRealtimeUrl(args.baseUrl, args.libraryId, currentRealtime);
6028
+ activeSocket = connect(url, {
6029
+ open() {
6030
+ if (id !== connectionId || stopped || authFailed) return;
6031
+ reconnectDelayMs = 1e3;
6032
+ void persistBestEffort({ ...currentRealtime, last_connected_at: driveIsoTimestamp(clock) }).then(() => handlers?.onConnected());
6033
+ },
6034
+ message(data) {
6035
+ if (id !== connectionId || stopped || authFailed) return;
6036
+ void handleMessage(data).catch((error) => handlers?.onWarning?.(redactedRealtimeError(error)));
6037
+ },
6038
+ close(error) {
6039
+ closeConnection(id, error ?? "close");
6040
+ }
6041
+ }, args.headers === void 0 ? void 0 : { headers: args.headers });
6042
+ }
6043
+ function closeConnection(id, error) {
6044
+ if (id !== connectionId || stopped || authFailed) return;
6045
+ connectionId += 1;
6046
+ const socket = activeSocket;
6047
+ activeSocket = void 0;
6048
+ clearReconnectTimer();
6049
+ socket?.close();
6050
+ if (isRealtimeAuthError(error)) {
6051
+ authFailed = true;
6052
+ handlers?.onAuthFailed(redactedRealtimeError(error));
6053
+ return;
6054
+ }
6055
+ const delayMs = reconnectDelayMs;
6056
+ handlers?.onReconnect(delayMs, redactedRealtimeError(error));
6057
+ reconnectTimer = scheduleTimeout(() => {
6058
+ reconnectTimer = void 0;
6059
+ connectNow();
6060
+ }, delayMs);
6061
+ reconnectDelayMs = Math.min(delayMs * 2, 6e4);
6062
+ }
6063
+ async function handleMessage(data) {
6064
+ const message = parseDriveRealtimeMessage(data);
6065
+ if (message.type === "ready") {
6066
+ if (message.replayed > 0) {
6067
+ handlers?.onEvent(optionalString({ debounce_ms: 2e3, reason: "ready_replay" }, "cursor", message.cursor));
6068
+ }
6069
+ if (message.cursor !== void 0) {
6070
+ await persistBestEffort({ ...currentRealtime, last_cursor: message.cursor });
6071
+ }
6072
+ return;
6073
+ }
6074
+ if (message.type === "library_changed") {
6075
+ handlers?.onEvent(optionalString(optionalString({
6076
+ debounce_ms: 2e3,
6077
+ reason: "library_changed"
6078
+ }, "cursor", message.cursor), "path", message.path));
6079
+ if (message.cursor !== void 0) {
6080
+ await persistBestEffort({ ...currentRealtime, last_cursor: message.cursor, last_event_at: driveIsoTimestamp(clock) });
6081
+ }
6082
+ return;
6083
+ }
6084
+ if (message.type === "resync_required") {
6085
+ const reason = message.reason ?? "resync_required";
6086
+ handlers?.onEvent(optionalString({ immediate: true, reason }, "cursor", message.cursor));
6087
+ if (message.cursor !== void 0 || isInvalidCursorReason(reason)) {
6088
+ await persistBestEffort(resyncRealtimeState(currentRealtime, message.cursor, reason, driveIsoTimestamp(clock)));
6089
+ }
6090
+ return;
6091
+ }
6092
+ if (message.type === "error") {
6093
+ const error = message.message ?? message.code ?? "realtime error";
6094
+ if (isRealtimeAuthError(error) || isRealtimeAuthError(message.code)) {
6095
+ authFailed = true;
6096
+ connectionId += 1;
6097
+ clearReconnectTimer();
6098
+ handlers?.onAuthFailed(redactedRealtimeError(error));
6099
+ activeSocket?.close();
6100
+ activeSocket = void 0;
6101
+ return;
6102
+ }
6103
+ handlers?.onWarning?.(redactedRealtimeError(error));
6104
+ return;
6105
+ }
6106
+ handlers?.onWarning?.("unknown realtime message");
6107
+ }
6108
+ return {
6109
+ async start(nextHandlers) {
6110
+ handlers = nextHandlers;
6111
+ stopped = false;
6112
+ authFailed = false;
6113
+ connectNow();
6114
+ },
6115
+ async close() {
6116
+ stopped = true;
6117
+ clearReconnectTimer();
6118
+ activeSocket?.close();
6119
+ activeSocket = void 0;
6120
+ }
6121
+ };
6122
+ }
6123
+ function buildDriveRealtimeUrl(baseUrl, libraryId, realtime) {
6124
+ if (realtime.client_id.length === 0) {
6125
+ throw new Error("drive realtime client_id is required");
6126
+ }
6127
+ const url = new URL(baseUrl);
6128
+ url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
6129
+ url.pathname = `/drive/libraries/${encodeURIComponent(libraryId)}/realtime`;
6130
+ url.search = "";
6131
+ url.hash = "";
6132
+ if (realtime.last_cursor !== void 0) {
6133
+ url.searchParams.set("cursor", realtime.last_cursor);
6134
+ }
6135
+ url.searchParams.set("client_id", realtime.client_id);
6136
+ return url;
6137
+ }
6138
+ function parseDriveRealtimeMessage(raw) {
6139
+ let value;
6140
+ try {
6141
+ value = JSON.parse(raw);
6142
+ } catch {
6143
+ return { type: "unknown" };
6144
+ }
6145
+ if (!isRecord3(value)) {
6146
+ return { type: "unknown" };
6147
+ }
6148
+ const messageType = typeof value.type === "string" ? value.type : void 0;
6149
+ const cursor = typeof value.cursor === "string" ? value.cursor : void 0;
6150
+ if (messageType === "ready") {
6151
+ return optionalString({ type: "ready", replayed: typeof value.replayed === "number" ? value.replayed : 0 }, "cursor", cursor);
6152
+ }
6153
+ if (messageType === "library_changed") {
6154
+ return optionalString(optionalString({ type: "library_changed" }, "cursor", cursor), "path", value.path);
6155
+ }
6156
+ if (messageType === "resync_required") {
6157
+ return optionalString(optionalString({ type: "resync_required" }, "cursor", cursor), "reason", value.reason);
6158
+ }
6159
+ if (messageType === "error") {
6160
+ return optionalString(
6161
+ optionalString({ type: "error" }, "code", value.code),
6162
+ "message",
6163
+ typeof value.message === "string" ? redactedRealtimeError(value.message) : void 0
6164
+ );
6165
+ }
6166
+ return optionalString({ type: "unknown" }, "message_type", messageType);
6167
+ }
6168
+ function redactedRealtimeError(error) {
6169
+ const text = error instanceof Error ? error.message : String(error);
6170
+ const status = text.match(/\bHTTP\s+(401|403|429|5\d\d)\b/i);
6171
+ if (status?.[1] !== void 0) {
6172
+ return `HTTP ${status[1]}`;
6173
+ }
6174
+ if (/\bauth|authorization\b/i.test(text)) {
6175
+ return "auth failed";
6176
+ }
6177
+ if (/\bnetwork|fetch|close\b/i.test(text)) {
6178
+ return "network error";
6179
+ }
6180
+ return "realtime error";
6181
+ }
6182
+ function isRecord3(value) {
6183
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6184
+ }
6185
+ function resyncRealtimeState(realtime, cursor, reason, lastEventAt) {
6186
+ if (isInvalidCursorReason(reason)) {
6187
+ const { last_cursor: _lastCursor, ...next } = realtime;
6188
+ return { ...next, last_event_at: lastEventAt };
6189
+ }
6190
+ return optionalString({ ...realtime, last_event_at: lastEventAt }, "last_cursor", cursor);
6191
+ }
6192
+ function isInvalidCursorReason(reason) {
6193
+ return /\bcursor[_ -]?(invalid|expired|gone|missing|not[_ -]?found)\b|\binvalid[_ -]?cursor\b/i.test(reason);
6194
+ }
6195
+ function isRealtimeAuthError(error) {
6196
+ return /\b(401|403|auth|authorization|unauthorized|forbidden)\b/i.test(String(error));
6197
+ }
6198
+ function optionalString(target, key, value) {
6199
+ if (typeof value !== "string") {
6200
+ return target;
6201
+ }
6202
+ return { ...target, [key]: value };
6203
+ }
6204
+ function nativeWebSocketConnector(url, handlers, init) {
6205
+ const WebSocketWithInit = WebSocket;
6206
+ const ws = new WebSocketWithInit(url.toString(), init);
6207
+ let closed = false;
6208
+ let pendingError;
6209
+ const closeOnce = (error) => {
6210
+ if (closed) return;
6211
+ closed = true;
6212
+ handlers.close(error);
6213
+ };
6214
+ ws.addEventListener("open", () => handlers.open());
6215
+ ws.addEventListener("message", (event) => handlers.message(String(event.data)));
6216
+ ws.addEventListener("close", (event) => closeOnce(webSocketCloseError(event) ?? pendingError));
6217
+ ws.addEventListener("error", (event) => {
6218
+ pendingError = webSocketError(event);
6219
+ if (isRealtimeAuthError(pendingError)) {
6220
+ closeOnce(pendingError);
6221
+ }
6222
+ });
6223
+ return { close: () => ws.close() };
6224
+ }
6225
+ function webSocketCloseError(event) {
6226
+ if (event.code === 1e3) return void 0;
6227
+ if (event.code === 4001 || event.code === 4401) return new Error("HTTP 401");
6228
+ if (event.code === 4003 || event.code === 4403) return new Error("HTTP 403");
6229
+ if (event.reason && isRealtimeAuthError(event.reason)) return new Error(event.reason);
6230
+ if (event.reason) return new Error(event.reason);
6231
+ return new Error(`WebSocket close ${event.code}`);
6232
+ }
6233
+ function webSocketError(event) {
6234
+ if (typeof ErrorEvent !== "undefined" && event instanceof ErrorEvent) {
6235
+ const detail = event.error instanceof Error ? event.error.message : event.message;
6236
+ if (detail) return new Error(detail);
6237
+ }
6238
+ return new Error("network error");
6239
+ }
6240
+
6241
+ // src/handwritten/commands/drive/watch.ts
5890
6242
  async function runDriveWatch(root, options = {}) {
5891
6243
  const runSync = options.runSync ?? runDriveSyncOnce;
5892
6244
  const debounceMs = options.debounceMs ?? 500;
6245
+ const remoteDebounceMs = options.remoteDebounceMs ?? 2e3;
5893
6246
  const emit = options.onEvent ?? ((event) => render({ kind: "drive_watch", display: { shape: "object" } }, event));
5894
6247
  let debounceTimer;
6248
+ let debounceDeadlineMs;
5895
6249
  let retryTimer;
5896
6250
  let resolveRetryTimer;
5897
6251
  let running = false;
@@ -5919,6 +6273,7 @@ async function runDriveWatch(root, options = {}) {
5919
6273
  } catch (error) {
5920
6274
  if (isAuthError(error) || isFatalWatchError(error) || !isRetryableWatchError(error)) throw error;
5921
6275
  emit({ kind: "drive_watch_retry", delay_ms: backoffMs, error: errorMessage2(error) });
6276
+ if (stopped) return;
5922
6277
  await waitForManagedTimer(backoffMs);
5923
6278
  if (stopped) return;
5924
6279
  backoffMs = Math.min(backoffMs * 2, 6e4);
@@ -5933,6 +6288,27 @@ async function runDriveWatch(root, options = {}) {
5933
6288
  if (debounceTimer === void 0) return;
5934
6289
  clearTimeout(debounceTimer);
5935
6290
  debounceTimer = void 0;
6291
+ debounceDeadlineMs = void 0;
6292
+ }
6293
+ function scheduleSync(delayMs) {
6294
+ if (running) {
6295
+ rerunRequested = true;
6296
+ return;
6297
+ }
6298
+ if (delayMs <= 0) {
6299
+ clearDebounceTimer();
6300
+ requestSync().catch(stopWithError);
6301
+ return;
6302
+ }
6303
+ const deadlineMs = Date.now() + delayMs;
6304
+ if (debounceTimer !== void 0 && debounceDeadlineMs !== void 0 && debounceDeadlineMs <= deadlineMs) return;
6305
+ clearDebounceTimer();
6306
+ debounceDeadlineMs = deadlineMs;
6307
+ debounceTimer = setTimeout(() => {
6308
+ debounceTimer = void 0;
6309
+ debounceDeadlineMs = void 0;
6310
+ requestSync().catch(stopWithError);
6311
+ }, delayMs);
5936
6312
  }
5937
6313
  function clearRetryTimer() {
5938
6314
  if (retryTimer === void 0) return;
@@ -5954,26 +6330,61 @@ async function runDriveWatch(root, options = {}) {
5954
6330
  }
5955
6331
  function stopWithError(error) {
5956
6332
  stopError = error;
6333
+ stopped = true;
5957
6334
  clearDebounceTimer();
5958
6335
  clearRetryTimer();
5959
6336
  stopWatch?.();
5960
6337
  }
5961
- const state = await (options.readState ?? readDriveState)(root);
5962
- const source = options.source ?? createChokidarSource(root);
6338
+ let source;
6339
+ let realtimeSource;
5963
6340
  try {
6341
+ let state = await (options.readState ?? readDriveState)(root);
6342
+ realtimeSource = options.once ? void 0 : options.realtimeSource;
6343
+ if (!options.once && realtimeSource === void 0) {
6344
+ state = await ensureDriveRealtimeState(root);
6345
+ const { baseUrl, headers } = await loadRealtimeAuthHeaders({
6346
+ verifyPath: `/drive/libraries/${encodeURIComponent(state.library_id)}`
6347
+ });
6348
+ const realtime = state.realtime;
6349
+ if (realtime === void 0) {
6350
+ throw new Error("drive realtime state is required");
6351
+ }
6352
+ realtimeSource = createDriveRealtimeSource({
6353
+ baseUrl,
6354
+ headers,
6355
+ libraryId: state.library_id,
6356
+ realtime,
6357
+ writeRealtimeState: (next) => writeDriveRealtimeState(root, next)
6358
+ });
6359
+ }
6360
+ source = options.source ?? createChokidarSource(root);
5964
6361
  source.onChange((path) => {
5965
6362
  if (isDriveInternalPath(root, path)) return;
5966
- if (running) {
5967
- rerunRequested = true;
5968
- return;
5969
- }
5970
- clearDebounceTimer();
5971
- debounceTimer = setTimeout(() => {
5972
- debounceTimer = void 0;
5973
- requestSync().catch(stopWithError);
5974
- }, debounceMs);
6363
+ scheduleSync(debounceMs);
5975
6364
  });
5976
6365
  emit({ kind: "drive_watch_started", root, library_id: state.library_id });
6366
+ realtimeSource?.start({
6367
+ onConnected() {
6368
+ emit({ kind: "drive_realtime_connected", library_id: state.library_id });
6369
+ },
6370
+ onEvent(event) {
6371
+ emit(realtimeEvent(event));
6372
+ if (event.immediate) {
6373
+ scheduleSync(0);
6374
+ return;
6375
+ }
6376
+ scheduleSync(event.debounce_ms ?? remoteDebounceMs);
6377
+ },
6378
+ onReconnect(delayMs, error) {
6379
+ emit({ kind: "drive_realtime_reconnecting", delay_ms: delayMs, error });
6380
+ },
6381
+ onAuthFailed(error) {
6382
+ emit({ kind: "drive_realtime_auth_failed", error: error ?? "auth failed" });
6383
+ },
6384
+ onWarning(warning) {
6385
+ emit({ kind: "drive_realtime_warning", warning });
6386
+ }
6387
+ }).catch(stopWithError);
5977
6388
  await requestSync();
5978
6389
  if (options.once) return;
5979
6390
  if (stopError !== void 0) throw stopError;
@@ -5987,7 +6398,7 @@ async function runDriveWatch(root, options = {}) {
5987
6398
  clearDebounceTimer();
5988
6399
  clearRetryTimer();
5989
6400
  cleanupSignalListeners();
5990
- await source.close();
6401
+ await Promise.all([source?.close(), realtimeSource?.close()]);
5991
6402
  }
5992
6403
  }
5993
6404
  function driveWatchCommand(options = {}) {
@@ -6029,6 +6440,15 @@ function isRetryableWatchError(error) {
6029
6440
  function errorMessage2(error) {
6030
6441
  return error instanceof Error ? error.message : String(error);
6031
6442
  }
6443
+ function realtimeEvent(event) {
6444
+ return {
6445
+ kind: "drive_realtime_event",
6446
+ message: "remote update received; syncing",
6447
+ ...event.path === void 0 ? {} : { path: event.path },
6448
+ ...event.reason === void 0 ? {} : { reason: event.reason },
6449
+ ...event.cursor === void 0 ? {} : { cursor: event.cursor }
6450
+ };
6451
+ }
6032
6452
  function waitForStopSignal(onRegistered) {
6033
6453
  return new Promise((resolveStop) => {
6034
6454
  const stop = () => resolveStop();