dsh-profile-gist-sync 0.0.0-stage → 1.0.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 +205 -2
- package/assets/icon-256.png +0 -0
- package/assets/make-icon.py +64 -0
- package/assets/screenshot-1.png +0 -0
- package/client.js +721 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +19 -0
- package/index.js +53 -0
- package/lib/backup.js +858 -0
- package/lib/errors.js +63 -0
- package/lib/gist.js +312 -0
- package/lib/routes.js +353 -0
- package/lib/store.js +73 -0
- package/lib/tool.js +194 -0
- package/locale/en.json +9 -0
- package/locale/zh.json +9 -0
- package/package.json +62 -4
- package/screenshots.json +3 -0
- package/test/activation.test.mjs +134 -0
- package/test/first-run.test.mjs +89 -0
- package/test/harness.mjs +169 -0
- package/test/library.test.mjs +147 -0
- package/test/package.test.mjs +159 -0
- package/test/run.mjs +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Meta-Virgo
|
|
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,206 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-profile-gist-sync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Back up this DSH profile's configuration to an **unlisted secret GitHub Gist**,
|
|
4
|
+
pull it back on another machine, and see at a glance whether the two agree.
|
|
5
|
+
|
|
6
|
+
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`)
|
|
7
|
+
plugin: a page in **Settings → Plugins → 配置同步 / Config sync**, and a
|
|
8
|
+
`config_backup` tool for the agent.
|
|
9
|
+
|
|
10
|
+
页面位置:**设置 → 插件 → 配置同步**。Agent 侧工具:`config_backup`。
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
dsh plugin --profile web add dsh-profile-gist-sync # from npm
|
|
16
|
+
dsh plugin --profile web add github:Meta-Virgo/dsh-profile-gist-sync # from source
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then restart DSH. No dependencies, no build step, no install scripts: the
|
|
20
|
+
package is plain ESM that Node runs as-is.
|
|
21
|
+
|
|
22
|
+
## The page
|
|
23
|
+
|
|
24
|
+
Three numbered steps, one button each; nothing has to be typed.
|
|
25
|
+
|
|
26
|
+
1. **状态** — one line saying whether the cloud copy and this machine agree,
|
|
27
|
+
plus the profile, the file count, and the last upload/sync. It checks the
|
|
28
|
+
cloud by itself on open; 重新检查 does it again.
|
|
29
|
+
2. **上传** — 上传到云端 backs this machine up to the cloud. The Gist this
|
|
30
|
+
profile already uses is updated, and the first upload creates one and
|
|
31
|
+
remembers it. 另存为本地文件 writes the same backup to a local file instead.
|
|
32
|
+
3. **同步** — 从云端同步 writes the cloud copy here, with 撤销上次同步 offered
|
|
33
|
+
afterwards.
|
|
34
|
+
|
|
35
|
+
Everything fiddly (the Gist id, a token, the machine-state checkbox, restoring
|
|
36
|
+
from a backup file, the file list) sits in a collapsed **高级设置** section. The
|
|
37
|
+
Gist id and the last upload/sync are remembered in
|
|
38
|
+
`<profile>/.dsh-config-sync/settings.json`; that file is never part of a backup.
|
|
39
|
+
|
|
40
|
+
## What leaves this machine
|
|
41
|
+
|
|
42
|
+
Short version, for anyone deciding whether to install this:
|
|
43
|
+
|
|
44
|
+
- The only host ever contacted is `api.github.com`. The checks in `test/` fail if
|
|
45
|
+
the plugin opens any other connection.
|
|
46
|
+
- Upload sends text configuration only — the files listed below. Credential
|
|
47
|
+
stores, binaries, `node_modules`, the lockfile, and this machine's usage
|
|
48
|
+
counters are excluded, and the UI names every exclusion instead of hiding it.
|
|
49
|
+
- The Gist is created `public: false`. **Unlisted, not private**: anyone with the
|
|
50
|
+
link can read it, so the link is the secret.
|
|
51
|
+
- The GitHub token is never written anywhere; it is used for one request and can
|
|
52
|
+
come from the request body, the host environment, or a signed-in `gh` CLI.
|
|
53
|
+
- The Gist id lives in `<profile>/.dsh-config-sync/settings.json` — inside a
|
|
54
|
+
hidden directory, which backups never carry, so syncing a machine never leaks
|
|
55
|
+
the id of the machine it came from.
|
|
56
|
+
- Restoring is merge-style and reversible: nothing is written before the
|
|
57
|
+
overwritten bytes are snapshotted for 撤销上次同步.
|
|
58
|
+
|
|
59
|
+
## What a backup carries
|
|
60
|
+
|
|
61
|
+
Text configuration from the profile directory: `package.json`,
|
|
62
|
+
`cordis.patch.yml`, `cordis.yml`, `.npmrc`, `pnpm-workspace.yaml`,
|
|
63
|
+
`data/*.json`, `suppliers/*`, and any other text file the profile holds. The
|
|
64
|
+
document format is `dsh-profile-backup` v0.2 — the same one the DSH plugin market
|
|
65
|
+
reads and writes, so a backup from here imports there and vice versa.
|
|
66
|
+
|
|
67
|
+
Never included, and always listed as excluded in the UI:
|
|
68
|
+
|
|
69
|
+
| Path | Why |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `node_modules/`, `pnpm-lock.yaml` | reproducible from `package.json` |
|
|
72
|
+
| `.git/`, `*.bak` | VCS state and repair leftovers |
|
|
73
|
+
| hidden directories (`.dsh-market/`, `.plugin-manager/`, `.dsh-config-sync/`) | logs, caches, this plugin's own undo snapshots |
|
|
74
|
+
| `data/auths/credentials.sqlite`, any other binary file | a credential store, and not portable |
|
|
75
|
+
| `data/usage.json` | this machine's counters — include with the checkbox when wanted |
|
|
76
|
+
|
|
77
|
+
A backup written elsewhere may still carry entries this plugin does not write
|
|
78
|
+
into a profile: the plugin market's exporter, for example, includes
|
|
79
|
+
`data/auths/credentials.sqlite`. Importing such a document is not refused — the
|
|
80
|
+
credential store, cached binaries, backup copies, oversized files, and hidden
|
|
81
|
+
directories are skipped, the rest of the configuration is restored, and the plan
|
|
82
|
+
lists exactly what was ignored and why. Only a path that escapes the profile, a
|
|
83
|
+
dependency directory, a duplicate path, a malformed entry, or a document with no
|
|
84
|
+
usable `package.json` is a hard refusal.
|
|
85
|
+
|
|
86
|
+
## Restore
|
|
87
|
+
|
|
88
|
+
Merge-style and reversible:
|
|
89
|
+
|
|
90
|
+
- Files in the backup are written; files only the local machine has stay.
|
|
91
|
+
- `package.json` is **merged**: dependencies and `dsh.profile.bundles` are
|
|
92
|
+
unioned, so plugins already installed on the target machine are kept.
|
|
93
|
+
- Every overwritten or created path is copied into
|
|
94
|
+
`<profile>/.dsh-config-sync/undo/<stamp>/` first, so the import can be undone
|
|
95
|
+
from the page or with the tool's `undo` action.
|
|
96
|
+
- Installed plugins are not part of a backup: after a restore that adds
|
|
97
|
+
dependencies, install them from the plugins page and restart DSH.
|
|
98
|
+
|
|
99
|
+
## GitHub Gist
|
|
100
|
+
|
|
101
|
+
Upload updates the Gist this profile remembers, or creates a secret one and
|
|
102
|
+
remembers it the first time; leave the id empty in 高级设置 and save to start a
|
|
103
|
+
different Gist, or paste an id/link to adopt one. Download reads
|
|
104
|
+
`dsh-profile-backup.json` from it and validates the document before anything is
|
|
105
|
+
written.
|
|
106
|
+
|
|
107
|
+
The token is never stored: paste one per session, or set `DSH_GITHUB_TOKEN` /
|
|
108
|
+
`GITHUB_TOKEN` on the host, or sign in with the `gh` CLI — the page offers a
|
|
109
|
+
"check token" button that reports which source answered. A secret Gist is
|
|
110
|
+
unlisted, **not private**: anyone with the link can read it.
|
|
111
|
+
|
|
112
|
+
## Agent tool
|
|
113
|
+
|
|
114
|
+
`config_backup` with `action`: `status`, `export`, `gist_upload`, `gist_read`,
|
|
115
|
+
`restore`, `undo`. Gist actions use the remembered cloud copy when `gistId` is
|
|
116
|
+
omitted, so "back this up" and "sync my configuration" work without an id.
|
|
117
|
+
`export` writes inside `<profile>/.dsh-config-sync/exports/`, and `restore`
|
|
118
|
+
reads only from inside the DSH home directory, so the tool cannot be used as a
|
|
119
|
+
file-system escape.
|
|
120
|
+
|
|
121
|
+
## Checks
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
node test/run.mjs
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Four check files, each a separate process, all offline: profiles are temporary
|
|
128
|
+
directories, the GitHub HTTP layer is stubbed (and refuses every host but
|
|
129
|
+
`api.github.com`), and the only server any check talks to is its own loopback
|
|
130
|
+
port. They cover the document format and its exclusions, the restore plan and
|
|
131
|
+
undo, the activation contract, the route and tool surface, the first-run journey
|
|
132
|
+
against the stubbed GitHub — and that the package still activates after being
|
|
133
|
+
packed with only the manifest's `files` list.
|
|
134
|
+
|
|
135
|
+
## Which profile this plugin works on
|
|
136
|
+
|
|
137
|
+
A DSH process does **not** necessarily carry `DSH_PROFILE_DIR`. DSH Desktop
|
|
138
|
+
starts its host as
|
|
139
|
+
`DeepSeek Harness.exe --expose-internals …/dsh-desktop-host/lib/index.js <checkout> <profile dir> …`
|
|
140
|
+
with no profile variables at all — the `DSH_*` values a shell tool sees are
|
|
141
|
+
injected per call, not inherited by the host. So the profile is resolved from,
|
|
142
|
+
in order, and every source is checked against the filesystem before it is used:
|
|
143
|
+
|
|
144
|
+
1. what the running host reports (`profileContext`) — the launcher owns the
|
|
145
|
+
location, so this wins when present;
|
|
146
|
+
2. `DSH_PROFILE_DIR`, then `DSH_HOME` + `DSH_PROFILE` (a CLI-started DSH takes
|
|
147
|
+
these as instructions, and a missing directory is reported as such);
|
|
148
|
+
3. the `<profile dir>` argument the desktop host is started with;
|
|
149
|
+
4. the profile that has this plugin installed — the one running it;
|
|
150
|
+
5. the only profile present.
|
|
151
|
+
|
|
152
|
+
If none of them answers, the page says so instead of backing up the wrong
|
|
153
|
+
directory. `/api/status` carries the source that answered as `profileSource`
|
|
154
|
+
(and the `config_backup` tool prints it in its `status` summary); the page shows
|
|
155
|
+
the profile it settled on at the bottom of 高级设置.
|
|
156
|
+
|
|
157
|
+
## If the plugin does not load
|
|
158
|
+
|
|
159
|
+
设置 → 插件 → 插件列表 shows one row per loaded entry, with its `fiberPhase` and,
|
|
160
|
+
when it fails, a one-line diagnostic. When this plugin fails, none of its routes
|
|
161
|
+
exist: the page then answers `操作失败。HTTP 404` and names the missing host half
|
|
162
|
+
itself.
|
|
163
|
+
|
|
164
|
+
Two failures seen while building this, both worth avoiding:
|
|
165
|
+
|
|
166
|
+
- **`Invalid effect`** — a `ctx.effect(...)` callback returned something other
|
|
167
|
+
than a disposer, and Cordis rejects the *whole row* for it (so the routes and
|
|
168
|
+
the `config_backup` tool both disappear). Work that owns no resource belongs in
|
|
169
|
+
a plain call inside `apply`, not in an effect.
|
|
170
|
+
- **`no-profile`** — the host was started without `DSH_PROFILE_DIR` and the
|
|
171
|
+
fallback chain could not name a profile. See above.
|
|
172
|
+
|
|
173
|
+
Toggling that entry off and on re-imports the module from disk, so a source fix
|
|
174
|
+
applies **without restarting DSH**. A restart is only needed when the row's own
|
|
175
|
+
name changed, because the bundle then has to be resolved again at boot.
|
|
176
|
+
|
|
177
|
+
## Route surface
|
|
178
|
+
|
|
179
|
+
One `prefix` route on the bare web server:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
GET /dsh-profile-gist-sync/api/status
|
|
183
|
+
GET /dsh-profile-gist-sync/api/export[?include=machine]
|
|
184
|
+
POST /dsh-profile-gist-sync/api/settings { gistId?, includeMachine? }
|
|
185
|
+
POST /dsh-profile-gist-sync/api/sync { id?, token? }
|
|
186
|
+
POST /dsh-profile-gist-sync/api/preview { backup }
|
|
187
|
+
POST /dsh-profile-gist-sync/api/restore { backup, confirm: true }
|
|
188
|
+
POST /dsh-profile-gist-sync/api/undo {}
|
|
189
|
+
POST /dsh-profile-gist-sync/api/file/read { path }
|
|
190
|
+
POST /dsh-profile-gist-sync/api/gist/token { token? }
|
|
191
|
+
POST /dsh-profile-gist-sync/api/gist/upload { id?, token?, includeMachine? }
|
|
192
|
+
POST /dsh-profile-gist-sync/api/gist/read { id?, token? }
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`/api/sync` is `/api/gist/read` plus `/api/restore` in one step, restricted to a
|
|
196
|
+
Gist target — that restriction is what keeps it from being a general write
|
|
197
|
+
endpoint: the document still has to be a valid `dsh-profile-backup`.
|
|
198
|
+
|
|
199
|
+
Every request must pass the host's own gate (`connection.requestRejection`)
|
|
200
|
+
when the host publishes one, and its Host must be loopback or an authority the
|
|
201
|
+
deployment declared.
|
|
202
|
+
|
|
203
|
+
## Licence
|
|
204
|
+
|
|
205
|
+
MIT. `icon.svg` is the plugin's own mark; `assets/icon-256.png` is the raster
|
|
206
|
+
form the plugin market asks for, generated by `assets/make-icon.py`.
|
|
Binary file
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Render the storefront icon as a raster PNG.
|
|
2
|
+
|
|
3
|
+
The plugin market's catalogue accepts only GitHub-hosted raster images for a
|
|
4
|
+
storefront tile (SVG is refused), so the same mark as `icon.svg` is drawn here:
|
|
5
|
+
a rounded blue tile, a white cloud, and a downward arrow into it.
|
|
6
|
+
|
|
7
|
+
python assets/make-icon.py
|
|
8
|
+
"""
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from PIL import Image, ImageDraw
|
|
12
|
+
|
|
13
|
+
SIZE = 256
|
|
14
|
+
SCALE = 4 # draw large, then downsample for clean edges
|
|
15
|
+
BLUE_TOP = (79, 143, 247)
|
|
16
|
+
BLUE_BOTTOM = (43, 98, 214)
|
|
17
|
+
ARROW = (31, 79, 176)
|
|
18
|
+
WHITE = (255, 255, 255)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def lerp(a, b, t):
|
|
22
|
+
return tuple(round(x + (y - x) * t) for x, y in zip(a, b))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main():
|
|
26
|
+
size = SIZE * SCALE
|
|
27
|
+
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
|
28
|
+
|
|
29
|
+
# Rounded tile with a vertical gradient, masked by the rounded rectangle.
|
|
30
|
+
gradient = Image.new("RGBA", (size, size))
|
|
31
|
+
pen = ImageDraw.Draw(gradient)
|
|
32
|
+
for y in range(size):
|
|
33
|
+
pen.line([(0, y), (size, y)], fill=lerp(BLUE_TOP, BLUE_BOTTOM, y / size) + (255,))
|
|
34
|
+
mask = Image.new("L", (size, size), 0)
|
|
35
|
+
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size - 1, size - 1], radius=int(size * 0.22), fill=255)
|
|
36
|
+
image.paste(gradient, (0, 0), mask)
|
|
37
|
+
|
|
38
|
+
draw = ImageDraw.Draw(image)
|
|
39
|
+
|
|
40
|
+
# The cloud: three overlapping discs on a flat base, as in the SVG path.
|
|
41
|
+
unit = size / 64
|
|
42
|
+
cloud = WHITE + (242,)
|
|
43
|
+
draw.ellipse([22 * unit, 21 * unit, 40 * unit, 39 * unit], fill=cloud)
|
|
44
|
+
draw.ellipse([33 * unit, 26 * unit, 46 * unit, 39 * unit], fill=cloud)
|
|
45
|
+
draw.rounded_rectangle([20 * unit, 28 * unit, 45 * unit, 39 * unit], radius=5 * unit, fill=cloud)
|
|
46
|
+
|
|
47
|
+
# The arrow pointing down into the cloud.
|
|
48
|
+
stroke = 3.4 * unit
|
|
49
|
+
draw.line([32 * unit, 41 * unit, 32 * unit, 52 * unit], fill=ARROW, width=round(stroke))
|
|
50
|
+
draw.line([26.5 * unit, 46 * unit, 32 * unit, 52 * unit], fill=ARROW, width=round(stroke))
|
|
51
|
+
draw.line([37.5 * unit, 46 * unit, 32 * unit, 52 * unit], fill=ARROW, width=round(stroke))
|
|
52
|
+
|
|
53
|
+
# The two white ticks either side, as in the SVG.
|
|
54
|
+
tick = 3 * unit
|
|
55
|
+
draw.line([18 * unit, 46 * unit, 24 * unit, 46 * unit], fill=WHITE + (230,), width=round(tick))
|
|
56
|
+
draw.line([40 * unit, 46 * unit, 46 * unit, 46 * unit], fill=WHITE + (230,), width=round(tick))
|
|
57
|
+
|
|
58
|
+
out = Path(__file__).with_name("icon-256.png")
|
|
59
|
+
image.resize((SIZE, SIZE), Image.LANCZOS).save(out)
|
|
60
|
+
print(f"wrote {out} ({out.stat().st_size} bytes, {SIZE}x{SIZE})")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
main()
|
|
Binary file
|