@gfgghgftgffg/amox-pi-sync 0.1.1 → 0.1.3
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 +3 -3
- package/README.zh-CN.md +4 -4
- package/core.ts +14 -2
- package/index.ts +173 -82
- 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.3`, 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. Anchor bubbles to the clicked Starline cell, including submenus and confirmations; commands reuse the last clicked anchor or center before one is known. Forward pill colours by default and offer semantic theme colour overrides through bubbleColor; do not override selected-row text contrast. Colour changes must not start timers or network work. Without Starline use native Pi UI and hide the Starline colour option. Recompute theme styles when rendering, keep RPC/native status text plain, and do not store styled strings in settings. On Windows prefer native OpenSSH when present for non-ASCII home paths; retain batch mode, timeout and host-key verification. 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. Pill clicks anchor bubbles to the corresponding Starline cell, like Model; settings, submenus and confirmations retain that anchor. `/sync-status` uses the last clicked anchor, or a centered fallback before the first click. Text entry still uses Pi input dialogs. **Bubble colour** in settings defaults to **Follow pill**; alternatively choose the current theme’s accent, success, warning or error colour for borders and selection. Before a pill click, Follow pill uses theme accent. The optional `bubbleColor` setting stores `pill`, `accent`, `success`, `warning` or `error`; changing it performs no transfer. Without Starline this option is hidden and Pi renders its native 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
|
|
|
@@ -43,7 +43,7 @@ Manual/off startup performs no network access. Auto startup computes a local fin
|
|
|
43
43
|
- Paths are relative to Pi's agent-data directory, not the current project. Arbitrary safe data paths can be included or excluded; the extension does not change the working directory.
|
|
44
44
|
- Raw files are transferred without Markdown/JSON export or JSONL merging. Concurrent edits are not a semantic merge; coordinate devices and inspect diagnostics.
|
|
45
45
|
- Local-only files are retained; deletions are not propagated. Incoming content replaces differing same-path files after backup; active sessions must not be overwritten. Restore applies only the currently managed paths.
|
|
46
|
-
- Git commands disable automatic line-ending conversion so Windows `core.autocrlf` does not rewrite raw sessions. SSH
|
|
46
|
+
- Git commands disable automatic line-ending conversion so Windows `core.autocrlf` does not rewrite raw sessions. On Windows, SSH prefers native OpenSSH (`System32/OpenSSH/ssh.exe`) when available to support non-ASCII user directories; otherwise it uses `ssh`. Batch mode, connection timeout and host-key verification remain enabled, overriding a custom `core.sshCommand`.
|
|
47
47
|
- Backups use `amox-pi-sync-backups` under the agent-data directory. Cleanup only deletes local backups after manual preview/confirmation, using complete backup directories rather than individual files. The default limits are 20 complete backups and a maximum age of 180 days; backups older than this or beyond the newest 20 become cleanup candidates. There is no automatic deletion; sessions and missions are never cleanup targets.
|
|
48
48
|
- Settings use `amox-pi-sync.json`. If absent, legacy `pi-sync-suite.json` can be read for migration. The old file is not changed before new settings are saved; saving writes the new file. Migration uses a separate `amox-pi-sync-repo` directory rather than reusing the old snapshot.
|
|
49
49
|
|
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。点击胶囊时,气泡像 Model 一样锚定对应的 Starline cell;设置、子菜单及确认框沿用该锚点。`/sync-status` 使用最近点击的锚点,首次点击前回退居中。文本输入仍使用 Pi 输入框。设置中的 **Bubble colour** 默认 **Follow pill**(跟随胶囊),也可选择当前主题的 accent、success、warning 或 error 作为边框和选中项颜色;首次点击前使用主题 accent。可选配置 `bubbleColor` 保存为 `pill`、`accent`、`success`、`warning` 或 `error`,修改配色不触发传输。未安装 Starline 时隐藏此选项,由 Pi 渲染原生弹框。标签、数值和异常使用 Pi 主题色区分,可选操作使用粗体并保留 Starline 自带的选中对比度。State 直接显示 Manual、Auto 或 Off;执行中显示 Push/Pull,另列当前模式,不追加 idle。未安装 Starline 时回退原生界面,状态输出保留纯文本。
|
|
37
37
|
|
|
38
38
|
## 数据与安全
|
|
39
39
|
|
|
@@ -43,7 +43,7 @@ manual/off 启动不访问网络。auto 启动先计算本地 fingerprint,再
|
|
|
43
43
|
- 路径相对于 Pi agent 数据目录,不是当前项目。可包含/排除任意安全的数据相对路径;扩展不改写工作目录。
|
|
44
44
|
- 传输原始文件,不生成 Markdown/JSON 归档,不合并 JSONL。并发编辑不是语义合并;请协调多设备操作并查看诊断。
|
|
45
45
|
- 保留本地独有文件,不传播删除;同路径的不同内容以拉取数据为准,覆盖前备份;不得覆盖活跃会话。恢复备份只处理当前纳管范围。
|
|
46
|
-
- Git 操作关闭自动换行转换,避免 Windows 的 `core.autocrlf` 改写原始会话。
|
|
46
|
+
- Git 操作关闭自动换行转换,避免 Windows 的 `core.autocrlf` 改写原始会话。Windows 优先使用原生 OpenSSH(`System32/OpenSSH/ssh.exe`),支持中文用户目录;不存在时及其他平台使用 `ssh`。保留非交互模式、连接超时及主机密钥验证,不复用自定义 `core.sshCommand`。
|
|
47
47
|
- 备份位于 agent 数据目录下的 `amox-pi-sync-backups`。Cleanup 仅在手动预览/确认后删除本地备份,按完整备份目录计数,不按单个文件计数。默认数量上限 20 份、最长 180 天;超龄或超出最新 20 份的备份纳入清理候选,不自动删除,不清理 sessions 或 missions。
|
|
48
48
|
- 新设置文件为 `amox-pi-sync.json`;不存在时可读取旧 `pi-sync-suite.json` 迁移。保存新设置前不改旧文件,保存时写入新文件。迁移使用独立的 `amox-pi-sync-repo` 目录,不复用旧快照。
|
|
49
49
|
|
package/core.ts
CHANGED
|
@@ -22,9 +22,11 @@ import * as fs from "node:fs/promises";
|
|
|
22
22
|
import * as path from "node:path";
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { createHash, randomUUID } from "node:crypto";
|
|
25
|
-
import { realpathSync } from "node:fs";
|
|
25
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
26
26
|
|
|
27
|
+
export type BubbleColor = "pill" | "accent" | "success" | "warning" | "error";
|
|
27
28
|
export type Config = {
|
|
29
|
+
bubbleColor?: BubbleColor;
|
|
28
30
|
version: 1; repoUrl: string; repoDir: string; mode: "manual" | "auto" | "off";
|
|
29
31
|
paths: string[]; pullIntervalMinutes: number; watchIntervalMs: number; pushDebounceMs: number;
|
|
30
32
|
lastPushAt?: string; lastPullAt?: string; lastResult?: string;
|
|
@@ -91,6 +93,7 @@ function validate(config: Config, paths: Paths): void {
|
|
|
91
93
|
if ([config.repoDir, paths.backupDir, paths.configFile, `${paths.configFile}.lock`, path.join(paths.agentDir, "pi-sync-suite.json")].some(other => overlaps(p, other))) throw new Error("Managed paths overlap sync config, repository or backup directory");
|
|
92
94
|
}
|
|
93
95
|
if (overlaps(config.repoDir, paths.backupDir) || inside(config.repoDir, paths.agentDir) || inside(config.repoDir, paths.configFile) || inside(paths.backupDir, paths.agentDir)) throw new Error("Unsafe repository/backup directory nesting");
|
|
96
|
+
if (config.bubbleColor !== undefined && !["pill", "accent", "success", "warning", "error"].includes(config.bubbleColor)) throw new Error("Unsupported bubble colour");
|
|
94
97
|
const retention = config.retention ?? { keepBackups: 20, maxAgeDays: 180 };
|
|
95
98
|
if (!Number.isInteger(retention.keepBackups) || retention.keepBackups < 0 || !Number.isFinite(retention.maxAgeDays) || retention.maxAgeDays <= 0) throw new Error("Invalid backup retention: keepBackups must be a nonnegative integer and maxAgeDays positive");
|
|
96
99
|
for (const key of ["pullIntervalMinutes", "watchIntervalMs", "pushDebounceMs"] as const) if (!Number.isFinite(config[key]) || config[key] <= 0) throw new Error(`${key} must be positive`);
|
|
@@ -134,7 +137,16 @@ export async function saveConfig(config: Config, paths = defaultPaths()): Promis
|
|
|
134
137
|
// Raw sessions are opaque bytes and intentionally bypass content scanning. Use a
|
|
135
138
|
// trusted PRIVATE repository: sessions may contain credentials or private text.
|
|
136
139
|
const secret = /-----BEGIN (?:RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----|\bghp_[A-Za-z0-9_]{30,}\b|\bsk-[A-Za-z0-9_-]{20,}\b|\b(?:api[_-]?key|token|password|secret)\b\s*[:=]\s*["']?[^"'\s]{12,}/i;
|
|
137
|
-
|
|
140
|
+
// Git for Windows SSH can misdecode non-ASCII home paths; prefer native OpenSSH.
|
|
141
|
+
const nativeSsh = path.join(process.env.SystemRoot ?? "C:/Windows", "System32", "OpenSSH", "ssh.exe");
|
|
142
|
+
const sshCommand = process.platform === "win32" && existsSync(nativeSsh)
|
|
143
|
+
? `"${nativeSsh.replace(/\\/g, "/")}"` : "ssh";
|
|
144
|
+
const gitOptions = [
|
|
145
|
+
"-c", "core.hooksPath=",
|
|
146
|
+
"-c", "core.autocrlf=false",
|
|
147
|
+
"-c", "credential.interactive=false",
|
|
148
|
+
"-c", `core.sshCommand=${sshCommand} -oBatchMode=yes -oConnectTimeout=10`,
|
|
149
|
+
];
|
|
138
150
|
type Files = Map<string, Buffer>;
|
|
139
151
|
export class SyncService {
|
|
140
152
|
public status: Status = {};
|
package/index.ts
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
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 };
|
|
14
|
+
type PillColors = { background: string; foreground: string };
|
|
15
|
+
type BubbleAnchor = PillColors & { key?: string; startColumn?: number; endColumn?: number; centerColumn: number; terminalWidth: number };
|
|
9
16
|
type PillAPI = {
|
|
10
17
|
version: number;
|
|
11
|
-
register(key: string, action: (input: { ctx: ExtensionContext; pill?:
|
|
12
|
-
showBubble(ctx: ExtensionContext, source: () => BubbleContent, pill?:
|
|
18
|
+
register(key: string, action: (input: { ctx: ExtensionContext; pill?: BubbleAnchor }) => void): () => void;
|
|
19
|
+
showBubble(ctx: ExtensionContext, source: () => BubbleContent, pill?: BubbleAnchor): BubbleHandle | undefined;
|
|
13
20
|
};
|
|
14
21
|
const STATUS_KEY = "pi-sync";
|
|
15
22
|
const pillAPI = () => (globalThis as Record<symbol, unknown>)[Symbol.for("pi-starline.pill-api.v1")] as PillAPI | undefined;
|
|
16
|
-
const
|
|
17
|
-
const
|
|
23
|
+
const bubbleAPI = (context: ExtensionContext) => context.mode === "rpc" ? undefined : pillAPI();
|
|
24
|
+
const message = (error: unknown) => terminalText(error instanceof Error ? error.message : String(error));
|
|
25
|
+
const displayTime = (value?: string) => value ? new Date(value).toLocaleString() : "Never";
|
|
26
|
+
const modeLabel = (mode: Config["mode"]) => ({ manual: "Manual", auto: "Auto", off: "Off" })[mode];
|
|
27
|
+
const terminalText = (value: string) => stripVTControlCharacters(value).replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
28
|
+
const menuHint = "↑↓ Enter Esc";
|
|
18
29
|
|
|
19
30
|
export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
20
31
|
let ctx: ExtensionContext | undefined;
|
|
@@ -26,6 +37,29 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
26
37
|
let error: string | undefined;
|
|
27
38
|
let notice: string | undefined;
|
|
28
39
|
let bubble: BubbleHandle | undefined;
|
|
40
|
+
let pillAnchor: BubbleAnchor | undefined;
|
|
41
|
+
const colorChoices = [
|
|
42
|
+
{ value: "pill", label: "Follow pill" }, { value: "accent", label: "Theme accent" },
|
|
43
|
+
{ value: "success", label: "Theme success" }, { value: "warning", label: "Theme warning" },
|
|
44
|
+
{ value: "error", label: "Theme error" },
|
|
45
|
+
];
|
|
46
|
+
// Keep Starline's cell geometry; only override its colours when requested.
|
|
47
|
+
function bubbleAnchor(context: ExtensionContext): BubbleAnchor {
|
|
48
|
+
const background = () => {
|
|
49
|
+
const color = config?.bubbleColor ?? "pill";
|
|
50
|
+
if (color === "pill" && pillAnchor?.background) return pillAnchor.background;
|
|
51
|
+
return context.ui.theme.getFgAnsi(color === "pill" ? "accent" : color)
|
|
52
|
+
.replace(/\x1b\[(3[0-7]|9[0-7]|38)(?=[;m])/g, (_, code: string) => `\x1b[${Number(code) + 10}`);
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
key: STATUS_KEY,
|
|
56
|
+
centerColumn: (process.stdout.columns || 80) / 2,
|
|
57
|
+
terminalWidth: process.stdout.columns || 80,
|
|
58
|
+
...pillAnchor,
|
|
59
|
+
get background() { return background(); },
|
|
60
|
+
get foreground() { return (config?.bubbleColor ?? "pill") === "pill" ? pillAnchor?.foreground ?? "" : ""; },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
29
63
|
let unregister: (() => void) | undefined;
|
|
30
64
|
let pullTimer: ReturnType<typeof setInterval> | undefined;
|
|
31
65
|
let watchTimer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -34,8 +68,10 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
34
68
|
let watching = false;
|
|
35
69
|
let generation = 0;
|
|
36
70
|
|
|
71
|
+
const stateLabel = () => busy ?? (error ? "Error" : config ? modeLabel(config.mode) : "Not configured");
|
|
72
|
+
const stateTone = (): Tone => error && !busy ? "error" : busy ? "accent" : config?.mode === "off" ? "muted" : config ? "accent" : "warning";
|
|
37
73
|
const publish = () => {
|
|
38
|
-
ctx?.ui.setStatus(STATUS_KEY, `Sync: ${
|
|
74
|
+
ctx?.ui.setStatus(STATUS_KEY, `Sync: ${stateLabel()}`);
|
|
39
75
|
bubble?.refresh();
|
|
40
76
|
};
|
|
41
77
|
|
|
@@ -62,7 +98,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
62
98
|
return false;
|
|
63
99
|
}
|
|
64
100
|
if (busy) {
|
|
65
|
-
if (!quiet) ctx.ui.notify(`Sync is
|
|
101
|
+
if (!quiet) ctx.ui.notify(`Sync is busy: ${busy}.`, "warning");
|
|
66
102
|
return false;
|
|
67
103
|
}
|
|
68
104
|
const active = service;
|
|
@@ -75,16 +111,17 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
75
111
|
error = undefined;
|
|
76
112
|
publish();
|
|
77
113
|
try {
|
|
78
|
-
const
|
|
114
|
+
const pushedFingerprint = label === "Push" ? await active.fingerprint() : undefined;
|
|
79
115
|
const result = await action(active);
|
|
80
|
-
notice =
|
|
81
|
-
// Mark only the pre-
|
|
116
|
+
notice = `${label} completed`;
|
|
117
|
+
// Mark only the pre-push snapshot as observed. Changes made while Git
|
|
82
118
|
// runs must remain detectable by the next watcher pass.
|
|
83
|
-
if (
|
|
84
|
-
if (!quiet) context.ui.notify(notice, "info");
|
|
119
|
+
if (pushedFingerprint !== undefined) fingerprint = pushedFingerprint;
|
|
120
|
+
if (!quiet) context.ui.notify(typeof result === "string" ? terminalText(result) : notice, "info");
|
|
85
121
|
return true;
|
|
86
122
|
} catch (failure) {
|
|
87
123
|
error = message(failure);
|
|
124
|
+
notice = `${label} failed`;
|
|
88
125
|
context.ui.notify(`amox-pi-sync ${label}: ${error}`, "error");
|
|
89
126
|
return false;
|
|
90
127
|
} finally {
|
|
@@ -110,7 +147,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
110
147
|
}
|
|
111
148
|
if (token !== generation) return;
|
|
112
149
|
pullTimer = setInterval(() => {
|
|
113
|
-
if (!busy) void run("
|
|
150
|
+
if (!busy) void run("Pull", sync => sync.pull(), true);
|
|
114
151
|
}, config.pullIntervalMinutes * 60_000);
|
|
115
152
|
watchTimer = setInterval(() => {
|
|
116
153
|
if (busy || watching) return;
|
|
@@ -119,7 +156,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
119
156
|
if (token !== generation || next === fingerprint || busy) return;
|
|
120
157
|
if (debounce) clearTimeout(debounce);
|
|
121
158
|
debounce = setTimeout(() => {
|
|
122
|
-
if (token === generation && !busy) void run("
|
|
159
|
+
if (token === generation && !busy) void run("Push", sync => sync.push(), true);
|
|
123
160
|
}, config!.pushDebounceMs);
|
|
124
161
|
}).catch(failure => {
|
|
125
162
|
const text = message(failure);
|
|
@@ -129,63 +166,100 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
129
166
|
}).finally(() => { watching = false; });
|
|
130
167
|
}, config.watchIntervalMs);
|
|
131
168
|
// Do not await network during session startup.
|
|
132
|
-
void run("
|
|
169
|
+
void run("Pull", sync => sync.pull(), true);
|
|
133
170
|
}
|
|
134
171
|
|
|
135
|
-
function
|
|
136
|
-
if (!config || !service) return [
|
|
172
|
+
function overviewRows(): DetailRow[] {
|
|
173
|
+
if (!config || !service) return [
|
|
174
|
+
{ label: "State", value: stateLabel(), tone: stateTone() },
|
|
175
|
+
{ label: "Setup", value: "/sync-setup <private-ssh-repository>", tone: "accent" },
|
|
176
|
+
...(error ? [{ label: "Error", value: error, tone: "error" as const }] : []),
|
|
177
|
+
];
|
|
178
|
+
return [
|
|
179
|
+
{ label: "State", value: stateLabel(), tone: stateTone() },
|
|
180
|
+
...(busy || error ? [{ label: "Mode", value: modeLabel(config.mode), tone: "muted" as const }] : []),
|
|
181
|
+
{ label: "Paths", value: config.paths.join(", ") || "None" },
|
|
182
|
+
{ label: "Remote", value: config.repoUrl },
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function detailRows(): DetailRow[] {
|
|
187
|
+
const rows = overviewRows();
|
|
188
|
+
if (!config || !service) return rows;
|
|
137
189
|
const status = service.status;
|
|
190
|
+
const local = status.localChanges?.trim();
|
|
191
|
+
const remoteChecked = Boolean(status.checkedAt) && status.remoteChanges !== undefined;
|
|
138
192
|
return [
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
...(
|
|
148
|
-
...(notice ? [`Last action: ${notice}`] : []),
|
|
149
|
-
...(error ? [`Error: ${error}`] : []),
|
|
193
|
+
...rows,
|
|
194
|
+
{ label: "Last push", value: displayTime(config.lastPushAt), tone: config.lastPushAt ? "text" : "dim" },
|
|
195
|
+
{ label: "Last pull", value: displayTime(config.lastPullAt), tone: config.lastPullAt ? "text" : "dim" },
|
|
196
|
+
{ label: "Last check", value: displayTime(status.checkedAt), tone: status.checkedAt ? "text" : "dim" },
|
|
197
|
+
{ label: "Local changes", value: local || (status.checkedAt ? "None at last check" : "Not checked"), tone: local ? "warning" : status.checkedAt ? "success" : "dim" },
|
|
198
|
+
{ label: "Remote updates", value: remoteChecked ? String(status.remoteChanges) : "Not checked", tone: remoteChecked ? status.remoteChanges ? "warning" : "success" : "dim" },
|
|
199
|
+
...(config.lastResult ? [{ label: "Last sync", value: config.lastResult }] : []),
|
|
200
|
+
...(notice ? [{ label: "Last action", value: notice }] : []),
|
|
201
|
+
...(error ? [{ label: "Error", value: error, tone: "error" as const }] : []),
|
|
150
202
|
];
|
|
151
203
|
}
|
|
152
204
|
|
|
205
|
+
function detailLines(): string[] {
|
|
206
|
+
return detailRows().map(row => `${row.label}: ${terminalText(row.value)}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function renderRows(context: ExtensionContext, rows: DetailRow[]): string[] {
|
|
210
|
+
const theme = context.ui.theme;
|
|
211
|
+
return rows.map(row => theme.fg("muted", `${row.label.padEnd(14)} `)
|
|
212
|
+
+ theme.fg(row.tone ?? "text", row.label === "State" ? theme.bold(terminalText(row.value)) : terminalText(row.value)));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Targets inherit Starline's foreground so a selected row remains legible on
|
|
216
|
+
// its pill-coloured background. Bold adds hierarchy without overriding it.
|
|
217
|
+
const actionText = (context: ExtensionContext, text: string) => context.ui.theme.bold(terminalText(text));
|
|
218
|
+
|
|
153
219
|
function content(context: ExtensionContext): BubbleContent {
|
|
154
|
-
const body: BubbleLine[] =
|
|
220
|
+
const body: BubbleLine[] = renderRows(context, detailRows());
|
|
155
221
|
if (config && !busy) {
|
|
156
|
-
body.push(
|
|
157
|
-
{ text: "Check status", onSelect: () => { void run("
|
|
158
|
-
{ text: "
|
|
159
|
-
{ text: "Pull", onSelect: () => { void run("
|
|
222
|
+
body.push("", context.ui.theme.fg("muted", "Actions"),
|
|
223
|
+
{ text: actionText(context, "Check status"), onSelect: () => { void run("Check", sync => sync.check()); } },
|
|
224
|
+
{ text: actionText(context, "Push"), onSelect: () => { void run("Push", sync => sync.push()); } },
|
|
225
|
+
{ text: actionText(context, "Pull"), onSelect: () => { void run("Pull", sync => sync.pull()); } },
|
|
160
226
|
);
|
|
161
|
-
}
|
|
162
|
-
if (!busy) body.push({ text: "Settings", onSelect: () => settings(context).catch(report) });
|
|
163
|
-
|
|
227
|
+
} else if (!busy) body.push("");
|
|
228
|
+
if (!busy) body.push({ text: actionText(context, "Settings"), onSelect: () => settings(context).catch(report) });
|
|
229
|
+
body.push("", context.ui.theme.fg("dim", "Values reflect the last check."));
|
|
230
|
+
return { title: "Amox Pi Sync", body, hint: menuHint };
|
|
164
231
|
}
|
|
165
232
|
|
|
166
233
|
function showStatus(context: ExtensionContext): void {
|
|
167
|
-
const api =
|
|
234
|
+
const api = bubbleAPI(context);
|
|
168
235
|
if (api?.version === 1 && context.hasUI) {
|
|
169
|
-
|
|
170
|
-
bubble = api.showBubble(context, () => content(context));
|
|
236
|
+
bubble = api.showBubble(context, () => content(context), bubbleAnchor(context));
|
|
171
237
|
} else {
|
|
172
238
|
context.ui.notify(detailLines().join("\n"), error ? "warning" : "info");
|
|
173
239
|
}
|
|
174
240
|
}
|
|
175
241
|
|
|
176
|
-
async function select(context: ExtensionContext, title: string, options:
|
|
177
|
-
const
|
|
178
|
-
|
|
242
|
+
async function select(context: ExtensionContext, title: string, options: Choice[], details: SelectDetails = {}): Promise<string | undefined> {
|
|
243
|
+
const choices = options.map(option => typeof option === "string" ? { value: option, label: option } : option);
|
|
244
|
+
const nativeSelect = async () => {
|
|
245
|
+
const heading = details.description ? `${title}\n${terminalText(details.description)}` : title;
|
|
246
|
+
const label = await context.ui.select(heading, choices.map(choice => terminalText(choice.label)));
|
|
247
|
+
return choices.find(choice => terminalText(choice.label) === label)?.value;
|
|
248
|
+
};
|
|
249
|
+
const api = bubbleAPI(context);
|
|
250
|
+
if (api?.version !== 1 || !context.hasUI) return nativeSelect();
|
|
179
251
|
return new Promise(resolve => {
|
|
180
252
|
let selected = false;
|
|
181
253
|
const handle = api.showBubble(context, () => ({
|
|
182
254
|
title,
|
|
183
255
|
body: [
|
|
184
|
-
...(
|
|
185
|
-
...
|
|
256
|
+
...(details.rows ? [...renderRows(context, details.rows()), ""] : []),
|
|
257
|
+
...(details.description ? [context.ui.theme.fg("muted", terminalText(details.description)), ""] : []),
|
|
258
|
+
...choices.map(choice => ({ text: actionText(context, choice.label), current: choice.value === details.current,
|
|
259
|
+
onSelect: () => { selected = true; resolve(choice.value); } })),
|
|
186
260
|
],
|
|
187
|
-
hint:
|
|
188
|
-
}));
|
|
261
|
+
hint: menuHint,
|
|
262
|
+
}), bubbleAnchor(context));
|
|
189
263
|
bubble = handle;
|
|
190
264
|
if (handle?.onClose) {
|
|
191
265
|
// Starline closes the bubble before calling a target's onSelect.
|
|
@@ -193,14 +267,14 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
193
267
|
handle.onClose(() => queueMicrotask(() => { if (!selected) resolve(undefined); }));
|
|
194
268
|
} else {
|
|
195
269
|
handle?.close();
|
|
196
|
-
void
|
|
270
|
+
void nativeSelect().then(resolve);
|
|
197
271
|
}
|
|
198
272
|
});
|
|
199
273
|
}
|
|
200
274
|
|
|
201
275
|
async function confirm(context: ExtensionContext, title: string, description: string): Promise<boolean> {
|
|
202
|
-
if (
|
|
203
|
-
return await select(context, title, ["Cancel", "Confirm"], description) === "Confirm";
|
|
276
|
+
if (bubbleAPI(context)?.version !== 1 || !context.hasUI) return context.ui.confirm(title, description);
|
|
277
|
+
return await select(context, title, ["Cancel", "Confirm"], { description, current: "Cancel" }) === "Confirm";
|
|
204
278
|
}
|
|
205
279
|
|
|
206
280
|
const report = (failure: unknown) => {
|
|
@@ -210,7 +284,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
210
284
|
};
|
|
211
285
|
|
|
212
286
|
async function persist(next: Config): Promise<void> {
|
|
213
|
-
if (busy) throw new Error(`Sync is
|
|
287
|
+
if (busy) throw new Error(`Sync is busy: ${busy}; retry the settings change after it finishes.`);
|
|
214
288
|
await saveConfig(next, paths);
|
|
215
289
|
config = next;
|
|
216
290
|
makeService();
|
|
@@ -219,17 +293,29 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
219
293
|
}
|
|
220
294
|
|
|
221
295
|
async function settings(context: ExtensionContext): Promise<void> {
|
|
222
|
-
if (busy) { context.ui.notify(`Sync is
|
|
296
|
+
if (busy) { context.ui.notify(`Sync is busy: ${busy}.`, "warning"); return; }
|
|
223
297
|
for (;;) {
|
|
224
|
-
const choice = await select(context, "
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
298
|
+
const choice = await select(context, "Sync settings", [
|
|
299
|
+
{ value: "status", label: "Status" },
|
|
300
|
+
{ value: "mode", label: `Mode ${config ? modeLabel(config.mode) : "Not configured"}` },
|
|
301
|
+
{ value: "paths", label: `Paths ${config?.paths.length ?? 0}` },
|
|
302
|
+
...(bubbleAPI(context)?.version === 1 ? [{ value: "color", label: `Bubble colour ${colorChoices.find(choice => choice.value === (config?.bubbleColor ?? "pill"))!.label}` }] : []),
|
|
303
|
+
"Backups", "Cleanup", "Diagnostics", "Back",
|
|
304
|
+
], { rows: overviewRows });
|
|
228
305
|
if (!choice || choice === "Back") return;
|
|
229
|
-
if (choice === "
|
|
306
|
+
if (choice === "status") { showStatus(context); return; }
|
|
230
307
|
if (!config || !service) { context.ui.notify("Run /sync-setup first.", "warning"); continue; }
|
|
231
|
-
if (choice
|
|
232
|
-
const selected = await select(context, "Sync
|
|
308
|
+
if (choice === "color") {
|
|
309
|
+
const selected = await select(context, "Sync bubble colour", [...colorChoices, "Back"], { current: config.bubbleColor ?? "pill" });
|
|
310
|
+
if (!selected || selected === "Back") continue;
|
|
311
|
+
const next = { ...config, bubbleColor: selected as NonNullable<Config["bubbleColor"]> };
|
|
312
|
+
await saveConfig(next, paths);
|
|
313
|
+
config.bubbleColor = next.bubbleColor;
|
|
314
|
+
publish();
|
|
315
|
+
} else if (choice === "mode") {
|
|
316
|
+
const selected = await select(context, "Sync mode", ["Manual", "Auto", "Off", "Back"], {
|
|
317
|
+
current: modeLabel(config.mode), description: "Auto runs in the background. Manual and Off still allow Push and Pull.",
|
|
318
|
+
});
|
|
233
319
|
if (!selected || selected === "Back") continue;
|
|
234
320
|
const next = structuredClone(config);
|
|
235
321
|
if (selected === "Auto") {
|
|
@@ -242,7 +328,7 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
242
328
|
}
|
|
243
329
|
next.mode = selected.toLowerCase() as Config["mode"];
|
|
244
330
|
await persist(next);
|
|
245
|
-
} else if (choice
|
|
331
|
+
} else if (choice === "paths") {
|
|
246
332
|
const selected = await select(context, "Sync paths", [
|
|
247
333
|
...config.paths.map(item => `Exclude ${item}`), "Include path", "Back",
|
|
248
334
|
]);
|
|
@@ -256,38 +342,38 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
256
342
|
} else next.paths = next.paths.filter(item => `Exclude ${item}` !== selected);
|
|
257
343
|
await persist(next);
|
|
258
344
|
} else if (choice === "Backups") {
|
|
259
|
-
const selected = await select(context, "Backups
|
|
260
|
-
if (selected === "Create") await run("
|
|
345
|
+
const selected = await select(context, "Backups", ["Create", "List", "Restore", "Back"], { description: "Local backups. Cleanup is manual; nothing is deleted automatically." });
|
|
346
|
+
if (selected === "Create") await run("Create backup", async sync => {
|
|
261
347
|
const backup = await sync.backup(); return JSON.stringify(backup, null, 2);
|
|
262
348
|
});
|
|
263
|
-
if (selected === "List") await run("
|
|
349
|
+
if (selected === "List") await run("List backups", async sync => JSON.stringify(await sync.listBackups(), null, 2));
|
|
264
350
|
if (selected === "Restore") {
|
|
265
351
|
const backups = await service.listBackups();
|
|
266
352
|
if (!backups.length) { context.ui.notify("No backups.", "info"); continue; }
|
|
267
353
|
const id = await context.ui.input("Backup ID", "latest");
|
|
268
354
|
if (!id) continue;
|
|
269
355
|
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("
|
|
356
|
+
await run("Restore backup", sync => sync.restore(id));
|
|
271
357
|
}
|
|
272
358
|
} else if (choice === "Cleanup") {
|
|
273
|
-
const selected = await select(context, "
|
|
274
|
-
if (selected === "Preview" || selected === "
|
|
359
|
+
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." });
|
|
360
|
+
if (selected === "Preview" || selected === "Delete eligible backups") {
|
|
275
361
|
let candidates: Awaited<ReturnType<SyncService["planCleanup"]>> = [];
|
|
276
|
-
const planned = await run("
|
|
362
|
+
const planned = await run("Preview cleanup", async sync => {
|
|
277
363
|
candidates = await sync.planCleanup();
|
|
278
364
|
return candidates.length ? JSON.stringify(candidates, null, 2) : "No backup cleanup candidates.";
|
|
279
365
|
});
|
|
280
|
-
if (!planned || selected !== "
|
|
366
|
+
if (!planned || selected !== "Delete eligible backups" || !candidates.length) continue;
|
|
281
367
|
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("
|
|
368
|
+
await run("Delete backups", async sync => `Deleted ${await sync.applyCleanup(candidates.map(item => item.id))} backup(s).`);
|
|
283
369
|
}
|
|
284
|
-
} else if (selected === "Delete
|
|
370
|
+
} else if (selected === "Delete backup") {
|
|
285
371
|
const backups = await service.listBackups();
|
|
286
372
|
if (!backups.length) { context.ui.notify("No backups.", "info"); continue; }
|
|
287
|
-
const id = await select(context, "
|
|
373
|
+
const id = await select(context, "Choose backup to delete", [...backups.map(item => item.id), "Back"]);
|
|
288
374
|
if (!id || id === "Back") continue;
|
|
289
375
|
if (await confirm(context, "Delete backup?", `Permanently delete local backup ${id}?`)) {
|
|
290
|
-
await run("
|
|
376
|
+
await run("Delete backups", async sync => `Deleted ${await sync.applyCleanup([id])} backup.`);
|
|
291
377
|
}
|
|
292
378
|
} else if (selected === "Retention") {
|
|
293
379
|
const keep = await context.ui.input("Keep newest complete backups (0 or more)", String(config.retention?.keepBackups ?? 20));
|
|
@@ -303,10 +389,10 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
303
389
|
context.ui.notify("Retention saved. Cleanup remains manual; no files deleted.", "info");
|
|
304
390
|
}
|
|
305
391
|
} else if (choice === "Diagnostics") {
|
|
306
|
-
const selected = await select(context, "Diagnostics", ["Doctor", "
|
|
307
|
-
if (selected === "Doctor") await run("
|
|
308
|
-
if (selected === "
|
|
309
|
-
if (selected === "
|
|
392
|
+
const selected = await select(context, "Diagnostics", ["Doctor", "Check status", "View log", "Back"]);
|
|
393
|
+
if (selected === "Doctor") await run("Doctor", async sync => JSON.stringify(await sync.doctor(), null, 2));
|
|
394
|
+
if (selected === "Check status") await run("Check", async sync => { await sync.check(); return detailLines().join("\n"); });
|
|
395
|
+
if (selected === "View log") await run("View log", sync => sync.log());
|
|
310
396
|
}
|
|
311
397
|
}
|
|
312
398
|
}
|
|
@@ -326,8 +412,12 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
326
412
|
context.ui.setWidget(STATUS_KEY, undefined);
|
|
327
413
|
publish();
|
|
328
414
|
unregister?.();
|
|
329
|
-
const api =
|
|
330
|
-
|
|
415
|
+
const api = bubbleAPI(context);
|
|
416
|
+
pillAnchor = undefined;
|
|
417
|
+
unregister = api?.version === 1 ? api.register(STATUS_KEY, ({ ctx: current, pill }) => {
|
|
418
|
+
if (pill) pillAnchor = pill;
|
|
419
|
+
showStatus(current);
|
|
420
|
+
}) : undefined;
|
|
331
421
|
await startTimers();
|
|
332
422
|
});
|
|
333
423
|
|
|
@@ -351,17 +441,17 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
351
441
|
handler: async (_args, context) => { await settings(context).catch(report); },
|
|
352
442
|
});
|
|
353
443
|
pi.registerCommand("sync-push", {
|
|
354
|
-
description: "
|
|
355
|
-
handler: async () => { await run("
|
|
444
|
+
description: "Push the managed raw data snapshot",
|
|
445
|
+
handler: async () => { await run("Push", sync => sync.push()); },
|
|
356
446
|
});
|
|
357
447
|
pi.registerCommand("sync-pull", {
|
|
358
448
|
description: "Pull raw data, backing up files before replacement",
|
|
359
|
-
handler: async () => { await run("
|
|
449
|
+
handler: async () => { await run("Pull", sync => sync.pull()); },
|
|
360
450
|
});
|
|
361
451
|
pi.registerCommand("sync-setup", {
|
|
362
452
|
description: "Configure a private SSH repository: /sync-setup <ssh-url> [pull-minutes]",
|
|
363
453
|
handler: async (args, context) => {
|
|
364
|
-
if (busy) { context.ui.notify(`Sync is
|
|
454
|
+
if (busy) { context.ui.notify(`Sync is busy: ${busy}.`, "warning"); return; }
|
|
365
455
|
try {
|
|
366
456
|
const [url, interval, ...extra] = args.trim().split(/\s+/);
|
|
367
457
|
if (!url || extra.length) throw new Error("Usage: /sync-setup <private-ssh-url> [pull-minutes]");
|
|
@@ -374,8 +464,9 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
|
|
|
374
464
|
if (!await confirm(context, "Replace sync repository configuration?", "Existing managed paths and mode will be retained; no data is transferred by setup.")) return;
|
|
375
465
|
next.paths = [...config.paths];
|
|
376
466
|
next.mode = config.mode;
|
|
467
|
+
next.bubbleColor = config.bubbleColor;
|
|
377
468
|
}
|
|
378
|
-
if (busy) throw new Error(`Sync is
|
|
469
|
+
if (busy) throw new Error(`Sync is busy: ${busy}; retry setup after it finishes.`);
|
|
379
470
|
stopTimers();
|
|
380
471
|
await saveConfig(next, paths);
|
|
381
472
|
config = next;
|
package/package.json
CHANGED