@gfgghgftgffg/amox-pi-sync 0.1.4 → 0.1.5

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 CHANGED
@@ -5,7 +5,7 @@
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.4`, standalone TypeScript entrypoint `index.ts` and `core.ts`; no runtime dependency on the original suite.
8
+ - Local package: `@gfgghgftgffg/amox-pi-sync@0.1.5`, standalone TypeScript entrypoint `index.ts` and `core.ts`; no runtime dependency on the original suite.
9
9
 
10
10
  ## Scope
11
11
 
@@ -13,7 +13,7 @@ Keep raw agent-data push/pull, safe relative include/exclude paths (sessions + m
13
13
 
14
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 separates labels and values with a colon; the path count is explicitly marked configured. Starline action rows have a leading bullet, while native Pi selectors retain their own indicators. 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
- 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.
16
+ Safety requirements: private SSH remote, no working-directory mutation, no semantic JSONL merging, preserve local-only files, back up replacements and never overwrite active sessions. Network transfers use a 10-minute timeout and local Git commands 60 seconds; a terminated or timed-out process must never be recorded as success, even when an executor reports code 0. Pull fast-forwards or performs a clean file-level three-way merge; conflicts abort and apply nothing. A rejected non-fast-forward push fetches, integrates without rebase or force, and retries once; files integrated during push are applied on the next pull. Git commands force `core.autocrlf=false` and `core.eol=lf`, with a repository-local `* -text` attribute; do not rewrite raw bytes or existing mixed-EOL history automatically. 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
 
18
18
  ## 功能取舍与维护边界
19
19
 
package/README.md CHANGED
@@ -41,9 +41,10 @@ Manual/off startup performs no network access. Auto startup computes a local fin
41
41
  > Sessions, missions and other selected files may contain secrets. Use a **private SSH repository**, review included paths, and secure both the remote and local backups. Private Git is not encryption or a secret scrubber.
42
42
 
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
- - Raw files are transferred without Markdown/JSON export or JSONL merging. Concurrent edits are not a semantic merge; coordinate devices and inspect diagnostics.
44
+ - Raw files are transferred without Markdown/JSON export or semantic JSONL merging. Concurrent edits are not a semantic merge; an automatic three-way merge is file-level and aborts if both sides changed the same file. 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. 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`.
46
+ - Network Git operations (`clone`, `fetch`, `pull`, `push`) use a 10-minute timeout; local Git operations use 60 seconds. A process terminated by timeout or cancellation is a failure and is never recorded as completed, even if Pi reports exit code 0. Pull fast-forwards when possible; otherwise it performs a clean three-way merge in the snapshot repository. Conflicts abort the merge and apply no files. A rejected non-fast-forward push fetches, integrates remote history without rebase or force, and retries once; files integrated during push are applied on the next pull.
47
+ - Git commands force `core.autocrlf=false` and `core.eol=lf`, and the snapshot repository uses a local `* -text` attribute so Git does not convert raw bytes. Existing history with mixed CRLF/LF is not rewritten automatically; normalizing it requires an explicit manual migration and repack. 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
48
  - 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
49
  - 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
50
 
package/README.zh-CN.md CHANGED
@@ -41,9 +41,10 @@ manual/off 启动不访问网络。auto 启动先计算本地 fingerprint,再
41
41
  > 会话、missions 及其他选中文件可能包含秘密。请使用**私有 SSH 仓库**,检查同步路径,并保护远端和本地备份。私有 Git 不提供加密或秘密清除。
42
42
 
43
43
  - 路径相对于 Pi agent 数据目录,不是当前项目。可包含/排除任意安全的数据相对路径;扩展不改写工作目录。
44
- - 传输原始文件,不生成 Markdown/JSON 归档,不合并 JSONL。并发编辑不是语义合并;请协调多设备操作并查看诊断。
44
+ - 传输原始文件,不生成 Markdown/JSON 归档,不做语义化 JSONL 合并。并发编辑不是语义合并;自动三方合并按文件进行,双方修改同一文件时会中止。请协调多设备操作并查看诊断。
45
45
  - 保留本地独有文件,不传播删除;同路径的不同内容以拉取数据为准,覆盖前备份;不得覆盖活跃会话。恢复备份只处理当前纳管范围。
