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 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
- # Temporary Holding Version
1
+ # dsh-profile-gist-sync
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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