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