@singh-gur/pi-session-sync 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +138 -2
- package/package.json +54 -3
- package/src/config.ts +48 -0
- package/src/git.ts +59 -0
- package/src/index.ts +265 -0
- package/src/layout.ts +23 -0
- package/src/merge.ts +65 -0
- package/src/picklist.ts +40 -0
- package/src/rewrite.ts +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gurakhshish Singh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,139 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-session-sync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Start on your laptop. Pick it up on your desktop. Keep coding.**
|
|
4
|
+
|
|
5
|
+
Your [pi](https://pi.dev) sessions, on every machine. Push a conversation from one machine, pull it on another, and carry on exactly where you left off: same history, same `/tree`, with paths rewritten for the machine you're on. Everything travels through a private git repo you already own. No server, no account, no new service.
|
|
6
|
+
|
|
7
|
+
[Install](#get-started) · [Configure](#configure) · [How it works](#how-it-works) · [npm](https://www.npmjs.com/package/@singh-gur/pi-session-sync)
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
*Illustration of the extension's picklist with synthetic sessions. Colors and spacing depend on your terminal and pi theme.*
|
|
12
|
+
|
|
13
|
+
> [!WARNING]
|
|
14
|
+
> **Your sync repo must be private.** Sessions contain your prompts, code, tool output (file contents, command output) and possibly secrets such as API keys or tokens. They are not encrypted or redacted.
|
|
15
|
+
|
|
16
|
+
## Why you'll like it
|
|
17
|
+
|
|
18
|
+
- **Pick exactly what travels.** `/session-sync-push` and `/session-sync-pull` open a picklist with the current session pre-selected. Nothing moves without you choosing it.
|
|
19
|
+
- **Paths that follow you.** `/Users/me/code/api` on your Mac becomes `/home/me/dev/api` on your Linux box. Named roots turn machine paths into portable placeholders, so `/resume` finds the session in the right project.
|
|
20
|
+
- **Work on both machines, lose nothing.** Sessions are merged entry by entry. Continue the same conversation in two places and both branches show up in `/tree`, with the newest one active.
|
|
21
|
+
- **Your files stay the source of truth.** Pull never deletes local sessions, and the active session only reloads when the agent is idle.
|
|
22
|
+
- **Just git.** Any remote `git` can reach: GitHub, GitLab, Gitea, a bare repo over SSH. Authentication uses your existing git setup.
|
|
23
|
+
- **Hands-free if you want it.** Optional auto mode pulls when pi starts and pushes when you quit. Off by default; startup pulls run in the background, and network failures only show a warning.
|
|
24
|
+
|
|
25
|
+
## Get started
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pi install npm:@singh-gur/pi-session-sync
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
1. Create an **empty, private** git repo for your sessions.
|
|
32
|
+
2. Add the [config file](#configure) on each machine.
|
|
33
|
+
3. Start pi (or run `/reload`), then:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
/session-sync-push # on the machine you worked on
|
|
37
|
+
/session-sync-pull # on the machine you're moving to
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires **Node.js >= 24.16**, **pi 1.x** (tested with 1.5.2), and `git` on your `PATH` with access to your sync repo. Install the package on every machine you want to sync.
|
|
41
|
+
|
|
42
|
+
<details>
|
|
43
|
+
<summary>Try without installing, install from GitHub, pin, or update</summary>
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
# Try it for the current run only
|
|
47
|
+
pi -e npm:@singh-gur/pi-session-sync
|
|
48
|
+
|
|
49
|
+
# Install from GitHub
|
|
50
|
+
pi install git:github.com/singh-gur/pi-session-sync
|
|
51
|
+
|
|
52
|
+
# Install from a local checkout
|
|
53
|
+
pi install /path/to/pi-session-sync
|
|
54
|
+
|
|
55
|
+
# Update installed pi packages
|
|
56
|
+
pi update --extensions
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
To pin a release, append `@<version>`, for example `npm:@singh-gur/pi-session-sync@0.1.0`. Pinned versions are skipped by package updates.
|
|
60
|
+
|
|
61
|
+
</details>
|
|
62
|
+
|
|
63
|
+
## Configure
|
|
64
|
+
|
|
65
|
+
Create `~/.pi/agent/git-session-sync.json` on each machine. It is machine-local and never synced.
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"repoUrl": "git@github.com:me/pi-sessions.git",
|
|
70
|
+
"branch": "main",
|
|
71
|
+
"roots": { "dev": "/home/me/dev", "home": "/home/me" },
|
|
72
|
+
"auto": { "pullOnStart": false, "pushOnShutdown": false }
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Key | Required | What it does |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| `repoUrl` | yes | Any URL `git` can clone. Use an empty private repo. |
|
|
79
|
+
| `branch` | no | Branch to sync. Defaults to `main`. |
|
|
80
|
+
| `roots` | yes | Named root directories. Names use `a-z`, `0-9`, `_` and `-`; paths must be absolute. Only sessions under a root are synced. |
|
|
81
|
+
| `auto.pullOnStart` | no | Pull new or changed sessions in the background when pi starts. Defaults to `false`. |
|
|
82
|
+
| `auto.pushOnShutdown` | no | Push the current session when you quit pi. Defaults to `false`. |
|
|
83
|
+
|
|
84
|
+
### Same project, different paths
|
|
85
|
+
|
|
86
|
+
Give a root the **same name** on every machine and point it at that machine's folder:
|
|
87
|
+
|
|
88
|
+
| Machine | Config | Session cwd |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| MacBook | `"dev": "/Users/me/code"` | `/Users/me/code/api` |
|
|
91
|
+
| In the repo | | `{{PISYNC:dev}}/api` |
|
|
92
|
+
| Linux desktop | `"dev": "/home/me/dev"` | `/home/me/dev/api` |
|
|
93
|
+
|
|
94
|
+
Every root path in a session (cwd, messages, tool calls and results) is replaced at path boundaries, with the longest matching root winning. Sessions that use a root name this machine doesn't define are skipped on pull, with a warning.
|
|
95
|
+
|
|
96
|
+
## Commands
|
|
97
|
+
|
|
98
|
+
| Command | What it does |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `/session-sync-push` | Pick sessions to push. Only the current session is pre-selected. |
|
|
101
|
+
| `/session-sync-pull` | Fetch the repo and pick from sessions that are **[new]** or **[changed]** compared to your local copies. If the current session is updated, pi reloads it. |
|
|
102
|
+
|
|
103
|
+
In the picklist: `Space`/`Enter` toggles a session, `Esc` finishes, then confirm with **Yes** or back out with **No**. Each run ends with a summary: synced, unchanged, new entries, conflicts and anything skipped (with the reason).
|
|
104
|
+
|
|
105
|
+
## How it works
|
|
106
|
+
|
|
107
|
+
- **Merging.** Sessions are merged by entry id and ordered by timestamp, so the newest entry becomes the active leaf. If the same entry id has different content on each side, your local version is kept and the conflict is reported.
|
|
108
|
+
- **Local first.** Your session files are the source of truth. The clone at `~/.pi/agent/git-session-sync/repo` is a disposable cache, reset to the remote before every operation. Concurrent pushes from other machines are merged and retried automatically.
|
|
109
|
+
- **Safe writes.** Session files are written atomically, never outside pi's sessions directory, and never deleted.
|
|
110
|
+
- **Repo layout.** `sessions/<root>/<project>/<session-file>.jsonl`, readable and diffable like any other repo.
|
|
111
|
+
|
|
112
|
+
## Limitations
|
|
113
|
+
|
|
114
|
+
- POSIX paths only (no Windows).
|
|
115
|
+
- Only pi's default session directory is supported (no custom `sessionDir`).
|
|
116
|
+
- Sessions with a different session format version are skipped.
|
|
117
|
+
- The repo grows without bound (sessions can include base64 images).
|
|
118
|
+
- Path rewriting also changes coincidental matches of a root path inside message text.
|
|
119
|
+
- No encryption or redaction; see the warning above.
|
|
120
|
+
|
|
121
|
+
## Development
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
just install # pnpm install --frozen-lockfile
|
|
125
|
+
just check # typecheck + tests + pack dry run
|
|
126
|
+
just try # run pi with the extension loaded
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Release:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
just check
|
|
133
|
+
just bump patch # or minor / major: bumps version, tags, pushes
|
|
134
|
+
just publish # pnpm publish to npm
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Found a problem? [Open an issue](https://github.com/singh-gur/pi-session-sync/issues). Never paste session contents that may include secrets.
|
|
138
|
+
|
|
139
|
+
[MIT license](https://github.com/singh-gur/pi-session-sync/blob/main/LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@singh-gur/pi-session-sync",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: sync your pi sessions across machines through a private git repo, with per-machine path mapping and branch-preserving merges.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/singh-gur/pi-session-sync.git"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"pi-package",
|
|
11
|
+
"pi",
|
|
12
|
+
"pi-extension",
|
|
13
|
+
"session",
|
|
14
|
+
"sync",
|
|
15
|
+
"git",
|
|
16
|
+
"multi-machine",
|
|
17
|
+
"backup",
|
|
18
|
+
"sessions"
|
|
19
|
+
],
|
|
20
|
+
"author": "singh-gur",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/singh-gur/pi-session-sync/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/singh-gur/pi-session-sync#readme",
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./src/index.ts"
|
|
30
|
+
],
|
|
31
|
+
"image": "https://raw.githubusercontent.com/singh-gur/pi-session-sync/main/assets/hero.png"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
35
|
+
"@earendil-works/pi-tui": "*"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
39
|
+
"@earendil-works/pi-tui": "*",
|
|
40
|
+
"@types/node": "^26.6.6",
|
|
41
|
+
"typescript": "^7.0.2"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"src"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=24.16.0"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"test": "node --test 'test/**/*.test.ts'",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"pack:check": "pnpm pack --dry-run"
|
|
56
|
+
}
|
|
6
57
|
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export type SyncConfig = {
|
|
4
|
+
repoUrl: string;
|
|
5
|
+
branch: string;
|
|
6
|
+
roots: Record<string, string>;
|
|
7
|
+
auto: { pullOnStart: boolean; pushOnShutdown: boolean };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const ROOT_NAME = /^[a-z0-9_-]+$/;
|
|
11
|
+
|
|
12
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
13
|
+
|
|
14
|
+
export function parseConfig(raw: unknown): SyncConfig {
|
|
15
|
+
if (!isObject(raw)) throw new Error("config must be a JSON object");
|
|
16
|
+
if (typeof raw.repoUrl !== "string" || !raw.repoUrl.trim()) throw new Error("config.repoUrl is required");
|
|
17
|
+
if (raw.branch !== undefined && (typeof raw.branch !== "string" || !raw.branch.trim()))
|
|
18
|
+
throw new Error("config.branch must be a non-empty string");
|
|
19
|
+
if (!isObject(raw.roots) || Object.keys(raw.roots).length === 0)
|
|
20
|
+
throw new Error("config.roots must be a non-empty object of name -> absolute path");
|
|
21
|
+
|
|
22
|
+
const roots: Record<string, string> = {};
|
|
23
|
+
for (const [name, path] of Object.entries(raw.roots)) {
|
|
24
|
+
if (!ROOT_NAME.test(name)) throw new Error(`config.roots: invalid root name "${name}" (use a-z, 0-9, _ and -)`);
|
|
25
|
+
if (typeof path !== "string" || !path.startsWith("/"))
|
|
26
|
+
throw new Error(`config.roots.${name} must be an absolute path`);
|
|
27
|
+
const stripped = path.replace(/\/+$/, "");
|
|
28
|
+
if (!stripped) throw new Error(`config.roots.${name}: "/" is not allowed as a root`);
|
|
29
|
+
roots[name] = stripped;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const auto = isObject(raw.auto) ? raw.auto : {};
|
|
33
|
+
return {
|
|
34
|
+
repoUrl: raw.repoUrl,
|
|
35
|
+
branch: (raw.branch as string | undefined) ?? "main",
|
|
36
|
+
roots,
|
|
37
|
+
auto: { pullOnStart: auto.pullOnStart === true, pushOnShutdown: auto.pushOnShutdown === true },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function loadConfig(file: string): SyncConfig | undefined {
|
|
42
|
+
if (!existsSync(file)) return undefined;
|
|
43
|
+
try {
|
|
44
|
+
return parseConfig(JSON.parse(readFileSync(file, "utf8")));
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw new Error(`${file}: ${(e as Error).message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { SyncConfig } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
export type Exec = (
|
|
6
|
+
cmd: string,
|
|
7
|
+
args: string[],
|
|
8
|
+
opts: { cwd?: string; timeout?: number },
|
|
9
|
+
) => Promise<{ stdout: string; stderr: string; code: number }>;
|
|
10
|
+
|
|
11
|
+
const TIMEOUT = 60_000;
|
|
12
|
+
|
|
13
|
+
async function git(exec: Exec, cwd: string, args: string[], allowFail = false) {
|
|
14
|
+
const r = await exec("git", args, { cwd, timeout: TIMEOUT });
|
|
15
|
+
if (r.code !== 0 && !allowFail) throw new Error(`git ${args.join(" ")} failed: ${r.stderr.trim() || r.stdout.trim()}`);
|
|
16
|
+
return r;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function ensureClone(exec: Exec, cfg: SyncConfig, cloneDir: string) {
|
|
20
|
+
if (existsSync(join(cloneDir, ".git"))) return;
|
|
21
|
+
const parent = dirname(cloneDir);
|
|
22
|
+
mkdirSync(parent, { recursive: true });
|
|
23
|
+
const r = await git(exec, parent, ["clone", "--branch", cfg.branch, cfg.repoUrl, cloneDir], true);
|
|
24
|
+
if (r.code === 0) return;
|
|
25
|
+
// Empty remote (or branch not created yet): plain clone, then create the branch locally.
|
|
26
|
+
await git(exec, parent, ["clone", cfg.repoUrl, cloneDir]);
|
|
27
|
+
await git(exec, cloneDir, ["checkout", "-B", cfg.branch]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function syncToRemote(exec: Exec, cfg: SyncConfig, cloneDir: string) {
|
|
31
|
+
await git(exec, cloneDir, ["fetch", "origin"]);
|
|
32
|
+
const has = await git(exec, cloneDir, ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${cfg.branch}`], true);
|
|
33
|
+
if (has.code !== 0) return;
|
|
34
|
+
await git(exec, cloneDir, ["checkout", "-B", cfg.branch, `origin/${cfg.branch}`]);
|
|
35
|
+
await git(exec, cloneDir, ["reset", "--hard", `origin/${cfg.branch}`]);
|
|
36
|
+
await git(exec, cloneDir, ["clean", "-fd"]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** rebuild() writes files into the clone and returns how many changed. Retries on push rejection. */
|
|
40
|
+
export async function commitAndPush(
|
|
41
|
+
exec: Exec,
|
|
42
|
+
cfg: SyncConfig,
|
|
43
|
+
cloneDir: string,
|
|
44
|
+
message: string,
|
|
45
|
+
rebuild: () => Promise<number>,
|
|
46
|
+
): Promise<{ pushed: number }> {
|
|
47
|
+
let lastErr = "";
|
|
48
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
49
|
+
await syncToRemote(exec, cfg, cloneDir);
|
|
50
|
+
const n = await rebuild();
|
|
51
|
+
if (n === 0) return { pushed: 0 };
|
|
52
|
+
await git(exec, cloneDir, ["add", "-A", "sessions"]);
|
|
53
|
+
await git(exec, cloneDir, ["commit", "-q", "-m", message]);
|
|
54
|
+
const p = await git(exec, cloneDir, ["push", "origin", `HEAD:${cfg.branch}`], true);
|
|
55
|
+
if (p.code === 0) return { pushed: n };
|
|
56
|
+
lastErr = p.stderr.trim();
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`git push failed after 3 attempts: ${lastErr}`);
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { CURRENT_SESSION_VERSION, getAgentDir, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { loadConfig, type SyncConfig } from "./config.ts";
|
|
7
|
+
import { commitAndPush, ensureClone, type Exec, syncToRemote } from "./git.ts";
|
|
8
|
+
import { localSessionDirFor, repoPathFor } from "./layout.ts";
|
|
9
|
+
import { mergeSessions } from "./merge.ts";
|
|
10
|
+
import { pickSessions } from "./picklist.ts";
|
|
11
|
+
import { rewriteFileToLocal, rewriteFileToPortable } from "./rewrite.ts";
|
|
12
|
+
|
|
13
|
+
const EXAMPLE = `{
|
|
14
|
+
"repoUrl": "git@github.com:me/pi-sessions.git",
|
|
15
|
+
"branch": "main",
|
|
16
|
+
"roots": { "dev": "/home/me/dev" },
|
|
17
|
+
"auto": { "pullOnStart": false, "pushOnShutdown": false }
|
|
18
|
+
}`;
|
|
19
|
+
|
|
20
|
+
const configFile = () => join(getAgentDir(), "git-session-sync.json");
|
|
21
|
+
const cloneDir = () => join(getAgentDir(), "git-session-sync", "repo");
|
|
22
|
+
const sessionsRoot = () => join(getAgentDir(), "sessions");
|
|
23
|
+
|
|
24
|
+
type Summary = { synced: number; added: number; unchanged: number; conflicts: number; skipped: string[] };
|
|
25
|
+
const newSummary = (): Summary => ({ synced: 0, added: 0, unchanged: 0, conflicts: 0, skipped: [] });
|
|
26
|
+
|
|
27
|
+
function header(text: string): { version?: unknown; cwd?: unknown } | undefined {
|
|
28
|
+
try {
|
|
29
|
+
const h = JSON.parse(text.split("\n", 1)[0] ?? "");
|
|
30
|
+
return h?.type === "session" ? h : undefined;
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const readIf = (f: string) => (existsSync(f) ? readFileSync(f, "utf8") : undefined);
|
|
37
|
+
|
|
38
|
+
function writeAtomic(file: string, text: string) {
|
|
39
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
40
|
+
const tmp = `${file}.tmp-pss`;
|
|
41
|
+
writeFileSync(tmp, text);
|
|
42
|
+
renameSync(tmp, file);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Load config and check preconditions; notifies and returns undefined when sync cannot run. */
|
|
46
|
+
function getConfig(ctx: ExtensionContext, quietIfMissing = false): SyncConfig | undefined {
|
|
47
|
+
let cfg: SyncConfig | undefined;
|
|
48
|
+
try {
|
|
49
|
+
cfg = loadConfig(configFile());
|
|
50
|
+
} catch (e) {
|
|
51
|
+
ctx.ui.notify(`session-sync: ${(e as Error).message}`, "error");
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (!cfg) {
|
|
55
|
+
if (!quietIfMissing) ctx.ui.notify(`session-sync: create ${configFile()} like:\n${EXAMPLE}`, "warning");
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
if (resolve(ctx.sessionManager.getSessionDir()) !== resolve(localSessionDirFor(resolve(ctx.cwd), getAgentDir()))) {
|
|
59
|
+
ctx.ui.notify("session-sync: custom sessionDir is unsupported", "error");
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return cfg;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Push local session files (merged into the repo copy). */
|
|
66
|
+
async function pushSessions(exec: Exec, cfg: SyncConfig, paths: string[]): Promise<Summary> {
|
|
67
|
+
const clone = cloneDir();
|
|
68
|
+
await ensureClone(exec, cfg, clone);
|
|
69
|
+
let s = newSummary();
|
|
70
|
+
await commitAndPush(exec, cfg, clone, `push ${paths.length} session(s) from ${hostname()}`, async () => {
|
|
71
|
+
s = newSummary();
|
|
72
|
+
for (const path of paths) {
|
|
73
|
+
const text = readIf(path);
|
|
74
|
+
const h = text && header(text);
|
|
75
|
+
if (!h) {
|
|
76
|
+
s.skipped.push(`${basename(path)}: unreadable`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (h.version !== CURRENT_SESSION_VERSION) {
|
|
80
|
+
s.skipped.push(`${basename(path)}: session version ${h.version}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const rel = typeof h.cwd === "string" ? repoPathFor(h.cwd, basename(path), cfg.roots) : undefined;
|
|
84
|
+
if (!rel) {
|
|
85
|
+
s.skipped.push(`${basename(path)}: cwd not under a configured root`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const repoFile = join(clone, rel);
|
|
89
|
+
const repoText = readIf(repoFile);
|
|
90
|
+
const m = mergeSessions(rewriteFileToPortable(text, cfg.roots), repoText);
|
|
91
|
+
s.conflicts += m.conflicts;
|
|
92
|
+
if (m.text === repoText) s.unchanged++;
|
|
93
|
+
else {
|
|
94
|
+
writeAtomic(repoFile, m.text);
|
|
95
|
+
s.synced++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return s.synced;
|
|
99
|
+
});
|
|
100
|
+
return s;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type PullCandidate = { repoFile: string; target: string; isNew: boolean; label: string };
|
|
104
|
+
|
|
105
|
+
function walkJsonl(dir: string): string[] {
|
|
106
|
+
if (!existsSync(dir)) return [];
|
|
107
|
+
return readdirSync(dir, { withFileTypes: true, recursive: true })
|
|
108
|
+
.filter((d) => d.isFile() && d.name.endsWith(".jsonl"))
|
|
109
|
+
.map((d) => join(d.parentPath, d.name));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Localize a repo file; returns the target path and localized text, or a skip reason. */
|
|
113
|
+
function localize(cfg: SyncConfig, repoFile: string): { target: string; text: string } | { skip: string } {
|
|
114
|
+
const name = basename(repoFile);
|
|
115
|
+
const { text, unknown } = rewriteFileToLocal(readFileSync(repoFile, "utf8"), cfg.roots);
|
|
116
|
+
if (unknown.length) return { skip: `${name}: unknown root(s) ${unknown.join(", ")}` };
|
|
117
|
+
const h = header(text);
|
|
118
|
+
if (!h || typeof h.cwd !== "string" || !h.cwd.startsWith("/")) return { skip: `${name}: invalid header` };
|
|
119
|
+
if (h.version !== CURRENT_SESSION_VERSION) return { skip: `${name}: session version ${h.version}` };
|
|
120
|
+
const target = resolve(localSessionDirFor(h.cwd, getAgentDir()), name);
|
|
121
|
+
// Trust boundary: repo content must never write outside the sessions dir.
|
|
122
|
+
if (!target.startsWith(resolve(sessionsRoot()) + sep)) return { skip: `${name}: target outside sessions dir` };
|
|
123
|
+
return { target, text };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function scanPull(exec: Exec, cfg: SyncConfig, s: Summary): Promise<PullCandidate[]> {
|
|
127
|
+
const clone = cloneDir();
|
|
128
|
+
await ensureClone(exec, cfg, clone);
|
|
129
|
+
await syncToRemote(exec, cfg, clone);
|
|
130
|
+
const out: PullCandidate[] = [];
|
|
131
|
+
for (const repoFile of walkJsonl(join(clone, "sessions"))) {
|
|
132
|
+
const l = localize(cfg, repoFile);
|
|
133
|
+
if ("skip" in l) {
|
|
134
|
+
s.skipped.push(l.skip);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const local = readIf(l.target);
|
|
138
|
+
if (local !== undefined && !mergeSessions(local, l.text).changed) continue;
|
|
139
|
+
const cwd = String(header(l.text)?.cwd);
|
|
140
|
+
out.push({ repoFile, target: l.target, isNew: local === undefined, label: `${local === undefined ? "[new]" : "[changed]"} ${cwd} · ${basename(repoFile)}` });
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function applyPull(cfg: SyncConfig, c: PullCandidate, s: Summary) {
|
|
146
|
+
const l = localize(cfg, c.repoFile);
|
|
147
|
+
if ("skip" in l) return void s.skipped.push(l.skip);
|
|
148
|
+
const m = mergeSessions(readIf(c.target), l.text); // re-read: local may have changed since the scan
|
|
149
|
+
s.conflicts += m.conflicts;
|
|
150
|
+
if (!m.changed) return void s.unchanged++;
|
|
151
|
+
writeAtomic(c.target, m.text);
|
|
152
|
+
s.synced++;
|
|
153
|
+
s.added += m.added;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function report(ctx: ExtensionContext, verb: string, s: Summary) {
|
|
157
|
+
const parts = [`${verb} ${s.synced}`, `unchanged ${s.unchanged}`];
|
|
158
|
+
if (s.added) parts.push(`new entries ${s.added}`);
|
|
159
|
+
if (s.conflicts) parts.push(`id conflicts ${s.conflicts} (kept local)`);
|
|
160
|
+
if (s.skipped.length) parts.push(`skipped ${s.skipped.length}:\n ${s.skipped.join("\n ")}`);
|
|
161
|
+
ctx.ui.notify(`session-sync: ${parts.join(", ")}`, s.skipped.length ? "warning" : "info");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
165
|
+
|
|
166
|
+
export default function (pi: ExtensionAPI) {
|
|
167
|
+
const exec: Exec = (cmd, args, opts) => pi.exec(cmd, args, opts);
|
|
168
|
+
|
|
169
|
+
pi.registerCommand("session-sync-push", {
|
|
170
|
+
description: "Push selected pi sessions to the sync git repo",
|
|
171
|
+
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
172
|
+
const cfg = getConfig(ctx);
|
|
173
|
+
if (!cfg) return;
|
|
174
|
+
const current = ctx.sessionManager.getSessionFile();
|
|
175
|
+
const infos = (await SessionManager.listAll())
|
|
176
|
+
.filter((i) => repoPathFor(i.cwd, basename(i.path), cfg.roots))
|
|
177
|
+
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
178
|
+
if (!infos.length) return ctx.ui.notify("session-sync: no sessions under configured roots", "info");
|
|
179
|
+
const picked = await pickSessions(
|
|
180
|
+
ctx,
|
|
181
|
+
"Push sessions",
|
|
182
|
+
infos.map((i) => ({
|
|
183
|
+
id: i.path,
|
|
184
|
+
label: `${(i.name || i.firstMessage || "(empty)").replace(/\s+/g, " ").slice(0, 60)} · ${i.cwd} · ${i.modified.toLocaleString()}`,
|
|
185
|
+
checked: i.path === current,
|
|
186
|
+
})),
|
|
187
|
+
);
|
|
188
|
+
if (!picked?.length) return ctx.ui.notify("session-sync: nothing selected", "info");
|
|
189
|
+
ctx.ui.setStatus("session-sync", "pushing sessions...");
|
|
190
|
+
try {
|
|
191
|
+
report(ctx, "pushed", await pushSessions(exec, cfg, picked));
|
|
192
|
+
} catch (e) {
|
|
193
|
+
ctx.ui.notify(`session-sync push failed: ${errMsg(e)}`, "error");
|
|
194
|
+
} finally {
|
|
195
|
+
ctx.ui.setStatus("session-sync", undefined);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
pi.registerCommand("session-sync-pull", {
|
|
201
|
+
description: "Pull selected pi sessions from the sync git repo",
|
|
202
|
+
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
203
|
+
const cfg = getConfig(ctx);
|
|
204
|
+
if (!cfg) return;
|
|
205
|
+
const current = ctx.sessionManager.getSessionFile();
|
|
206
|
+
const s = newSummary();
|
|
207
|
+
let currentUpdated = false;
|
|
208
|
+
ctx.ui.setStatus("session-sync", "pulling sessions...");
|
|
209
|
+
try {
|
|
210
|
+
const candidates = await scanPull(exec, cfg, s);
|
|
211
|
+
ctx.ui.setStatus("session-sync", undefined);
|
|
212
|
+
if (!candidates.length) return report(ctx, "pulled", s);
|
|
213
|
+
const picked = await pickSessions(
|
|
214
|
+
ctx,
|
|
215
|
+
"Pull sessions",
|
|
216
|
+
candidates.map((c) => ({ id: c.target, label: c.label, checked: c.target === current })),
|
|
217
|
+
);
|
|
218
|
+
if (!picked?.length) return ctx.ui.notify("session-sync: nothing selected", "info");
|
|
219
|
+
for (const c of candidates.filter((c) => picked.includes(c.target))) {
|
|
220
|
+
if (c.target === current && !ctx.isIdle()) {
|
|
221
|
+
s.skipped.push(`${basename(c.target)}: agent busy (current session)`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const before = s.synced;
|
|
225
|
+
applyPull(cfg, c, s);
|
|
226
|
+
if (c.target === current && s.synced > before) currentUpdated = true;
|
|
227
|
+
}
|
|
228
|
+
report(ctx, "pulled", s);
|
|
229
|
+
} catch (e) {
|
|
230
|
+
return ctx.ui.notify(`session-sync pull failed: ${errMsg(e)}`, "error");
|
|
231
|
+
} finally {
|
|
232
|
+
ctx.ui.setStatus("session-sync", undefined);
|
|
233
|
+
}
|
|
234
|
+
// Reload the active session from disk; ctx is stale afterwards, so this is the last use.
|
|
235
|
+
if (currentUpdated && current) await ctx.switchSession(current);
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
pi.on("session_start", async (event, ctx) => {
|
|
240
|
+
if (event.reason !== "startup") return;
|
|
241
|
+
const cfg = getConfig(ctx, true);
|
|
242
|
+
if (!cfg?.auto.pullOnStart) return;
|
|
243
|
+
const current = ctx.sessionManager.getSessionFile();
|
|
244
|
+
// Fire and forget: never block startup on the network.
|
|
245
|
+
void (async () => {
|
|
246
|
+
const s = newSummary();
|
|
247
|
+
for (const c of await scanPull(exec, cfg, s)) if (c.target !== current) applyPull(cfg, c, s);
|
|
248
|
+
if (s.synced || s.skipped.length) report(ctx, "auto-pulled", s);
|
|
249
|
+
})().catch((e) => ctx.ui.notify(`session-sync auto-pull failed: ${errMsg(e)}`, "warning"));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
253
|
+
if (event.reason !== "quit") return;
|
|
254
|
+
const cfg = getConfig(ctx, true);
|
|
255
|
+
const current = ctx.sessionManager.getSessionFile();
|
|
256
|
+
if (!cfg?.auto.pushOnShutdown || !current || !existsSync(current)) return;
|
|
257
|
+
const h = header(readFileSync(current, "utf8"));
|
|
258
|
+
if (typeof h?.cwd !== "string" || !repoPathFor(h.cwd, basename(current), cfg.roots)) return;
|
|
259
|
+
try {
|
|
260
|
+
await pushSessions(exec, cfg, [current]);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
ctx.ui.notify(`session-sync auto-push failed: ${errMsg(e)}`, "warning");
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
package/src/layout.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { Roots } from "./rewrite.ts";
|
|
3
|
+
|
|
4
|
+
export const encodeRel = (rel: string) => (rel ? rel.replace(/[/\\:]/g, "-") : "_root_");
|
|
5
|
+
|
|
6
|
+
/** Longest configured root containing cwd (equal or prefix + "/"). */
|
|
7
|
+
export function rootFor(cwd: string, roots: Roots): { name: string; rel: string } | undefined {
|
|
8
|
+
let best: { name: string; rel: string; len: number } | undefined;
|
|
9
|
+
for (const [name, path] of Object.entries(roots)) {
|
|
10
|
+
if ((cwd === path || cwd.startsWith(`${path}/`)) && (!best || path.length > best.len))
|
|
11
|
+
best = { name, rel: cwd.slice(path.length + 1), len: path.length };
|
|
12
|
+
}
|
|
13
|
+
return best && { name: best.name, rel: best.rel };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function repoPathFor(localCwd: string, fileName: string, roots: Roots): string | undefined {
|
|
17
|
+
const r = rootFor(localCwd, roots);
|
|
18
|
+
return r && `sessions/${r.name}/${encodeRel(r.rel)}/${fileName}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Mirror of pi's getDefaultSessionDirPath (session-manager.js:291-294), which is not exported. */
|
|
22
|
+
export const localSessionDirFor = (cwd: string, agentDir: string) =>
|
|
23
|
+
join(agentDir, "sessions", `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`);
|
package/src/merge.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
type Line = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
function parse(text: string | undefined): { header: Line | undefined; entries: Line[] } {
|
|
4
|
+
const entries: Line[] = [];
|
|
5
|
+
let header: Line | undefined;
|
|
6
|
+
for (const raw of (text ?? "").split("\n")) {
|
|
7
|
+
if (!raw.trim()) continue;
|
|
8
|
+
const obj = JSON.parse(raw) as Line;
|
|
9
|
+
if (obj.type === "session" && !header) header = obj;
|
|
10
|
+
else entries.push(obj);
|
|
11
|
+
}
|
|
12
|
+
return { header, entries };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Merge two versions of one session JSONL file. Local wins on header and id conflicts.
|
|
17
|
+
* Entries are ordered by timestamp so the newest entry is the last line (pi's leaf, session-manager.js:733-742).
|
|
18
|
+
*/
|
|
19
|
+
export function mergeSessions(
|
|
20
|
+
localText: string | undefined,
|
|
21
|
+
remoteText: string | undefined,
|
|
22
|
+
): { text: string; added: number; conflicts: number; changed: boolean } {
|
|
23
|
+
const local = parse(localText);
|
|
24
|
+
const remote = parse(remoteText);
|
|
25
|
+
if (local.header && remote.header && local.header.id !== remote.header.id)
|
|
26
|
+
throw new Error(`session header id mismatch: ${local.header.id} vs ${remote.header.id}`);
|
|
27
|
+
|
|
28
|
+
const header = local.header ?? remote.header;
|
|
29
|
+
const byId = new Map<unknown, string>();
|
|
30
|
+
const merged: Line[] = [];
|
|
31
|
+
const noId: Line[] = [];
|
|
32
|
+
let added = 0;
|
|
33
|
+
let conflicts = 0;
|
|
34
|
+
|
|
35
|
+
for (const e of local.entries) {
|
|
36
|
+
if (e.id === undefined) noId.push(e);
|
|
37
|
+
else if (!byId.has(e.id)) {
|
|
38
|
+
byId.set(e.id, JSON.stringify(e));
|
|
39
|
+
merged.push(e);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const e of remote.entries) {
|
|
43
|
+
if (e.id === undefined) {
|
|
44
|
+
if (!localText) noId.push(e);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const existing = byId.get(e.id);
|
|
48
|
+
if (existing === undefined) {
|
|
49
|
+
byId.set(e.id, JSON.stringify(e));
|
|
50
|
+
merged.push(e);
|
|
51
|
+
added++;
|
|
52
|
+
} else if (existing !== JSON.stringify(e)) conflicts++;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Array.prototype.sort is stable: ties keep local-before-remote input order.
|
|
56
|
+
merged.sort((a, b) => {
|
|
57
|
+
const ta = String(a.timestamp ?? "");
|
|
58
|
+
const tb = String(b.timestamp ?? "");
|
|
59
|
+
return ta < tb ? -1 : ta > tb ? 1 : 0;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const lines = [...(header ? [header] : []), ...merged, ...noId].map((l) => JSON.stringify(l));
|
|
63
|
+
const text = lines.length ? `${lines.join("\n")}\n` : "";
|
|
64
|
+
return { text, added, conflicts, changed: text !== localText };
|
|
65
|
+
}
|
package/src/picklist.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Container, SettingsList } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
export type PickItem = { id: string; label: string; checked: boolean };
|
|
6
|
+
|
|
7
|
+
/** Multi-select list (pattern from examples/extensions/tools.ts). Returns selected ids, or undefined if cancelled. */
|
|
8
|
+
export async function pickSessions(ctx: ExtensionContext, title: string, items: PickItem[]): Promise<string[] | undefined> {
|
|
9
|
+
if (ctx.mode !== "tui") return items.filter((i) => i.checked).map((i) => i.id);
|
|
10
|
+
|
|
11
|
+
const selected = new Set(items.filter((i) => i.checked).map((i) => i.id));
|
|
12
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
13
|
+
const container = new Container();
|
|
14
|
+
container.addChild({
|
|
15
|
+
render: () => [theme.fg("accent", theme.bold(title)), theme.fg("dim", "space/enter toggle · esc done"), ""],
|
|
16
|
+
invalidate() {},
|
|
17
|
+
});
|
|
18
|
+
const list = new SettingsList(
|
|
19
|
+
items.map((i) => ({ id: i.id, label: i.label, currentValue: i.checked ? "on" : "off", values: ["on", "off"] })),
|
|
20
|
+
Math.min(items.length + 2, 15),
|
|
21
|
+
getSettingsListTheme(),
|
|
22
|
+
(id, value) => (value === "on" ? selected.add(id) : selected.delete(id)),
|
|
23
|
+
() => done(undefined),
|
|
24
|
+
);
|
|
25
|
+
container.addChild(list);
|
|
26
|
+
return {
|
|
27
|
+
capturesViewportInput: list.capturesViewportInput,
|
|
28
|
+
render: (w: number) => container.render(w),
|
|
29
|
+
invalidate: () => container.invalidate(),
|
|
30
|
+
handleInput(data: string) {
|
|
31
|
+
list.handleInput?.(data);
|
|
32
|
+
tui.requestRender();
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (selected.size === 0) return undefined;
|
|
38
|
+
const ok = await ctx.ui.confirm(title, `Sync ${selected.size} session(s)?`);
|
|
39
|
+
return ok ? items.filter((i) => selected.has(i.id)).map((i) => i.id) : undefined;
|
|
40
|
+
}
|
package/src/rewrite.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// shortcut: POSIX paths only, upgrade when Windows support is needed.
|
|
2
|
+
|
|
3
|
+
export type Roots = Record<string, string>;
|
|
4
|
+
|
|
5
|
+
export const TOKEN = (name: string) => `{{PISYNC:${name}}}`;
|
|
6
|
+
const TOKEN_RE = /\{\{PISYNC:([a-z0-9_-]+)\}\}/g;
|
|
7
|
+
|
|
8
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
9
|
+
|
|
10
|
+
// Root path only matches at path boundaries: not glued to a preceding path/word char, and followed by "/" or a non-path char.
|
|
11
|
+
function rootRegexes(roots: Roots): [RegExp, string][] {
|
|
12
|
+
return Object.entries(roots)
|
|
13
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
14
|
+
.map(([name, path]) => [new RegExp(`(?<![A-Za-z0-9._/-])${escapeRe(path)}(?![A-Za-z0-9._-])`, "g"), TOKEN(name)]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function toPortable(s: string, roots: Roots): string {
|
|
18
|
+
for (const [re, token] of rootRegexes(roots)) s = s.replace(re, token);
|
|
19
|
+
return s;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function toLocal(s: string, roots: Roots): { value: string; unknown: string[] } {
|
|
23
|
+
const unknown = new Set<string>();
|
|
24
|
+
const value = s.replace(TOKEN_RE, (m, name: string) => {
|
|
25
|
+
const path = Object.hasOwn(roots, name) ? roots[name] : undefined;
|
|
26
|
+
if (path !== undefined) return path;
|
|
27
|
+
unknown.add(name);
|
|
28
|
+
return m;
|
|
29
|
+
});
|
|
30
|
+
return { value, unknown: [...unknown] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function walk(v: unknown, fn: (s: string) => string): unknown {
|
|
34
|
+
if (typeof v === "string") return fn(v);
|
|
35
|
+
if (Array.isArray(v)) return v.map((x) => walk(x, fn));
|
|
36
|
+
if (v && typeof v === "object") {
|
|
37
|
+
const out: Record<string, unknown> = {};
|
|
38
|
+
for (const [k, x] of Object.entries(v)) out[k] = walk(x, fn);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
return v;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function rewriteLine(line: string, fn: (s: string) => string): string {
|
|
45
|
+
if (!line.trim()) return line;
|
|
46
|
+
return JSON.stringify(walk(JSON.parse(line), fn));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const mapLines = (text: string, fn: (s: string) => string) =>
|
|
50
|
+
text
|
|
51
|
+
.split("\n")
|
|
52
|
+
.map((l) => rewriteLine(l, fn))
|
|
53
|
+
.join("\n");
|
|
54
|
+
|
|
55
|
+
export function rewriteFileToPortable(text: string, roots: Roots): string {
|
|
56
|
+
const rs = rootRegexes(roots);
|
|
57
|
+
return mapLines(text, (s) => rs.reduce((acc, [re, token]) => acc.replace(re, token), s));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function rewriteFileToLocal(text: string, roots: Roots): { text: string; unknown: string[] } {
|
|
61
|
+
const unknown = new Set<string>();
|
|
62
|
+
const out = mapLines(text, (s) => {
|
|
63
|
+
const r = toLocal(s, roots);
|
|
64
|
+
for (const u of r.unknown) unknown.add(u);
|
|
65
|
+
return r.value;
|
|
66
|
+
});
|
|
67
|
+
return { text: out, unknown: [...unknown] };
|
|
68
|
+
}
|