46
- - Git 操作关闭自动换行转换,避免 Windows 的 `core.autocrlf` 改写原始会话。Windows 优先使用原生 OpenSSH(`System32/OpenSSH/ssh.exe`),支持中文用户目录;不存在时及其他平台使用 `ssh`。保留非交互模式、连接超时及主机密钥验证,不复用自定义 `core.sshCommand`。
46
+ - 网络 Git 操作(`clone`、`fetch`、`pull`、`push`)超时为 10 分钟,本地 Git 操作为 60 秒。被超时或取消终止的进程一律视为失败,即使 Pi 返回 exit code 0,也不会写入完成时间。能快进时 Pull 直接快进,否则在快照仓库中执行干净的三方合并;冲突会中止合并且不应用任何文件。Push 被非快进拒绝时会 fetch、在不 rebase、不 force 的前提下整合远端历史,并重试一次;Push 整合的文件在下一次 Pull 时应用。
47
+ - Git 操作强制 `core.autocrlf=false`、`core.eol=lf`,快照仓库使用本地 `* -text` 属性,避免 Git 转换原始字节。已有 CRLF/LF 混合历史不会被自动改写;统一换行需要显式的手动迁移和 repack。Windows 优先使用原生 OpenSSH(`System32/OpenSSH/ssh.exe`),支持中文用户目录;不存在时及其他平台使用 `ssh`。保留非交互模式、连接超时及主机密钥验证,不复用自定义 `core.sshCommand`。
47
48
  - 备份位于 agent 数据目录下的 `amox-pi-sync-backups`。Cleanup 仅在手动预览/确认后删除本地备份,按完整备份目录计数,不按单个文件计数。默认数量上限 20 份、最长 180 天;超龄或超出最新 20 份的备份纳入清理候选,不自动删除,不清理 sessions 或 missions。
48
49
  - 新设置文件为 `amox-pi-sync.json`;不存在时可读取旧 `pi-sync-suite.json` 迁移。保存新设置前不改旧文件,保存时写入新文件。迁移使用独立的 `amox-pi-sync-repo` 目录,不复用旧快照。
49
50
 
package/core.ts CHANGED
@@ -33,7 +33,7 @@ export type Config = {
33
33
  retention?: { keepBackups: number; maxAgeDays: number };
34
34
  };
35
35
  export type Paths = { agentDir: string; configFile: string; backupDir: string };
36
- export type GitExecutor = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string; code: number }>;
36
+ export type GitExecutor = (args: string[], cwd: string, timeoutMs?: number) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>;
37
37
  export type Status = { busy?: string; error?: string; localChanges?: string; remoteChanges?: number; checkedAt?: string };
38
38
  export type Backup = { id: string; createdAt: string; reason: string; includedPaths: string[] };
39
39
  export type BackupSummary = Backup;
@@ -137,6 +137,10 @@ export async function saveConfig(config: Config, paths = defaultPaths()): Promis
137
137
  // Raw sessions are opaque bytes and intentionally bypass content scanning. Use a
138
138
  // trusted PRIVATE repository: sessions may contain credentials or private text.
139
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;
140
+ // Local Git work is bounded; network transfers can legitimately take minutes
141
+ // for large session histories, and a killed transfer must never look successful.
142
+ const LOCAL_TIMEOUT_MS = 60_000;
143
+ const NETWORK_TIMEOUT_MS = 10 * 60_000;
140
144
  // Git for Windows SSH can misdecode non-ASCII home paths; prefer native OpenSSH.
141
145
  const nativeSsh = path.join(process.env.SystemRoot ?? "C:/Windows", "System32", "OpenSSH", "ssh.exe");
142
146
  const sshCommand = process.platform === "win32" && existsSync(nativeSsh)
