@lisang233/pi-sync 0.1.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,126 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Dirent } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import type { SyncConfig } from "./config.js";
6
+ import { agentDir, syncRootPath } from "./paths.js";
7
+
8
+ export const SNAPSHOT_VERSION = 1;
9
+ export const MAX_SYNC_FILE_BYTES = 16 * 1024 * 1024;
10
+ const MAX_SCAN_FILES = 5_000;
11
+
12
+ export interface SnapshotFile {
13
+ path: string;
14
+ sha256: string;
15
+ contentBase64: string;
16
+ }
17
+
18
+ export interface Snapshot {
19
+ version: number;
20
+ createdAt: string;
21
+ files: SnapshotFile[];
22
+ }
23
+
24
+ export function snapshotSha256(snapshot: Snapshot): string {
25
+ return createHash("sha256")
26
+ .update(Buffer.from(JSON.stringify(snapshot)))
27
+ .digest("hex");
28
+ }
29
+
30
+ export function fileHashMap(snapshot: Snapshot): Map<string, string> {
31
+ return new Map(snapshot.files.map((file) => [file.path, file.sha256]));
32
+ }
33
+
34
+ /** Build the current snapshot of the configured include paths under the agent dir. */
35
+ export async function createSnapshot(config: SyncConfig): Promise<Snapshot> {
36
+ const files: SnapshotFile[] = [];
37
+ const seen = new Set<string>();
38
+ for (const entry of config.include) {
39
+ const root = syncRootPath(entry);
40
+ await collectFiles(root, agentDir(), files, seen, entry);
41
+ }
42
+ files.sort((left, right) => left.path.localeCompare(right.path));
43
+ return { version: SNAPSHOT_VERSION, createdAt: new Date().toISOString(), files };
44
+ }
45
+
46
+ async function collectFiles(
47
+ root: string,
48
+ baseDir: string,
49
+ files: SnapshotFile[],
50
+ seen: Set<string>,
51
+ entry: string,
52
+ ): Promise<void> {
53
+ let stat: Awaited<ReturnType<typeof fs.lstat>>;
54
+ try {
55
+ stat = await fs.lstat(root);
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
58
+ throw error;
59
+ }
60
+ if (stat.isSymbolicLink()) return;
61
+ if (stat.isFile()) {
62
+ await collectFile(root, baseDir, files, seen, entry);
63
+ return;
64
+ }
65
+ if (!stat.isDirectory()) return;
66
+ await collectDirectory(root, baseDir, files, seen, entry);
67
+ }
68
+
69
+ async function collectDirectory(
70
+ directory: string,
71
+ baseDir: string,
72
+ files: SnapshotFile[],
73
+ seen: Set<string>,
74
+ entry: string,
75
+ ): Promise<void> {
76
+ let entries: Dirent[];
77
+ try {
78
+ entries = await fs.readdir(directory, { withFileTypes: true });
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
81
+ throw error;
82
+ }
83
+ entries.sort((left, right) => left.name.localeCompare(right.name));
84
+ for (const dirent of entries) {
85
+ if (files.length >= MAX_SCAN_FILES) return;
86
+ if (dirent.isSymbolicLink()) continue;
87
+ const child = path.join(directory, dirent.name);
88
+ if (dirent.isDirectory()) {
89
+ await collectDirectory(child, baseDir, files, seen, entry);
90
+ } else if (dirent.isFile()) {
91
+ await collectFile(child, baseDir, files, seen, entry);
92
+ }
93
+ }
94
+ }
95
+
96
+ async function collectFile(
97
+ filePath: string,
98
+ baseDir: string,
99
+ files: SnapshotFile[],
100
+ seen: Set<string>,
101
+ _entry: string,
102
+ ): Promise<void> {
103
+ const stat = await fs.stat(filePath);
104
+ if (stat.size > MAX_SYNC_FILE_BYTES) return;
105
+ const content = await fs.readFile(filePath);
106
+ if (content.includes(0)) return;
107
+ const relative = path.relative(baseDir, filePath).split(path.sep).join("/");
108
+ if (!relative || relative.startsWith("../") || seen.has(relative)) return;
109
+ seen.add(relative);
110
+ files.push({
111
+ path: relative,
112
+ sha256: sha256Buffer(content),
113
+ contentBase64: content.toString("base64"),
114
+ });
115
+ }
116
+
117
+ function sha256Buffer(content: Buffer): string {
118
+ return createHash("sha256").update(content).digest("hex");
119
+ }
120
+
121
+ /** Decode snapshot file content; returns undefined for unknown paths. */
122
+ export function snapshotFileContent(snapshot: Snapshot, filePath: string): string | undefined {
123
+ const file = snapshot.files.find((candidate) => candidate.path === filePath);
124
+ if (!file) return undefined;
125
+ return Buffer.from(file.contentBase64, "base64").toString("utf8");
126
+ }
package/src/state.ts ADDED
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { stateDir } from "./config.js";
4
+
5
+ const STATE_FILE_NAME = "state.json";
6
+ const STATE_VERSION = 1;
7
+
8
+ export interface SyncState {
9
+ version: number;
10
+ lastAppliedSnapshot: string;
11
+ lastRemoteRevision: string | undefined;
12
+ lastHashes: Record<string, string>;
13
+ }
14
+
15
+ export function stateFilePath(): string {
16
+ return path.join(stateDir(), STATE_FILE_NAME);
17
+ }
18
+
19
+ export async function loadState(): Promise<SyncState | undefined> {
20
+ let text: string;
21
+ try {
22
+ text = await fs.readFile(stateFilePath(), "utf8");
23
+ } catch (error) {
24
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
25
+ throw error;
26
+ }
27
+ let parsed: unknown;
28
+ try {
29
+ parsed = JSON.parse(text);
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ return parseState(parsed);
34
+ }
35
+
36
+ export async function saveState(state: SyncState): Promise<void> {
37
+ await fs.mkdir(stateDir(), { recursive: true });
38
+ const serialized = `${JSON.stringify(state, null, "\t")}\n`;
39
+ await fs.writeFile(stateFilePath(), serialized, { mode: 0o600 });
40
+ }
41
+
42
+ function parseState(value: unknown): SyncState | undefined {
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
44
+ const record = value as Record<string, unknown>;
45
+ if (record.version !== STATE_VERSION || typeof record.lastAppliedSnapshot !== "string") {
46
+ return undefined;
47
+ }
48
+ return {
49
+ version: STATE_VERSION,
50
+ lastAppliedSnapshot: record.lastAppliedSnapshot,
51
+ lastRemoteRevision:
52
+ typeof record.lastRemoteRevision === "string" ? record.lastRemoteRevision : undefined,
53
+ lastHashes: isHashMap(record.lastHashes) ? record.lastHashes : {},
54
+ };
55
+ }
56
+
57
+ function isHashMap(value: unknown): value is Record<string, string> {
58
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
59
+ return Object.values(value).every((item) => typeof item === "string");
60
+ }
package/src/status.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { planMerge } from "./merge.js";
2
+ import type { Snapshot } from "./snapshot.js";
3
+
4
+ export type SyncStateLabel =
5
+ | "unconfigured"
6
+ | "unknown"
7
+ | "up-to-date"
8
+ | "unpublished"
9
+ | "ahead"
10
+ | "behind"
11
+ | "conflict";
12
+
13
+ export interface SyncStatusInfo {
14
+ label: SyncStateLabel;
15
+ ahead: number;
16
+ behind: number;
17
+ conflicts: number;
18
+ }
19
+
20
+ /**
21
+ * Derive the sync state from the local snapshot, the remote snapshot, and the
22
+ * base snapshot (the last remote revision we applied). A missing remote means
23
+ * nothing has ever been published; divergent or two-sided changes are a
24
+ * conflict, one-sided changes are ahead (push) or behind (pull).
25
+ */
26
+ export function deriveSyncStatus(
27
+ local: Snapshot,
28
+ remote: Snapshot | undefined,
29
+ base: Snapshot | undefined,
30
+ ): SyncStatusInfo {
31
+ if (!remote) {
32
+ return { label: "unpublished", ahead: 0, behind: 0, conflicts: 0 };
33
+ }
34
+ const plan = planMerge(local, remote, base);
35
+ if (plan.conflicts.length > 0 || (plan.takeLocal.length > 0 && plan.takeRemote.length > 0)) {
36
+ return {
37
+ label: "conflict",
38
+ ahead: plan.takeLocal.length,
39
+ behind: plan.takeRemote.length,
40
+ conflicts: plan.conflicts.length,
41
+ };
42
+ }
43
+ if (plan.takeLocal.length > 0) {
44
+ return { label: "ahead", ahead: plan.takeLocal.length, behind: 0, conflicts: 0 };
45
+ }
46
+ if (plan.takeRemote.length > 0) {
47
+ return { label: "behind", ahead: 0, behind: plan.takeRemote.length, conflicts: 0 };
48
+ }
49
+ return { label: "up-to-date", ahead: 0, behind: 0, conflicts: 0 };
50
+ }
51
+
52
+ /** Short status-bar text for the sync indicator. */
53
+ export function syncIndicatorText(info: SyncStatusInfo): string {
54
+ switch (info.label) {
55
+ case "unconfigured":
56
+ return "sync: unconfigured";
57
+ case "unknown":
58
+ return "sync: unknown";
59
+ case "up-to-date":
60
+ return "sync: up-to-date";
61
+ case "unpublished":
62
+ return "sync: unpublished — push";
63
+ case "ahead":
64
+ return `sync: ${info.ahead} ahead — push`;
65
+ case "behind":
66
+ return `sync: ${info.behind} behind — pull`;
67
+ case "conflict":
68
+ return `sync: conflict (${info.conflicts}) — pull --merge or --force`;
69
+ }
70
+ }
package/src/wizard.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { DEFAULT_CONFIG, loadConfig, type SyncConfig, saveConfig } from "./config.js";
3
+ import { DEFAULT_INCLUDE } from "./paths.js";
4
+
5
+ const INCLUDE_CHOICES = [
6
+ "settings.json",
7
+ "keybindings.json",
8
+ "models.json",
9
+ "skills",
10
+ "prompts",
11
+ "themes",
12
+ "extensions",
13
+ "extension-settings",
14
+ ];
15
+
16
+ /**
17
+ * First-run wizard: collect the git remote, branch, included content, and the
18
+ * automatic-sync switch, then persist a single config file.
19
+ */
20
+ export async function runSetupWizard(ui: ExtensionUIContext): Promise<SyncConfig | undefined> {
21
+ const existing = await loadConfig();
22
+ const hasConfig = existing.remote.length > 0;
23
+
24
+ const remote = await ui.input("Git remote URL", "git@github.com:you/pi-sync.git or https://…");
25
+ if (remote === undefined) return undefined;
26
+ if (remote.trim().length === 0) {
27
+ ui.notify("pi-sync setup cancelled: a git remote is required.", "warning");
28
+ return undefined;
29
+ }
30
+
31
+ const branchInput = await ui.input("Remote branch", existing.branch || DEFAULT_CONFIG.branch);
32
+ const branch = branchInput?.trim() || existing.branch || DEFAULT_CONFIG.branch;
33
+
34
+ let include: string[];
35
+ if (hasConfig) {
36
+ const keep = await ui.confirm(
37
+ "Keep current included content?",
38
+ `Current: ${existing.include.join(", ") || "none"}\n\nChoose No to reselect.`,
39
+ );
40
+ include = keep ? [...existing.include] : await selectInclude(ui);
41
+ } else {
42
+ include = await selectInclude(ui);
43
+ }
44
+
45
+ const automatic = await ui.confirm(
46
+ "Enable automatic sync?",
47
+ "Sync in the background at session start and push on shutdown. You can change this later in pi-sync.json.",
48
+ );
49
+
50
+ const config: SyncConfig = {
51
+ remote: remote.trim(),
52
+ branch,
53
+ include,
54
+ automatic,
55
+ };
56
+ await saveConfig(config);
57
+ ui.notify(`pi-sync configured: ${config.remote} (branch ${config.branch}).`, "info");
58
+ return config;
59
+ }
60
+
61
+ async function selectInclude(ui: ExtensionUIContext): Promise<string[]> {
62
+ const selected: string[] = [];
63
+ for (const choice of INCLUDE_CHOICES) {
64
+ const enabled = await ui.confirm(
65
+ `Include ${choice}?`,
66
+ `Sync ${choice} between machines.${choice === "settings.json" ? " (recommended)" : ""}`,
67
+ );
68
+ if (enabled) selected.push(choice);
69
+ }
70
+ if (selected.length === 0) {
71
+ ui.notify("pi-sync setup cancelled: include at least one item.", "warning");
72
+ return [...DEFAULT_INCLUDE];
73
+ }
74
+ return selected;
75
+ }