@narumitw/pi-sync 0.25.0 → 0.29.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,280 @@
1
+ import fs from "node:fs/promises";
2
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
4
+ import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
5
+ import {
6
+ agentDir,
7
+ deprecatedPiSyncEnvironmentWarnings,
8
+ isExplicitlyEnabled,
9
+ loadPartialConfig,
10
+ localConfigPath,
11
+ updateLocalConfig,
12
+ } from "./config.js";
13
+ import {
14
+ DEFAULT_SYNC_FILES,
15
+ isSafeExtraFileName,
16
+ normalizeExtraFiles,
17
+ normalizeSyncFiles,
18
+ } from "./sync-policy.js";
19
+
20
+ const INCLUDED = "included";
21
+ const EXCLUDED = "excluded";
22
+ const BUILT_IN_PREFIX = "builtin:";
23
+ const EXTRA_PREFIX = "extra:";
24
+ const SESSIONS_ID = "sessions";
25
+
26
+ interface SelectionDraft {
27
+ builtIns: Set<string>;
28
+ extras: Set<string>;
29
+ sessions: boolean;
30
+ }
31
+
32
+ export async function showFileSelection(ctx: ExtensionCommandContext, targetName?: string) {
33
+ const partial = await loadPartialConfig(targetName);
34
+ const original: SelectionDraft = {
35
+ builtIns: new Set(normalizeSyncFiles(partial.syncFiles)),
36
+ extras: new Set(normalizeExtraFiles(partial.extraFiles)),
37
+ sessions: isExplicitlyEnabled(partial.syncSessions),
38
+ };
39
+ const draft = cloneDraft(original);
40
+ const sessionEnvironmentOverride = Object.hasOwn(process.env, "PI_SYNC_SESSIONS");
41
+ const extraCandidates = await listExtraFileCandidates(draft.extras);
42
+
43
+ if (ctx.mode !== "tui") {
44
+ ctx.ui.notify(
45
+ [
46
+ `pi-sync selected files for target ${safeTerminalText(partial.target ?? "default")}:`,
47
+ `built-ins: ${[...draft.builtIns].join(", ") || "none"}`,
48
+ `sessions: ${draft.sessions ? INCLUDED : EXCLUDED}${sessionEnvironmentOverride ? " (PI_SYNC_SESSIONS, deprecated)" : ""}`,
49
+ `extra files: ${[...draft.extras].map(safeTerminalText).join(", ") || "none"}`,
50
+ `Edit syncFiles, syncSessions, and extraFiles in ${safeTerminalText(localConfigPath())}.`,
51
+ ...deprecatedPiSyncEnvironmentWarnings(),
52
+ ].join("\n"),
53
+ "info",
54
+ );
55
+ return;
56
+ }
57
+
58
+ while (true) {
59
+ await showDraftEditor(
60
+ ctx,
61
+ partial.target ?? "default",
62
+ draft,
63
+ extraCandidates,
64
+ sessionEnvironmentOverride,
65
+ );
66
+ if (sameDraft(original, draft)) return;
67
+ const choice = await ctx.ui.select(formatDraftPreview(original, draft), [
68
+ "Save changes",
69
+ "Discard changes",
70
+ "Continue editing",
71
+ ]);
72
+ if (choice === "Continue editing") continue;
73
+ if (choice !== "Save changes") {
74
+ ctx.ui.notify("Synced-content changes discarded.", "info");
75
+ return;
76
+ }
77
+ try {
78
+ await persistSelectionDraft(draft, targetName, sessionEnvironmentOverride);
79
+ ctx.ui.notify(
80
+ `Saved synced content for target “${safeTerminalText(partial.target ?? "default")}”. It applies to the next manual or automatic sync.`,
81
+ "info",
82
+ );
83
+ } catch (error) {
84
+ ctx.ui.notify(`Could not save pi-sync file selection: ${errorMessage(error)}`, "error");
85
+ }
86
+ return;
87
+ }
88
+ }
89
+
90
+ async function showDraftEditor(
91
+ ctx: ExtensionCommandContext,
92
+ targetName: string,
93
+ draft: SelectionDraft,
94
+ extraCandidates: string[],
95
+ sessionEnvironmentOverride: boolean,
96
+ ) {
97
+ const items: SettingItem[] = [
98
+ ...DEFAULT_SYNC_FILES.map((fileName) => ({
99
+ id: `${BUILT_IN_PREFIX}${fileName}`,
100
+ label: fileName,
101
+ description: fileName.includes(".")
102
+ ? `Sync the top-level ${fileName} file when present.`
103
+ : `Recursively sync every safe file under ${fileName}/.`,
104
+ currentValue: draft.builtIns.has(fileName) ? INCLUDED : EXCLUDED,
105
+ values: [INCLUDED, EXCLUDED],
106
+ })),
107
+ {
108
+ id: SESSIONS_ID,
109
+ label: "sessions",
110
+ description: sessionEnvironmentOverride
111
+ ? "Read-only because deprecated PI_SYNC_SESSIONS currently overrides this target. Move it into target settings before the future major removal."
112
+ : "Session JSONL may contain prompts, tool output, paths, images, and secrets. Sync only to storage you trust.",
113
+ currentValue: sessionEnvironmentOverride
114
+ ? `${draft.sessions ? INCLUDED : EXCLUDED} (environment, deprecated)`
115
+ : draft.sessions
116
+ ? INCLUDED
117
+ : EXCLUDED,
118
+ ...(sessionEnvironmentOverride ? {} : { values: [INCLUDED, EXCLUDED] }),
119
+ },
120
+ ...extraCandidates.map((fileName) => ({
121
+ id: `${EXTRA_PREFIX}${fileName}`,
122
+ label: safeTerminalText(fileName),
123
+ description:
124
+ "Additional safe top-level file. It may be absent locally and pulled from another machine.",
125
+ currentValue: draft.extras.has(fileName) ? INCLUDED : EXCLUDED,
126
+ values: [INCLUDED, EXCLUDED],
127
+ })),
128
+ ];
129
+
130
+ await ctx.ui.custom((tui, theme, _keybindings, done) => {
131
+ const container = new Container();
132
+ const title = new Text("", 1, 0);
133
+ const hint = new Text("", 1, 0);
134
+ const updateChrome = () => {
135
+ title.setText(
136
+ theme.fg("accent", theme.bold(`Synced Content · ${safeTerminalText(targetName)}`)),
137
+ );
138
+ hint.setText(theme.fg("dim", "Draft only · Esc reviews Save, Discard, or Continue editing."));
139
+ };
140
+ updateChrome();
141
+ container.addChild(title);
142
+ const settingsList = new SettingsList(
143
+ items,
144
+ Math.min(items.length + 2, 15),
145
+ getSettingsListTheme(),
146
+ (id, newValue) => updateDraft(draft, id, newValue === INCLUDED),
147
+ () => done(undefined),
148
+ { enableSearch: true },
149
+ );
150
+ container.addChild(settingsList);
151
+ container.addChild(hint);
152
+ return {
153
+ render(width: number) {
154
+ return container.render(width);
155
+ },
156
+ invalidate() {
157
+ updateChrome();
158
+ container.invalidate();
159
+ },
160
+ handleInput(data: string) {
161
+ settingsList.handleInput(data);
162
+ tui.requestRender();
163
+ },
164
+ };
165
+ });
166
+ }
167
+
168
+ function updateDraft(draft: SelectionDraft, id: string, included: boolean) {
169
+ if (id.startsWith(BUILT_IN_PREFIX)) {
170
+ const fileName = id.slice(BUILT_IN_PREFIX.length);
171
+ if (included) draft.builtIns.add(fileName);
172
+ else draft.builtIns.delete(fileName);
173
+ return;
174
+ }
175
+ if (id.startsWith(EXTRA_PREFIX)) {
176
+ const fileName = id.slice(EXTRA_PREFIX.length);
177
+ if (included) draft.extras.add(fileName);
178
+ else draft.extras.delete(fileName);
179
+ return;
180
+ }
181
+ if (id === SESSIONS_ID) {
182
+ draft.sessions = included;
183
+ return;
184
+ }
185
+ throw new Error(`Unknown file selection: ${id}`);
186
+ }
187
+
188
+ async function persistSelectionDraft(
189
+ draft: SelectionDraft,
190
+ targetName: string | undefined,
191
+ sessionEnvironmentOverride: boolean,
192
+ ) {
193
+ await updateLocalConfig((current) => {
194
+ const selection = {
195
+ syncFiles: DEFAULT_SYNC_FILES.filter((candidate) => draft.builtIns.has(candidate)),
196
+ ...(!sessionEnvironmentOverride ? { syncSessions: draft.sessions } : {}),
197
+ extraFiles: [...draft.extras],
198
+ };
199
+ if (current.version !== 2) return { ...current, ...selection };
200
+ const targets = asObject(current.targets);
201
+ const selectedTarget =
202
+ targetName ?? (typeof current.activeTarget === "string" ? current.activeTarget : undefined);
203
+ if (!targets || !selectedTarget) throw new Error("Current sync target is not configured.");
204
+ const target = asObject(targets[selectedTarget]);
205
+ if (!target) throw new Error(`Sync target not found: ${selectedTarget}`);
206
+ return {
207
+ ...current,
208
+ targets: { ...targets, [selectedTarget]: { ...target, ...selection } },
209
+ };
210
+ });
211
+ }
212
+
213
+ function formatDraftPreview(original: SelectionDraft, draft: SelectionDraft) {
214
+ const lines = ["Review synced-content changes", ""];
215
+ for (const item of DEFAULT_SYNC_FILES) {
216
+ if (original.builtIns.has(item) === draft.builtIns.has(item)) continue;
217
+ lines.push(`${draft.builtIns.has(item) ? "Include" : "Exclude"}: ${item}`);
218
+ }
219
+ for (const item of new Set([...original.extras, ...draft.extras])) {
220
+ if (original.extras.has(item) === draft.extras.has(item)) continue;
221
+ lines.push(`${draft.extras.has(item) ? "Include" : "Exclude"}: ${safeTerminalText(item)}`);
222
+ }
223
+ if (original.sessions !== draft.sessions) {
224
+ lines.push(`${draft.sessions ? "Include" : "Exclude"}: sessions`);
225
+ if (draft.sessions)
226
+ lines.push("Warning: sessions may contain prompts, tool output, images, and secrets.");
227
+ }
228
+ lines.push("", "Saving does not start a network sync.");
229
+ return lines.join("\n");
230
+ }
231
+
232
+ async function listExtraFileCandidates(configured: Set<string>) {
233
+ const candidates = new Map([...configured].map((fileName) => [fileName.toLowerCase(), fileName]));
234
+ try {
235
+ for (const entry of await fs.readdir(agentDir(), { withFileTypes: true })) {
236
+ if (!entry.isFile() || !isSafeExtraFileName(entry.name)) continue;
237
+ if (!candidates.has(entry.name.toLowerCase())) {
238
+ candidates.set(entry.name.toLowerCase(), entry.name);
239
+ }
240
+ }
241
+ } catch (error) {
242
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
243
+ }
244
+ return [...candidates.values()].sort((left, right) => left.localeCompare(right));
245
+ }
246
+
247
+ function cloneDraft(value: SelectionDraft): SelectionDraft {
248
+ return {
249
+ builtIns: new Set(value.builtIns),
250
+ extras: new Set(value.extras),
251
+ sessions: value.sessions,
252
+ };
253
+ }
254
+
255
+ function sameDraft(left: SelectionDraft, right: SelectionDraft) {
256
+ return (
257
+ left.sessions === right.sessions &&
258
+ sameSet(left.builtIns, right.builtIns) &&
259
+ sameSet(left.extras, right.extras)
260
+ );
261
+ }
262
+
263
+ function sameSet(left: Set<string>, right: Set<string>) {
264
+ return left.size === right.size && [...left].every((item) => right.has(item));
265
+ }
266
+
267
+ function asObject(value: unknown): Record<string, unknown> | undefined {
268
+ return value && typeof value === "object" && !Array.isArray(value)
269
+ ? (value as Record<string, unknown>)
270
+ : undefined;
271
+ }
272
+
273
+ function safeTerminalText(value: string) {
274
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Escape untrusted terminal controls.
275
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "?");
276
+ }
277
+
278
+ function errorMessage(error: unknown) {
279
+ return safeTerminalText(error instanceof Error ? error.message : String(error));
280
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./sync.js";
package/src/lock.ts CHANGED
@@ -82,7 +82,7 @@ export async function withLock<T>(command: string, fn: () => Promise<T>): Promis
82
82
  const inspection = await inspectLock();
83
83
  if (inspection.status === "valid" && isStaleLock(inspection.lock)) {
84
84
  throw new Error(
85
- `pi-sync lock is stale (pid ${inspection.lock.pid}). Run /pisync unlock --stale, then retry.`,
85
+ `pi-sync lock is stale (pid ${inspection.lock.pid}). Run /sync unlock --stale, then retry.`,
86
86
  );
87
87
  }
88
88
  if (inspection.status === "valid") {
@@ -92,7 +92,7 @@ export async function withLock<T>(command: string, fn: () => Promise<T>): Promis
92
92
  }
93
93
  if (inspection.status === "unreadable") {
94
94
  throw new Error(
95
- "pi-sync lock metadata is unreadable. Run /pisync unlock --stale after verifying no sync is running.",
95
+ "pi-sync lock metadata is unreadable. Run /sync unlock --stale after verifying no sync is running.",
96
96
  );
97
97
  }
98
98
 
@@ -208,7 +208,7 @@ async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptio
208
208
  if (inspection.status === "unreadable") {
209
209
  if (!options.stale) {
210
210
  ctx.ui.notify(
211
- "Pi-sync lock metadata is unreadable. Use /pisync unlock --stale only after verifying no sync is running.",
211
+ "Pi-sync lock metadata is unreadable. Use /sync unlock --stale only after verifying no sync is running.",
212
212
  "warning",
213
213
  );
214
214
  return;