@gfgghgftgffg/amox-pi-sync 0.1.1 → 0.1.2
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/MAINTENANCE.md +2 -2
- package/README.md +2 -2
- package/README.zh-CN.md +3 -3
- package/index.ts +130 -76
- package/package.json +1 -1
package/MAINTENANCE.md
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
- Upstream: [MuratAitov/pi-sync](https://github.com/MuratAitov/pi-sync), npm `pi-sync-suite@0.1.1`.
|
|
6
6
|
- Source ref: `npm:pi-sync-suite@0.1.1` (published source baseline, not the repository's moving HEAD).
|
|
7
7
|
- Upstream MIT notice is preserved verbatim in `LICENSE`: Copyright (c) 2026 Murat.
|
|
8
|
-
- Local package: `@gfgghgftgffg/amox-pi-sync@0.1.
|
|
8
|
+
- Local package: `@gfgghgftgffg/amox-pi-sync@0.1.2`, standalone TypeScript entrypoint `index.ts` and `core.ts`; no runtime dependency on the original suite.
|
|
9
9
|
|
|
10
10
|
## Scope
|
|
11
11
|
|
|
12
12
|
Keep raw agent-data push/pull, safe relative include/exclude paths (sessions + missions by default), manual/background automatic sync, backups/restoration and diagnostics. No configuration sync/settings merging, Markdown/JSON exports, Environment Tools or Local Packages. Cleanup is retained only for manually deleting local backup directories (Preview, Run, Delete Selected Backup, Retention); never sessions/missions, with no automatic deletion or archive restoration. Default cleanup candidates are complete backups older than 180 days or beyond the newest 20; counting individual files is not the retention unit. No three-line widget: optional Starline capsule/details, with `/sync-status` fallback.
|
|
13
13
|
|
|
14
|
-
Settings are stored in `amox-pi-sync.json`; legacy `pi-sync-suite.json` is read only as a migration fallback until new settings are saved. Backups use `amox-pi-sync-backups` and require manual retention management via Cleanup. Manual/off startup must not access the network; auto startup fingerprints locally then starts timers and a non-awaited background pull. Setup only saves configuration and stops timers, requiring reload to activate auto mode. The automatic loop must not block the UI. Migration uses an independent `amox-pi-sync-repo`, not the old snapshot directory. The `pi-sync` status key preserves existing Starline placement; the pill bridge takes priority over legacy command mapping. `off` disables automation, not explicit commands.
|
|
14
|
+
Settings are stored in `amox-pi-sync.json`; legacy `pi-sync-suite.json` is read only as a migration fallback until new settings are saved. Backups use `amox-pi-sync-backups` and require manual retention management via Cleanup. Manual/off startup must not access the network; auto startup fingerprints locally then starts timers and a non-awaited background pull. Setup only saves configuration and stops timers, requiring reload to activate auto mode. The automatic loop must not block the UI. Migration uses an independent `amox-pi-sync-repo`, not the old snapshot directory. The `pi-sync` status key preserves existing Starline placement; the pill bridge takes priority over legacy command mapping. `off` disables automation, not explicit commands. Presentation uses semantic Pi theme colours for labels, values and errors, and bold targets without fixed foreground/background colours so Starline owns selection contrast. Keep Manual/Auto/Off and Push/Pull terminology consistent across capsule, details, menus and command feedback; never append idle to the mode. Recompute styles when rendering, keep RPC/native status text plain, and do not store styled strings in settings. Keep large diagnostic results in the action notification rather than copying JSON dumps into status details.
|
|
15
15
|
|
|
16
16
|
Safety requirements: private SSH remote, no working-directory mutation, no JSONL merging, preserve local-only files, back up replacements and never overwrite active sessions. Safe-path validation must exclude configuration and implementation data even when users select arbitrary relative data paths. Do not claim encryption, conflict-free multi-device merging or automatic secret removal.
|
|
17
17
|
|
package/README.md
CHANGED
|
@@ -26,14 +26,14 @@ pi update --extensions
|
|
|
26
26
|
| Command | Purpose |
|
|
27
27
|
| --- | --- |
|
|
28
28
|
| `/sync-setup git@host:owner/private-repo.git [interval]` | Configure an SSH repository and optional pull interval in minutes (no transfer) |
|
|
29
|
-
| `/sync-push` |
|
|
29
|
+
| `/sync-push` | Push selected raw agent data |
|
|
30
30
|
| `/sync-pull` | Pull selected raw agent data |
|
|
31
31
|
| `/sync-settings` | Open settings and maintenance actions |
|
|
32
32
|
| `/sync-status` | View status and actions; also works without Starline |
|
|
33
33
|
|
|
34
34
|
The settings menu contains **View Status**, **Sync Mode** (`auto`, `manual`, `off`), **Sync Paths** (include/exclude safe relative agent-data paths), **Backups** (create/list/restore), **Diagnostics** (doctor/diff/log), and **Cleanup** (Preview, Run, Delete Selected Backup, Retention). `sessions` and `missions` are the default paths. Manual push and pull commands remain available in both `manual` and `off` modes.
|
|
35
35
|
|
|
36
|
-
Manual/off startup performs no network access. Auto startup computes a local fingerprint, starts timers and launches a background pull without awaiting network completion. `/sync-setup` only saves configuration and stops timers; reload to activate auto mode if enabled. With Starline installed, use its clickable status capsule to open details and actions: check status,
|
|
36
|
+
Manual/off startup performs no network access. Auto startup computes a local fingerprint, starts timers and launches a background pull without awaiting network completion. `/sync-setup` only saves configuration and stops timers; reload to activate auto mode if enabled. With Starline installed, use its clickable status capsule to open details and actions: check status, push, pull and settings. The status key remains `pi-sync` for existing Starline placement; the pill bridge takes precedence over legacy `extensionStatuses.commands`, with no Starline configuration changes required. There is no three-line widget. `/sync-status`, pill clicks, settings, submenus and confirmations share centered Starline bubbles above the footer; text entry still uses Pi input dialogs. Labels and values use Pi theme colours, while bold action rows keep Starline’s selection contrast. State shows Manual, Auto or Off directly; active operations use Push/Pull, with the configured mode shown separately. Without Starline, the native UI remains available and status output stays plain text.
|
|
37
37
|
|
|
38
38
|
## Data and safety
|
|
39
39
|
|
package/README.zh-CN.md
CHANGED
|
@@ -26,14 +26,14 @@ pi update --extensions
|
|
|
26
26
|
| 命令 | 用途 |
|
|
27
27
|
| --- | --- |
|
|
28
28
|
| `/sync-setup git@host:owner/private-repo.git [interval]` | 配置 SSH 仓库及可选拉取间隔(分钟),不传输数据 |
|
|
29
|
-
| `/sync-push` |
|
|
30
|
-
| `/sync-pull` |
|
|
29
|
+
| `/sync-push` | Push 选中的原始 agent 数据 |
|
|
30
|
+
| `/sync-pull` | Pull 选中的原始 agent 数据 |
|
|
31
31
|
| `/sync-settings` | 打开设置与维护操作 |
|
|
32
32
|
| `/sync-status` | 查看状态和操作;未安装 Starline 时也可使用 |
|
|
33
33
|
|
|
34
34
|
设置菜单保留 **View Status**、**Sync Mode**(`auto`、`manual`、`off`)、**Sync Paths**(包含/排除任意安全的 agent 数据相对路径)、**Backups**(创建/列表/恢复)、**Diagnostics**(doctor/diff/log)和 **Cleanup**(Preview、Run、Delete Selected Backup、Retention)。默认路径为 `sessions` 和 `missions`。`manual` 和 `off` 模式仍可手动上传、拉取。
|
|
35
35
|
|
|
36
|
-
manual/off 启动不访问网络。auto 启动先计算本地 fingerprint,再启动定时器并发起后台拉取,不等待网络完成。`/sync-setup` 只保存配置并停止定时器;已启用 auto 时需 reload 激活。安装 Starline
|
|
36
|
+
manual/off 启动不访问网络。auto 启动先计算本地 fingerprint,再启动定时器并发起后台拉取,不等待网络完成。`/sync-setup` 只保存配置并停止定时器;已启用 auto 时需 reload 激活。安装 Starline 时,优先点击状态胶囊打开详情,执行 Check status、Push、Pull 或 Settings。状态 key 保留为 `pi-sync`,兼容原 Starline placement;pill bridge 优先于旧 `extensionStatuses.commands`,无需修改 Starline 配置。没有三行 widget。`/sync-status`、点击胶囊、设置和子菜单统一使用底栏上方居中的 Starline 气泡,确认操作也使用相同样式;文本输入仍使用 Pi 输入框。标签、数值和异常使用 Pi 主题色区分,可选操作使用粗体并保留 Starline 自带的选中对比度。State 直接显示 Manual、Auto 或 Off;执行中显示 Push/Pull,另列当前模式,不追加 idle。未安装 Starline 时回退原生界面,状态输出保留纯文本。
|
|
37
37
|
|
|
38
38
|
## 数据与安全
|
|
39
39
|
|
package/index.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
// Derived from pi-sync-suite 0.1.1, Copyright (c) 2026 Murat (MIT).
|
|
2
2
|
// Upstream: https://github.com/MuratAitov/pi-sync
|
|
3
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { SyncService, createConfig, defaultPaths, loadConfig, saveConfig, validatePath, type Config } from "./core.ts";
|
|
5
6
|
|
|
6
|
-
type BubbleLine = string | { text: string; onSelect: () => void };
|
|
7
|
+
type BubbleLine = string | { text: string; onSelect: () => void; current?: boolean };
|
|
8
|
+
type Tone = "text" | "muted" | "dim" | "accent" | "success" | "warning" | "error";
|
|
9
|
+
type DetailRow = { label: string; value: string; tone?: Tone };
|
|
10
|
+
type Choice = string | { value: string; label: string };
|
|
11
|
+
type SelectDetails = { description?: string; rows?: () => DetailRow[]; current?: string };
|
|
7
12
|
type BubbleContent = { title: string; body: BubbleLine[]; hint: string };
|
|
8
13
|
type BubbleHandle = { refresh(): void; close(): void; onClose?(callback: () => void): void };
|
|
9
14
|
type PillAPI = {
|
|
@@ -13,8 +18,12 @@ type PillAPI = {
|
|
|
13
18
|
};
|
|
14
19
|
const STATUS_KEY = "pi-sync";
|
|
15
20
|
const pillAPI = () => (globalThis as Record<symbol, unknown>)[Symbol.for("pi-starline.pill-api.v1")] as PillAPI | undefined;
|
|
16
|
-
const
|
|
17
|
-
const
|
|
21
|
+
const bubbleAPI = (context: ExtensionContext) => context.mode === "rpc" ? undefined : pillAPI();
|
|
22
|
+
const message = (error: unknown) => terminalText(error instanceof Error ? error.message : String(error));
|
|
23
|
+
const displayTime = (value?: string) => value ? new Date(value).toLocaleString() : "Never";
|
|
24
|
+
const modeLabel = (mode: Config["mode"]) => ({ manual: "Manual", auto: "Auto", off: "Off" })[mode];
|
|
25
|
+
const terminalText = (value: string) => stripVTControlCharacters(value).replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
26
|
+
const menuHint = "↑↓ Enter Esc";
|
|
18
27
|
|
|
19
28
|
export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
20
29
|
let ctx: ExtensionContext | undefined;
|
|
@@ -34,8 +43,10 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
34
43
|
let watching = false;
|
|
35
44
|
let generation = 0;
|
|
36
45
|
|
|
46
|
+
const stateLabel = () => busy ?? (error ? "Error" : config ? modeLabel(config.mode) : "Not configured");
|
|
47
|
+
const stateTone = (): Tone => error && !busy ? "error" : busy ? "accent" : config?.mode === "off" ? "muted" : config ? "accent" : "warning";
|
|
37
48
|
const publish = () => {
|
|
38
|
-
ctx?.ui.setStatus(STATUS_KEY, `Sync: ${
|
|
49
|
+
ctx?.ui.setStatus(STATUS_KEY, `Sync: ${stateLabel()}`);
|
|
39
50
|
bubble?.refresh();
|
|
40
51
|
};
|
|
41
52
|
|
|
@@ -62,7 +73,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
62
73
|
return false;
|
|
63
74
|
}
|
|
64
75
|
if (busy) {
|
|
65
|
-
if (!quiet) ctx.ui.notify(`Sync is
|
|
76
|
+
if (!quiet) ctx.ui.notify(`Sync is busy: ${busy}.`, "warning");
|
|
66
77
|
return false;
|
|
67
78
|
}
|
|
68
79
|
const active = service;
|
|
@@ -75,16 +86,17 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
75
86
|
error = undefined;
|
|
76
87
|
publish();
|
|
77
88
|
try {
|
|
78
|
-
const
|
|
89
|
+
const pushedFingerprint = label === "Push" ? await active.fingerprint() : undefined;
|
|
79
90
|
const result = await action(active);
|
|
80
|
-
notice =
|
|
81
|
-
// Mark only the pre-
|
|
91
|
+
notice = `${label} completed`;
|
|
92
|
+
// Mark only the pre-push snapshot as observed. Changes made while Git
|
|
82
93
|
// runs must remain detectable by the next watcher pass.
|
|
83
|
-
if (
|
|
84
|
-
if (!quiet) context.ui.notify(notice, "info");
|
|
94
|
+
if (pushedFingerprint !== undefined) fingerprint = pushedFingerprint;
|
|
95
|
+
if (!quiet) context.ui.notify(typeof result === "string" ? terminalText(result) : notice, "info");
|
|
85
96
|
return true;
|
|
86
97
|
} catch (failure) {
|
|
87
98
|
error = message(failure);
|
|
99
|
+
notice = `${label} failed`;
|
|
88
100
|
context.ui.notify(`amox-pi-sync ${label}: ${error}`, "error");
|
|
89
101
|
return false;
|
|
90
102
|
} finally {
|
|
@@ -110,7 +122,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
110
122
|
}
|
|
111
123
|
if (token !== generation) return;
|
|
112
124
|
pullTimer = setInterval(() => {
|
|
113
|
-
if (!busy) void run("
|
|
125
|
+
if (!busy) void run("Pull", sync => sync.pull(), true);
|
|
114
126
|
}, config.pullIntervalMinutes * 60_000);
|
|
115
127
|
watchTimer = setInterval(() => {
|
|
116
128
|
if (busy || watching) return;
|
|
@@ -119,7 +131,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
119
131
|
if (token !== generation || next === fingerprint || busy) return;
|
|
120
132
|
if (debounce) clearTimeout(debounce);
|
|
121
133
|
debounce = setTimeout(() => {
|
|
122
|
-
if (token === generation && !busy) void run("
|
|
134
|
+
if (token === generation && !busy) void run("Push", sync => sync.push(), true);
|
|
123
135
|
}, config!.pushDebounceMs);
|
|
124
136
|
}).catch(failure => {
|
|
125
137
|
const text = message(failure);
|
|
@@ -129,42 +141,72 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
129
141
|
}).finally(() => { watching = false; });
|
|
130
142
|
}, config.watchIntervalMs);
|
|
131
143
|
// Do not await network during session startup.
|
|
132
|
-
void run("
|
|
144
|
+
void run("Pull", sync => sync.pull(), true);
|
|
133
145
|
}
|
|
134
146
|
|
|
135
|
-
function
|
|
136
|
-
if (!config || !service) return [
|
|
147
|
+
function overviewRows(): DetailRow[] {
|
|
148
|
+
if (!config || !service) return [
|
|
149
|
+
{ label: "State", value: stateLabel(), tone: stateTone() },
|
|
150
|
+
{ label: "Setup", value: "/sync-setup <private-ssh-repository>", tone: "accent" },
|
|
151
|
+
...(error ? [{ label: "Error", value: error, tone: "error" as const }] : []),
|
|
152
|
+
];
|
|
153
|
+
return [
|
|
154
|
+
{ label: "State", value: stateLabel(), tone: stateTone() },
|
|
155
|
+
...(busy || error ? [{ label: "Mode", value: modeLabel(config.mode), tone: "muted" as const }] : []),
|
|
156
|
+
{ label: "Paths", value: config.paths.join(", ") || "None" },
|
|
157
|
+
{ label: "Remote", value: config.repoUrl },
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function detailRows(): DetailRow[] {
|
|
162
|
+
const rows = overviewRows();
|
|
163
|
+
if (!config || !service) return rows;
|
|
137
164
|
const status = service.status;
|
|
165
|
+
const local = status.localChanges?.trim();
|
|
166
|
+
const remoteChecked = Boolean(status.checkedAt) && status.remoteChanges !== undefined;
|
|
138
167
|
return [
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
...(
|
|
148
|
-
...(notice ? [`Last action: ${notice}`] : []),
|
|
149
|
-
...(error ? [`Error: ${error}`] : []),
|
|
168
|
+
...rows,
|
|
169
|
+
{ label: "Last push", value: displayTime(config.lastPushAt), tone: config.lastPushAt ? "text" : "dim" },
|
|
170
|
+
{ label: "Last pull", value: displayTime(config.lastPullAt), tone: config.lastPullAt ? "text" : "dim" },
|
|
171
|
+
{ label: "Last check", value: displayTime(status.checkedAt), tone: status.checkedAt ? "text" : "dim" },
|
|
172
|
+
{ label: "Local changes", value: local || (status.checkedAt ? "None at last check" : "Not checked"), tone: local ? "warning" : status.checkedAt ? "success" : "dim" },
|
|
173
|
+
{ label: "Remote updates", value: remoteChecked ? String(status.remoteChanges) : "Not checked", tone: remoteChecked ? status.remoteChanges ? "warning" : "success" : "dim" },
|
|
174
|
+
...(config.lastResult ? [{ label: "Last sync", value: config.lastResult }] : []),
|
|
175
|
+
...(notice ? [{ label: "Last action", value: notice }] : []),
|
|
176
|
+
...(error ? [{ label: "Error", value: error, tone: "error" as const }] : []),
|
|
150
177
|
];
|
|
151
178
|
}
|
|
152
179
|
|
|
180
|
+
function detailLines(): string[] {
|
|
181
|
+
return detailRows().map(row => `${row.label}: ${terminalText(row.value)}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderRows(context: ExtensionContext, rows: DetailRow[]): string[] {
|
|
185
|
+
const theme = context.ui.theme;
|
|
186
|
+
return rows.map(row => theme.fg("muted", `${row.label.padEnd(14)} `)
|
|
187
|
+
+ theme.fg(row.tone ?? "text", row.label === "State" ? theme.bold(terminalText(row.value)) : terminalText(row.value)));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Targets inherit Starline's foreground so a selected row remains legible on
|
|
191
|
+
// its pill-coloured background. Bold adds hierarchy without overriding it.
|
|
192
|
+
const actionText = (context: ExtensionContext, text: string) => context.ui.theme.bold(terminalText(text));
|
|
193
|
+
|
|
153
194
|
function content(context: ExtensionContext): BubbleContent {
|
|
154
|
-
const body: BubbleLine[] =
|
|
195
|
+
const body: BubbleLine[] = renderRows(context, detailRows());
|
|
155
196
|
if (config && !busy) {
|
|
156
|
-
body.push(
|
|
157
|
-
{ text: "Check status", onSelect: () => { void run("
|
|
158
|
-
{ text: "
|
|
159
|
-
{ text: "Pull", onSelect: () => { void run("
|
|
197
|
+
body.push("", context.ui.theme.fg("muted", "Actions"),
|
|
198
|
+
{ text: actionText(context, "Check status"), onSelect: () => { void run("Check", sync => sync.check()); } },
|
|
199
|
+
{ text: actionText(context, "Push"), onSelect: () => { void run("Push", sync => sync.push()); } },
|
|
200
|
+
{ text: actionText(context, "Pull"), onSelect: () => { void run("Pull", sync => sync.pull()); } },
|
|
160
201
|
);
|
|
161
|
-
}
|
|
162
|
-
if (!busy) body.push({ text: "Settings", onSelect: () => settings(context).catch(report) });
|
|
163
|
-
|
|
202
|
+
} else if (!busy) body.push("");
|
|
203
|
+
if (!busy) body.push({ text: actionText(context, "Settings"), onSelect: () => settings(context).catch(report) });
|
|
204
|
+
body.push("", context.ui.theme.fg("dim", "Values reflect the last check."));
|
|
205
|
+
return { title: "Amox Pi Sync", body, hint: menuHint };
|
|
164
206
|
}
|
|
165
207
|
|
|
166
208
|
function showStatus(context: ExtensionContext): void {
|
|
167
|
-
const api =
|
|
209
|
+
const api = bubbleAPI(context);
|
|
168
210
|
if (api?.version === 1 && context.hasUI) {
|
|
169
211
|
// All command, pill and menu views share Starline's unanchored placement.
|
|
170
212
|
bubble = api.showBubble(context, () => content(context));
|
|
@@ -173,18 +215,26 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
173
215
|
}
|
|
174
216
|
}
|
|
175
217
|
|
|
176
|
-
async function select(context: ExtensionContext, title: string, options:
|
|
177
|
-
const
|
|
178
|
-
|
|
218
|
+
async function select(context: ExtensionContext, title: string, options: Choice[], details: SelectDetails = {}): Promise<string | undefined> {
|
|
219
|
+
const choices = options.map(option => typeof option === "string" ? { value: option, label: option } : option);
|
|
220
|
+
const nativeSelect = async () => {
|
|
221
|
+
const heading = details.description ? `${title}\n${terminalText(details.description)}` : title;
|
|
222
|
+
const label = await context.ui.select(heading, choices.map(choice => terminalText(choice.label)));
|
|
223
|
+
return choices.find(choice => terminalText(choice.label) === label)?.value;
|
|
224
|
+
};
|
|
225
|
+
const api = bubbleAPI(context);
|
|
226
|
+
if (api?.version !== 1 || !context.hasUI) return nativeSelect();
|
|
179
227
|
return new Promise(resolve => {
|
|
180
228
|
let selected = false;
|
|
181
229
|
const handle = api.showBubble(context, () => ({
|
|
182
230
|
title,
|
|
183
231
|
body: [
|
|
184
|
-
...(
|
|
185
|
-
...
|
|
232
|
+
...(details.rows ? [...renderRows(context, details.rows()), ""] : []),
|
|
233
|
+
...(details.description ? [context.ui.theme.fg("muted", terminalText(details.description)), ""] : []),
|
|
234
|
+
...choices.map(choice => ({ text: actionText(context, choice.label), current: choice.value === details.current,
|
|
235
|
+
onSelect: () => { selected = true; resolve(choice.value); } })),
|
|
186
236
|
],
|
|
187
|
-
hint:
|
|
237
|
+
hint: menuHint,
|
|
188
238
|
}));
|
|
189
239
|
bubble = handle;
|
|
190
240
|
if (handle?.onClose) {
|
|
@@ -193,14 +243,14 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
193
243
|
handle.onClose(() => queueMicrotask(() => { if (!selected) resolve(undefined); }));
|
|
194
244
|
} else {
|
|
195
245
|
handle?.close();
|
|
196
|
-
void
|
|
246
|
+
void nativeSelect().then(resolve);
|
|
197
247
|
}
|
|
198
248
|
});
|
|
199
249
|
}
|
|
200
250
|
|
|
201
251
|
async function confirm(context: ExtensionContext, title: string, description: string): Promise<boolean> {
|
|
202
|
-
if (
|
|
203
|
-
return await select(context, title, ["Cancel", "Confirm"], description) === "Confirm";
|
|
252
|
+
if (bubbleAPI(context)?.version !== 1 || !context.hasUI) return context.ui.confirm(title, description);
|
|
253
|
+
return await select(context, title, ["Cancel", "Confirm"], { description, current: "Cancel" }) === "Confirm";
|
|
204
254
|
}
|
|
205
255
|
|
|
206
256
|
const report = (failure: unknown) => {
|
|
@@ -210,7 +260,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
210
260
|
};
|
|
211
261
|
|
|
212
262
|
async function persist(next: Config): Promise<void> {
|
|
213
|
-
if (busy) throw new Error(`Sync is
|
|
263
|
+
if (busy) throw new Error(`Sync is busy: ${busy}; retry the settings change after it finishes.`);
|
|
214
264
|
await saveConfig(next, paths);
|
|
215
265
|
config = next;
|
|
216
266
|
makeService();
|
|
@@ -219,17 +269,21 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
219
269
|
}
|
|
220
270
|
|
|
221
271
|
async function settings(context: ExtensionContext): Promise<void> {
|
|
222
|
-
if (busy) { context.ui.notify(`Sync is
|
|
272
|
+
if (busy) { context.ui.notify(`Sync is busy: ${busy}.`, "warning"); return; }
|
|
223
273
|
for (;;) {
|
|
224
|
-
const choice = await select(context, "
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
274
|
+
const choice = await select(context, "Sync settings", [
|
|
275
|
+
{ value: "status", label: "Status" },
|
|
276
|
+
{ value: "mode", label: `Mode ${config ? modeLabel(config.mode) : "Not configured"}` },
|
|
277
|
+
{ value: "paths", label: `Paths ${config?.paths.length ?? 0}` },
|
|
278
|
+
"Backups", "Cleanup", "Diagnostics", "Back",
|
|
279
|
+
], { rows: overviewRows });
|
|
228
280
|
if (!choice || choice === "Back") return;
|
|
229
|
-
if (choice === "
|
|
281
|
+
if (choice === "status") { showStatus(context); return; }
|
|
230
282
|
if (!config || !service) { context.ui.notify("Run /sync-setup first.", "warning"); continue; }
|
|
231
|
-
if (choice
|
|
232
|
-
const selected = await select(context, "Sync mode", ["Manual", "Auto", "Off", "Back"]
|
|
283
|
+
if (choice === "mode") {
|
|
284
|
+
const selected = await select(context, "Sync mode", ["Manual", "Auto", "Off", "Back"], {
|
|
285
|
+
current: modeLabel(config.mode), description: "Auto runs in the background. Manual and Off still allow Push and Pull.",
|
|
286
|
+
});
|
|
233
287
|
if (!selected || selected === "Back") continue;
|
|
234
288
|
const next = structuredClone(config);
|
|
235
289
|
if (selected === "Auto") {
|
|
@@ -242,7 +296,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
242
296
|
}
|
|
243
297
|
next.mode = selected.toLowerCase() as Config["mode"];
|
|
244
298
|
await persist(next);
|
|
245
|
-
} else if (choice
|
|
299
|
+
} else if (choice === "paths") {
|
|
246
300
|
const selected = await select(context, "Sync paths", [
|
|
247
301
|
...config.paths.map(item => `Exclude ${item}`), "Include path", "Back",
|
|
248
302
|
]);
|
|
@@ -256,38 +310,38 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
256
310
|
} else next.paths = next.paths.filter(item => `Exclude ${item}` !== selected);
|
|
257
311
|
await persist(next);
|
|
258
312
|
} else if (choice === "Backups") {
|
|
259
|
-
const selected = await select(context, "Backups
|
|
260
|
-
if (selected === "Create") await run("
|
|
313
|
+
const selected = await select(context, "Backups", ["Create", "List", "Restore", "Back"], { description: "Local backups. Cleanup is manual; nothing is deleted automatically." });
|
|
314
|
+
if (selected === "Create") await run("Create backup", async sync => {
|
|
261
315
|
const backup = await sync.backup(); return JSON.stringify(backup, null, 2);
|
|
262
316
|
});
|
|
263
|
-
if (selected === "List") await run("
|
|
317
|
+
if (selected === "List") await run("List backups", async sync => JSON.stringify(await sync.listBackups(), null, 2));
|
|
264
318
|
if (selected === "Restore") {
|
|
265
319
|
const backups = await service.listBackups();
|
|
266
320
|
if (!backups.length) { context.ui.notify("No backups.", "info"); continue; }
|
|
267
321
|
const id = await context.ui.input("Backup ID", "latest");
|
|
268
322
|
if (!id) continue;
|
|
269
323
|
if (!await confirm(context, "Restore backup?", "Matching managed files can be overwritten. A pre-restore backup will be created. The active session cannot be replaced.")) continue;
|
|
270
|
-
await run("
|
|
324
|
+
await run("Restore backup", sync => sync.restore(id));
|
|
271
325
|
}
|
|
272
326
|
} else if (choice === "Cleanup") {
|
|
273
|
-
const selected = await select(context, "
|
|
274
|
-
if (selected === "Preview" || selected === "
|
|
327
|
+
const selected = await select(context, "Backup cleanup", ["Preview", "Delete eligible backups", "Delete backup", "Retention", "Back"], { description: "Only complete local backups can be deleted. Sessions and missions are never cleanup targets." });
|
|
328
|
+
if (selected === "Preview" || selected === "Delete eligible backups") {
|
|
275
329
|
let candidates: Awaited<ReturnType<SyncService["planCleanup"]>> = [];
|
|
276
|
-
const planned = await run("
|
|
330
|
+
const planned = await run("Preview cleanup", async sync => {
|
|
277
331
|
candidates = await sync.planCleanup();
|
|
278
332
|
return candidates.length ? JSON.stringify(candidates, null, 2) : "No backup cleanup candidates.";
|
|
279
333
|
});
|
|
280
|
-
if (!planned || selected !== "
|
|
334
|
+
if (!planned || selected !== "Delete eligible backups" || !candidates.length) continue;
|
|
281
335
|
if (await confirm(context, "Delete complete backups?", `Delete ${candidates.length} previewed backup(s)? Sessions, missions and the sync repository will not be deleted.`)) {
|
|
282
|
-
await run("
|
|
336
|
+
await run("Delete backups", async sync => `Deleted ${await sync.applyCleanup(candidates.map(item => item.id))} backup(s).`);
|
|
283
337
|
}
|
|
284
|
-
} else if (selected === "Delete
|
|
338
|
+
} else if (selected === "Delete backup") {
|
|
285
339
|
const backups = await service.listBackups();
|
|
286
340
|
if (!backups.length) { context.ui.notify("No backups.", "info"); continue; }
|
|
287
|
-
const id = await select(context, "
|
|
341
|
+
const id = await select(context, "Choose backup to delete", [...backups.map(item => item.id), "Back"]);
|
|
288
342
|
if (!id || id === "Back") continue;
|
|
289
343
|
if (await confirm(context, "Delete backup?", `Permanently delete local backup ${id}?`)) {
|
|
290
|
-
await run("
|
|
344
|
+
await run("Delete backups", async sync => `Deleted ${await sync.applyCleanup([id])} backup.`);
|
|
291
345
|
}
|
|
292
346
|
} else if (selected === "Retention") {
|
|
293
347
|
const keep = await context.ui.input("Keep newest complete backups (0 or more)", String(config.retention?.keepBackups ?? 20));
|
|
@@ -303,10 +357,10 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
303
357
|
context.ui.notify("Retention saved. Cleanup remains manual; no files deleted.", "info");
|
|
304
358
|
}
|
|
305
359
|
} else if (choice === "Diagnostics") {
|
|
306
|
-
const selected = await select(context, "Diagnostics", ["Doctor", "
|
|
307
|
-
if (selected === "Doctor") await run("
|
|
308
|
-
if (selected === "
|
|
309
|
-
if (selected === "
|
|
360
|
+
const selected = await select(context, "Diagnostics", ["Doctor", "Check status", "View log", "Back"]);
|
|
361
|
+
if (selected === "Doctor") await run("Doctor", async sync => JSON.stringify(await sync.doctor(), null, 2));
|
|
362
|
+
if (selected === "Check status") await run("Check", async sync => { await sync.check(); return detailLines().join("\n"); });
|
|
363
|
+
if (selected === "View log") await run("View log", sync => sync.log());
|
|
310
364
|
}
|
|
311
365
|
}
|
|
312
366
|
}
|
|
@@ -326,7 +380,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
326
380
|
context.ui.setWidget(STATUS_KEY, undefined);
|
|
327
381
|
publish();
|
|
328
382
|
unregister?.();
|
|
329
|
-
const api =
|
|
383
|
+
const api = bubbleAPI(context);
|
|
330
384
|
unregister = api?.version === 1 ? api.register(STATUS_KEY, ({ ctx: current }) => showStatus(current)) : undefined;
|
|
331
385
|
await startTimers();
|
|
332
386
|
});
|
|
@@ -351,17 +405,17 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
351
405
|
handler: async (_args, context) => { await settings(context).catch(report); },
|
|
352
406
|
});
|
|
353
407
|
pi.registerCommand("sync-push", {
|
|
354
|
-
description: "
|
|
355
|
-
handler: async () => { await run("
|
|
408
|
+
description: "Push the managed raw data snapshot",
|
|
409
|
+
handler: async () => { await run("Push", sync => sync.push()); },
|
|
356
410
|
});
|
|
357
411
|
pi.registerCommand("sync-pull", {
|
|
358
412
|
description: "Pull raw data, backing up files before replacement",
|
|
359
|
-
handler: async () => { await run("
|
|
413
|
+
handler: async () => { await run("Pull", sync => sync.pull()); },
|
|
360
414
|
});
|
|
361
415
|
pi.registerCommand("sync-setup", {
|
|
362
416
|
description: "Configure a private SSH repository: /sync-setup <ssh-url> [pull-minutes]",
|
|
363
417
|
handler: async (args, context) => {
|
|
364
|
-
if (busy) { context.ui.notify(`Sync is
|
|
418
|
+
if (busy) { context.ui.notify(`Sync is busy: ${busy}.`, "warning"); return; }
|
|
365
419
|
try {
|
|
366
420
|
const [url, interval, ...extra] = args.trim().split(/\s+/);
|
|
367
421
|
if (!url || extra.length) throw new Error("Usage: /sync-setup <private-ssh-url> [pull-minutes]");
|
|
@@ -375,7 +429,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
375
429
|
next.paths = [...config.paths];
|
|
376
430
|
next.mode = config.mode;
|
|
377
431
|
}
|
|
378
|
-
if (busy) throw new Error(`Sync is
|
|
432
|
+
if (busy) throw new Error(`Sync is busy: ${busy}; retry setup after it finishes.`);
|
|
379
433
|
stopTimers();
|
|
380
434
|
await saveConfig(next, paths);
|
|
381
435
|
config = next;
|
package/package.json
CHANGED