@narumitw/pi-sync 0.25.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ import path from "node:path";
2
+ import { isDeniedPath, toPosix } from "./paths.js";
3
+
4
+ export const DEFAULT_SYNC_FILES = [
5
+ "settings.json",
6
+ "keybindings.json",
7
+ "models.json",
8
+ "AGENTS.md",
9
+ "APPEND_SYSTEM.md",
10
+ "skills",
11
+ "prompts",
12
+ "themes",
13
+ "extensions",
14
+ ] as const;
15
+
16
+ export type BuiltInSyncFile = (typeof DEFAULT_SYNC_FILES)[number];
17
+
18
+ const BUILT_IN_BY_LOWER = new Map<string, BuiltInSyncFile>(
19
+ DEFAULT_SYNC_FILES.map((fileName) => [fileName.toLowerCase(), fileName]),
20
+ );
21
+ const TOP_LEVEL_FILE_PATHS = new Map<string, string>(
22
+ DEFAULT_SYNC_FILES.filter((fileName) => fileName.includes(".")).map((fileName) => [
23
+ fileName.toLowerCase(),
24
+ fileName,
25
+ ]),
26
+ );
27
+ const TOP_LEVEL_FILE_NAMES = new Set<string>(TOP_LEVEL_FILE_PATHS.keys());
28
+ const TOP_LEVEL_DIRS = new Set<string>(
29
+ DEFAULT_SYNC_FILES.filter((fileName) => !fileName.includes(".")),
30
+ );
31
+ const RESERVED_TOP_LEVEL_NAMES = new Set<string>([...TOP_LEVEL_DIRS, "sessions"]);
32
+
33
+ export function normalizeSyncFiles(value: unknown): BuiltInSyncFile[] {
34
+ if (value === undefined) return [...DEFAULT_SYNC_FILES];
35
+ if (!Array.isArray(value)) throw new Error("syncFiles must be an array of built-in file names.");
36
+
37
+ const result: BuiltInSyncFile[] = [];
38
+ const seen = new Set<string>();
39
+ for (const item of value) {
40
+ if (typeof item !== "string") throw new Error("syncFiles items must be strings.");
41
+ const canonical = BUILT_IN_BY_LOWER.get(item.trim().toLowerCase());
42
+ if (!canonical) throw new Error(`Unknown syncFiles item: ${item}`);
43
+ const lower = canonical.toLowerCase();
44
+ if (seen.has(lower)) continue;
45
+ seen.add(lower);
46
+ result.push(canonical);
47
+ }
48
+ return result;
49
+ }
50
+
51
+ export function normalizeExtraFiles(value: unknown) {
52
+ if (!Array.isArray(value)) return [];
53
+ const seen = new Set<string>();
54
+ return value
55
+ .filter((item): item is string => typeof item === "string")
56
+ .map((item) => item.trim())
57
+ .filter((item) => {
58
+ const lower = item.toLowerCase();
59
+ if (
60
+ item === "" ||
61
+ item === "." ||
62
+ item === ".." ||
63
+ item.includes("/") ||
64
+ item.includes("\\") ||
65
+ TOP_LEVEL_FILE_NAMES.has(lower) ||
66
+ isDeniedPath(item) ||
67
+ RESERVED_TOP_LEVEL_NAMES.has(lower) ||
68
+ seen.has(lower)
69
+ ) {
70
+ return false;
71
+ }
72
+ seen.add(lower);
73
+ return true;
74
+ });
75
+ }
76
+
77
+ export function extraFilePathsByLower(value: unknown) {
78
+ return new Map(normalizeExtraFiles(value).map((fileName) => [fileName.toLowerCase(), fileName]));
79
+ }
80
+
81
+ export function selectedSyncFileSet(value: unknown) {
82
+ return new Set(normalizeSyncFiles(value));
83
+ }
84
+
85
+ export function isConfiguredSnapshotPath(
86
+ relativePath: string,
87
+ config: { syncFiles?: unknown; syncSessions: boolean },
88
+ extraFiles: Set<string>,
89
+ ) {
90
+ const normalized = toPosix(relativePath);
91
+ if (normalized.startsWith("sessions/")) return config.syncSessions;
92
+ const selected = selectedSyncFileSet(config.syncFiles);
93
+ if (!normalized.includes("/")) {
94
+ const lower = normalized.toLowerCase();
95
+ const builtIn = BUILT_IN_BY_LOWER.get(lower);
96
+ return builtIn ? selected.has(builtIn) && !TOP_LEVEL_DIRS.has(builtIn) : extraFiles.has(lower);
97
+ }
98
+ const topLevel = normalized.slice(0, normalized.indexOf("/"));
99
+ return selected.has(topLevel as BuiltInSyncFile) && TOP_LEVEL_DIRS.has(topLevel);
100
+ }
101
+
102
+ export function canonicalSnapshotPathForConfig(
103
+ relativePath: string,
104
+ extraFilePaths: Map<string, string>,
105
+ ) {
106
+ const normalized = toPosix(relativePath);
107
+ if (normalized.includes("/")) return normalized;
108
+ const lower = normalized.toLowerCase();
109
+ return TOP_LEVEL_FILE_PATHS.get(lower) ?? extraFilePaths.get(lower) ?? normalized;
110
+ }
111
+
112
+ export function isPreservableUnmanagedSnapshotPath(relativePath: string) {
113
+ const normalized = toPosix(relativePath);
114
+ if (!normalized || isDeniedPath(normalized)) return false;
115
+ if (normalized.startsWith("sessions/")) return normalized.endsWith(".jsonl");
116
+ if (!normalized.includes("/")) {
117
+ const lower = normalized.toLowerCase();
118
+ return TOP_LEVEL_FILE_NAMES.has(lower) || !RESERVED_TOP_LEVEL_NAMES.has(lower);
119
+ }
120
+ return TOP_LEVEL_DIRS.has(normalized.slice(0, normalized.indexOf("/")));
121
+ }
122
+
123
+ export function isSafeExtraFileName(fileName: string) {
124
+ return normalizeExtraFiles([fileName]).length === 1;
125
+ }
126
+
127
+ export function isBuiltInTopLevelFile(fileName: string) {
128
+ return TOP_LEVEL_FILE_NAMES.has(path.posix.basename(fileName).toLowerCase());
129
+ }
@@ -0,0 +1,167 @@
1
+ import { toPosix } from "./paths.js";
2
+ import {
3
+ canonicalSnapshotPathForConfig,
4
+ filterSnapshotForConfigPolicy,
5
+ isConfiguredSnapshotPath,
6
+ isSessionPath,
7
+ } from "./snapshot.js";
8
+ import { extraFilePathsByLower, normalizeExtraFiles, normalizeSyncFiles } from "./sync-policy.js";
9
+ import type { LatestPointer, RemoteObject, Snapshot, SyncConfig, SyncState } from "./types.js";
10
+
11
+ export function hasLocalChanges(local: Snapshot, state: SyncState, config: SyncConfig) {
12
+ return !sameHashes(fileHashMap(local), stateHashMapForConfig(state, config));
13
+ }
14
+
15
+ export function remoteChangedSinceState(
16
+ latest: RemoteObject<LatestPointer>,
17
+ state: SyncState,
18
+ config: SyncConfig,
19
+ ) {
20
+ if (latest.missing) return Boolean(state.lastAppliedSnapshot);
21
+ if (latest.value?.snapshot !== state.lastAppliedSnapshot) return true;
22
+ if (syncFilesChanged(state, config) || extraFilesChanged(state, config)) return true;
23
+ return config.syncSessions && state.syncSessions !== true && latest.value?.syncSessions === true;
24
+ }
25
+
26
+ export function hasRemoteChanges(
27
+ remote: Snapshot,
28
+ state: SyncState,
29
+ config: SyncConfig,
30
+ ignoredPaths = new Set<string>(),
31
+ ) {
32
+ if (remote.id === state.lastAppliedSnapshot && !syncPolicyChanged(state, config)) return false;
33
+ return !snapshotHashesMatchState(
34
+ filterSnapshotForConfigPolicy(remote, config),
35
+ state,
36
+ config,
37
+ ignoredPaths,
38
+ );
39
+ }
40
+
41
+ export function sameHashes(left: Record<string, string>, right: Record<string, string>) {
42
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
43
+ for (const key of keys) {
44
+ if (left[key] !== right[key]) return false;
45
+ }
46
+ return true;
47
+ }
48
+
49
+ export function fileHashMap(snapshot: Snapshot) {
50
+ return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
51
+ }
52
+
53
+ function stateHashMapForConfig(
54
+ state: SyncState,
55
+ config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
56
+ ) {
57
+ const extraFilePaths = extraFilePathsByLower(config.extraFiles);
58
+ const extraFiles = new Set(extraFilePaths.keys());
59
+ return Object.fromEntries(
60
+ Object.entries(state.lastFileHashes)
61
+ .filter(([filePath]) => isConfiguredSnapshotPath(filePath, config, extraFiles))
62
+ .map(([filePath, hash]) => [canonicalSnapshotPathForConfig(filePath, extraFilePaths), hash]),
63
+ );
64
+ }
65
+
66
+ export function snapshotHashesMatchState(
67
+ snapshot: Snapshot,
68
+ state: SyncState,
69
+ config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
70
+ ignoredPaths = new Set<string>(),
71
+ ) {
72
+ return sameHashes(
73
+ withoutHashPaths(fileHashMap(snapshot), ignoredPaths),
74
+ withoutHashPaths(stateHashMapForConfig(state, config), ignoredPaths),
75
+ );
76
+ }
77
+
78
+ export function snapshotsMatch(left: Snapshot, right: Snapshot) {
79
+ return (
80
+ left.syncSessions === right.syncSessions && sameHashes(fileHashMap(left), fileHashMap(right))
81
+ );
82
+ }
83
+
84
+ function withoutHashPaths(hashes: Record<string, string>, ignoredPaths: Set<string>) {
85
+ if (ignoredPaths.size === 0) return hashes;
86
+ return Object.fromEntries(
87
+ Object.entries(hashes).filter(([filePath]) => !ignoredPaths.has(toPosix(filePath))),
88
+ );
89
+ }
90
+
91
+ export function syncPolicyChanged(
92
+ state: SyncState,
93
+ config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
94
+ ) {
95
+ return (
96
+ syncFilesChanged(state, config) ||
97
+ (state.syncSessions ?? false) !== config.syncSessions ||
98
+ extraFilesChanged(state, config)
99
+ );
100
+ }
101
+
102
+ export function shouldRefreshSyncedState(
103
+ remote: Snapshot,
104
+ state: SyncState,
105
+ config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
106
+ ) {
107
+ return remote.id !== state.lastAppliedSnapshot || syncPolicyChanged(state, config);
108
+ }
109
+
110
+ function syncFilesChanged(state: SyncState, config: Pick<SyncConfig, "syncFiles">) {
111
+ return !sameStringSet(normalizeSyncFiles(state.syncFiles), normalizeSyncFiles(config.syncFiles));
112
+ }
113
+
114
+ function extraFilesChanged(state: SyncState, config: Pick<SyncConfig, "extraFiles">) {
115
+ return !sameStringSet(
116
+ normalizeExtraFiles(state.extraFiles),
117
+ normalizeExtraFiles(config.extraFiles),
118
+ );
119
+ }
120
+
121
+ function sameStringSet(left: string[], right: string[]) {
122
+ const leftSet = new Set(left);
123
+ const rightSet = new Set(right);
124
+ if (leftSet.size !== rightSet.size) return false;
125
+ return [...leftSet].every((item) => rightSet.has(item));
126
+ }
127
+
128
+ export function settingsHashMap(snapshot: Snapshot) {
129
+ return Object.fromEntries(
130
+ snapshot.files
131
+ .filter((file) => !isSessionPath(file.path))
132
+ .map((file) => [file.path, file.sha256]),
133
+ );
134
+ }
135
+
136
+ export function sessionHashMap(snapshot: Snapshot) {
137
+ return Object.fromEntries(
138
+ snapshot.files
139
+ .filter((file) => isSessionPath(file.path))
140
+ .map((file) => [file.path, file.sha256]),
141
+ );
142
+ }
143
+
144
+ export function settingsHashMapFromState(state: SyncState) {
145
+ return Object.fromEntries(
146
+ Object.entries(state.lastFileHashes).filter(([filePath]) => !isSessionPath(filePath)),
147
+ );
148
+ }
149
+
150
+ export function settingsHashesMatchState(remote: Snapshot, state: SyncState) {
151
+ return sameHashes(settingsHashMap(remote), settingsHashMapFromState(state));
152
+ }
153
+
154
+ export function canPullRemoteSettingsOnFirstSync(local: Snapshot, remote: Snapshot) {
155
+ const remoteSettings = settingsHashMap(remote);
156
+ return Object.entries(settingsHashMap(local)).every(
157
+ ([filePath, hash]) => remoteSettings[filePath] === hash,
158
+ );
159
+ }
160
+
161
+ export function canPullRemoteSessionsOnFirstSync(local: Snapshot, remote: Snapshot) {
162
+ const localSessions = sessionHashMap(local);
163
+ const remoteSessions = sessionHashMap(remote);
164
+ return Object.entries(localSessions).every(
165
+ ([filePath, hash]) => remoteSessions[filePath] === hash,
166
+ );
167
+ }