@narumitw/pi-sync 0.31.0 → 0.35.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 +159 -236
- package/package.json +11 -3
- package/src/backend-factory.ts +20 -0
- package/src/command.ts +39 -39
- package/src/config-errors.ts +7 -0
- package/src/config-file.ts +156 -62
- package/src/config.ts +605 -476
- package/src/file-selection.ts +121 -140
- package/src/git-backend.ts +832 -0
- package/src/git-config.ts +136 -0
- package/src/git-runner.ts +276 -0
- package/src/git-storage.ts +288 -0
- package/src/git-ui.ts +307 -0
- package/src/manager-helpers.ts +64 -0
- package/src/manager-ui.ts +537 -542
- package/src/s3-backend.ts +498 -0
- package/src/s3-client.ts +147 -64
- package/src/s3-credentials-ui.ts +92 -0
- package/src/secret-input.ts +199 -0
- package/src/settings-management.ts +191 -277
- package/src/settings-ui.ts +94 -115
- package/src/setup-switch.ts +150 -0
- package/src/snapshot-apply.ts +13 -7
- package/src/snapshot-codec.ts +48 -0
- package/src/snapshot-transaction.ts +3 -1
- package/src/snapshot.ts +63 -32
- package/src/storage-connections-ui.ts +308 -0
- package/src/sync-backend.ts +97 -0
- package/src/sync-format.ts +31 -33
- package/src/sync-operations.ts +912 -0
- package/src/sync-policy.ts +137 -61
- package/src/sync-setups-ui.ts +177 -0
- package/src/sync-state.ts +48 -48
- package/src/sync.ts +180 -739
- package/src/types.ts +177 -53
- package/src/webdav-backend.ts +678 -0
- package/src/webdav-client.ts +390 -0
- package/src/webdav-config.ts +92 -0
- package/src/webdav-ui.ts +421 -0
- package/src/target-switch.ts +0 -119
package/src/file-selection.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
16
|
+
const CUSTOM_PREFIX = "custom:";
|
|
24
17
|
const SESSIONS_ID = "sessions";
|
|
25
18
|
|
|
26
19
|
interface SelectionDraft {
|
|
27
20
|
builtIns: Set<string>;
|
|
28
|
-
|
|
21
|
+
custom: Set<string>;
|
|
29
22
|
sessions: boolean;
|
|
30
23
|
}
|
|
31
24
|
|
|
32
|
-
export async function showFileSelection(
|
|
33
|
-
|
|
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(
|
|
36
|
-
|
|
37
|
-
sessions:
|
|
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
|
|
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
|
|
47
|
-
`
|
|
48
|
-
`
|
|
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 (
|
|
59
|
-
await showDraftEditor(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
draft,
|
|
63
|
-
|
|
64
|
-
|
|
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 (
|
|
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("
|
|
64
|
+
ctx.ui.notify("Included-content changes discarded.", "info");
|
|
75
65
|
return;
|
|
76
66
|
}
|
|
77
67
|
try {
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
112
|
+
setupName: string,
|
|
93
113
|
draft: SelectionDraft,
|
|
94
|
-
|
|
95
|
-
|
|
114
|
+
customCandidates: string[],
|
|
115
|
+
signal?: AbortSignal,
|
|
96
116
|
) {
|
|
97
117
|
const items: SettingItem[] = [
|
|
98
|
-
...
|
|
99
|
-
id: `${BUILT_IN_PREFIX}${
|
|
100
|
-
label:
|
|
101
|
-
description:
|
|
102
|
-
? `Sync the top-level ${
|
|
103
|
-
: `Recursively sync every safe file under ${
|
|
104
|
-
currentValue: draft.builtIns.has(
|
|
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
|
-
"
|
|
125
|
-
currentValue: draft.
|
|
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(`
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
214
|
-
for (const item of
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
232
|
-
const candidates = new Map([...configured].map((
|
|
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() || !
|
|
236
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
}
|