@@ -144,6 +148,7 @@ const sshCommand = process.platform === "win32" && existsSync(nativeSsh)
144
148
  const gitOptions = [
145
149
  "-c", "core.hooksPath=",
146
150
  "-c", "core.autocrlf=false",
151
+ "-c", "core.eol=lf",
147
152
  "-c", "credential.interactive=false",
148
153
  "-c", `core.sshCommand=${sshCommand} -oBatchMode=yes -oConnectTimeout=10`,
149
154
  ];
@@ -167,23 +172,42 @@ export class SyncService {
167
172
  } catch (e) { this.status.error = e instanceof Error ? e.message : String(e); throw e; }
168
173
  finally { try { if (lock) { await lock.close(); await fs.unlink(lockFile); } } finally { this.status.busy = undefined; } }
169
174
  }
170
- private async git(args: string[], cwd = this.config.repoDir): Promise<string> {
171
- const result = await this.exec([...gitOptions, ...args], cwd);
175
+ private async gitResult(args: string[], cwd = this.config.repoDir, timeoutMs = LOCAL_TIMEOUT_MS) {
176
+ const result = await this.exec([...gitOptions, ...args], cwd, timeoutMs);
177
+ // pi.exec can report code 0 for a process killed by its timeout; the killed
178
+ // flag is the only reliable failure signal for that path.
179
+ if (result.killed) throw new Error(`git ${args[0]} was terminated before completion (timeout or cancellation); no success was recorded. Verify the repository state before retrying.`);
180
+ return result;
181
+ }
182
+ private async git(args: string[], cwd = this.config.repoDir, timeoutMs = LOCAL_TIMEOUT_MS): Promise<string> {
183
+ const result = await this.gitResult(args, cwd, timeoutMs);
172
184
  if (result.code !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}. Resolve repository conflicts manually; no force push or JSONL merge is performed.`);
173
185
  return result.stdout;
174
186
  }
187
+ /** Keep raw bytes raw: local attributes override any text/EOL rules in the snapshot. */
188
+ private async ensureAttributes(): Promise<void> {
189
+ const file = path.join(this.config.repoDir, ".git", "info", "attributes");
190
+ await noLinks(file);
191
+ await fs.mkdir(path.dirname(file), { recursive: true });
192
+ let current = "";
193
+ try { current = await fs.readFile(file, "utf8"); }
194
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
195
+ if (current.split(/\r?\n/).filter(Boolean).at(-1) === "* -text") return;
196
+ await fs.writeFile(file, `${current}${current && !current.endsWith("\n") ? "\n" : ""}* -text\n`, { mode: 0o600 });
197
+ }
175
198
  private async ensureRepo(): Promise<void> {
176
199
  await noLinks(this.config.repoDir);
177
200
  const gitDir = path.join(this.config.repoDir, ".git"); await noLinks(gitDir);
178
201
  if (!(await stat(gitDir))) {
179
202
  if (await stat(this.config.repoDir) && (await fs.readdir(this.config.repoDir)).length) throw new Error("Repository directory is nonempty but has no .git; choose an empty directory");
180
203
  await fs.mkdir(path.dirname(this.config.repoDir), { recursive: true });
181
- await this.git(["clone", "--", this.config.repoUrl, this.config.repoDir], path.dirname(this.config.repoDir));
204
+ await this.git(["clone", "--", this.config.repoUrl, this.config.repoDir], path.dirname(this.config.repoDir), NETWORK_TIMEOUT_MS);
182
205
  }
183
206
  const origin = (await this.git(["remote", "get-url", "origin"])).trim();
184
207
  if (origin !== this.config.repoUrl) throw new Error("Repository origin differs from configured SSH URL; inspect it before syncing");
185
208
  const pushUrls = (await this.git(["remote", "get-url", "--push", "--all", "origin"])).trim().split(/\r?\n/);
186
209
  if (pushUrls.some(url => url !== this.config.repoUrl)) throw new Error("Repository push URL differs from configured SSH URL");
210
+ await this.ensureAttributes();
187
211
  for (const file of (await this.git(["ls-files", "-z"])).split("\0").filter(Boolean)) validatePath(file);
188
212
  }
189
213
  private async collect(root: string, scan = false): Promise<Files> {
@@ -266,7 +290,7 @@ export class SyncService {
266
290
  }
267
291
  async check(): Promise<Status> { return this.run("check", async () => {
268
292
  this.status.checkedAt = undefined; this.status.remoteChanges = undefined;
269
- await this.ensureRepo(); await this.git(["fetch", "origin"]);
293
+ await this.ensureRepo(); await this.git(["fetch", "origin"], this.config.repoDir, NETWORK_TIMEOUT_MS);
270
294
  const local = await this.collect(this.paths.agentDir, true);
271
295
  const snapshot = await this.collect(this.config.repoDir, true);
272
296
  let added = 0; let changed = 0; let remoteOnly = 0;
@@ -277,11 +301,35 @@ export class SyncService {
277
301
  }
278
302
  for (const file of snapshot.keys()) if (!local.has(file)) remoteOnly++;
279
303
  const localChanges = added || changed || remoteOnly ? `added/local-only: ${added}; changed: ${changed}; remote-only: ${remoteOnly}` : "";
280
- const upstream = await this.exec([...gitOptions, "rev-parse", "--verify", "@{u}"], this.config.repoDir);
304
+ const upstream = await this.gitResult(["rev-parse", "--verify", "@{u}"], this.config.repoDir);
281
305
  const remoteChanges = upstream.code === 0 ? Number((await this.git(["rev-list", "--count", "HEAD..@{u}"])).trim()) : undefined;
282
306
  this.status.localChanges = localChanges; this.status.remoteChanges = remoteChanges;
283
307
  this.status.checkedAt = new Date().toISOString(); return { ...this.status, busy: undefined };
284
308
  }); }
309
+ /** Fetch and integrate remote history without rewriting local commits. */
310
+ private async integrateRemote(): Promise<{ merged: boolean; fastForward: boolean }> {
311
+ await this.git(["fetch", "origin"], this.config.repoDir, NETWORK_TIMEOUT_MS);
312
+ const relation = await this.gitResult(["merge-base", "--is-ancestor", "HEAD", "@{u}"]);
313
+ if (relation.code === 0) {
314
+ await this.git(["merge", "--ff-only", "@{u}"]);
315
+ return { merged: false, fastForward: true };
316
+ }
317
+ if (relation.code !== 1) throw new Error(`git merge-base failed: ${relation.stderr.trim() || relation.stdout.trim() || `exit ${relation.code}`}`);
318
+ // The remote is not ahead. Distinguish a local-only commit (nothing to
319
+ // integrate) from true divergence before attempting a merge.
320
+ const ahead = await this.gitResult(["merge-base", "--is-ancestor", "@{u}", "HEAD"]);
321
+ if (ahead.code === 0) return { merged: false, fastForward: false };
322
+ if (ahead.code !== 1) throw new Error(`git merge-base failed: ${ahead.stderr.trim() || ahead.stdout.trim() || `exit ${ahead.code}`}`);
323
+ // Local and remote both have commits. Only accept a clean merge; any
324
+ // conflict restores the previous clean repository.
325
+ try {
326
+ await this.git(["merge", "--no-edit", "@{u}"]);
327
+ return { merged: true, fastForward: false };
328
+ } catch (error) {
329
+ try { await this.git(["merge", "--abort"]); } catch { /* no merge in progress */ }
330
+ throw new Error(`Remote and local snapshots diverged, and the three-way merge was not clean. The repository was restored. Resolve the divergence manually. ${error instanceof Error ? error.message : String(error)}`);
331
+ }
332
+ }
285
333
  async push(): Promise<string> { return this.run("push", async () => {
286
334
  await this.ensureRepo();
287
335
  if ((await this.git(["diff", "--cached", "--name-only"])).trim()) throw new Error("Repository has staged changes; inspect/commit them manually before sync");
@@ -301,19 +349,34 @@ export class SyncService {
301
349
  if (batch.length) await this.git(["--literal-pathspecs", "add", "--", ...batch]);
302
350
  const staged = (await this.git(["diff", "--cached", "--name-only"])).trim();
303
351
  if (staged) await this.git(["commit", "-m", `amox-pi-sync: ${new Date().toISOString()}`]);
304
- // Always retry push, even when the previous attempt committed but failed offline.
305
- await this.git(["push", "-u", "origin", "HEAD"]);
352
+ // A rejected non-fast-forward push is recoverable without force. Any other
353
+ // failure, including a killed transfer, is final and records no success.
354
+ const first = await this.gitResult(["push", "-u", "origin", "HEAD"], this.config.repoDir, NETWORK_TIMEOUT_MS);
355
+ let integrated = false;
356
+ if (first.code !== 0) {
357
+ if (!/non-fast-forward|fetch first|\[rejected\]|Updates were rejected/i.test(first.stderr)) {
358
+ throw new Error(`git push failed: ${first.stderr.trim() || first.stdout.trim() || `exit ${first.code}`}. Inspect the repository before retrying; no force push is performed.`);
359
+ }
360
+ const result = await this.integrateRemote();
361
+ integrated = result.merged || result.fastForward;
362
+ if (!integrated) throw new Error("git push was rejected, but the fetched remote is not ahead; inspect the repository before retrying.");
363
+ await this.git(["push", "-u", "origin", "HEAD"], this.config.repoDir, NETWORK_TIMEOUT_MS);
364
+ }
306
365
  this.config.lastPushAt = new Date().toISOString();
307
- this.config.lastResult = "Pushed managed snapshot (no force push; remote-only files preserved)";
366
+ this.config.lastResult = integrated
367
+ ? "Integrated remote history and pushed managed snapshot; run Pull to apply remote files (no force push)"
368
+ : "Pushed managed snapshot (no force push; remote-only files preserved)";
308
369
  await saveConfig(this.config, this.paths);
309
370
  return this.config.lastResult;
310
371
  }); }
311
372
  async pull(): Promise<string> { return this.run("pull", async () => {
312
373
  await this.ensureRepo();
313
- if ((await this.git(["status", "--porcelain"])).trim()) throw new Error("Repository has uncommitted changes; resolve them before ff-only pull");
314
- await this.git(["pull", "--ff-only", "origin"]);
315
- const result = await this.apply(await this.collect(this.config.repoDir, true), "before applying remote snapshot");
316
- this.config.lastPullAt = new Date().toISOString(); await saveConfig(this.config, this.paths); return result.message;
374
+ if ((await this.git(["status", "--porcelain"])).trim()) throw new Error("Repository has uncommitted changes; resolve them before pulling");
375
+ const result = await this.integrateRemote();
376
+ const applied = await this.apply(await this.collect(this.config.repoDir, true), "before applying remote snapshot");
377
+ this.config.lastPullAt = new Date().toISOString();
378
+ this.config.lastResult = `${result.merged ? "Merged diverged snapshots cleanly. " : ""}${applied.message}`;
379
+ await saveConfig(this.config, this.paths); return this.config.lastResult;
317
380
  }); }
318
381
  async backup(): Promise<Backup> { return this.run("backup", () => this.makeBackup("manual")); }
319
382
  async listBackups(): Promise<Backup[]> { return this.run("listBackups", () => this.backups()); }
package/index.ts CHANGED
@@ -85,9 +85,9 @@ export default function amoxPiSync(pi: ExtensionAPI): void {
85
85
  }
86
86
 
87
87
  function makeService(): void {
88
- service = config ? new SyncService(config, paths, async (args, cwd) => {
89
- const result = await pi.exec("git", args, { cwd, timeout: 60_000 });
90
- return { stdout: result.stdout, stderr: result.stderr, code: result.code };
88
+ service = config ? new SyncService(config, paths, async (args, cwd, timeoutMs) => {
89
+ const result = await pi.exec("git", args, { cwd, timeout: timeoutMs });
90
+ return { stdout: result.stdout, stderr: result.stderr, code: result.code, killed: result.killed };
91
91
  }) : undefined;
92
92
  if (service) service.activeSessionFile = ctx?.sessionManager.getSessionFile();
93
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gfgghgftgffg/amox-pi-sync",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Independent Pi agent-data sync fork with SSH Git transport, backups and optional Starline status.",
6
6
  "license": "MIT",