@narumitw/pi-sync 0.28.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.
- package/README.md +227 -142
- package/package.json +1 -1
- package/src/command.ts +102 -7
- package/src/config.ts +292 -33
- package/src/file-selection.ts +158 -70
- package/src/manager-ui.ts +961 -0
- package/src/s3-client.ts +47 -13
- package/src/settings-management.ts +307 -0
- package/src/snapshot-apply.ts +6 -6
- package/src/snapshot-transaction.ts +201 -0
- package/src/sync-format.ts +168 -0
- package/src/sync.ts +217 -136
- package/src/types.ts +43 -0
package/src/file-selection.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
|
|
5
5
|
import {
|
|
6
6
|
agentDir,
|
|
7
|
+
deprecatedPiSyncEnvironmentWarnings,
|
|
7
8
|
isExplicitlyEnabled,
|
|
8
9
|
loadPartialConfig,
|
|
9
10
|
localConfigPath,
|
|
@@ -22,28 +23,77 @@ const BUILT_IN_PREFIX = "builtin:";
|
|
|
22
23
|
const EXTRA_PREFIX = "extra:";
|
|
23
24
|
const SESSIONS_ID = "sessions";
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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);
|
|
29
40
|
const sessionEnvironmentOverride = Object.hasOwn(process.env, "PI_SYNC_SESSIONS");
|
|
30
|
-
const
|
|
31
|
-
const extraCandidates = await listExtraFileCandidates(selectedExtras);
|
|
41
|
+
const extraCandidates = await listExtraFileCandidates(draft.extras);
|
|
32
42
|
|
|
33
43
|
if (ctx.mode !== "tui") {
|
|
34
44
|
ctx.ui.notify(
|
|
35
45
|
[
|
|
36
|
-
|
|
37
|
-
`built-ins: ${[...
|
|
38
|
-
`sessions: ${
|
|
39
|
-
`extra files: ${[...
|
|
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"}`,
|
|
40
50
|
`Edit syncFiles, syncSessions, and extraFiles in ${safeTerminalText(localConfigPath())}.`,
|
|
51
|
+
...deprecatedPiSyncEnvironmentWarnings(),
|
|
41
52
|
].join("\n"),
|
|
42
53
|
"info",
|
|
43
54
|
);
|
|
44
55
|
return;
|
|
45
56
|
}
|
|
46
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
|
+
) {
|
|
47
97
|
const items: SettingItem[] = [
|
|
48
98
|
...DEFAULT_SYNC_FILES.map((fileName) => ({
|
|
49
99
|
id: `${BUILT_IN_PREFIX}${fileName}`,
|
|
@@ -51,18 +101,18 @@ export async function showFileSelection(ctx: ExtensionCommandContext) {
|
|
|
51
101
|
description: fileName.includes(".")
|
|
52
102
|
? `Sync the top-level ${fileName} file when present.`
|
|
53
103
|
: `Recursively sync every safe file under ${fileName}/.`,
|
|
54
|
-
currentValue:
|
|
104
|
+
currentValue: draft.builtIns.has(fileName) ? INCLUDED : EXCLUDED,
|
|
55
105
|
values: [INCLUDED, EXCLUDED],
|
|
56
106
|
})),
|
|
57
107
|
{
|
|
58
108
|
id: SESSIONS_ID,
|
|
59
109
|
label: "sessions",
|
|
60
110
|
description: sessionEnvironmentOverride
|
|
61
|
-
? "Read-only
|
|
111
|
+
? "Read-only because deprecated PI_SYNC_SESSIONS currently overrides this target. Move it into target settings before the future major removal."
|
|
62
112
|
: "Session JSONL may contain prompts, tool output, paths, images, and secrets. Sync only to storage you trust.",
|
|
63
113
|
currentValue: sessionEnvironmentOverride
|
|
64
|
-
? `${
|
|
65
|
-
:
|
|
114
|
+
? `${draft.sessions ? INCLUDED : EXCLUDED} (environment, deprecated)`
|
|
115
|
+
: draft.sessions
|
|
66
116
|
? INCLUDED
|
|
67
117
|
: EXCLUDED,
|
|
68
118
|
...(sessionEnvironmentOverride ? {} : { values: [INCLUDED, EXCLUDED] }),
|
|
@@ -72,59 +122,33 @@ export async function showFileSelection(ctx: ExtensionCommandContext) {
|
|
|
72
122
|
label: safeTerminalText(fileName),
|
|
73
123
|
description:
|
|
74
124
|
"Additional safe top-level file. It may be absent locally and pulled from another machine.",
|
|
75
|
-
currentValue:
|
|
125
|
+
currentValue: draft.extras.has(fileName) ? INCLUDED : EXCLUDED,
|
|
76
126
|
values: [INCLUDED, EXCLUDED],
|
|
77
127
|
})),
|
|
78
128
|
];
|
|
79
129
|
|
|
80
|
-
let saveQueue = Promise.resolve();
|
|
81
|
-
let nextOwner = 0;
|
|
82
|
-
const owners = new Map<string, number>();
|
|
83
|
-
let closed = false;
|
|
84
|
-
|
|
85
130
|
await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
86
131
|
const container = new Container();
|
|
87
132
|
const title = new Text("", 1, 0);
|
|
88
133
|
const hint = new Text("", 1, 0);
|
|
89
134
|
const updateChrome = () => {
|
|
90
|
-
title.setText(
|
|
91
|
-
|
|
92
|
-
theme.fg("dim", "Changes save immediately and apply to the next manual or automatic sync."),
|
|
135
|
+
title.setText(
|
|
136
|
+
theme.fg("accent", theme.bold(`Synced Content · ${safeTerminalText(targetName)}`)),
|
|
93
137
|
);
|
|
138
|
+
hint.setText(theme.fg("dim", "Draft only · Esc reviews Save, Discard, or Continue editing."));
|
|
94
139
|
};
|
|
95
140
|
updateChrome();
|
|
96
141
|
container.addChild(title);
|
|
97
|
-
|
|
98
|
-
let settingsList: SettingsList;
|
|
99
|
-
const queueChange = (id: string, newValue: string) => {
|
|
100
|
-
const previousValue = newValue === INCLUDED ? EXCLUDED : INCLUDED;
|
|
101
|
-
const owner = ++nextOwner;
|
|
102
|
-
owners.set(id, owner);
|
|
103
|
-
const save = async () => {
|
|
104
|
-
try {
|
|
105
|
-
await persistSelectionChange(id, newValue === INCLUDED);
|
|
106
|
-
} catch (error) {
|
|
107
|
-
if (owners.get(id) === owner && !closed) {
|
|
108
|
-
settingsList.updateValue(id, previousValue);
|
|
109
|
-
tui.requestRender();
|
|
110
|
-
}
|
|
111
|
-
ctx.ui.notify(`Could not save pi-sync file selection: ${errorMessage(error)}`, "error");
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
saveQueue = saveQueue.then(save, save);
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
settingsList = new SettingsList(
|
|
142
|
+
const settingsList = new SettingsList(
|
|
118
143
|
items,
|
|
119
144
|
Math.min(items.length + 2, 15),
|
|
120
145
|
getSettingsListTheme(),
|
|
121
|
-
|
|
146
|
+
(id, newValue) => updateDraft(draft, id, newValue === INCLUDED),
|
|
122
147
|
() => done(undefined),
|
|
123
148
|
{ enableSearch: true },
|
|
124
149
|
);
|
|
125
150
|
container.addChild(settingsList);
|
|
126
151
|
container.addChild(hint);
|
|
127
|
-
|
|
128
152
|
return {
|
|
129
153
|
render(width: number) {
|
|
130
154
|
return container.render(width);
|
|
@@ -139,8 +163,70 @@ export async function showFileSelection(ctx: ExtensionCommandContext) {
|
|
|
139
163
|
},
|
|
140
164
|
};
|
|
141
165
|
});
|
|
142
|
-
|
|
143
|
-
|
|
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");
|
|
144
230
|
}
|
|
145
231
|
|
|
146
232
|
async function listExtraFileCandidates(configured: Set<string>) {
|
|
@@ -158,28 +244,30 @@ async function listExtraFileCandidates(configured: Set<string>) {
|
|
|
158
244
|
return [...candidates.values()].sort((left, right) => left.localeCompare(right));
|
|
159
245
|
}
|
|
160
246
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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;
|
|
183
271
|
}
|
|
184
272
|
|
|
185
273
|
function safeTerminalText(value: string) {
|