@narumitw/pi-sync 0.35.0 → 0.38.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 +14 -2
- package/package.json +2 -1
- package/src/file-selection.ts +88 -73
- package/src/manager-state.ts +155 -0
- package/src/manager-ui.ts +230 -289
- package/src/settings-ui.ts +103 -145
- package/src/storage-connections-ui.ts +157 -83
- package/src/sync-setups-ui.ts +186 -99
package/README.md
CHANGED
|
@@ -52,7 +52,11 @@ Automatic sync: On
|
|
|
52
52
|
Remote status: Not checked
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
Primary actions include **Sync now**, **Switch sync setup**, **Status & changes**, **Settings**, and
|
|
55
|
+
Primary actions include **Sync now**, **Switch sync setup**, **Status & changes**, **Settings**, and
|
|
56
|
+
**More…**. The standard manager also owns setup and connection lists/details plus history and
|
|
57
|
+
recovery. Secondary screens provide **Back**; Escape goes back and Ctrl+C closes the flow.
|
|
58
|
+
Destructive, credential-bearing, and externally visible operations retain exact specialized previews
|
|
59
|
+
and confirmations.
|
|
56
60
|
|
|
57
61
|
## ⚙️ Settings
|
|
58
62
|
|
|
@@ -165,6 +169,12 @@ Safe agent-relative custom files or directories may also be included. Absolute p
|
|
|
165
169
|
|
|
166
170
|
An empty array is valid. It means no useful transfer is selected: **Sync now** reports the condition and does not claim that the setup is up to date. Unselected content remains unmanaged locally and is preserved when republishing existing remote snapshots.
|
|
167
171
|
|
|
172
|
+
The Included Content editor is a standard bounded multi-select backed by one in-memory draft. Toggles
|
|
173
|
+
never write settings. Leaving the editor opens an exact Include/Exclude review with **Save changes**,
|
|
174
|
+
**Discard changes**, and **Continue editing**; only reviewed Save publishes, while Continue preserves
|
|
175
|
+
the draft and Discard/cancellation preserves the settings bytes. RPC remains a read-only summary with
|
|
176
|
+
the manual `sync.include` path.
|
|
177
|
+
|
|
168
178
|
Adding `sessions` requires a privacy acknowledgement in interactive flows. Session JSONL can contain prompts, tool output, file paths, images, and secrets. Automatic apply protects the currently open session file; restart Pi or resume a pulled session to use newly synchronized conversations.
|
|
169
179
|
|
|
170
180
|
### Unsupported old settings and recovery
|
|
@@ -209,7 +219,9 @@ The menu is preferred, while deterministic routes remain available:
|
|
|
209
219
|
|
|
210
220
|
The former version 2 setup-addressing flag is rejected. Unknown flags, unknown commands, trailing values, and missing setup/snapshot values are rejected. Completion includes known setup names and preserves preceding command tokens.
|
|
211
221
|
|
|
212
|
-
TUI mode
|
|
222
|
+
TUI mode uses standard manager, settings, resource, and included-content screens plus specialized
|
|
223
|
+
secret, wizard, confirmation, and commit-aware loader components. RPC uses Pi's supported
|
|
224
|
+
dialog/notification protocol and does not gain settings mutation. Print and JSON modes never enter TUI-only screens; unsupported interactive routes reject or remain protocol-safe rather than relying on no-op output.
|
|
213
225
|
|
|
214
226
|
## 🔄 Backend and recovery model
|
|
215
227
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Pi extension that syncs Pi configuration through Git, WebDAV, Cloudflare R2, or S3-compatible storage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"typecheck": "tsc --noEmit"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"@narumitw/pi-tui-kit": "<1",
|
|
39
40
|
"fast-xml-parser": "^5.10.1",
|
|
40
41
|
"proper-lockfile": "^4.1.2"
|
|
41
42
|
},
|
package/src/file-selection.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
4
|
-
import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
|
|
5
4
|
import { agentDir, loadConfig, localConfigPath } from "./config.js";
|
|
6
5
|
import { updateSyncSetup } from "./settings-management.js";
|
|
7
6
|
import {
|
|
@@ -10,8 +9,6 @@ import {
|
|
|
10
9
|
syncIncludeSelection,
|
|
11
10
|
} from "./sync-policy.js";
|
|
12
11
|
|
|
13
|
-
const INCLUDED = "included";
|
|
14
|
-
const EXCLUDED = "excluded";
|
|
15
12
|
const BUILT_IN_PREFIX = "builtin:";
|
|
16
13
|
const CUSTOM_PREFIX = "custom:";
|
|
17
14
|
const SESSIONS_ID = "sessions";
|
|
@@ -53,11 +50,7 @@ export async function showFileSelection(
|
|
|
53
50
|
while (!signal?.aborted) {
|
|
54
51
|
await showDraftEditor(ctx, config.setupName, draft, customCandidates, signal);
|
|
55
52
|
if (signal?.aborted || sameDraft(original, draft)) return;
|
|
56
|
-
const choice = await ctx
|
|
57
|
-
formatDraftPreview(original, draft),
|
|
58
|
-
["Save changes", "Discard changes", "Continue editing"],
|
|
59
|
-
{ signal },
|
|
60
|
-
);
|
|
53
|
+
const choice = await showDraftReview(ctx, original, draft, signal);
|
|
61
54
|
if (signal?.aborted) return;
|
|
62
55
|
if (choice === "Continue editing") continue;
|
|
63
56
|
if (choice !== "Save changes") {
|
|
@@ -114,74 +107,96 @@ async function showDraftEditor(
|
|
|
114
107
|
customCandidates: string[],
|
|
115
108
|
signal?: AbortSignal,
|
|
116
109
|
) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
110
|
+
const menu = defineMenu<undefined, "editor", "toggle", ExtensionCommandContext>({
|
|
111
|
+
start: "editor",
|
|
112
|
+
screens: {
|
|
113
|
+
editor: () => ({
|
|
114
|
+
kind: "multiSelect",
|
|
115
|
+
title: `Included Content · ${safeTerminalText(setupName)}`,
|
|
116
|
+
lines: ["Draft only · leaving this screen opens Save, Discard, or Continue editing."],
|
|
117
|
+
viewportSize: 12,
|
|
118
|
+
items: [
|
|
119
|
+
...BUILT_IN_SYNC_ROOTS.map((relativePath) => ({
|
|
120
|
+
id: `${BUILT_IN_PREFIX}${relativePath}`,
|
|
121
|
+
label: relativePath,
|
|
122
|
+
description: relativePath.includes(".")
|
|
123
|
+
? `Sync the top-level ${relativePath} file when present.`
|
|
124
|
+
: `Recursively sync every safe file under ${relativePath}/.`,
|
|
125
|
+
selected: draft.builtIns.has(relativePath),
|
|
126
|
+
})),
|
|
127
|
+
...customCandidates.map((relativePath) => ({
|
|
128
|
+
id: `${CUSTOM_PREFIX}${relativePath}`,
|
|
129
|
+
label: safeTerminalText(relativePath),
|
|
130
|
+
description: "Additional safe agent-relative file or directory.",
|
|
131
|
+
selected: draft.custom.has(relativePath),
|
|
132
|
+
})),
|
|
133
|
+
{
|
|
134
|
+
id: SESSIONS_ID,
|
|
135
|
+
label: "sessions",
|
|
136
|
+
description:
|
|
137
|
+
"Session JSONL may contain prompts, tool output, paths, images, and secrets. Sync only to storage you trust.",
|
|
138
|
+
selected: draft.sessions,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
action: "toggle",
|
|
142
|
+
hint: "close",
|
|
143
|
+
doneLabel: "Review changes",
|
|
144
|
+
}),
|
|
134
145
|
},
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
currentValue: draft.custom.has(relativePath) ? INCLUDED : EXCLUDED,
|
|
140
|
-
values: [INCLUDED, EXCLUDED],
|
|
141
|
-
})),
|
|
142
|
-
];
|
|
143
|
-
|
|
144
|
-
await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
145
|
-
const container = new Container();
|
|
146
|
-
let disposed = false;
|
|
147
|
-
const onAbort = () => done(undefined);
|
|
148
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
149
|
-
const title = new Text("", 1, 0);
|
|
150
|
-
const hint = new Text("", 1, 0);
|
|
151
|
-
const updateChrome = () => {
|
|
152
|
-
title.setText(
|
|
153
|
-
theme.fg("accent", theme.bold(`Included Content · ${safeTerminalText(setupName)}`)),
|
|
154
|
-
);
|
|
155
|
-
hint.setText(theme.fg("dim", "Draft only · Esc reviews Save, Discard, or Continue editing."));
|
|
156
|
-
};
|
|
157
|
-
updateChrome();
|
|
158
|
-
container.addChild(title);
|
|
159
|
-
const list = new SettingsList(
|
|
160
|
-
items,
|
|
161
|
-
Math.min(items.length + 2, 15),
|
|
162
|
-
getSettingsListTheme(),
|
|
163
|
-
(id, newValue) => updateDraft(draft, id, newValue === INCLUDED),
|
|
164
|
-
() => done(undefined),
|
|
165
|
-
);
|
|
166
|
-
container.addChild(list);
|
|
167
|
-
container.addChild(hint);
|
|
168
|
-
return {
|
|
169
|
-
render: (width: number) => container.render(width),
|
|
170
|
-
invalidate() {
|
|
171
|
-
updateChrome();
|
|
172
|
-
container.invalidate();
|
|
173
|
-
},
|
|
174
|
-
handleInput(data: string) {
|
|
175
|
-
if (disposed) return;
|
|
176
|
-
list.handleInput(data);
|
|
177
|
-
tui.requestRender();
|
|
146
|
+
actions: {
|
|
147
|
+
toggle: async ({ itemId, selected }) => {
|
|
148
|
+
updateDraft(draft, itemId, selected === true);
|
|
149
|
+
return { kind: "stay" };
|
|
178
150
|
},
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
await runMenu(ctx, menu, {
|
|
154
|
+
getState: () => undefined,
|
|
155
|
+
signal,
|
|
156
|
+
isCurrent: () => !signal?.aborted,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function showDraftReview(
|
|
161
|
+
ctx: ExtensionCommandContext,
|
|
162
|
+
original: SelectionDraft,
|
|
163
|
+
draft: SelectionDraft,
|
|
164
|
+
signal?: AbortSignal,
|
|
165
|
+
) {
|
|
166
|
+
let choice: "Save changes" | "Discard changes" | "Continue editing" | undefined;
|
|
167
|
+
const menu = defineMenu<undefined, "review", "choose", ExtensionCommandContext>({
|
|
168
|
+
start: "review",
|
|
169
|
+
screens: {
|
|
170
|
+
review: () => ({
|
|
171
|
+
kind: "actions",
|
|
172
|
+
title: "Review included-content changes",
|
|
173
|
+
lines: formatDraftPreview(original, draft).split("\n").slice(2),
|
|
174
|
+
items: [
|
|
175
|
+
{ id: "save", label: "Save changes", action: "choose" },
|
|
176
|
+
{ id: "discard", label: "Discard changes", action: "choose" },
|
|
177
|
+
{ id: "continue", label: "Continue editing", action: "choose" },
|
|
178
|
+
],
|
|
179
|
+
hint: "close",
|
|
180
|
+
}),
|
|
181
|
+
},
|
|
182
|
+
actions: {
|
|
183
|
+
choose: async ({ itemId }) => {
|
|
184
|
+
choice =
|
|
185
|
+
itemId === "save"
|
|
186
|
+
? "Save changes"
|
|
187
|
+
: itemId === "continue"
|
|
188
|
+
? "Continue editing"
|
|
189
|
+
: "Discard changes";
|
|
190
|
+
return { kind: "close" };
|
|
182
191
|
},
|
|
183
|
-
}
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
await runMenu(ctx, menu, {
|
|
195
|
+
getState: () => undefined,
|
|
196
|
+
signal,
|
|
197
|
+
isCurrent: () => !signal?.aborted,
|
|
184
198
|
});
|
|
199
|
+
return choice;
|
|
185
200
|
}
|
|
186
201
|
|
|
187
202
|
function updateDraft(draft: SelectionDraft, id: string, included: boolean) {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {
|
|
2
|
+
activeLocalConfigPath,
|
|
3
|
+
isCloudflareR2Endpoint,
|
|
4
|
+
loadConfig,
|
|
5
|
+
readLocalConfigObject,
|
|
6
|
+
readStateForConfig,
|
|
7
|
+
} from "./config.js";
|
|
8
|
+
import { inspectLock, isStaleLock } from "./lock.js";
|
|
9
|
+
import { errorMessage, ownRecord, safeTerminalText } from "./manager-helpers.js";
|
|
10
|
+
import { syncIncludeSelection } from "./sync-policy.js";
|
|
11
|
+
import { countValidSyncSetups } from "./sync-setups-ui.js";
|
|
12
|
+
import type { AnySyncConfig } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export const MAIN_MENU_ACTIONS = [
|
|
15
|
+
"Sync now (recommended)",
|
|
16
|
+
"Switch sync setup",
|
|
17
|
+
"Status & changes",
|
|
18
|
+
"Settings",
|
|
19
|
+
"More…",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export async function describeManagerState(
|
|
23
|
+
signal?: AbortSignal,
|
|
24
|
+
): Promise<{ title: string; actions: string[] }> {
|
|
25
|
+
let raw: Record<string, unknown> | undefined;
|
|
26
|
+
try {
|
|
27
|
+
raw = await readLocalConfigObject();
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return {
|
|
30
|
+
title: [
|
|
31
|
+
"Manage sync",
|
|
32
|
+
"",
|
|
33
|
+
"Settings file needs repair. Automatic sync and settings writes are paused.",
|
|
34
|
+
`Error: ${safeTerminalText(errorMessage(error))}`,
|
|
35
|
+
`File: ${safeTerminalText(await activeLocalConfigPath())}`,
|
|
36
|
+
"",
|
|
37
|
+
"Repair the JSON file, then reopen /sync.",
|
|
38
|
+
].join("\n"),
|
|
39
|
+
actions: ["Help"],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (!raw) {
|
|
43
|
+
return {
|
|
44
|
+
title: ["Manage sync", "", "Not set up.", "", "What do you want to do?"].join("\n"),
|
|
45
|
+
actions: ["Set up sync", "Help"],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const configuredTargets = ownRecord(raw.syncSetups);
|
|
49
|
+
if (raw.version === 3 && configuredTargets && Object.keys(configuredTargets).length === 0) {
|
|
50
|
+
return {
|
|
51
|
+
title: [
|
|
52
|
+
"Manage sync",
|
|
53
|
+
"",
|
|
54
|
+
"No sync setups are configured.",
|
|
55
|
+
"Add a sync setup using an existing storage connection.",
|
|
56
|
+
"",
|
|
57
|
+
"What do you want to do?",
|
|
58
|
+
].join("\n"),
|
|
59
|
+
actions: ["Sync setups…", "Storage connections…", "Help"],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const config = await loadConfig();
|
|
64
|
+
const lock = await inspectLock();
|
|
65
|
+
const liveLock = lock.status === "valid" && !isStaleLock(lock.lock);
|
|
66
|
+
const recoverableLock =
|
|
67
|
+
lock.status === "unreadable" || (lock.status === "valid" && isStaleLock(lock.lock));
|
|
68
|
+
const selection = syncIncludeSelection(config.include);
|
|
69
|
+
const noSyncedContent = config.include.length === 0;
|
|
70
|
+
const syncState =
|
|
71
|
+
liveLock || recoverableLock
|
|
72
|
+
? undefined
|
|
73
|
+
: await readStateForConfig(config).catch(() => undefined);
|
|
74
|
+
const lastAppliedSnapshot =
|
|
75
|
+
liveLock || recoverableLock
|
|
76
|
+
? "Unavailable while operations are locked"
|
|
77
|
+
: syncState?.lastAppliedSnapshot
|
|
78
|
+
? safeTerminalText(syncState.lastAppliedSnapshot)
|
|
79
|
+
: syncState
|
|
80
|
+
? "Never synced"
|
|
81
|
+
: "Unavailable";
|
|
82
|
+
const canSwitch = (await countValidSyncSetups(configuredTargets, signal)) > 1;
|
|
83
|
+
const mainActions = MAIN_MENU_ACTIONS.filter(
|
|
84
|
+
(action) => action !== "Switch sync setup" || canSwitch,
|
|
85
|
+
);
|
|
86
|
+
return {
|
|
87
|
+
title: [
|
|
88
|
+
"Manage sync",
|
|
89
|
+
"",
|
|
90
|
+
`Current sync setup: ${safeTerminalText(config.setupName)}`,
|
|
91
|
+
`Storage: ${backendStorageDescription(config)}`,
|
|
92
|
+
`Included: ${selection.builtIns.length} built-in group${selection.builtIns.length === 1 ? "" : "s"} · ${selection.custom.length} extra path${selection.custom.length === 1 ? "" : "s"} · Sessions ${selection.sessions ? "on" : "off"}`,
|
|
93
|
+
`Automatic sync: ${config.automatic ? "On" : "Off"}`,
|
|
94
|
+
`Last applied: ${lastAppliedSnapshot}`,
|
|
95
|
+
"Remote status: Not checked",
|
|
96
|
+
...(noSyncedContent
|
|
97
|
+
? [
|
|
98
|
+
"",
|
|
99
|
+
"No included content is selected. Choose included content in Settings before syncing.",
|
|
100
|
+
]
|
|
101
|
+
: []),
|
|
102
|
+
...(liveLock
|
|
103
|
+
? [
|
|
104
|
+
"",
|
|
105
|
+
`Operation in progress: ${safeTerminalText(lock.lock.command)} (pid ${lock.lock.pid}). Sync and settings changes are disabled.`,
|
|
106
|
+
]
|
|
107
|
+
: []),
|
|
108
|
+
...(recoverableLock
|
|
109
|
+
? ["", "Recovery required: lock metadata is stale or unreadable."]
|
|
110
|
+
: []),
|
|
111
|
+
"",
|
|
112
|
+
"What do you want to do?",
|
|
113
|
+
].join("\n"),
|
|
114
|
+
actions:
|
|
115
|
+
liveLock || recoverableLock
|
|
116
|
+
? ["Status & changes", "History & recovery…", "Help"]
|
|
117
|
+
: noSyncedContent
|
|
118
|
+
? ["Settings", ...(canSwitch ? ["Switch sync setup"] : []), "Status & changes", "More…"]
|
|
119
|
+
: mainActions,
|
|
120
|
+
};
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (signal?.aborted) throw error;
|
|
123
|
+
return {
|
|
124
|
+
title: [
|
|
125
|
+
"Manage sync",
|
|
126
|
+
"",
|
|
127
|
+
"Settings need attention. Automatic sync is paused.",
|
|
128
|
+
`Current sync setup: ${safeTerminalText(typeof raw.activeSyncSetup === "string" ? raw.activeSyncSetup : "none")}`,
|
|
129
|
+
`Error: ${safeTerminalText(errorMessage(error))}`,
|
|
130
|
+
`File: ${safeTerminalText(await activeLocalConfigPath())}`,
|
|
131
|
+
"",
|
|
132
|
+
"What do you want to do?",
|
|
133
|
+
].join("\n"),
|
|
134
|
+
actions: ["Sync setups…", "Storage connections…", "History & recovery…", "Help"],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function backendStorageDescription(config: AnySyncConfig) {
|
|
140
|
+
const connection = safeTerminalText(config.connectionName);
|
|
141
|
+
switch (config.backend.type) {
|
|
142
|
+
case "s3": {
|
|
143
|
+
const type =
|
|
144
|
+
config.backend.profile.kind === "r2" ||
|
|
145
|
+
isCloudflareR2Endpoint(config.backend.profile.endpoint)
|
|
146
|
+
? "Cloudflare R2"
|
|
147
|
+
: "S3-compatible";
|
|
148
|
+
return `${type} · ${connection} · ${safeTerminalText(config.backend.destination.bucket)}`;
|
|
149
|
+
}
|
|
150
|
+
case "webdav":
|
|
151
|
+
return `WebDAV · ${connection} · ${safeTerminalText(config.backend.destination.path)}`;
|
|
152
|
+
case "git":
|
|
153
|
+
return `Git · ${connection} · ${safeTerminalText(config.backend.destination.branch)}`;
|
|
154
|
+
}
|
|
155
|
+
}
|