@wspc/cli 0.1.2 → 0.1.4
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/README.md +26 -9
- package/dist/cli.js +1289 -833
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
1467
|
+
var VERSION = "0.1.4";
|
|
1468
1468
|
var SPEC_SHA = "e0bbc1e3";
|
|
1469
|
-
var SPEC_FETCHED_AT = "2026-06-
|
|
1469
|
+
var SPEC_FETCHED_AT = "2026-06-22T06:55:37.490Z";
|
|
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
|
-
|
|
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
|
|
1871
|
-
const
|
|
1872
|
-
if (
|
|
1873
|
-
process.stdout.write(dim(` \u2026 more results \u2014 re-run with --cursor ${
|
|
1874
|
-
}
|
|
1875
|
-
const id =
|
|
1876
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
1926
|
+
if (isRecord(data)) {
|
|
1914
1927
|
const keys = Object.keys(data);
|
|
1915
|
-
const arrayKeys = keys.filter((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 (
|
|
1924
|
-
for (const
|
|
1925
|
-
|
|
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 (
|
|
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 (
|
|
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) =>
|
|
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
|
-
|
|
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 (!
|
|
2062
|
-
const
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
const status =
|
|
2066
|
-
|
|
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 (!
|
|
2070
|
-
const
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
const
|
|
2074
|
-
const
|
|
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 (!
|
|
2079
|
-
const
|
|
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
|
|
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) {
|
|
@@ -3262,7 +3294,7 @@ var pushTestCommand = new Command45("test").description("Send a test push notifi
|
|
|
3262
3294
|
}
|
|
3263
3295
|
render({ kind: "push_test", display: { "shape": "object", "fields": ["ok", "status", "detail", "durationMs"], "format": { "ok": "bool-badge" } } }, result.data);
|
|
3264
3296
|
if (result.data?.ok === false) {
|
|
3265
|
-
process.
|
|
3297
|
+
process.exitCode = 1;
|
|
3266
3298
|
}
|
|
3267
3299
|
});
|
|
3268
3300
|
|
|
@@ -3840,13 +3872,15 @@ async function ensureClientId(opts) {
|
|
|
3840
3872
|
apiBase: opts.baseUrl,
|
|
3841
3873
|
fetchImpl: opts.fetchImpl
|
|
3842
3874
|
});
|
|
3843
|
-
|
|
3844
|
-
|
|
3875
|
+
let existing;
|
|
3876
|
+
await opts.store.update((c) => {
|
|
3877
|
+
existing = c.envs[opts.envName]?.client_id;
|
|
3878
|
+
if (existing) return;
|
|
3879
|
+
const targetEnv = c.envs[opts.envName] ??= { api_base: opts.baseUrl, accounts: {} };
|
|
3880
|
+
targetEnv.api_base = opts.baseUrl;
|
|
3881
|
+
targetEnv.accounts ??= {};
|
|
3882
|
+
});
|
|
3845
3883
|
if (existing) return existing;
|
|
3846
|
-
const targetEnv = c.envs[opts.envName] ??= { api_base: opts.baseUrl, accounts: {} };
|
|
3847
|
-
targetEnv.api_base = opts.baseUrl;
|
|
3848
|
-
targetEnv.accounts ??= {};
|
|
3849
|
-
await opts.store.write(c);
|
|
3850
3884
|
const res = await fetchImpl(`${opts.baseUrl}/auth/oauth/register`, {
|
|
3851
3885
|
method: "POST",
|
|
3852
3886
|
headers: { "content-type": "application/json" },
|
|
@@ -3862,12 +3896,18 @@ async function ensureClientId(opts) {
|
|
|
3862
3896
|
}
|
|
3863
3897
|
const body = await res.json();
|
|
3864
3898
|
if (!body.client_id) throw new Error("client_registration_failed: missing client_id in response");
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3899
|
+
let clientId = body.client_id;
|
|
3900
|
+
await opts.store.update((c) => {
|
|
3901
|
+
const env = c.envs[opts.envName] ??= { api_base: opts.baseUrl, accounts: {} };
|
|
3902
|
+
env.api_base = opts.baseUrl;
|
|
3903
|
+
env.accounts ??= {};
|
|
3904
|
+
if (env.client_id) {
|
|
3905
|
+
clientId = env.client_id;
|
|
3906
|
+
return;
|
|
3907
|
+
}
|
|
3908
|
+
env.client_id = body.client_id;
|
|
3909
|
+
});
|
|
3910
|
+
return clientId;
|
|
3871
3911
|
}
|
|
3872
3912
|
|
|
3873
3913
|
// src/handwritten/auth/fetch-me.ts
|
|
@@ -3924,36 +3964,36 @@ async function runLogin(opts) {
|
|
|
3924
3964
|
const now = opts.now ?? Date.now;
|
|
3925
3965
|
const me = opts.fetchMe ?? ((o) => fetchMe(o));
|
|
3926
3966
|
if (opts.apiKey) {
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3967
|
+
await opts.store.update((initial) => {
|
|
3968
|
+
getOrCreateEnv(initial, envName, opts.baseUrl);
|
|
3969
|
+
initial.current_env = envName;
|
|
3970
|
+
});
|
|
3931
3971
|
const who2 = await me({
|
|
3932
3972
|
baseUrl: opts.baseUrl,
|
|
3933
3973
|
token: opts.apiKey,
|
|
3934
3974
|
store: opts.store,
|
|
3935
3975
|
envName
|
|
3936
3976
|
});
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3977
|
+
await opts.store.update((c) => {
|
|
3978
|
+
const env = getOrCreateEnv(c, envName, opts.baseUrl);
|
|
3979
|
+
const prev = env.accounts[who2.email] ?? { email: who2.email };
|
|
3980
|
+
const acct = env.accounts[who2.email] = {
|
|
3981
|
+
...prev,
|
|
3982
|
+
email: who2.email,
|
|
3983
|
+
user_id: who2.user_id,
|
|
3984
|
+
api_key: opts.apiKey
|
|
3985
|
+
};
|
|
3986
|
+
delete acct.refresh_token;
|
|
3987
|
+
delete acct.access_token;
|
|
3988
|
+
delete acct.access_token_expires_at;
|
|
3989
|
+
env.current_account = who2.email;
|
|
3990
|
+
if (who2.email !== LEGACY_ACCOUNT_KEY) delete env.accounts[LEGACY_ACCOUNT_KEY];
|
|
3991
|
+
c.current_env = envName;
|
|
3992
|
+
});
|
|
3953
3993
|
opts.output.write(`${green("\u2713")} logged in (api key) as ${bold(who2.email)} ${dim(`\u2192 env "${envName}"`)}`);
|
|
3954
3994
|
return;
|
|
3955
3995
|
}
|
|
3956
|
-
const ensureClient = opts.ensureClient ?? ((
|
|
3996
|
+
const ensureClient = opts.ensureClient ?? ((env) => ensureClientId({ store: opts.store, envName: env, baseUrl: opts.baseUrl }));
|
|
3957
3997
|
const clientId = opts.clientId ?? await ensureClient(envName);
|
|
3958
3998
|
const flow = opts.deviceFlow ?? runDeviceFlow;
|
|
3959
3999
|
const result = await flow({
|
|
@@ -3973,22 +4013,22 @@ async function runLogin(opts) {
|
|
|
3973
4013
|
store: opts.store,
|
|
3974
4014
|
envName
|
|
3975
4015
|
});
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
4016
|
+
await opts.store.update((c) => {
|
|
4017
|
+
const env = getOrCreateEnv(c, envName, opts.baseUrl);
|
|
4018
|
+
const prev = env.accounts[who.email] ?? { email: who.email };
|
|
4019
|
+
const acct = env.accounts[who.email] = {
|
|
4020
|
+
...prev,
|
|
4021
|
+
email: who.email,
|
|
4022
|
+
user_id: who.user_id,
|
|
4023
|
+
refresh_token: result.refresh_token,
|
|
4024
|
+
access_token: result.access_token,
|
|
4025
|
+
access_token_expires_at: now() + result.expires_in * 1e3
|
|
4026
|
+
};
|
|
4027
|
+
delete acct.api_key;
|
|
4028
|
+
env.current_account = who.email;
|
|
4029
|
+
if (who.email !== LEGACY_ACCOUNT_KEY) delete env.accounts[LEGACY_ACCOUNT_KEY];
|
|
4030
|
+
c.current_env = envName;
|
|
4031
|
+
});
|
|
3992
4032
|
opts.output.writeJson({ event: "login_success", email: who.email });
|
|
3993
4033
|
opts.output.write(`${green("\u2713")} logged in as ${bold(who.email)} ${dim(`\u2192 env "${envName}"`)}`);
|
|
3994
4034
|
}
|
|
@@ -4025,27 +4065,32 @@ import { Command as Command63 } from "commander";
|
|
|
4025
4065
|
|
|
4026
4066
|
// src/handwritten/auth/logout.ts
|
|
4027
4067
|
async function runLogout(opts) {
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
const
|
|
4035
|
-
env.accounts
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4068
|
+
let removed = [];
|
|
4069
|
+
let newActive;
|
|
4070
|
+
let removedSingleAccount = false;
|
|
4071
|
+
await opts.store.update((c) => {
|
|
4072
|
+
const envName = opts.envName ?? c.current_env;
|
|
4073
|
+
if (!envName || !c.envs[envName]) return;
|
|
4074
|
+
const env = c.envs[envName];
|
|
4075
|
+
env.accounts ??= {};
|
|
4076
|
+
if (opts.all) {
|
|
4077
|
+
removed = Object.keys(env.accounts);
|
|
4078
|
+
env.accounts = {};
|
|
4079
|
+
env.current_account = void 0;
|
|
4080
|
+
return;
|
|
4081
|
+
}
|
|
4082
|
+
const target = opts.email ?? env.current_account ?? (Object.keys(env.accounts).length === 1 ? Object.keys(env.accounts)[0] : void 0);
|
|
4083
|
+
if (!target || !env.accounts[target]) return;
|
|
4084
|
+
delete env.accounts[target];
|
|
4085
|
+
removed = [target];
|
|
4086
|
+
removedSingleAccount = true;
|
|
4087
|
+
if (env.current_account === target) {
|
|
4088
|
+
const remaining = Object.keys(env.accounts);
|
|
4089
|
+
env.current_account = remaining.length === 1 ? remaining[0] : void 0;
|
|
4090
|
+
}
|
|
4091
|
+
newActive = env.current_account;
|
|
4092
|
+
});
|
|
4093
|
+
return removedSingleAccount ? { removed, newActive } : { removed };
|
|
4049
4094
|
}
|
|
4050
4095
|
|
|
4051
4096
|
// src/handwritten/commands/logout.ts
|
|
@@ -4089,10 +4134,9 @@ registerRenderer("whoami", (data) => {
|
|
|
4089
4134
|
}
|
|
4090
4135
|
});
|
|
4091
4136
|
async function backfillActiveEmail(store, envName, email, userId) {
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
}
|
|
4137
|
+
await store.update((cfg) => {
|
|
4138
|
+
rekeyLegacyAccount(cfg, envName, email, userId);
|
|
4139
|
+
});
|
|
4096
4140
|
}
|
|
4097
4141
|
var whoamiCommand = new Command64("whoami").description("Show the active env, signed-in account, and organization").action(async () => {
|
|
4098
4142
|
const store = new ConfigStore();
|
|
@@ -4167,24 +4211,24 @@ registerRenderer("config_show", (data) => {
|
|
|
4167
4211
|
process.stdout.write(table(headers, rows));
|
|
4168
4212
|
});
|
|
4169
4213
|
async function setConfigKey(store, key, value) {
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4214
|
+
await store.update((c) => {
|
|
4215
|
+
const resolved = resolveAccount(c, { accountOverride: process.env.WSPC_ACCOUNT });
|
|
4216
|
+
const env = c.envs[resolved.envName];
|
|
4217
|
+
if (!env) throw new Error(`env "${resolved.envName}" not found`);
|
|
4218
|
+
const acct = env.accounts[resolved.email];
|
|
4219
|
+
if (!acct) throw new Error(`account "${resolved.email}" not found`);
|
|
4220
|
+
switch (key) {
|
|
4221
|
+
case "actor":
|
|
4222
|
+
if (value !== "user" && value !== "agent") throw new Error("actor must be 'user' or 'agent'");
|
|
4223
|
+
acct.actor = value;
|
|
4224
|
+
break;
|
|
4225
|
+
case "agent-label":
|
|
4226
|
+
acct.agent_label = value;
|
|
4227
|
+
break;
|
|
4228
|
+
default:
|
|
4229
|
+
throw new Error(`unknown config key: ${key}`);
|
|
4230
|
+
}
|
|
4231
|
+
});
|
|
4188
4232
|
}
|
|
4189
4233
|
configCommand.command("show").description("List configured envs (tokens redacted, current marked with \u2713)").action(async () => {
|
|
4190
4234
|
const c = await new ConfigStore().read();
|
|
@@ -4210,10 +4254,10 @@ configCommand.command("set <key> <value>").description("Set a field on the activ
|
|
|
4210
4254
|
});
|
|
4211
4255
|
configCommand.command("use <env>").description("Switch current_env").action(async (env) => {
|
|
4212
4256
|
const store = new ConfigStore();
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4257
|
+
await store.update((c) => {
|
|
4258
|
+
if (!c.envs[env]) throw new Error(`env "${env}" not found`);
|
|
4259
|
+
c.current_env = env;
|
|
4260
|
+
});
|
|
4217
4261
|
process.stdout.write(`\u2713 current_env=${env}
|
|
4218
4262
|
`);
|
|
4219
4263
|
});
|
|
@@ -4234,15 +4278,15 @@ async function listAccounts(store) {
|
|
|
4234
4278
|
}));
|
|
4235
4279
|
}
|
|
4236
4280
|
async function switchAccount(store, email) {
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4281
|
+
await store.update((c) => {
|
|
4282
|
+
const envName = c.current_env;
|
|
4283
|
+
if (!envName || !c.envs[envName]) throw new Error("no current env; run `wspc login` first");
|
|
4284
|
+
const env = c.envs[envName];
|
|
4285
|
+
if (!env.accounts?.[email]) {
|
|
4286
|
+
throw new Error(`no account '${email}' in env '${envName}'. Run \`wspc account ls\` or \`wspc login\`.`);
|
|
4287
|
+
}
|
|
4288
|
+
env.current_account = email;
|
|
4289
|
+
});
|
|
4246
4290
|
}
|
|
4247
4291
|
registerRenderer("account_ls", (data) => {
|
|
4248
4292
|
const rows = data.accounts;
|
|
@@ -4463,11 +4507,12 @@ import { pipeline } from "stream/promises";
|
|
|
4463
4507
|
function parseContentDispositionFilename(header) {
|
|
4464
4508
|
if (!header) return void 0;
|
|
4465
4509
|
if (!header.toLowerCase().startsWith("attachment")) return void 0;
|
|
4466
|
-
const quoted = header.match(/filename="([^"]
|
|
4467
|
-
if (quoted) return quoted[1];
|
|
4510
|
+
const quoted = header.match(/filename="([^"]*)"/i);
|
|
4468
4511
|
const unquoted = header.match(/filename=([^;\s]+)/i);
|
|
4469
|
-
|
|
4470
|
-
return void 0;
|
|
4512
|
+
const filename = quoted?.[1] ?? unquoted?.[1];
|
|
4513
|
+
if (!filename || filename === "." || filename === "..") return void 0;
|
|
4514
|
+
if (filename.includes("/") || filename.includes("\\")) return void 0;
|
|
4515
|
+
return filename;
|
|
4471
4516
|
}
|
|
4472
4517
|
|
|
4473
4518
|
// src/handwritten/commands/email/attachment.ts
|
|
@@ -4595,6 +4640,29 @@ async function createDriveApi(opts = {}) {
|
|
|
4595
4640
|
import { mkdir, open, readFile as readFile2, rename, rm, writeFile } from "fs/promises";
|
|
4596
4641
|
import { randomUUID } from "crypto";
|
|
4597
4642
|
import { join as join2 } from "path";
|
|
4643
|
+
import { DateTime as DateTime4 } from "luxon";
|
|
4644
|
+
|
|
4645
|
+
// src/handwritten/commands/drive/clock.ts
|
|
4646
|
+
import { DateTime as DateTime3 } from "luxon";
|
|
4647
|
+
var systemDriveClock = {
|
|
4648
|
+
now: () => DateTime3.utc()
|
|
4649
|
+
};
|
|
4650
|
+
function driveIsoTimestamp(clock = systemDriveClock) {
|
|
4651
|
+
const timestamp = clock.now().toISO();
|
|
4652
|
+
if (timestamp === null) {
|
|
4653
|
+
throw new Error("invalid drive clock timestamp");
|
|
4654
|
+
}
|
|
4655
|
+
return timestamp;
|
|
4656
|
+
}
|
|
4657
|
+
function driveConflictTimestamp(clock = systemDriveClock) {
|
|
4658
|
+
const timestamp = clock.now().toUTC();
|
|
4659
|
+
if (!timestamp.isValid) {
|
|
4660
|
+
throw new Error("invalid drive clock timestamp");
|
|
4661
|
+
}
|
|
4662
|
+
return timestamp.toFormat("yyyyLLdd'T'HHmmss'Z'");
|
|
4663
|
+
}
|
|
4664
|
+
|
|
4665
|
+
// src/handwritten/commands/drive/state.ts
|
|
4598
4666
|
var DRIVE_DIR = ".wspc-drive";
|
|
4599
4667
|
var STATE_FILE = "state.json";
|
|
4600
4668
|
function statePath(root) {
|
|
@@ -4608,13 +4676,13 @@ async function readDriveState(root) {
|
|
|
4608
4676
|
}
|
|
4609
4677
|
return parsed;
|
|
4610
4678
|
}
|
|
4611
|
-
async function writeDriveState(root, state) {
|
|
4679
|
+
async function writeDriveState(root, state, clock) {
|
|
4612
4680
|
await mkdir(join2(root, DRIVE_DIR), { recursive: true });
|
|
4613
4681
|
const tmp = join2(root, DRIVE_DIR, `state.json.tmp-${process.pid}-${randomUUID()}`);
|
|
4614
4682
|
const snapshot = JSON.stringify(
|
|
4615
4683
|
{
|
|
4616
4684
|
...state,
|
|
4617
|
-
updated_at: (
|
|
4685
|
+
updated_at: driveIsoTimestamp(clock)
|
|
4618
4686
|
},
|
|
4619
4687
|
null,
|
|
4620
4688
|
2
|
|
@@ -4633,7 +4701,7 @@ async function writeDriveState(root, state) {
|
|
|
4633
4701
|
await rm(tmp, { force: true });
|
|
4634
4702
|
}
|
|
4635
4703
|
}
|
|
4636
|
-
async function initDriveState(root, libraryId) {
|
|
4704
|
+
async function initDriveState(root, libraryId, clock) {
|
|
4637
4705
|
await mkdir(join2(root, DRIVE_DIR), { recursive: true });
|
|
4638
4706
|
try {
|
|
4639
4707
|
const existing = await readDriveState(root);
|
|
@@ -4644,7 +4712,7 @@ async function initDriveState(root, libraryId) {
|
|
|
4644
4712
|
} catch (err) {
|
|
4645
4713
|
if (err.code !== "ENOENT") throw err;
|
|
4646
4714
|
}
|
|
4647
|
-
const now = (
|
|
4715
|
+
const now = driveIsoTimestamp(clock);
|
|
4648
4716
|
const state = {
|
|
4649
4717
|
schema_version: 1,
|
|
4650
4718
|
library_id: libraryId,
|
|
@@ -4653,9 +4721,32 @@ async function initDriveState(root, libraryId) {
|
|
|
4653
4721
|
entries: {},
|
|
4654
4722
|
conflicts: {}
|
|
4655
4723
|
};
|
|
4656
|
-
await writeDriveState(root, state);
|
|
4724
|
+
await writeDriveState(root, state, clock);
|
|
4657
4725
|
return state;
|
|
4658
4726
|
}
|
|
4727
|
+
async function ensureDriveRealtimeState(root) {
|
|
4728
|
+
return withDriveLock(root, async () => {
|
|
4729
|
+
const state = await readDriveState(root);
|
|
4730
|
+
if (state.realtime?.client_id !== void 0) {
|
|
4731
|
+
return state;
|
|
4732
|
+
}
|
|
4733
|
+
const next = {
|
|
4734
|
+
...state,
|
|
4735
|
+
realtime: {
|
|
4736
|
+
...state.realtime,
|
|
4737
|
+
client_id: `drvcli_${randomUUID().replace(/-/g, "")}`
|
|
4738
|
+
}
|
|
4739
|
+
};
|
|
4740
|
+
await writeDriveState(root, next);
|
|
4741
|
+
return next;
|
|
4742
|
+
});
|
|
4743
|
+
}
|
|
4744
|
+
async function writeDriveRealtimeState(root, realtime) {
|
|
4745
|
+
await withDriveLock(root, async () => {
|
|
4746
|
+
const current = await readDriveState(root);
|
|
4747
|
+
await writeDriveState(root, { ...current, realtime });
|
|
4748
|
+
});
|
|
4749
|
+
}
|
|
4659
4750
|
async function withDriveLock(root, fn) {
|
|
4660
4751
|
await mkdir(join2(root, DRIVE_DIR), { recursive: true });
|
|
4661
4752
|
const lockFile = join2(root, DRIVE_DIR, "sync.lock");
|
|
@@ -4674,18 +4765,28 @@ async function withDriveLock(root, fn) {
|
|
|
4674
4765
|
});
|
|
4675
4766
|
}
|
|
4676
4767
|
}
|
|
4677
|
-
function
|
|
4768
|
+
function isRecord2(value) {
|
|
4678
4769
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4679
4770
|
}
|
|
4680
4771
|
function isDriveStateEntry(value) {
|
|
4681
|
-
return
|
|
4772
|
+
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
4773
|
}
|
|
4683
4774
|
function isDriveConflict(value) {
|
|
4684
|
-
return
|
|
4775
|
+
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"));
|
|
4776
|
+
}
|
|
4777
|
+
function isDriveRealtimeState(value) {
|
|
4778
|
+
const allowedKeys = /* @__PURE__ */ new Set(["client_id", "last_cursor", "last_connected_at", "last_event_at"]);
|
|
4779
|
+
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);
|
|
4780
|
+
}
|
|
4781
|
+
function isOptionalIsoTimestamp(value) {
|
|
4782
|
+
if (value === void 0) return true;
|
|
4783
|
+
if (typeof value !== "string") return false;
|
|
4784
|
+
if (!/T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value)) return false;
|
|
4785
|
+
return DateTime4.fromISO(value, { setZone: true }).isValid;
|
|
4685
4786
|
}
|
|
4686
4787
|
function isValidDriveState(value) {
|
|
4687
|
-
if (!
|
|
4688
|
-
if (value.schema_version !== 1 || typeof value.library_id !== "string" || typeof value.created_at !== "string" || typeof value.updated_at !== "string" || !
|
|
4788
|
+
if (!isRecord2(value)) return false;
|
|
4789
|
+
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
4790
|
return false;
|
|
4690
4791
|
}
|
|
4691
4792
|
for (const entry of Object.values(value.entries)) {
|
|
@@ -4729,12 +4830,9 @@ function driveBindCommand() {
|
|
|
4729
4830
|
|
|
4730
4831
|
// src/handwritten/commands/drive/sync.ts
|
|
4731
4832
|
import { Command as Command71 } from "commander";
|
|
4732
|
-
import {
|
|
4733
|
-
import {
|
|
4734
|
-
import {
|
|
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";
|
|
4833
|
+
import { mkdir as mkdir3, readFile as readFile4, rm as rm3, writeFile as writeFile3 } from "fs/promises";
|
|
4834
|
+
import { basename as basename3, dirname as dirname2, join as join5, posix as pathPosix2, resolve as resolve3 } from "path";
|
|
4835
|
+
import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
|
|
4738
4836
|
|
|
4739
4837
|
// src/handwritten/commands/drive/decision.ts
|
|
4740
4838
|
function decideDriveAction(entry, local, remote) {
|
|
@@ -4806,6 +4904,96 @@ function getRemoteStatus(entry, remote) {
|
|
|
4806
4904
|
return "changed";
|
|
4807
4905
|
}
|
|
4808
4906
|
|
|
4907
|
+
// src/handwritten/commands/drive/path-policy.ts
|
|
4908
|
+
import { isAbsolute, relative, resolve as resolve2, sep } from "path";
|
|
4909
|
+
var UTF8_SEGMENT_LIMIT = 255;
|
|
4910
|
+
var UTF8_PATH_LIMIT = 1024;
|
|
4911
|
+
var CONTROL_CHARS = /[\0-\x1f\x7f]/;
|
|
4912
|
+
var WINDOWS_DRIVE_PREFIX = /^[a-zA-Z]:/;
|
|
4913
|
+
var UNC_PREFIX = /^\\\\/;
|
|
4914
|
+
var ABSOLUTE_POSIX_DOUBLE_SLASH = /^\/\//;
|
|
4915
|
+
function validateDrivePath(drivePath) {
|
|
4916
|
+
if (drivePath.length === 0) {
|
|
4917
|
+
throw new Error("invalid drive path: empty");
|
|
4918
|
+
}
|
|
4919
|
+
if (isAbsolute(drivePath) || ABSOLUTE_POSIX_DOUBLE_SLASH.test(drivePath)) {
|
|
4920
|
+
throw new Error(`invalid drive path: ${drivePath}`);
|
|
4921
|
+
}
|
|
4922
|
+
if (drivePath.includes("\\")) {
|
|
4923
|
+
throw new Error("invalid drive path: backslash");
|
|
4924
|
+
}
|
|
4925
|
+
if (WINDOWS_DRIVE_PREFIX.test(drivePath) || UNC_PREFIX.test(drivePath)) {
|
|
4926
|
+
throw new Error(`invalid drive path: ${drivePath}`);
|
|
4927
|
+
}
|
|
4928
|
+
if (CONTROL_CHARS.test(drivePath)) {
|
|
4929
|
+
throw new Error(`invalid drive path: control character`);
|
|
4930
|
+
}
|
|
4931
|
+
if (Buffer.byteLength(drivePath, "utf8") > UTF8_PATH_LIMIT) {
|
|
4932
|
+
throw new Error(`invalid drive path: exceeds ${UTF8_PATH_LIMIT} bytes`);
|
|
4933
|
+
}
|
|
4934
|
+
const segments = drivePath.split("/");
|
|
4935
|
+
if (segments.some((segment) => segment.length === 0)) {
|
|
4936
|
+
throw new Error("invalid drive path: empty segment");
|
|
4937
|
+
}
|
|
4938
|
+
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
4939
|
+
throw new Error("invalid drive path: relative segment");
|
|
4940
|
+
}
|
|
4941
|
+
if (segments.some((segment) => Buffer.byteLength(segment, "utf8") > UTF8_SEGMENT_LIMIT)) {
|
|
4942
|
+
throw new Error(`invalid drive path: segment exceeds ${UTF8_SEGMENT_LIMIT} bytes`);
|
|
4943
|
+
}
|
|
4944
|
+
return drivePath;
|
|
4945
|
+
}
|
|
4946
|
+
function resolveInsideRoot(root, drivePath) {
|
|
4947
|
+
const normalizedPath = validateDrivePath(drivePath);
|
|
4948
|
+
const absoluteRoot = resolve2(root);
|
|
4949
|
+
const absolutePath = resolve2(absoluteRoot, normalizedPath);
|
|
4950
|
+
const relativePath = relative(absoluteRoot, absolutePath);
|
|
4951
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
|
|
4952
|
+
throw new Error(`drive path escapes root: ${drivePath}`);
|
|
4953
|
+
}
|
|
4954
|
+
return absolutePath;
|
|
4955
|
+
}
|
|
4956
|
+
|
|
4957
|
+
// src/handwritten/commands/drive/manifest.ts
|
|
4958
|
+
function normalizeRemoteManifest(root, entries) {
|
|
4959
|
+
const candidates = [];
|
|
4960
|
+
const remoteFiles = {};
|
|
4961
|
+
const pathErrors = [];
|
|
4962
|
+
for (const entry of entries) {
|
|
4963
|
+
try {
|
|
4964
|
+
validateDrivePath(entry.path);
|
|
4965
|
+
resolveInsideRoot(root, entry.path);
|
|
4966
|
+
candidates.push(entry);
|
|
4967
|
+
} catch (error) {
|
|
4968
|
+
pathErrors.push({ path: entry.path, error: error instanceof Error ? error : new Error(String(error)) });
|
|
4969
|
+
}
|
|
4970
|
+
}
|
|
4971
|
+
const byCaseFoldedPath = /* @__PURE__ */ new Map();
|
|
4972
|
+
for (const entry of candidates) {
|
|
4973
|
+
const folded = entry.path.toLowerCase();
|
|
4974
|
+
const group = byCaseFoldedPath.get(folded) ?? [];
|
|
4975
|
+
group.push(entry);
|
|
4976
|
+
byCaseFoldedPath.set(folded, group);
|
|
4977
|
+
}
|
|
4978
|
+
for (const group of byCaseFoldedPath.values()) {
|
|
4979
|
+
if (group.length > 1) {
|
|
4980
|
+
const hasExactDuplicate = new Set(group.map((entry2) => entry2.path)).size < group.length;
|
|
4981
|
+
const reason = hasExactDuplicate ? "REMOTE_PATH_DUPLICATE" : "REMOTE_PATH_CASE_CONFLICT";
|
|
4982
|
+
for (const entry2 of group.sort((left, right) => left.path.localeCompare(right.path))) {
|
|
4983
|
+
pathErrors.push({
|
|
4984
|
+
path: entry2.path,
|
|
4985
|
+
error: new Error(`${reason}: ${entry2.path}`),
|
|
4986
|
+
appendPathResult: true
|
|
4987
|
+
});
|
|
4988
|
+
}
|
|
4989
|
+
continue;
|
|
4990
|
+
}
|
|
4991
|
+
const [entry] = group;
|
|
4992
|
+
if (entry) remoteFiles[entry.path] = entry;
|
|
4993
|
+
}
|
|
4994
|
+
return { remoteFiles, pathErrors };
|
|
4995
|
+
}
|
|
4996
|
+
|
|
4809
4997
|
// src/handwritten/commands/drive/merge.ts
|
|
4810
4998
|
import { posix as pathPosix } from "path";
|
|
4811
4999
|
import { TextDecoder } from "util";
|
|
@@ -4864,9 +5052,8 @@ function mergeText3(base, local, remote) {
|
|
|
4864
5052
|
}
|
|
4865
5053
|
return { clean: true, text: mergedLines.join(localNewline) };
|
|
4866
5054
|
}
|
|
4867
|
-
function conflictCopyPath(path, side,
|
|
5055
|
+
function conflictCopyPath(path, side, timestamp, versionId) {
|
|
4868
5056
|
const parsed = pathPosix.parse(path);
|
|
4869
|
-
const timestamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
4870
5057
|
const shortVersionId = safeShortVersionId(versionId);
|
|
4871
5058
|
const fileName = `${parsed.name}.${side}-conflict-${timestamp}-${shortVersionId}${parsed.ext}`;
|
|
4872
5059
|
if (parsed.dir === "") {
|
|
@@ -4902,56 +5089,6 @@ function safeShortVersionId(versionId) {
|
|
|
4902
5089
|
return (safeVersionId.length > 0 ? safeVersionId : "unknown").slice(0, 8);
|
|
4903
5090
|
}
|
|
4904
5091
|
|
|
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
5092
|
// src/handwritten/commands/drive/scanner.ts
|
|
4956
5093
|
import { createHash } from "crypto";
|
|
4957
5094
|
import { constants as fsConstants } from "fs";
|
|
@@ -5072,470 +5209,33 @@ async function hashDriveFile(path) {
|
|
|
5072
5209
|
}
|
|
5073
5210
|
}
|
|
5074
5211
|
|
|
5075
|
-
// src/handwritten/commands/drive/
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5212
|
+
// src/handwritten/commands/drive/local-mutations.ts
|
|
5213
|
+
import { createWriteStream as createWriteStream2 } from "fs";
|
|
5214
|
+
import { link, mkdir as mkdir2, readFile as readFile3, rename as rename2, rm as rm2, unlink, writeFile as writeFile2 } from "fs/promises";
|
|
5215
|
+
import { basename as basename2, dirname, join as join4 } from "path";
|
|
5216
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
5217
|
+
import { Readable as Readable2, Transform } from "stream";
|
|
5218
|
+
import { pipeline as pipeline2 } from "stream/promises";
|
|
5219
|
+
async function assertLocalStillScanned(localPath, scanned) {
|
|
5220
|
+
const snapshot = await hashDriveFile(localPath).catch((error) => {
|
|
5221
|
+
if (isNotFoundError(error)) return void 0;
|
|
5222
|
+
throw error;
|
|
5223
|
+
});
|
|
5224
|
+
if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
|
|
5225
|
+
throw new Error("local file changed after scan");
|
|
5226
|
+
}
|
|
5088
5227
|
}
|
|
5089
|
-
async function
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
}
|
|
5228
|
+
async function writeMergedLocalFile(root, path, bytes, digest, scanned, onLocalMutation) {
|
|
5229
|
+
const target = resolveInsideRoot(root, path);
|
|
5230
|
+
await mkdir2(dirname(target), { recursive: true });
|
|
5231
|
+
const tmp = join4(dirname(target), `.${basename2(target)}.wspc-merge-${randomUUID2()}.tmp`);
|
|
5232
|
+
try {
|
|
5233
|
+
await writeFile2(tmp, bytes, { flag: "wx" });
|
|
5234
|
+
return await installMergedLocalFile(root, path, tmp, scanned, digest, bytes.byteLength, onLocalMutation);
|
|
5235
|
+
} finally {
|
|
5236
|
+
await rm2(tmp, { force: true }).catch(() => {
|
|
5099
5237
|
});
|
|
5100
|
-
|
|
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;
|
|
5113
|
-
});
|
|
5114
|
-
}
|
|
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;
|
|
5125
|
-
}
|
|
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);
|
|
5138
|
-
}
|
|
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
|
-
}
|
|
5238
|
+
}
|
|
5539
5239
|
}
|
|
5540
5240
|
async function installMergedLocalFile(root, path, tmp, scanned, mergedSha256, mergedSizeBytes, onLocalMutation) {
|
|
5541
5241
|
const target = resolveInsideRoot(root, path);
|
|
@@ -5645,160 +5345,584 @@ async function installDownloadedFile(root, path, tmp, entry, onLocalMutation) {
|
|
|
5645
5345
|
const expectedSha256 = expectedLocalBaseSha256(entry);
|
|
5646
5346
|
let backupIsExpectedBase = false;
|
|
5647
5347
|
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;
|
|
5348
|
+
try {
|
|
5349
|
+
await rename2(target, backup);
|
|
5350
|
+
onLocalMutation();
|
|
5351
|
+
} catch (error) {
|
|
5352
|
+
if (!isNotFoundError(error)) throw error;
|
|
5353
|
+
await installNoOverwrite(tmp, target, onLocalMutation);
|
|
5354
|
+
return;
|
|
5355
|
+
}
|
|
5356
|
+
const backupDigest = await hashDriveFile(backup);
|
|
5357
|
+
if (!backupDigest) {
|
|
5358
|
+
await restoreBackupWhenPossible(backup, target);
|
|
5359
|
+
throw new Error("local file changed before download");
|
|
5360
|
+
}
|
|
5361
|
+
if (!expectedSha256 || backupDigest.sha256 !== expectedSha256) {
|
|
5362
|
+
await restoreBackupWhenPossible(backup, target);
|
|
5363
|
+
throw new Error("local file changed before download");
|
|
5364
|
+
}
|
|
5365
|
+
backupIsExpectedBase = true;
|
|
5366
|
+
try {
|
|
5367
|
+
await installNoOverwrite(tmp, target, onLocalMutation);
|
|
5368
|
+
} catch (error) {
|
|
5369
|
+
const restored = await restoreBackupWhenPossible(backup, target);
|
|
5370
|
+
if (!restored && backupIsExpectedBase) {
|
|
5371
|
+
await unlink(backup).catch(() => {
|
|
5372
|
+
});
|
|
5373
|
+
}
|
|
5374
|
+
throw error;
|
|
5375
|
+
}
|
|
5376
|
+
await unlink(backup);
|
|
5377
|
+
} catch (error) {
|
|
5378
|
+
if (!backupIsExpectedBase) {
|
|
5379
|
+
await restoreBackupWhenPossible(backup, target);
|
|
5380
|
+
}
|
|
5381
|
+
throw error;
|
|
5382
|
+
}
|
|
5383
|
+
}
|
|
5384
|
+
async function removeLocalIfStillBase(root, path, entry, onLocalMutation) {
|
|
5385
|
+
const target = resolveInsideRoot(root, path);
|
|
5386
|
+
const backup = localMutationBackupPath(target);
|
|
5387
|
+
const expectedSha256 = expectedLocalBaseSha256(entry);
|
|
5388
|
+
if (!expectedSha256) {
|
|
5389
|
+
throw new Error("local file has no sync base");
|
|
5390
|
+
}
|
|
5391
|
+
let backupIsExpectedBase = false;
|
|
5392
|
+
try {
|
|
5393
|
+
try {
|
|
5394
|
+
await rename2(target, backup);
|
|
5395
|
+
onLocalMutation();
|
|
5396
|
+
} catch (error) {
|
|
5397
|
+
if (isNotFoundError(error)) {
|
|
5398
|
+
throw new Error("local file changed before delete");
|
|
5399
|
+
}
|
|
5400
|
+
throw error;
|
|
5401
|
+
}
|
|
5402
|
+
const backupDigest = await hashDriveFile(backup);
|
|
5403
|
+
if (!backupDigest || backupDigest.sha256 !== expectedSha256) {
|
|
5404
|
+
await restoreBackupWhenPossible(backup, target);
|
|
5405
|
+
throw new Error("local file changed before delete");
|
|
5406
|
+
}
|
|
5407
|
+
backupIsExpectedBase = true;
|
|
5408
|
+
if (await localFileExists(target)) {
|
|
5409
|
+
await unlink(backup).catch(() => {
|
|
5410
|
+
});
|
|
5411
|
+
throw new Error("local file reappeared during delete");
|
|
5412
|
+
}
|
|
5413
|
+
await unlink(backup);
|
|
5414
|
+
if (await localFileExists(target)) {
|
|
5415
|
+
throw new Error("local file reappeared during delete");
|
|
5416
|
+
}
|
|
5417
|
+
} catch (error) {
|
|
5418
|
+
if (!backupIsExpectedBase) {
|
|
5419
|
+
await restoreBackupWhenPossible(backup, target);
|
|
5420
|
+
}
|
|
5421
|
+
throw error;
|
|
5422
|
+
}
|
|
5423
|
+
}
|
|
5424
|
+
async function installNoOverwrite(source, target, onLinked) {
|
|
5425
|
+
await link(source, target);
|
|
5426
|
+
onLinked?.();
|
|
5427
|
+
await unlink(source);
|
|
5428
|
+
}
|
|
5429
|
+
async function restoreBackupWhenPossible(backup, target) {
|
|
5430
|
+
try {
|
|
5431
|
+
await installNoOverwrite(backup, target);
|
|
5432
|
+
return true;
|
|
5433
|
+
} catch (error) {
|
|
5434
|
+
if (isAlreadyExistsError(error)) return false;
|
|
5435
|
+
if (isNotFoundError(error)) return true;
|
|
5436
|
+
return false;
|
|
5437
|
+
}
|
|
5438
|
+
}
|
|
5439
|
+
async function localFileExists(path) {
|
|
5440
|
+
const digest = await hashDriveFile(path).catch((error) => {
|
|
5441
|
+
if (isNotFoundError(error)) return void 0;
|
|
5442
|
+
throw error;
|
|
5443
|
+
});
|
|
5444
|
+
return digest !== void 0;
|
|
5445
|
+
}
|
|
5446
|
+
function localMutationBackupPath(target) {
|
|
5447
|
+
return join4(dirname(target), `.${basename2(target)}.wspc-backup-${randomUUID2()}.tmp`);
|
|
5448
|
+
}
|
|
5449
|
+
function expectedLocalBaseSha256(entry) {
|
|
5450
|
+
return entry?.last_local_sha256 ?? entry?.content_sha256;
|
|
5451
|
+
}
|
|
5452
|
+
async function readStableUploadBody(localPath, scanned) {
|
|
5453
|
+
if (!scanned) {
|
|
5454
|
+
throw new Error("local file missing from scan");
|
|
5455
|
+
}
|
|
5456
|
+
const snapshot = await hashDriveFile(localPath).catch((error) => {
|
|
5457
|
+
if (isNotFoundError(error)) return void 0;
|
|
5458
|
+
throw error;
|
|
5459
|
+
});
|
|
5460
|
+
if (!snapshot || snapshot.sha256 !== scanned.sha256 || snapshot.sizeBytes !== scanned.size_bytes) {
|
|
5461
|
+
throw new Error("local file changed after scan");
|
|
5462
|
+
}
|
|
5463
|
+
const body = await readFile3(localPath).catch((error) => {
|
|
5464
|
+
if (isNotFoundError(error)) return void 0;
|
|
5465
|
+
throw error;
|
|
5466
|
+
});
|
|
5467
|
+
if (!body) {
|
|
5468
|
+
throw new Error("local file changed after scan");
|
|
5469
|
+
}
|
|
5470
|
+
const uploadBytes = new Uint8Array(body.byteLength);
|
|
5471
|
+
uploadBytes.set(body);
|
|
5472
|
+
const digest = createHash2("sha256").update(uploadBytes).digest("hex");
|
|
5473
|
+
if (digest !== scanned.sha256 || uploadBytes.byteLength !== scanned.size_bytes) {
|
|
5474
|
+
throw new Error("local file changed after scan");
|
|
5475
|
+
}
|
|
5476
|
+
return { body: uploadBytes.buffer, digest };
|
|
5477
|
+
}
|
|
5478
|
+
async function assertLocalSafeForDownload(root, path, entry) {
|
|
5479
|
+
const target = resolveInsideRoot(root, path);
|
|
5480
|
+
const digest = await hashDriveFile(target).catch((error) => {
|
|
5481
|
+
if (isNotFoundError(error)) return void 0;
|
|
5482
|
+
throw error;
|
|
5483
|
+
});
|
|
5484
|
+
if (!digest) return;
|
|
5485
|
+
if (!entry?.last_local_sha256) {
|
|
5486
|
+
throw new Error("local file appeared before download");
|
|
5487
|
+
}
|
|
5488
|
+
if (digest.sha256 !== entry.last_local_sha256) {
|
|
5489
|
+
throw new Error("local file changed before download");
|
|
5490
|
+
}
|
|
5491
|
+
}
|
|
5492
|
+
async function assertLocalAbsentBeforeRemoteDelete(root, path) {
|
|
5493
|
+
const digest = await hashDriveFile(resolveInsideRoot(root, path)).catch((error) => {
|
|
5494
|
+
if (isNotFoundError(error)) return void 0;
|
|
5495
|
+
throw error;
|
|
5496
|
+
});
|
|
5497
|
+
if (digest) {
|
|
5498
|
+
throw new Error("local file appeared before remote delete");
|
|
5499
|
+
}
|
|
5500
|
+
}
|
|
5501
|
+
function isNotFoundError(error) {
|
|
5502
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
5503
|
+
}
|
|
5504
|
+
function isAlreadyExistsError(error) {
|
|
5505
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
5506
|
+
}
|
|
5507
|
+
|
|
5508
|
+
// src/handwritten/commands/drive/sync.ts
|
|
5509
|
+
function emptySummary() {
|
|
5510
|
+
return {
|
|
5511
|
+
uploaded: 0,
|
|
5512
|
+
downloaded: 0,
|
|
5513
|
+
deleted: 0,
|
|
5514
|
+
unchanged: 0,
|
|
5515
|
+
merged: 0,
|
|
5516
|
+
conflicts: 0,
|
|
5517
|
+
errors: 0,
|
|
5518
|
+
conflict_paths: [],
|
|
5519
|
+
paths: []
|
|
5520
|
+
};
|
|
5521
|
+
}
|
|
5522
|
+
async function runDriveSyncOnce(root, api, clock = systemDriveClock) {
|
|
5523
|
+
return withDriveLock(root, async () => {
|
|
5524
|
+
let state = await readDriveState(root);
|
|
5525
|
+
const syncApi = api ?? await createDriveApi();
|
|
5526
|
+
const summary = emptySummary();
|
|
5527
|
+
const blockedPaths = /* @__PURE__ */ new Set();
|
|
5528
|
+
const localFiles = await scanDriveFiles(root, {
|
|
5529
|
+
onPathError: async (path, error) => {
|
|
5530
|
+
await recordPathError(summary, blockedPaths, path, error);
|
|
5531
|
+
}
|
|
5532
|
+
});
|
|
5533
|
+
const remoteFiles = await fetchRemoteManifest(root, state, syncApi, summary, blockedPaths);
|
|
5534
|
+
const paths = Array.from(
|
|
5535
|
+
/* @__PURE__ */ new Set([...Object.keys(localFiles), ...Object.keys(remoteFiles), ...Object.keys(state.entries)])
|
|
5536
|
+
).filter((path) => !blockedPaths.has(path)).sort((left, right) => left.localeCompare(right));
|
|
5537
|
+
for (const path of paths) {
|
|
5538
|
+
const remote = remoteFiles[path];
|
|
5539
|
+
const action = decideDriveAction(state.entries[path], localFiles[path], remote);
|
|
5540
|
+
const result = await processPath({ root, state, api: syncApi, path, action, remote, local: localFiles[path], summary, clock });
|
|
5541
|
+
state = result.state;
|
|
5542
|
+
if (result.stop) break;
|
|
5543
|
+
}
|
|
5544
|
+
recordUnresolvedConflicts(summary, state);
|
|
5545
|
+
return summary;
|
|
5546
|
+
});
|
|
5547
|
+
}
|
|
5548
|
+
function driveSyncCommand(api) {
|
|
5549
|
+
const sync = new Command71("sync").description("Drive sync commands");
|
|
5550
|
+
sync.command("once").description("Run one Drive sync pass").argument("[path]", "local folder path", ".").action(async (path) => {
|
|
5551
|
+
const summary = await runDriveSyncOnce(resolve3(path), api);
|
|
5552
|
+
render({ kind: "drive_sync_once", display: { shape: "object" } }, summary);
|
|
5553
|
+
if (summary.conflicts > 0 || summary.errors > 0) {
|
|
5554
|
+
process.exitCode = 1;
|
|
5555
|
+
}
|
|
5556
|
+
});
|
|
5557
|
+
return sync;
|
|
5558
|
+
}
|
|
5559
|
+
async function fetchRemoteManifest(root, state, api, summary, blockedPaths) {
|
|
5560
|
+
const entries = [];
|
|
5561
|
+
let cursor;
|
|
5562
|
+
do {
|
|
5563
|
+
const page = await api.getManifest(state.library_id, cursor);
|
|
5564
|
+
entries.push(...page.entries);
|
|
5565
|
+
cursor = page.next_cursor ?? void 0;
|
|
5566
|
+
} while (cursor !== void 0);
|
|
5567
|
+
const normalized = normalizeRemoteManifest(root, entries);
|
|
5568
|
+
for (const pathError of normalized.pathErrors) {
|
|
5569
|
+
await recordPathError(summary, blockedPaths, pathError.path, pathError.error, {
|
|
5570
|
+
appendPathResult: pathError.appendPathResult
|
|
5571
|
+
});
|
|
5572
|
+
}
|
|
5573
|
+
return normalized.remoteFiles;
|
|
5574
|
+
}
|
|
5575
|
+
async function processPath(args) {
|
|
5576
|
+
const { root, state, api, path, action, remote, local, summary, clock } = args;
|
|
5577
|
+
summary.paths.push({ path, action: action.type });
|
|
5578
|
+
let durableStateRequired = false;
|
|
5579
|
+
try {
|
|
5580
|
+
if (action.type === "upload_create" || action.type === "upload_update") {
|
|
5581
|
+
const localPath = resolveInsideRoot(root, path);
|
|
5582
|
+
const { body, digest: uploadDigest } = await readStableUploadBody(localPath, local);
|
|
5583
|
+
const uploaded = await api.uploadFile(state.library_id, path, body, uploadDigest, action.expectedEntryVersion);
|
|
5584
|
+
durableStateRequired = true;
|
|
5585
|
+
const nextState = cloneDriveState(state);
|
|
5586
|
+
nextState.entries[path] = stateEntryFromRemote(uploaded.entry, uploadDigest, clock);
|
|
5587
|
+
delete nextState.conflicts[path];
|
|
5588
|
+
await writeDriveState(root, nextState, clock);
|
|
5589
|
+
summary.uploaded += 1;
|
|
5590
|
+
return { state: nextState, stop: false };
|
|
5655
5591
|
}
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
await
|
|
5659
|
-
|
|
5592
|
+
if (action.type === "download") {
|
|
5593
|
+
if (!remote) throw new Error("remote entry missing for download");
|
|
5594
|
+
await assertLocalSafeForDownload(root, path, state.entries[path]);
|
|
5595
|
+
const digest = await downloadRemote(root, state.library_id, path, api, remote.content_sha256, state.entries[path], () => {
|
|
5596
|
+
durableStateRequired = true;
|
|
5597
|
+
});
|
|
5598
|
+
const nextState = cloneDriveState(state);
|
|
5599
|
+
nextState.entries[path] = stateEntryFromRemote(remote, digest, clock);
|
|
5600
|
+
delete nextState.conflicts[path];
|
|
5601
|
+
await writeDriveState(root, nextState, clock);
|
|
5602
|
+
summary.downloaded += 1;
|
|
5603
|
+
return { state: nextState, stop: false };
|
|
5660
5604
|
}
|
|
5661
|
-
if (
|
|
5662
|
-
await
|
|
5663
|
-
|
|
5605
|
+
if (action.type === "delete_remote") {
|
|
5606
|
+
await assertLocalAbsentBeforeRemoteDelete(root, path);
|
|
5607
|
+
await api.deleteFile(state.library_id, path, action.expectedEntryVersion);
|
|
5608
|
+
durableStateRequired = true;
|
|
5609
|
+
await assertLocalAbsentBeforeRemoteDelete(root, path);
|
|
5610
|
+
const nextState = cloneDriveState(state);
|
|
5611
|
+
delete nextState.entries[path];
|
|
5612
|
+
delete nextState.conflicts[path];
|
|
5613
|
+
await writeDriveState(root, nextState, clock);
|
|
5614
|
+
summary.deleted += 1;
|
|
5615
|
+
return { state: nextState, stop: false };
|
|
5664
5616
|
}
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
const
|
|
5670
|
-
|
|
5671
|
-
|
|
5617
|
+
if (action.type === "delete_local") {
|
|
5618
|
+
await removeLocalIfStillBase(root, path, state.entries[path], () => {
|
|
5619
|
+
durableStateRequired = true;
|
|
5620
|
+
});
|
|
5621
|
+
const nextState = cloneDriveState(state);
|
|
5622
|
+
delete nextState.entries[path];
|
|
5623
|
+
delete nextState.conflicts[path];
|
|
5624
|
+
await writeDriveState(root, nextState, clock);
|
|
5625
|
+
summary.deleted += 1;
|
|
5626
|
+
return { state: nextState, stop: false };
|
|
5627
|
+
}
|
|
5628
|
+
if (action.type === "state_only") {
|
|
5629
|
+
if (!remote) throw new Error("remote entry missing for state update");
|
|
5630
|
+
const nextState = cloneDriveState(state);
|
|
5631
|
+
nextState.entries[path] = stateEntryFromRemote(remote, local?.sha256 ?? remote.content_sha256, clock);
|
|
5632
|
+
delete nextState.conflicts[path];
|
|
5633
|
+
await writeDriveState(root, nextState, clock);
|
|
5634
|
+
summary.unchanged += 1;
|
|
5635
|
+
return { state: nextState, stop: false };
|
|
5636
|
+
}
|
|
5637
|
+
if (action.type === "remove_state") {
|
|
5638
|
+
const nextState = cloneDriveState(state);
|
|
5639
|
+
delete nextState.entries[path];
|
|
5640
|
+
delete nextState.conflicts[path];
|
|
5641
|
+
await writeDriveState(root, nextState, clock);
|
|
5642
|
+
summary.unchanged += 1;
|
|
5643
|
+
return { state: nextState, stop: false };
|
|
5644
|
+
}
|
|
5645
|
+
if (action.type === "conflict") {
|
|
5646
|
+
if (action.reason === "local_and_remote_changed") {
|
|
5647
|
+
try {
|
|
5648
|
+
const mergedState = await tryResolveConflict({
|
|
5649
|
+
root,
|
|
5650
|
+
state,
|
|
5651
|
+
api,
|
|
5652
|
+
path,
|
|
5653
|
+
remote,
|
|
5654
|
+
local,
|
|
5655
|
+
clock,
|
|
5656
|
+
onLocalMutation: () => {
|
|
5657
|
+
durableStateRequired = true;
|
|
5658
|
+
}
|
|
5659
|
+
});
|
|
5660
|
+
if (mergedState) {
|
|
5661
|
+
summary.merged += 1;
|
|
5662
|
+
summary.paths[summary.paths.length - 1] = { path, action: "merged" };
|
|
5663
|
+
return { state: mergedState, stop: false };
|
|
5664
|
+
}
|
|
5665
|
+
} catch (error) {
|
|
5666
|
+
if (!isLocalChangedDuringMerge(error)) {
|
|
5667
|
+
throw error;
|
|
5668
|
+
}
|
|
5669
|
+
const nextState2 = await recordTypedConflict(root, state, path, action.reason, remote, clock, {
|
|
5670
|
+
type: "edit_edit",
|
|
5671
|
+
strategy: "record_only",
|
|
5672
|
+
reason: "local_changed_during_merge"
|
|
5673
|
+
});
|
|
5674
|
+
summary.conflicts += 1;
|
|
5675
|
+
return { state: nextState2, stop: false };
|
|
5676
|
+
}
|
|
5677
|
+
const conflictCopyState = await recordRemoteConflictCopy({
|
|
5678
|
+
root,
|
|
5679
|
+
state,
|
|
5680
|
+
api,
|
|
5681
|
+
path,
|
|
5682
|
+
reason: action.reason,
|
|
5683
|
+
type: "edit_edit",
|
|
5684
|
+
remote,
|
|
5685
|
+
clock
|
|
5672
5686
|
});
|
|
5687
|
+
if (conflictCopyState) {
|
|
5688
|
+
summary.conflicts += 1;
|
|
5689
|
+
return { state: conflictCopyState, stop: false };
|
|
5690
|
+
}
|
|
5673
5691
|
}
|
|
5674
|
-
|
|
5692
|
+
if (action.reason === "local_and_remote_without_base") {
|
|
5693
|
+
const conflictCopyState = await recordRemoteConflictCopy({
|
|
5694
|
+
root,
|
|
5695
|
+
state,
|
|
5696
|
+
api,
|
|
5697
|
+
path,
|
|
5698
|
+
reason: action.reason,
|
|
5699
|
+
type: "create_create",
|
|
5700
|
+
remote,
|
|
5701
|
+
clock
|
|
5702
|
+
});
|
|
5703
|
+
if (conflictCopyState) {
|
|
5704
|
+
summary.conflicts += 1;
|
|
5705
|
+
return { state: conflictCopyState, stop: false };
|
|
5706
|
+
}
|
|
5707
|
+
}
|
|
5708
|
+
if (action.reason === "local_changed_remote_deleted") {
|
|
5709
|
+
const nextState2 = await recordTypedConflict(root, state, path, action.reason, remote, clock, {
|
|
5710
|
+
type: "edit_delete",
|
|
5711
|
+
strategy: "record_only"
|
|
5712
|
+
});
|
|
5713
|
+
summary.conflicts += 1;
|
|
5714
|
+
return { state: nextState2, stop: false };
|
|
5715
|
+
}
|
|
5716
|
+
if (action.reason === "remote_changed_before_delete") {
|
|
5717
|
+
const conflictCopyState = await recordRemoteConflictCopy({
|
|
5718
|
+
root,
|
|
5719
|
+
state,
|
|
5720
|
+
api,
|
|
5721
|
+
path,
|
|
5722
|
+
reason: action.reason,
|
|
5723
|
+
type: "delete_edit",
|
|
5724
|
+
remote,
|
|
5725
|
+
clock
|
|
5726
|
+
});
|
|
5727
|
+
if (conflictCopyState) {
|
|
5728
|
+
summary.conflicts += 1;
|
|
5729
|
+
return { state: conflictCopyState, stop: false };
|
|
5730
|
+
}
|
|
5731
|
+
}
|
|
5732
|
+
const nextState = await recordConflict(root, state, path, action.reason, remote, clock);
|
|
5733
|
+
summary.conflicts += 1;
|
|
5734
|
+
return { state: nextState, stop: false };
|
|
5675
5735
|
}
|
|
5676
|
-
|
|
5736
|
+
summary.unchanged += 1;
|
|
5677
5737
|
} catch (error) {
|
|
5678
|
-
if (
|
|
5679
|
-
|
|
5738
|
+
if (isVersionConflict(error)) {
|
|
5739
|
+
try {
|
|
5740
|
+
const nextState = await recordConflict(root, state, path, "VERSION_CONFLICT", remote, clock);
|
|
5741
|
+
summary.conflicts += 1;
|
|
5742
|
+
summary.paths[summary.paths.length - 1] = { path, action: "conflict" };
|
|
5743
|
+
return { state: nextState, stop: false };
|
|
5744
|
+
} catch (writeError) {
|
|
5745
|
+
await recordPathError(summary, void 0, path, writeError);
|
|
5746
|
+
return { state, stop: durableStateRequired };
|
|
5747
|
+
}
|
|
5680
5748
|
}
|
|
5681
|
-
|
|
5749
|
+
await recordPathError(summary, void 0, path, error);
|
|
5750
|
+
return { state, stop: durableStateRequired };
|
|
5682
5751
|
}
|
|
5752
|
+
return { state, stop: false };
|
|
5683
5753
|
}
|
|
5684
|
-
async function
|
|
5685
|
-
const
|
|
5686
|
-
const
|
|
5687
|
-
const
|
|
5688
|
-
|
|
5689
|
-
|
|
5754
|
+
async function tryResolveConflict(args) {
|
|
5755
|
+
const { root, state, api, path, remote, local, clock, onLocalMutation } = args;
|
|
5756
|
+
const entry = state.entries[path];
|
|
5757
|
+
const baseVersionId = entry?.current_version_id;
|
|
5758
|
+
const remoteVersionId = remote?.current_version_id;
|
|
5759
|
+
if (!entry || !remote || !local || baseVersionId === void 0 || remoteVersionId === void 0) {
|
|
5760
|
+
return void 0;
|
|
5690
5761
|
}
|
|
5691
|
-
|
|
5762
|
+
const localPath = resolveInsideRoot(root, path);
|
|
5763
|
+
let baseBytes;
|
|
5764
|
+
let remoteBytes;
|
|
5692
5765
|
try {
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
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
|
-
}
|
|
5766
|
+
const [downloadedBaseBytes, downloadedRemoteBytes] = await Promise.all([
|
|
5767
|
+
downloadBytes(api, state.library_id, path, baseVersionId),
|
|
5768
|
+
downloadBytes(api, state.library_id, path, remoteVersionId)
|
|
5769
|
+
]);
|
|
5770
|
+
baseBytes = downloadedBaseBytes;
|
|
5771
|
+
remoteBytes = downloadedRemoteBytes;
|
|
5717
5772
|
} catch (error) {
|
|
5718
|
-
if (
|
|
5719
|
-
|
|
5773
|
+
if (isExpectedVersionDownloadMissing(error)) {
|
|
5774
|
+
return void 0;
|
|
5720
5775
|
}
|
|
5721
5776
|
throw error;
|
|
5722
5777
|
}
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5778
|
+
const localBytes = await readFile4(localPath);
|
|
5779
|
+
const baseText = classifyMergeText(path, baseBytes, void 0);
|
|
5780
|
+
const localText = classifyMergeText(path, localBytes, void 0);
|
|
5781
|
+
const remoteText = classifyMergeText(path, remoteBytes, void 0);
|
|
5782
|
+
if (!baseText.mergeable || !localText.mergeable || !remoteText.mergeable) {
|
|
5783
|
+
return void 0;
|
|
5784
|
+
}
|
|
5785
|
+
const merged = mergeText3(baseText.text, localText.text, remoteText.text);
|
|
5786
|
+
if (!merged.clean) {
|
|
5787
|
+
return void 0;
|
|
5788
|
+
}
|
|
5789
|
+
await assertLocalStillScanned(localPath, local);
|
|
5790
|
+
const mergedBytes = new TextEncoder().encode(merged.text);
|
|
5791
|
+
const mergedDigest = createHash3("sha256").update(mergedBytes).digest("hex");
|
|
5792
|
+
const install = await writeMergedLocalFile(root, path, mergedBytes, mergedDigest, local, onLocalMutation);
|
|
5793
|
+
let uploaded;
|
|
5730
5794
|
try {
|
|
5731
|
-
await
|
|
5732
|
-
return true;
|
|
5795
|
+
uploaded = await api.uploadFile(state.library_id, path, mergedBytes, mergedDigest, remote.entry_version);
|
|
5733
5796
|
} catch (error) {
|
|
5734
|
-
|
|
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;
|
|
5797
|
+
await install.restore();
|
|
5742
5798
|
throw error;
|
|
5743
|
-
}
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5799
|
+
}
|
|
5800
|
+
await install.finalize();
|
|
5801
|
+
const nextState = cloneDriveState(state);
|
|
5802
|
+
nextState.entries[path] = stateEntryFromRemote(uploaded.entry, mergedDigest, clock);
|
|
5803
|
+
delete nextState.conflicts[path];
|
|
5804
|
+
await writeDriveState(root, nextState, clock);
|
|
5805
|
+
return nextState;
|
|
5748
5806
|
}
|
|
5749
|
-
function
|
|
5750
|
-
|
|
5807
|
+
async function downloadBytes(api, libraryId, path, versionId) {
|
|
5808
|
+
const response = await api.downloadFile(libraryId, path, versionId);
|
|
5809
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
5751
5810
|
}
|
|
5752
|
-
async function
|
|
5753
|
-
|
|
5754
|
-
|
|
5811
|
+
async function recordRemoteConflictCopy(args) {
|
|
5812
|
+
const { root, state, api, path, reason, type, remote, clock } = args;
|
|
5813
|
+
const entry = state.entries[path];
|
|
5814
|
+
const remoteVersionId = remote?.current_version_id;
|
|
5815
|
+
if (!remote || remoteVersionId === void 0) {
|
|
5816
|
+
return void 0;
|
|
5755
5817
|
}
|
|
5756
|
-
|
|
5757
|
-
|
|
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");
|
|
5818
|
+
if (await canReuseConflictCopy(root, state.conflicts[path], remoteVersionId)) {
|
|
5819
|
+
return state;
|
|
5762
5820
|
}
|
|
5763
|
-
const
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5821
|
+
const remoteBytes = await downloadBytes(api, state.library_id, path, remoteVersionId);
|
|
5822
|
+
const copyPath = await writeConflictCopy(root, path, "remote", remoteVersionId, remoteBytes, clock);
|
|
5823
|
+
const nextState = cloneDriveState(state);
|
|
5824
|
+
nextState.conflicts[path] = {
|
|
5825
|
+
detected_at: driveIsoTimestamp(clock),
|
|
5826
|
+
reason,
|
|
5827
|
+
type,
|
|
5828
|
+
strategy: "conflict_copy",
|
|
5829
|
+
base_version_id: entry?.current_version_id,
|
|
5830
|
+
remote_version_id: remoteVersionId,
|
|
5831
|
+
remote_entry_version: remote.entry_version,
|
|
5832
|
+
conflict_paths: [copyPath]
|
|
5833
|
+
};
|
|
5834
|
+
await writeDriveState(root, nextState, clock);
|
|
5835
|
+
return nextState;
|
|
5836
|
+
}
|
|
5837
|
+
async function canReuseConflictCopy(root, conflict2, remoteVersionId) {
|
|
5838
|
+
if (conflict2?.strategy !== "conflict_copy" || conflict2.remote_version_id !== remoteVersionId || !Array.isArray(conflict2.conflict_paths) || conflict2.conflict_paths.length === 0) {
|
|
5839
|
+
return false;
|
|
5769
5840
|
}
|
|
5770
|
-
const
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5841
|
+
for (const conflictPath of conflict2.conflict_paths) {
|
|
5842
|
+
try {
|
|
5843
|
+
validateDrivePath(conflictPath);
|
|
5844
|
+
if (!await localFileExists(resolveInsideRoot(root, conflictPath))) {
|
|
5845
|
+
return false;
|
|
5846
|
+
}
|
|
5847
|
+
} catch {
|
|
5848
|
+
return false;
|
|
5849
|
+
}
|
|
5775
5850
|
}
|
|
5776
|
-
return
|
|
5851
|
+
return true;
|
|
5777
5852
|
}
|
|
5778
|
-
async function
|
|
5779
|
-
const
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5853
|
+
async function writeConflictCopy(root, path, side, versionId, bytes, clock) {
|
|
5854
|
+
const baseCopyPath = conflictCopyPath(path, side, driveConflictTimestamp(clock), versionId);
|
|
5855
|
+
for (let suffix = 1; ; suffix += 1) {
|
|
5856
|
+
const candidate = conflictCopyPathWithSuffix(baseCopyPath, suffix);
|
|
5857
|
+
validateDrivePath(candidate);
|
|
5858
|
+
const target = resolveInsideRoot(root, candidate);
|
|
5859
|
+
await mkdir3(dirname2(target), { recursive: true });
|
|
5860
|
+
for (; ; ) {
|
|
5861
|
+
const tmp = join5(dirname2(target), `.${basename3(target)}.wspc-conflict-${randomUUID3()}.tmp`);
|
|
5862
|
+
let tmpWritten = false;
|
|
5863
|
+
try {
|
|
5864
|
+
await writeFile3(tmp, bytes, { flag: "wx" });
|
|
5865
|
+
tmpWritten = true;
|
|
5866
|
+
await installNoOverwrite(tmp, target);
|
|
5867
|
+
return candidate;
|
|
5868
|
+
} catch (error) {
|
|
5869
|
+
if (tmpWritten) {
|
|
5870
|
+
await rm3(tmp, { force: true }).catch(() => {
|
|
5871
|
+
});
|
|
5872
|
+
}
|
|
5873
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") {
|
|
5874
|
+
if (tmpWritten) break;
|
|
5875
|
+
continue;
|
|
5876
|
+
}
|
|
5877
|
+
throw error;
|
|
5878
|
+
}
|
|
5879
|
+
}
|
|
5787
5880
|
}
|
|
5788
|
-
|
|
5789
|
-
|
|
5881
|
+
}
|
|
5882
|
+
function conflictCopyPathWithSuffix(path, suffix) {
|
|
5883
|
+
if (suffix === 1) {
|
|
5884
|
+
return path;
|
|
5790
5885
|
}
|
|
5886
|
+
const parsed = pathPosix2.parse(path);
|
|
5887
|
+
const fileName = `${parsed.name}-${suffix}${parsed.ext}`;
|
|
5888
|
+
if (parsed.dir === "") {
|
|
5889
|
+
return fileName;
|
|
5890
|
+
}
|
|
5891
|
+
return pathPosix2.join(parsed.dir, fileName);
|
|
5791
5892
|
}
|
|
5792
|
-
|
|
5793
|
-
const
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5893
|
+
function isExpectedVersionDownloadMissing(error) {
|
|
5894
|
+
const structured = error;
|
|
5895
|
+
if (structured?.status === 404 || structured?.status === 410) return true;
|
|
5896
|
+
if (structured?.response?.status === 404 || structured?.response?.status === 410) return true;
|
|
5897
|
+
if (structured?.code === "VERSION_NOT_FOUND" || structured?.code === "NOT_FOUND") return true;
|
|
5898
|
+
return /\b(?:HTTP 40[410]|missing version|version not found|not found)\b/i.test(errorMessage(error));
|
|
5899
|
+
}
|
|
5900
|
+
function recordUnresolvedConflicts(summary, state) {
|
|
5901
|
+
const newlyRecorded = new Set(summary.paths.filter((result) => result.action === "conflict").map((result) => result.path));
|
|
5902
|
+
const reportedPaths = new Set(summary.paths.map((result) => result.path));
|
|
5903
|
+
for (const path of Object.keys(state.conflicts).sort((left, right) => left.localeCompare(right))) {
|
|
5904
|
+
const conflictPaths = state.conflicts[path]?.conflict_paths;
|
|
5905
|
+
if (conflictPaths) {
|
|
5906
|
+
summary.conflict_paths.push(...conflictPaths);
|
|
5907
|
+
}
|
|
5908
|
+
if (!newlyRecorded.has(path)) {
|
|
5909
|
+
summary.conflicts += 1;
|
|
5910
|
+
}
|
|
5911
|
+
const existingResult = summary.paths.find((result) => result.path === path);
|
|
5912
|
+
if (existingResult?.action === "unchanged") {
|
|
5913
|
+
existingResult.action = "conflict";
|
|
5914
|
+
if (conflictPaths) existingResult.conflict_paths = conflictPaths;
|
|
5915
|
+
continue;
|
|
5916
|
+
}
|
|
5917
|
+
if (existingResult?.action === "conflict" && conflictPaths) {
|
|
5918
|
+
existingResult.conflict_paths = conflictPaths;
|
|
5919
|
+
}
|
|
5920
|
+
if (!reportedPaths.has(path)) {
|
|
5921
|
+
summary.paths.push({ path, action: "conflict", ...conflictPaths ? { conflict_paths: conflictPaths } : {} });
|
|
5922
|
+
}
|
|
5799
5923
|
}
|
|
5800
5924
|
}
|
|
5801
|
-
function stateEntryFromRemote(remote, localSha256) {
|
|
5925
|
+
function stateEntryFromRemote(remote, localSha256, clock) {
|
|
5802
5926
|
return {
|
|
5803
5927
|
entry_id: remote.id,
|
|
5804
5928
|
entry_version: remote.entry_version,
|
|
@@ -5806,13 +5930,10 @@ function stateEntryFromRemote(remote, localSha256) {
|
|
|
5806
5930
|
content_sha256: remote.content_sha256,
|
|
5807
5931
|
size_bytes: remote.size_bytes,
|
|
5808
5932
|
last_local_sha256: localSha256,
|
|
5809
|
-
last_synced_at: (
|
|
5933
|
+
last_synced_at: driveIsoTimestamp(clock),
|
|
5810
5934
|
status: "synced"
|
|
5811
5935
|
};
|
|
5812
5936
|
}
|
|
5813
|
-
async function commitDriveState(root, nextState) {
|
|
5814
|
-
await writeDriveState(root, nextState);
|
|
5815
|
-
}
|
|
5816
5937
|
function cloneDriveState(state) {
|
|
5817
5938
|
return {
|
|
5818
5939
|
...state,
|
|
@@ -5820,21 +5941,21 @@ function cloneDriveState(state) {
|
|
|
5820
5941
|
conflicts: { ...state.conflicts }
|
|
5821
5942
|
};
|
|
5822
5943
|
}
|
|
5823
|
-
async function recordConflict(root, state, path, reason, remote) {
|
|
5944
|
+
async function recordConflict(root, state, path, reason, remote, clock) {
|
|
5824
5945
|
const nextState = cloneDriveState(state);
|
|
5825
|
-
nextState.conflicts[path] = conflict(reason, remote);
|
|
5826
|
-
await
|
|
5946
|
+
nextState.conflicts[path] = conflict(reason, remote, clock);
|
|
5947
|
+
await writeDriveState(root, nextState, clock);
|
|
5827
5948
|
return nextState;
|
|
5828
5949
|
}
|
|
5829
|
-
async function recordTypedConflict(root, state, path, reason, remote, metadata) {
|
|
5950
|
+
async function recordTypedConflict(root, state, path, reason, remote, clock, metadata) {
|
|
5830
5951
|
const nextState = cloneDriveState(state);
|
|
5831
5952
|
nextState.conflicts[path] = {
|
|
5832
|
-
...conflict(metadata.reason ?? reason, remote),
|
|
5953
|
+
...conflict(metadata.reason ?? reason, remote, clock),
|
|
5833
5954
|
type: metadata.type,
|
|
5834
5955
|
strategy: metadata.strategy,
|
|
5835
5956
|
base_version_id: state.entries[path]?.current_version_id
|
|
5836
5957
|
};
|
|
5837
|
-
await
|
|
5958
|
+
await writeDriveState(root, nextState, clock);
|
|
5838
5959
|
return nextState;
|
|
5839
5960
|
}
|
|
5840
5961
|
async function recordPathError(summary, blockedPaths, path, error, options = {}) {
|
|
@@ -5848,9 +5969,9 @@ async function recordPathError(summary, blockedPaths, path, error, options = {})
|
|
|
5848
5969
|
void errorMessage(error);
|
|
5849
5970
|
summary.errors += 1;
|
|
5850
5971
|
}
|
|
5851
|
-
function conflict(reason, remote) {
|
|
5972
|
+
function conflict(reason, remote, clock) {
|
|
5852
5973
|
return {
|
|
5853
|
-
detected_at: (
|
|
5974
|
+
detected_at: driveIsoTimestamp(clock),
|
|
5854
5975
|
reason,
|
|
5855
5976
|
remote_entry_version: remote?.entry_version,
|
|
5856
5977
|
remote_version_id: remote?.current_version_id
|
|
@@ -5876,22 +5997,291 @@ function containsVersionConflict(value) {
|
|
|
5876
5997
|
return false;
|
|
5877
5998
|
}
|
|
5878
5999
|
}
|
|
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
6000
|
|
|
5886
6001
|
// src/handwritten/commands/drive/watch.ts
|
|
5887
6002
|
import { Command as Command72 } from "commander";
|
|
5888
6003
|
import chokidar from "chokidar";
|
|
5889
6004
|
import { relative as relative2, resolve as resolve4 } from "path";
|
|
6005
|
+
|
|
6006
|
+
// src/handwritten/commands/drive/realtime.ts
|
|
6007
|
+
function createDriveRealtimeSource(args) {
|
|
6008
|
+
const connect = args.connect ?? nativeWebSocketConnector;
|
|
6009
|
+
const clock = args.clock ?? systemDriveClock;
|
|
6010
|
+
const scheduleTimeout = args.setTimeout ?? setTimeout;
|
|
6011
|
+
const cancelTimeout = args.clearTimeout ?? clearTimeout;
|
|
6012
|
+
let currentRealtime = { ...args.realtime };
|
|
6013
|
+
let handlers;
|
|
6014
|
+
let activeSocket;
|
|
6015
|
+
let reconnectTimer;
|
|
6016
|
+
let reconnectDelayMs = 1e3;
|
|
6017
|
+
let stopped = false;
|
|
6018
|
+
let authFailed = false;
|
|
6019
|
+
let connectionId = 0;
|
|
6020
|
+
function clearReconnectTimer() {
|
|
6021
|
+
if (reconnectTimer === void 0) return;
|
|
6022
|
+
cancelTimeout(reconnectTimer);
|
|
6023
|
+
reconnectTimer = void 0;
|
|
6024
|
+
}
|
|
6025
|
+
async function persist(next) {
|
|
6026
|
+
currentRealtime = next;
|
|
6027
|
+
await args.writeRealtimeState(next);
|
|
6028
|
+
}
|
|
6029
|
+
async function persistBestEffort(next) {
|
|
6030
|
+
try {
|
|
6031
|
+
await persist(next);
|
|
6032
|
+
} catch (error) {
|
|
6033
|
+
handlers?.onWarning?.(redactedRealtimeError(error));
|
|
6034
|
+
}
|
|
6035
|
+
}
|
|
6036
|
+
function connectNow() {
|
|
6037
|
+
if (stopped || authFailed) return;
|
|
6038
|
+
clearReconnectTimer();
|
|
6039
|
+
const id = ++connectionId;
|
|
6040
|
+
const url = buildDriveRealtimeUrl(args.baseUrl, args.libraryId, currentRealtime);
|
|
6041
|
+
activeSocket = connect(url, {
|
|
6042
|
+
open() {
|
|
6043
|
+
if (id !== connectionId || stopped || authFailed) return;
|
|
6044
|
+
reconnectDelayMs = 1e3;
|
|
6045
|
+
void persistBestEffort({ ...currentRealtime, last_connected_at: driveIsoTimestamp(clock) }).then(() => handlers?.onConnected());
|
|
6046
|
+
},
|
|
6047
|
+
message(data) {
|
|
6048
|
+
if (id !== connectionId || stopped || authFailed) return;
|
|
6049
|
+
void handleMessage(data).catch((error) => handlers?.onWarning?.(redactedRealtimeError(error)));
|
|
6050
|
+
},
|
|
6051
|
+
close(error) {
|
|
6052
|
+
closeConnection(id, error ?? "close");
|
|
6053
|
+
}
|
|
6054
|
+
}, args.headers === void 0 ? void 0 : { headers: args.headers });
|
|
6055
|
+
}
|
|
6056
|
+
function closeConnection(id, error) {
|
|
6057
|
+
if (id !== connectionId || stopped || authFailed) return;
|
|
6058
|
+
connectionId += 1;
|
|
6059
|
+
const socket = activeSocket;
|
|
6060
|
+
activeSocket = void 0;
|
|
6061
|
+
clearReconnectTimer();
|
|
6062
|
+
socket?.close();
|
|
6063
|
+
if (isRealtimeAuthError(error)) {
|
|
6064
|
+
authFailed = true;
|
|
6065
|
+
handlers?.onAuthFailed(redactedRealtimeError(error));
|
|
6066
|
+
return;
|
|
6067
|
+
}
|
|
6068
|
+
const delayMs = reconnectDelayMs;
|
|
6069
|
+
handlers?.onReconnect(delayMs, redactedRealtimeError(error));
|
|
6070
|
+
reconnectTimer = scheduleTimeout(() => {
|
|
6071
|
+
reconnectTimer = void 0;
|
|
6072
|
+
connectNow();
|
|
6073
|
+
}, delayMs);
|
|
6074
|
+
reconnectDelayMs = Math.min(delayMs * 2, 6e4);
|
|
6075
|
+
}
|
|
6076
|
+
async function handleMessage(data) {
|
|
6077
|
+
const message = parseDriveRealtimeMessage(data);
|
|
6078
|
+
if (message.type === "ready") {
|
|
6079
|
+
if (message.replayed > 0) {
|
|
6080
|
+
handlers?.onEvent({
|
|
6081
|
+
debounce_ms: 2e3,
|
|
6082
|
+
reason: "ready_replay",
|
|
6083
|
+
...message.cursor === void 0 ? {} : { cursor: message.cursor }
|
|
6084
|
+
});
|
|
6085
|
+
}
|
|
6086
|
+
if (message.cursor !== void 0) {
|
|
6087
|
+
await persistBestEffort({ ...currentRealtime, last_cursor: message.cursor });
|
|
6088
|
+
}
|
|
6089
|
+
return;
|
|
6090
|
+
}
|
|
6091
|
+
if (message.type === "library_changed") {
|
|
6092
|
+
handlers?.onEvent({
|
|
6093
|
+
debounce_ms: 2e3,
|
|
6094
|
+
reason: "library_changed",
|
|
6095
|
+
...message.cursor === void 0 ? {} : { cursor: message.cursor },
|
|
6096
|
+
...message.path === void 0 ? {} : { path: message.path }
|
|
6097
|
+
});
|
|
6098
|
+
if (message.cursor !== void 0) {
|
|
6099
|
+
await persistBestEffort({ ...currentRealtime, last_cursor: message.cursor, last_event_at: driveIsoTimestamp(clock) });
|
|
6100
|
+
}
|
|
6101
|
+
return;
|
|
6102
|
+
}
|
|
6103
|
+
if (message.type === "resync_required") {
|
|
6104
|
+
const reason = message.reason ?? "resync_required";
|
|
6105
|
+
handlers?.onEvent({
|
|
6106
|
+
immediate: true,
|
|
6107
|
+
reason,
|
|
6108
|
+
...message.cursor === void 0 ? {} : { cursor: message.cursor }
|
|
6109
|
+
});
|
|
6110
|
+
if (message.cursor !== void 0 || isInvalidCursorReason(reason)) {
|
|
6111
|
+
await persistBestEffort(resyncRealtimeState(currentRealtime, message.cursor, reason, driveIsoTimestamp(clock)));
|
|
6112
|
+
}
|
|
6113
|
+
return;
|
|
6114
|
+
}
|
|
6115
|
+
if (message.type === "error") {
|
|
6116
|
+
const error = message.message ?? message.code ?? "realtime error";
|
|
6117
|
+
if (isRealtimeAuthError(error) || isRealtimeAuthError(message.code)) {
|
|
6118
|
+
authFailed = true;
|
|
6119
|
+
connectionId += 1;
|
|
6120
|
+
clearReconnectTimer();
|
|
6121
|
+
handlers?.onAuthFailed(redactedRealtimeError(error));
|
|
6122
|
+
activeSocket?.close();
|
|
6123
|
+
activeSocket = void 0;
|
|
6124
|
+
return;
|
|
6125
|
+
}
|
|
6126
|
+
handlers?.onWarning?.(redactedRealtimeError(error));
|
|
6127
|
+
return;
|
|
6128
|
+
}
|
|
6129
|
+
handlers?.onWarning?.("unknown realtime message");
|
|
6130
|
+
}
|
|
6131
|
+
return {
|
|
6132
|
+
async start(nextHandlers) {
|
|
6133
|
+
handlers = nextHandlers;
|
|
6134
|
+
stopped = false;
|
|
6135
|
+
authFailed = false;
|
|
6136
|
+
connectNow();
|
|
6137
|
+
},
|
|
6138
|
+
async close() {
|
|
6139
|
+
stopped = true;
|
|
6140
|
+
clearReconnectTimer();
|
|
6141
|
+
activeSocket?.close();
|
|
6142
|
+
activeSocket = void 0;
|
|
6143
|
+
}
|
|
6144
|
+
};
|
|
6145
|
+
}
|
|
6146
|
+
function buildDriveRealtimeUrl(baseUrl, libraryId, realtime) {
|
|
6147
|
+
if (realtime.client_id.length === 0) {
|
|
6148
|
+
throw new Error("drive realtime client_id is required");
|
|
6149
|
+
}
|
|
6150
|
+
const url = new URL(baseUrl);
|
|
6151
|
+
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
|
|
6152
|
+
url.pathname = `/drive/libraries/${encodeURIComponent(libraryId)}/realtime`;
|
|
6153
|
+
url.search = "";
|
|
6154
|
+
url.hash = "";
|
|
6155
|
+
if (realtime.last_cursor !== void 0) {
|
|
6156
|
+
url.searchParams.set("cursor", realtime.last_cursor);
|
|
6157
|
+
}
|
|
6158
|
+
url.searchParams.set("client_id", realtime.client_id);
|
|
6159
|
+
return url;
|
|
6160
|
+
}
|
|
6161
|
+
function parseDriveRealtimeMessage(raw) {
|
|
6162
|
+
let value;
|
|
6163
|
+
try {
|
|
6164
|
+
value = JSON.parse(raw);
|
|
6165
|
+
} catch {
|
|
6166
|
+
return { type: "unknown" };
|
|
6167
|
+
}
|
|
6168
|
+
if (!isRecord3(value)) {
|
|
6169
|
+
return { type: "unknown" };
|
|
6170
|
+
}
|
|
6171
|
+
const messageType = typeof value.type === "string" ? value.type : void 0;
|
|
6172
|
+
const cursor = typeof value.cursor === "string" ? value.cursor : void 0;
|
|
6173
|
+
if (messageType === "ready") {
|
|
6174
|
+
return {
|
|
6175
|
+
type: "ready",
|
|
6176
|
+
replayed: typeof value.replayed === "number" ? value.replayed : 0,
|
|
6177
|
+
...cursor === void 0 ? {} : { cursor }
|
|
6178
|
+
};
|
|
6179
|
+
}
|
|
6180
|
+
if (messageType === "library_changed") {
|
|
6181
|
+
return {
|
|
6182
|
+
type: "library_changed",
|
|
6183
|
+
...cursor === void 0 ? {} : { cursor },
|
|
6184
|
+
...typeof value.path === "string" ? { path: value.path } : {}
|
|
6185
|
+
};
|
|
6186
|
+
}
|
|
6187
|
+
if (messageType === "resync_required") {
|
|
6188
|
+
return {
|
|
6189
|
+
type: "resync_required",
|
|
6190
|
+
...cursor === void 0 ? {} : { cursor },
|
|
6191
|
+
...typeof value.reason === "string" ? { reason: value.reason } : {}
|
|
6192
|
+
};
|
|
6193
|
+
}
|
|
6194
|
+
if (messageType === "error") {
|
|
6195
|
+
return {
|
|
6196
|
+
type: "error",
|
|
6197
|
+
...typeof value.code === "string" ? { code: value.code } : {},
|
|
6198
|
+
...typeof value.message === "string" ? { message: redactedRealtimeError(value.message) } : {}
|
|
6199
|
+
};
|
|
6200
|
+
}
|
|
6201
|
+
return {
|
|
6202
|
+
type: "unknown",
|
|
6203
|
+
...messageType === void 0 ? {} : { message_type: messageType }
|
|
6204
|
+
};
|
|
6205
|
+
}
|
|
6206
|
+
function redactedRealtimeError(error) {
|
|
6207
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
6208
|
+
const status = text.match(/\bHTTP\s+(401|403|429|5\d\d)\b/i);
|
|
6209
|
+
if (status?.[1] !== void 0) {
|
|
6210
|
+
return `HTTP ${status[1]}`;
|
|
6211
|
+
}
|
|
6212
|
+
if (/\bauth|authorization\b/i.test(text)) {
|
|
6213
|
+
return "auth failed";
|
|
6214
|
+
}
|
|
6215
|
+
if (/\bnetwork|fetch|close\b/i.test(text)) {
|
|
6216
|
+
return "network error";
|
|
6217
|
+
}
|
|
6218
|
+
return "realtime error";
|
|
6219
|
+
}
|
|
6220
|
+
function isRecord3(value) {
|
|
6221
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6222
|
+
}
|
|
6223
|
+
function resyncRealtimeState(realtime, cursor, reason, lastEventAt) {
|
|
6224
|
+
if (isInvalidCursorReason(reason)) {
|
|
6225
|
+
const { last_cursor: _lastCursor, ...next } = realtime;
|
|
6226
|
+
return { ...next, last_event_at: lastEventAt };
|
|
6227
|
+
}
|
|
6228
|
+
return {
|
|
6229
|
+
...realtime,
|
|
6230
|
+
last_event_at: lastEventAt,
|
|
6231
|
+
...cursor === void 0 ? {} : { last_cursor: cursor }
|
|
6232
|
+
};
|
|
6233
|
+
}
|
|
6234
|
+
function isInvalidCursorReason(reason) {
|
|
6235
|
+
return /\bcursor[_ -]?(invalid|expired|gone|missing|not[_ -]?found)\b|\binvalid[_ -]?cursor\b/i.test(reason);
|
|
6236
|
+
}
|
|
6237
|
+
function isRealtimeAuthError(error) {
|
|
6238
|
+
return /\b(401|403|auth|authorization|unauthorized|forbidden)\b/i.test(String(error));
|
|
6239
|
+
}
|
|
6240
|
+
function nativeWebSocketConnector(url, handlers, init) {
|
|
6241
|
+
const WebSocketWithInit = WebSocket;
|
|
6242
|
+
const ws = new WebSocketWithInit(url.toString(), init);
|
|
6243
|
+
let closed = false;
|
|
6244
|
+
let pendingError;
|
|
6245
|
+
const closeOnce = (error) => {
|
|
6246
|
+
if (closed) return;
|
|
6247
|
+
closed = true;
|
|
6248
|
+
handlers.close(error);
|
|
6249
|
+
};
|
|
6250
|
+
ws.addEventListener("open", () => handlers.open());
|
|
6251
|
+
ws.addEventListener("message", (event) => handlers.message(String(event.data)));
|
|
6252
|
+
ws.addEventListener("close", (event) => closeOnce(webSocketCloseError(event) ?? pendingError));
|
|
6253
|
+
ws.addEventListener("error", (event) => {
|
|
6254
|
+
pendingError = webSocketError(event);
|
|
6255
|
+
if (isRealtimeAuthError(pendingError)) {
|
|
6256
|
+
closeOnce(pendingError);
|
|
6257
|
+
}
|
|
6258
|
+
});
|
|
6259
|
+
return { close: () => ws.close() };
|
|
6260
|
+
}
|
|
6261
|
+
function webSocketCloseError(event) {
|
|
6262
|
+
if (event.code === 1e3) return void 0;
|
|
6263
|
+
if (event.code === 4001 || event.code === 4401) return new Error("HTTP 401");
|
|
6264
|
+
if (event.code === 4003 || event.code === 4403) return new Error("HTTP 403");
|
|
6265
|
+
if (event.reason && isRealtimeAuthError(event.reason)) return new Error(event.reason);
|
|
6266
|
+
if (event.reason) return new Error(event.reason);
|
|
6267
|
+
return new Error(`WebSocket close ${event.code}`);
|
|
6268
|
+
}
|
|
6269
|
+
function webSocketError(event) {
|
|
6270
|
+
if (typeof ErrorEvent !== "undefined" && event instanceof ErrorEvent) {
|
|
6271
|
+
const detail = event.error instanceof Error ? event.error.message : event.message;
|
|
6272
|
+
if (detail) return new Error(detail);
|
|
6273
|
+
}
|
|
6274
|
+
return new Error("network error");
|
|
6275
|
+
}
|
|
6276
|
+
|
|
6277
|
+
// src/handwritten/commands/drive/watch.ts
|
|
5890
6278
|
async function runDriveWatch(root, options = {}) {
|
|
5891
6279
|
const runSync = options.runSync ?? runDriveSyncOnce;
|
|
5892
6280
|
const debounceMs = options.debounceMs ?? 500;
|
|
6281
|
+
const remoteDebounceMs = options.remoteDebounceMs ?? 2e3;
|
|
5893
6282
|
const emit = options.onEvent ?? ((event) => render({ kind: "drive_watch", display: { shape: "object" } }, event));
|
|
5894
6283
|
let debounceTimer;
|
|
6284
|
+
let debounceDeadlineMs;
|
|
5895
6285
|
let retryTimer;
|
|
5896
6286
|
let resolveRetryTimer;
|
|
5897
6287
|
let running = false;
|
|
@@ -5919,6 +6309,7 @@ async function runDriveWatch(root, options = {}) {
|
|
|
5919
6309
|
} catch (error) {
|
|
5920
6310
|
if (isAuthError(error) || isFatalWatchError(error) || !isRetryableWatchError(error)) throw error;
|
|
5921
6311
|
emit({ kind: "drive_watch_retry", delay_ms: backoffMs, error: errorMessage2(error) });
|
|
6312
|
+
if (stopped) return;
|
|
5922
6313
|
await waitForManagedTimer(backoffMs);
|
|
5923
6314
|
if (stopped) return;
|
|
5924
6315
|
backoffMs = Math.min(backoffMs * 2, 6e4);
|
|
@@ -5933,6 +6324,27 @@ async function runDriveWatch(root, options = {}) {
|
|
|
5933
6324
|
if (debounceTimer === void 0) return;
|
|
5934
6325
|
clearTimeout(debounceTimer);
|
|
5935
6326
|
debounceTimer = void 0;
|
|
6327
|
+
debounceDeadlineMs = void 0;
|
|
6328
|
+
}
|
|
6329
|
+
function scheduleSync(delayMs) {
|
|
6330
|
+
if (running) {
|
|
6331
|
+
rerunRequested = true;
|
|
6332
|
+
return;
|
|
6333
|
+
}
|
|
6334
|
+
if (delayMs <= 0) {
|
|
6335
|
+
clearDebounceTimer();
|
|
6336
|
+
requestSync().catch(stopWithError);
|
|
6337
|
+
return;
|
|
6338
|
+
}
|
|
6339
|
+
const deadlineMs = Date.now() + delayMs;
|
|
6340
|
+
if (debounceTimer !== void 0 && debounceDeadlineMs !== void 0 && debounceDeadlineMs <= deadlineMs) return;
|
|
6341
|
+
clearDebounceTimer();
|
|
6342
|
+
debounceDeadlineMs = deadlineMs;
|
|
6343
|
+
debounceTimer = setTimeout(() => {
|
|
6344
|
+
debounceTimer = void 0;
|
|
6345
|
+
debounceDeadlineMs = void 0;
|
|
6346
|
+
requestSync().catch(stopWithError);
|
|
6347
|
+
}, delayMs);
|
|
5936
6348
|
}
|
|
5937
6349
|
function clearRetryTimer() {
|
|
5938
6350
|
if (retryTimer === void 0) return;
|
|
@@ -5954,26 +6366,61 @@ async function runDriveWatch(root, options = {}) {
|
|
|
5954
6366
|
}
|
|
5955
6367
|
function stopWithError(error) {
|
|
5956
6368
|
stopError = error;
|
|
6369
|
+
stopped = true;
|
|
5957
6370
|
clearDebounceTimer();
|
|
5958
6371
|
clearRetryTimer();
|
|
5959
6372
|
stopWatch?.();
|
|
5960
6373
|
}
|
|
5961
|
-
|
|
5962
|
-
|
|
6374
|
+
let source;
|
|
6375
|
+
let realtimeSource;
|
|
5963
6376
|
try {
|
|
6377
|
+
let state = await (options.readState ?? readDriveState)(root);
|
|
6378
|
+
realtimeSource = options.once ? void 0 : options.realtimeSource;
|
|
6379
|
+
if (!options.once && realtimeSource === void 0) {
|
|
6380
|
+
state = await ensureDriveRealtimeState(root);
|
|
6381
|
+
const { baseUrl, headers } = await loadRealtimeAuthHeaders({
|
|
6382
|
+
verifyPath: `/drive/libraries/${encodeURIComponent(state.library_id)}`
|
|
6383
|
+
});
|
|
6384
|
+
const realtime = state.realtime;
|
|
6385
|
+
if (realtime === void 0) {
|
|
6386
|
+
throw new Error("drive realtime state is required");
|
|
6387
|
+
}
|
|
6388
|
+
realtimeSource = createDriveRealtimeSource({
|
|
6389
|
+
baseUrl,
|
|
6390
|
+
headers,
|
|
6391
|
+
libraryId: state.library_id,
|
|
6392
|
+
realtime,
|
|
6393
|
+
writeRealtimeState: (next) => writeDriveRealtimeState(root, next)
|
|
6394
|
+
});
|
|
6395
|
+
}
|
|
6396
|
+
source = options.source ?? createChokidarSource(root);
|
|
5964
6397
|
source.onChange((path) => {
|
|
5965
6398
|
if (isDriveInternalPath(root, path)) return;
|
|
5966
|
-
|
|
5967
|
-
rerunRequested = true;
|
|
5968
|
-
return;
|
|
5969
|
-
}
|
|
5970
|
-
clearDebounceTimer();
|
|
5971
|
-
debounceTimer = setTimeout(() => {
|
|
5972
|
-
debounceTimer = void 0;
|
|
5973
|
-
requestSync().catch(stopWithError);
|
|
5974
|
-
}, debounceMs);
|
|
6399
|
+
scheduleSync(debounceMs);
|
|
5975
6400
|
});
|
|
5976
6401
|
emit({ kind: "drive_watch_started", root, library_id: state.library_id });
|
|
6402
|
+
realtimeSource?.start({
|
|
6403
|
+
onConnected() {
|
|
6404
|
+
emit({ kind: "drive_realtime_connected", library_id: state.library_id });
|
|
6405
|
+
},
|
|
6406
|
+
onEvent(event) {
|
|
6407
|
+
emit(realtimeEvent(event));
|
|
6408
|
+
if (event.immediate) {
|
|
6409
|
+
scheduleSync(0);
|
|
6410
|
+
return;
|
|
6411
|
+
}
|
|
6412
|
+
scheduleSync(event.debounce_ms ?? remoteDebounceMs);
|
|
6413
|
+
},
|
|
6414
|
+
onReconnect(delayMs, error) {
|
|
6415
|
+
emit({ kind: "drive_realtime_reconnecting", delay_ms: delayMs, error });
|
|
6416
|
+
},
|
|
6417
|
+
onAuthFailed(error) {
|
|
6418
|
+
emit({ kind: "drive_realtime_auth_failed", error: error ?? "auth failed" });
|
|
6419
|
+
},
|
|
6420
|
+
onWarning(warning) {
|
|
6421
|
+
emit({ kind: "drive_realtime_warning", warning });
|
|
6422
|
+
}
|
|
6423
|
+
}).catch(stopWithError);
|
|
5977
6424
|
await requestSync();
|
|
5978
6425
|
if (options.once) return;
|
|
5979
6426
|
if (stopError !== void 0) throw stopError;
|
|
@@ -5987,7 +6434,7 @@ async function runDriveWatch(root, options = {}) {
|
|
|
5987
6434
|
clearDebounceTimer();
|
|
5988
6435
|
clearRetryTimer();
|
|
5989
6436
|
cleanupSignalListeners();
|
|
5990
|
-
await source
|
|
6437
|
+
await Promise.all([source?.close(), realtimeSource?.close()]);
|
|
5991
6438
|
}
|
|
5992
6439
|
}
|
|
5993
6440
|
function driveWatchCommand(options = {}) {
|
|
@@ -6029,6 +6476,15 @@ function isRetryableWatchError(error) {
|
|
|
6029
6476
|
function errorMessage2(error) {
|
|
6030
6477
|
return error instanceof Error ? error.message : String(error);
|
|
6031
6478
|
}
|
|
6479
|
+
function realtimeEvent(event) {
|
|
6480
|
+
return {
|
|
6481
|
+
kind: "drive_realtime_event",
|
|
6482
|
+
message: "remote update received; syncing",
|
|
6483
|
+
...event.path === void 0 ? {} : { path: event.path },
|
|
6484
|
+
...event.reason === void 0 ? {} : { reason: event.reason },
|
|
6485
|
+
...event.cursor === void 0 ? {} : { cursor: event.cursor }
|
|
6486
|
+
};
|
|
6487
|
+
}
|
|
6032
6488
|
function waitForStopSignal(onRegistered) {
|
|
6033
6489
|
return new Promise((resolveStop) => {
|
|
6034
6490
|
const stop = () => resolveStop();
|