dsh-webdav-sync 1.0.3 → 1.0.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/README.md +38 -19
- package/lib/client.js +109 -31
- package/lib/dirs.d.ts +1 -1
- package/lib/dirs.js +1 -1
- package/lib/engine.d.ts +22 -5
- package/lib/engine.js +136 -34
- package/lib/index.js +30 -10
- package/lib/keys.d.ts +125 -0
- package/lib/keys.js +168 -0
- package/lib/state.js +19 -6
- package/lib/store.d.ts +18 -8
- package/lib/store.js +23 -33
- package/lib/wire.d.ts +90 -16
- package/lib/wire.js +12 -1
- package/package.json +2 -2
package/lib/keys.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `keys` part: the API keys this computer stores for the providers its
|
|
3
|
+
* profile names, and putting back the ones a merge brings.
|
|
4
|
+
*
|
|
5
|
+
* A DSH profile never holds a key. It holds the *name* of one — `apiKeyEnv` on a
|
|
6
|
+
* provider route, `secretEnv` on a webhook — and DSH resolves that name through
|
|
7
|
+
* its credential service, which layers the launch environment over this
|
|
8
|
+
* computer's own store (`$DSH_HOME/.credentials.yaml`). So a synced setup travels
|
|
9
|
+
* complete and still cannot reach a single provider: the names arrive, and on the
|
|
10
|
+
* other computer they resolve to nothing. That is the whole of this part.
|
|
11
|
+
*
|
|
12
|
+
* Two things follow from where the values live, and they are the two rules of
|
|
13
|
+
* this module:
|
|
14
|
+
*
|
|
15
|
+
* - The names come from the synced configuration, not from the store. The
|
|
16
|
+
* credential service has no way to enumerate references — a configuration
|
|
17
|
+
* surface learns which ones exist from its own schema — so this plugin reads the
|
|
18
|
+
* names out of the very parts it is about to send, and asks the service for each
|
|
19
|
+
* one. That also keeps the two in step for free: a key travels exactly when the
|
|
20
|
+
* part that names it travels.
|
|
21
|
+
* - Nothing here deletes. A key removed on one computer stays on the others, and a
|
|
22
|
+
* key that cannot be written is reported rather than skipped silently. See
|
|
23
|
+
* `docs/decisions/0054-sync-api-keys.md`.
|
|
24
|
+
*/
|
|
25
|
+
import { message } from './store.js';
|
|
26
|
+
/**
|
|
27
|
+
* The configuration field names DSH resolves as a credential reference.
|
|
28
|
+
*
|
|
29
|
+
* Taken from the schemas the shipped plugins declare: `apiKeyEnv` on the
|
|
30
|
+
* `llm-pi-ai` provider routes, on `llm-deepseek-api-key` and on
|
|
31
|
+
* `web-search-deepseek`; `secretEnv` on `webhook-github`. A plugin outside this
|
|
32
|
+
* list names its credential some other way and its key does not travel — which is
|
|
33
|
+
* why the list is here, in one place, rather than inline.
|
|
34
|
+
*/
|
|
35
|
+
export const CREDENTIAL_FIELDS = ['apiKeyEnv', 'secretEnv'];
|
|
36
|
+
/**
|
|
37
|
+
* The credential service in the shape this plugin uses it.
|
|
38
|
+
*
|
|
39
|
+
* `set` rejects while a read-only layer shadows the reference, which is exactly
|
|
40
|
+
* the fact that matters here: a value that would not take effect is not one to
|
|
41
|
+
* report as stored. {@link applyKeys} therefore asks first and writes second.
|
|
42
|
+
*/
|
|
43
|
+
export function keysPort(service) {
|
|
44
|
+
return {
|
|
45
|
+
resolve: (ref) => service.resolve(ref),
|
|
46
|
+
store: (ref, value) => service.set(ref, value),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every credential-reference name the given parts name, deduplicated and sorted.
|
|
51
|
+
*
|
|
52
|
+
* Walks the parts as they are — no schema, no interpretation — because the value
|
|
53
|
+
* of a reference field is a name wherever it sits, and the shape of a provider
|
|
54
|
+
* config is the provider plugin's to decide, not this one's.
|
|
55
|
+
*/
|
|
56
|
+
export function credentialRefs(parts) {
|
|
57
|
+
const found = new Set();
|
|
58
|
+
for (const part of Object.values(parts))
|
|
59
|
+
walk(part, found);
|
|
60
|
+
return [...found].sort();
|
|
61
|
+
}
|
|
62
|
+
function walk(value, into) {
|
|
63
|
+
if (value === null || typeof value !== 'object')
|
|
64
|
+
return;
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
for (const item of value)
|
|
67
|
+
walk(item, into);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const [key, held] of Object.entries(value)) {
|
|
71
|
+
if (CREDENTIAL_FIELDS.includes(key)) {
|
|
72
|
+
const ref = typeof held === 'string' ? held.trim() : '';
|
|
73
|
+
if (ref !== '')
|
|
74
|
+
into.add(ref);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
walk(held, into);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Read the keys this computer holds for the names in hand.
|
|
82
|
+
*
|
|
83
|
+
* Only the managed store travels: a name the launching environment supplies is
|
|
84
|
+
* left out, and so is one that falls through to a `.env`. Two reasons, and they
|
|
85
|
+
* agree — the store is the one layer this plugin can write back to, and an
|
|
86
|
+
* environment export belongs to whoever started DSH, not to this computer's
|
|
87
|
+
* stored setup. The environment-wins rule is the service's own
|
|
88
|
+
* (`resolve().source` is `'env'` whenever the inherited environment has the name,
|
|
89
|
+
* and a write is refused while it does), so a name that is both exported and
|
|
90
|
+
* stored is read as an export and left here; unsetting it in the shell is what
|
|
91
|
+
* makes the stored value the one in force, and the one that travels.
|
|
92
|
+
*
|
|
93
|
+
* A name with no stored value is skipped, not reported: plenty of profiles name a
|
|
94
|
+
* provider nobody has signed in to, and "this key is not here either" is not a
|
|
95
|
+
* failure of the sync. A name that cannot be resolved *at all* is different, and
|
|
96
|
+
* is reported — a store that cannot be read must not look like a store with
|
|
97
|
+
* nothing in it, or a sync would read it as "this computer has no keys".
|
|
98
|
+
*/
|
|
99
|
+
export async function readKeys(port, parts) {
|
|
100
|
+
const refs = credentialRefs(parts);
|
|
101
|
+
if (refs.length === 0)
|
|
102
|
+
return { problems: [] };
|
|
103
|
+
if (port === undefined)
|
|
104
|
+
return { problems: [{ ref: '', message: NO_SERVICE }] };
|
|
105
|
+
const found = {};
|
|
106
|
+
const problems = [];
|
|
107
|
+
for (const ref of refs) {
|
|
108
|
+
try {
|
|
109
|
+
const resolved = await port.resolve(ref);
|
|
110
|
+
if (resolved !== undefined && resolved.source === 'file' && resolved.value !== '')
|
|
111
|
+
found[ref] = resolved.value;
|
|
112
|
+
}
|
|
113
|
+
catch (cause) {
|
|
114
|
+
problems.push({ ref, message: message(cause) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (Object.keys(found).length === 0)
|
|
118
|
+
return { problems };
|
|
119
|
+
return { part: { refs: found }, problems };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Put the merged keys part into this computer's store, and report what would not
|
|
123
|
+
* go in.
|
|
124
|
+
*
|
|
125
|
+
* A value already in force here is left alone. That is not an optimisation: the
|
|
126
|
+
* service refuses a write while a read-only layer shadows the reference, and a
|
|
127
|
+
* value that is already the one in force — the same key, exported and stored —
|
|
128
|
+
* would be reported as a failure for saying so twice.
|
|
129
|
+
*
|
|
130
|
+
* `beforeWrite` is called at most once, just before the first value that really
|
|
131
|
+
* replaces something. A credential has no copy kept by the caller any other way,
|
|
132
|
+
* so it is the callback's job to seal one: overwriting a key without keeping the
|
|
133
|
+
* value it had would leave undo with nothing to put back.
|
|
134
|
+
*
|
|
135
|
+
* `null` — the merge's way of saying the part was taken away — writes nothing. No
|
|
136
|
+
* credential is ever removed by a sync.
|
|
137
|
+
*/
|
|
138
|
+
export async function applyKeys(port, part, beforeWrite = async () => undefined) {
|
|
139
|
+
if (part === null || part === undefined)
|
|
140
|
+
return [];
|
|
141
|
+
const entries = Object.entries(part.refs ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
142
|
+
if (entries.length === 0)
|
|
143
|
+
return [];
|
|
144
|
+
if (port === undefined)
|
|
145
|
+
return [{ ref: '', message: NO_SERVICE }];
|
|
146
|
+
const problems = [];
|
|
147
|
+
let kept = false;
|
|
148
|
+
for (const [ref, value] of entries) {
|
|
149
|
+
if (typeof value !== 'string' || value === '')
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
const current = await port.resolve(ref);
|
|
153
|
+
if (current !== undefined && current.value === value)
|
|
154
|
+
continue;
|
|
155
|
+
if (!kept) {
|
|
156
|
+
kept = true;
|
|
157
|
+
await beforeWrite();
|
|
158
|
+
}
|
|
159
|
+
await port.store(ref, value);
|
|
160
|
+
}
|
|
161
|
+
catch (cause) {
|
|
162
|
+
problems.push({ ref, message: message(cause) });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return problems;
|
|
166
|
+
}
|
|
167
|
+
/** Said whenever the host has no credential service to read or write at all. */
|
|
168
|
+
const NO_SERVICE = '这台电脑没有 DSH 的凭据服务,API 密钥不在这里读写';
|
package/lib/state.js
CHANGED
|
@@ -38,11 +38,19 @@ export function defaultConfig() {
|
|
|
38
38
|
passphrase: '',
|
|
39
39
|
autoEvery: AUTO_MANUAL,
|
|
40
40
|
models: true,
|
|
41
|
-
defaults: true,
|
|
42
41
|
plugins: true,
|
|
43
42
|
patch: true,
|
|
43
|
+
// On by default: the profile names where its API keys come from, so a setup
|
|
44
|
+
// that travels without them arrives with every provider unauthenticated. The
|
|
45
|
+
// switch exists so a person can keep secrets off the server; see
|
|
46
|
+
// `docs/decisions/0054-sync-api-keys.md`.
|
|
47
|
+
keys: true,
|
|
44
48
|
dirs: [],
|
|
45
|
-
|
|
49
|
+
// Off by default: a directory travels whole, and a fresh setup that has not
|
|
50
|
+
// named one yet has nothing to carry. The list is kept independently, so
|
|
51
|
+
// switching this on or off never forgets a path — see
|
|
52
|
+
// `docs/decisions/0056-drop-default-model-part.md`.
|
|
53
|
+
dirsEnabled: false,
|
|
46
54
|
rememberPassword: true,
|
|
47
55
|
headers: '',
|
|
48
56
|
relayEnabled: false,
|
|
@@ -211,13 +219,11 @@ export function syncsPart(config, part) {
|
|
|
211
219
|
switch (part) {
|
|
212
220
|
case 'models':
|
|
213
221
|
return config.models;
|
|
214
|
-
case 'defaults':
|
|
215
|
-
return config.defaults;
|
|
216
222
|
case 'plugins':
|
|
217
223
|
return config.plugins;
|
|
218
224
|
case 'patch':
|
|
219
225
|
return config.patch;
|
|
220
|
-
// The extra directories carry a switch of their own, like the
|
|
226
|
+
// The extra directories carry a switch of their own, like the three parts above:
|
|
221
227
|
// the list of paths says *which* directories, the switch says whether this
|
|
222
228
|
// computer carries them at all. Off keeps the list — the paths are not
|
|
223
229
|
// forgotten — and changes nothing anywhere else: the part is carried through
|
|
@@ -225,6 +231,13 @@ export function syncsPart(config, part) {
|
|
|
225
231
|
// others.
|
|
226
232
|
case 'dirs':
|
|
227
233
|
return config.dirsEnabled;
|
|
234
|
+
// The API keys are the one part that is a secret, so they are the one part
|
|
235
|
+
// whose switch a person may genuinely want off: the server holds the sealed
|
|
236
|
+
// file, and a sealed file still sits on someone else's disk. Off leaves every
|
|
237
|
+
// key here and carries the server's copy of the part through untouched, so
|
|
238
|
+
// nothing is deleted anywhere either.
|
|
239
|
+
case 'keys':
|
|
240
|
+
return config.keys;
|
|
228
241
|
}
|
|
229
242
|
}
|
|
230
243
|
/** Fill in defaults and coerce the several fields that come from a browser. */
|
|
@@ -249,9 +262,9 @@ export function normaliseConfig(raw) {
|
|
|
249
262
|
passphrase: text('passphrase'),
|
|
250
263
|
autoEvery,
|
|
251
264
|
models: flag('models'),
|
|
252
|
-
defaults: flag('defaults'),
|
|
253
265
|
plugins: flag('plugins'),
|
|
254
266
|
patch: flag('patch'),
|
|
267
|
+
keys: flag('keys'),
|
|
255
268
|
dirs: Array.isArray(raw.dirs) ? normaliseDirs(raw.dirs) : base.dirs,
|
|
256
269
|
dirsEnabled: flag('dirsEnabled'),
|
|
257
270
|
rememberPassword: flag('rememberPassword'),
|
package/lib/store.d.ts
CHANGED
|
@@ -8,16 +8,20 @@
|
|
|
8
8
|
* everything else — MCP servers, permission presets, UI settings, inserts — is
|
|
9
9
|
* the rest of them. `package.json` names the installed plugins.
|
|
10
10
|
*
|
|
11
|
+
* The default-model row is read here only to be kept out of the sync: it is one of
|
|
12
|
+
* the rows a sync never carries and never writes, like this plugin's own row. See
|
|
13
|
+
* `docs/decisions/0056-drop-default-model-part.md`.
|
|
14
|
+
*
|
|
11
15
|
* Writes go through the `yaml` Document API rather than a stringify of a parsed
|
|
12
16
|
* object, so the file's own comments and layout survive a sync that only meant to
|
|
13
17
|
* change one provider.
|
|
14
18
|
*/
|
|
15
19
|
import type { Document } from 'yaml';
|
|
16
|
-
import { type
|
|
20
|
+
import { type KeysPart, type ModelsPart, type PatchEntry, type PatchPart, type PluginsPart } from './wire.js';
|
|
17
21
|
import type { ResolvedPaths } from './paths.js';
|
|
18
22
|
/** The profile patch row holding the model catalog. */
|
|
19
23
|
export declare const MODELS_ID = "llm-pi-ai";
|
|
20
|
-
/** The profile patch row holding the default model. */
|
|
24
|
+
/** The profile patch row holding the default model; kept out of every part. */
|
|
21
25
|
export declare const DEFAULTS_ID = "agent-default-model";
|
|
22
26
|
/** Package whose patch row the plugin adds for itself; never carried as a part. */
|
|
23
27
|
export declare const SELF_ID = "dsh-webdav-sync";
|
|
@@ -50,9 +54,15 @@ export declare function readManifest(paths: ResolvedPaths): Promise<ManifestFile
|
|
|
50
54
|
/** Every part of a profile this plugin can carry, as collected from disk. */
|
|
51
55
|
export interface Collected {
|
|
52
56
|
models?: ModelsPart;
|
|
53
|
-
defaults?: DefaultsPart;
|
|
54
57
|
plugins?: PluginsPart;
|
|
55
58
|
patch?: PatchPart;
|
|
59
|
+
/**
|
|
60
|
+
* The keys, added by `take` rather than here: they are the one part that does
|
|
61
|
+
* not come from a profile file. Left out entirely when this computer holds
|
|
62
|
+
* nothing for any name the profile uses — the same "no opinion" an absent
|
|
63
|
+
* `models` carries, so the server's copy is carried through untouched.
|
|
64
|
+
*/
|
|
65
|
+
keys?: KeysPart;
|
|
56
66
|
}
|
|
57
67
|
/**
|
|
58
68
|
* Gather the parts from one profile.
|
|
@@ -73,14 +83,14 @@ export declare function missingPlugins(plugins: PluginsPart | undefined, install
|
|
|
73
83
|
/**
|
|
74
84
|
* Rewrite the profile patch so it holds the merged parts.
|
|
75
85
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* so a sync that changed nothing does not rewrite the
|
|
86
|
+
* The model row is replaced in place when it exists and appended when it does not;
|
|
87
|
+
* the remaining rows are replaced wholesale by the merged list, with the model, the
|
|
88
|
+
* default-model and this plugin's own rows kept where they were. A no-op merge
|
|
89
|
+
* leaves the bytes untouched, so a sync that changed nothing does not rewrite the
|
|
90
|
+
* file.
|
|
80
91
|
*/
|
|
81
92
|
export declare function applyPatch(patch: PatchFile, parts: {
|
|
82
93
|
models?: ModelsPart | null;
|
|
83
|
-
defaults?: DefaultsPart | null;
|
|
84
94
|
patch?: PatchPart;
|
|
85
95
|
}): string;
|
|
86
96
|
/**
|
package/lib/store.js
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* everything else — MCP servers, permission presets, UI settings, inserts — is
|
|
9
9
|
* the rest of them. `package.json` names the installed plugins.
|
|
10
10
|
*
|
|
11
|
+
* The default-model row is read here only to be kept out of the sync: it is one of
|
|
12
|
+
* the rows a sync never carries and never writes, like this plugin's own row. See
|
|
13
|
+
* `docs/decisions/0056-drop-default-model-part.md`.
|
|
14
|
+
*
|
|
11
15
|
* Writes go through the `yaml` Document API rather than a stringify of a parsed
|
|
12
16
|
* object, so the file's own comments and layout survive a sync that only meant to
|
|
13
17
|
* change one provider.
|
|
@@ -20,7 +24,7 @@ import { hashJson } from './crypto.js';
|
|
|
20
24
|
import { BACKUP_EXT, } from './wire.js';
|
|
21
25
|
/** The profile patch row holding the model catalog. */
|
|
22
26
|
export const MODELS_ID = 'llm-pi-ai';
|
|
23
|
-
/** The profile patch row holding the default model. */
|
|
27
|
+
/** The profile patch row holding the default model; kept out of every part. */
|
|
24
28
|
export const DEFAULTS_ID = 'agent-default-model';
|
|
25
29
|
/** Package whose patch row the plugin adds for itself; never carried as a part. */
|
|
26
30
|
export const SELF_ID = 'dsh-webdav-sync';
|
|
@@ -97,11 +101,6 @@ export function collect(patch, manifest) {
|
|
|
97
101
|
const config = modelsRow.value.config;
|
|
98
102
|
out.models = { config: isPlainObject(config) ? config : {} };
|
|
99
103
|
}
|
|
100
|
-
const defaultsRow = patch.rows.find((row) => row.key === `id:${DEFAULTS_ID}`);
|
|
101
|
-
if (defaultsRow !== undefined) {
|
|
102
|
-
const config = defaultsRow.value.config;
|
|
103
|
-
out.defaults = { config: isPlainObject(config) ? config : {} };
|
|
104
|
-
}
|
|
105
104
|
const dependencies = manifest.value.dependencies;
|
|
106
105
|
const bundles = readBundles(manifest.value);
|
|
107
106
|
if (isPlainObject(dependencies) || bundles.length > 0) {
|
|
@@ -152,10 +151,11 @@ export function missingPlugins(plugins, installed) {
|
|
|
152
151
|
/**
|
|
153
152
|
* Rewrite the profile patch so it holds the merged parts.
|
|
154
153
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* so a sync that changed nothing does not rewrite the
|
|
154
|
+
* The model row is replaced in place when it exists and appended when it does not;
|
|
155
|
+
* the remaining rows are replaced wholesale by the merged list, with the model, the
|
|
156
|
+
* default-model and this plugin's own rows kept where they were. A no-op merge
|
|
157
|
+
* leaves the bytes untouched, so a sync that changed nothing does not rewrite the
|
|
158
|
+
* file.
|
|
159
159
|
*/
|
|
160
160
|
export function applyPatch(patch, parts) {
|
|
161
161
|
const document = patch.document;
|
|
@@ -165,9 +165,6 @@ export function applyPatch(patch, parts) {
|
|
|
165
165
|
if (parts.models !== undefined && parts.models !== null) {
|
|
166
166
|
rows.push({ id: MODELS_ID, name: '@deepseek-ai/dsh-llm-pi-ai', config: parts.models.config });
|
|
167
167
|
}
|
|
168
|
-
if (parts.defaults !== undefined && parts.defaults !== null) {
|
|
169
|
-
rows.push({ id: DEFAULTS_ID, name: '@deepseek-ai/dsh-agent-default-model', config: parts.defaults.config });
|
|
170
|
-
}
|
|
171
168
|
if (parts.patch !== undefined)
|
|
172
169
|
rows.push(...parts.patch.entries);
|
|
173
170
|
const replacement = document.createNode(rows);
|
|
@@ -187,15 +184,13 @@ export function applyPatch(patch, parts) {
|
|
|
187
184
|
const value = item.toJSON();
|
|
188
185
|
byKey.set(keyOf(value), { node: item, index });
|
|
189
186
|
});
|
|
190
|
-
// 1. Replace or append the
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const existing = byKey.get(`id:${id}`);
|
|
198
|
-
if (part === null) {
|
|
187
|
+
// 1. Replace or append the model row. It is the one row with a part of its own;
|
|
188
|
+
// the default-model row that sits beside it is left exactly as it is, since
|
|
189
|
+
// it is not carried at all.
|
|
190
|
+
const modelPart = parts.models;
|
|
191
|
+
if (modelPart !== undefined) {
|
|
192
|
+
const existing = byKey.get(`id:${MODELS_ID}`);
|
|
193
|
+
if (modelPart === null) {
|
|
199
194
|
// The other computer took the row away, and that is the merge: a part that
|
|
200
195
|
// was removed there has to leave here, or the two never converge.
|
|
201
196
|
if (existing !== undefined) {
|
|
@@ -203,9 +198,8 @@ export function applyPatch(patch, parts) {
|
|
|
203
198
|
if (at !== -1)
|
|
204
199
|
contents.items.splice(at, 1);
|
|
205
200
|
}
|
|
206
|
-
continue;
|
|
207
201
|
}
|
|
208
|
-
if (existing !== undefined) {
|
|
202
|
+
else if (existing !== undefined) {
|
|
209
203
|
// Replace the config node only when it actually differs, and then merge
|
|
210
204
|
// into it rather than rebuilding: the catalog holds the user's own
|
|
211
205
|
// comments about their providers. `keepScalar` keeps the node itself.
|
|
@@ -217,16 +211,16 @@ export function applyPatch(patch, parts) {
|
|
|
217
211
|
// lacked the key does not gain an empty one.
|
|
218
212
|
const config = existing.node.has('config') ? existing.node.get('config', true) : undefined;
|
|
219
213
|
if (config === undefined) {
|
|
220
|
-
if (Object.keys(
|
|
221
|
-
existing.node.set('config', document.createNode(
|
|
214
|
+
if (Object.keys(modelPart.config).length > 0) {
|
|
215
|
+
existing.node.set('config', document.createNode(modelPart.config));
|
|
222
216
|
}
|
|
223
217
|
}
|
|
224
|
-
else if (!sameJson(config,
|
|
225
|
-
existing.node.set('config', mergeInto(document, config,
|
|
218
|
+
else if (!sameJson(config, modelPart.config)) {
|
|
219
|
+
existing.node.set('config', mergeInto(document, config, modelPart.config));
|
|
226
220
|
}
|
|
227
221
|
}
|
|
228
222
|
else {
|
|
229
|
-
const row = document.createNode({ id, name:
|
|
223
|
+
const row = document.createNode({ id: MODELS_ID, name: '@deepseek-ai/dsh-llm-pi-ai', config: modelPart.config });
|
|
230
224
|
contents.items.push(row);
|
|
231
225
|
}
|
|
232
226
|
}
|
|
@@ -275,10 +269,6 @@ export function applyPatch(patch, parts) {
|
|
|
275
269
|
return patch.text;
|
|
276
270
|
return document.toString();
|
|
277
271
|
}
|
|
278
|
-
/** The loader `name` a row carries when this plugin has to create it. */
|
|
279
|
-
function defaultNameFor(id) {
|
|
280
|
-
return id === MODELS_ID ? '@deepseek-ai/dsh-llm-pi-ai' : '@deepseek-ai/dsh-agent-default-model';
|
|
281
|
-
}
|
|
282
272
|
/**
|
|
283
273
|
* Rewrite the profile manifest with merged plugin declarations, two-space
|
|
284
274
|
* indented.
|
package/lib/wire.d.ts
CHANGED
|
@@ -62,8 +62,19 @@ export declare const LEGACY_REMOTE_FILE = "dsh-profile.dshsync";
|
|
|
62
62
|
* second computer gets both the path and what is under it. Its empty value is a
|
|
63
63
|
* real value (`{ dirs: [] }`), not an absent part, because "the list is now
|
|
64
64
|
* empty" is a change that has to travel like any other.
|
|
65
|
+
*
|
|
66
|
+
* `keys` is the one part whose content is a secret, and the one part that lives
|
|
67
|
+
* outside the profile: the API keys this computer's credential store holds for its
|
|
68
|
+
* providers. It is on by default, because a setup that travels without its keys
|
|
69
|
+
* arrives with every provider unauthenticated, and it can be switched off on its
|
|
70
|
+
* own; see {@link KeysPart}.
|
|
71
|
+
*
|
|
72
|
+
* The default model is deliberately *not* a part: which model a new session starts
|
|
73
|
+
* on is changed several times a day, and every change would push a file for
|
|
74
|
+
* something a person sets on each computer anyway. Its row stays out of `patch`
|
|
75
|
+
* too, like this plugin's own row. See `docs/decisions/0056-drop-default-model-part.md`.
|
|
65
76
|
*/
|
|
66
|
-
export declare const PARTS: readonly ["models", "
|
|
77
|
+
export declare const PARTS: readonly ["models", "plugins", "patch", "dirs", "keys"];
|
|
67
78
|
export type Part = (typeof PARTS)[number];
|
|
68
79
|
/**
|
|
69
80
|
* Most extra directories one configuration may name.
|
|
@@ -91,11 +102,6 @@ export interface ModelsPart {
|
|
|
91
102
|
/** The whole `llm-pi-ai` entry config, so fields beyond `providers` travel too. */
|
|
92
103
|
config: Record<string, unknown>;
|
|
93
104
|
}
|
|
94
|
-
/** The `defaults` part: which provider/model new sessions start on. */
|
|
95
|
-
export interface DefaultsPart {
|
|
96
|
-
/** The whole `agent-default-model` entry config. */
|
|
97
|
-
config: Record<string, unknown>;
|
|
98
|
-
}
|
|
99
105
|
/** One other top-level row of the profile patch, carried by the `patch` part. */
|
|
100
106
|
export interface PatchEntry {
|
|
101
107
|
/** `id` of an id-targeted row, or `insert` for an insert block. */
|
|
@@ -150,6 +156,20 @@ export interface DirSpec {
|
|
|
150
156
|
export interface DirsPart {
|
|
151
157
|
dirs: DirSpec[];
|
|
152
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* A credential this computer could not read or write, and why not.
|
|
161
|
+
*
|
|
162
|
+
* A store that cannot be read must not look like a store with nothing in it: an
|
|
163
|
+
* empty read would say "this computer has no keys", which is a real answer a sync
|
|
164
|
+
* acts on. This is how the two are told apart, the same way {@link DirProblem}
|
|
165
|
+
* keeps an unreadable directory from looking like an empty one.
|
|
166
|
+
*/
|
|
167
|
+
export interface KeyProblem {
|
|
168
|
+
/** The reference name, as the profile asks for it; '' when the store itself failed. */
|
|
169
|
+
ref: string;
|
|
170
|
+
/** What went wrong, in a sentence the card can show. */
|
|
171
|
+
message: string;
|
|
172
|
+
}
|
|
153
173
|
/** A configured directory this computer could not read, and why not. */
|
|
154
174
|
export interface DirProblem {
|
|
155
175
|
/** The configured path, as it is stored. */
|
|
@@ -167,6 +187,33 @@ export interface DirProblem {
|
|
|
167
187
|
*/
|
|
168
188
|
missing?: boolean;
|
|
169
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* The `keys` part: the API keys this computer holds for its providers.
|
|
192
|
+
*
|
|
193
|
+
* The profile names a credential instead of holding it — `apiKeyEnv: OFOX_API_KEY`
|
|
194
|
+
* on a provider route, `secretEnv` on a GitHub webhook — and the value behind that
|
|
195
|
+
* name lives in this computer's credential store (`$DSH_HOME/.credentials.yaml`).
|
|
196
|
+
* That is why a setup synced without this part arrives complete and still cannot
|
|
197
|
+
* talk to a single provider: it carries the names, and the names resolve to
|
|
198
|
+
* nothing here.
|
|
199
|
+
*
|
|
200
|
+
* A name is the whole address of a secret, so this part is a map and not a list:
|
|
201
|
+
* applying it puts each value back under the name the profile already asks for.
|
|
202
|
+
* Nothing here is ever deleted — a key removed on one computer stays on the others,
|
|
203
|
+
* because a credential a person stored by hand is not this plugin's to throw away.
|
|
204
|
+
* See `docs/decisions/0054-sync-api-keys.md`.
|
|
205
|
+
*/
|
|
206
|
+
export interface KeysPart {
|
|
207
|
+
/**
|
|
208
|
+
* Secret values by the reference name that resolves them, exactly as the
|
|
209
|
+
* credential store holds them (`apiKeyEnv`/`secretEnv` values, verbatim).
|
|
210
|
+
*
|
|
211
|
+
* Only names this computer actually has a stored value for appear; a name that
|
|
212
|
+
* falls through to the environment is left out, since the environment is the
|
|
213
|
+
* other computer's own business.
|
|
214
|
+
*/
|
|
215
|
+
refs: Record<string, string>;
|
|
216
|
+
}
|
|
170
217
|
/** Everything one sealed backup carries. Field names are the wire format. */
|
|
171
218
|
export interface Bundle {
|
|
172
219
|
format: 'dsh-webdav-sync-backup';
|
|
@@ -176,10 +223,10 @@ export interface Bundle {
|
|
|
176
223
|
/** Which app sealed it, for a file several tools might share a folder with. */
|
|
177
224
|
app: string;
|
|
178
225
|
models?: ModelsPart;
|
|
179
|
-
defaults?: DefaultsPart;
|
|
180
226
|
plugins?: PluginsPart;
|
|
181
227
|
patch?: PatchPart;
|
|
182
228
|
dirs?: DirsPart;
|
|
229
|
+
keys?: KeysPart;
|
|
183
230
|
}
|
|
184
231
|
/** The format word in the envelope; anything else on the server is not ours. */
|
|
185
232
|
export declare const BUNDLE_FORMAT = "dsh-webdav-sync-backup";
|
|
@@ -209,10 +256,10 @@ export interface Notice {
|
|
|
209
256
|
/** One side of the last completed sync, per part. */
|
|
210
257
|
export interface PartHashes {
|
|
211
258
|
models?: string;
|
|
212
|
-
defaults?: string;
|
|
213
259
|
plugins?: string;
|
|
214
260
|
patch?: string;
|
|
215
261
|
dirs?: string;
|
|
262
|
+
keys?: string;
|
|
216
263
|
}
|
|
217
264
|
/**
|
|
218
265
|
* The variable half of the state: everything a completed sync records.
|
|
@@ -245,6 +292,8 @@ export interface StoredState extends SyncRecord {
|
|
|
245
292
|
key?: string | undefined;
|
|
246
293
|
/** Last sync attempt, ISO-8601. */
|
|
247
294
|
last?: string | undefined;
|
|
295
|
+
/** True once keys have a trustworthy local comparison baseline. */
|
|
296
|
+
keysBaseline?: boolean | undefined;
|
|
248
297
|
/**
|
|
249
298
|
* Last attempt that actually moved something, ISO-8601: a push, a part taken
|
|
250
299
|
* from the server, or a restore. Absent until one has. This is the difference
|
|
@@ -276,12 +325,20 @@ export interface SyncConfig {
|
|
|
276
325
|
autoEvery: number;
|
|
277
326
|
/** Whether the model catalog travels. */
|
|
278
327
|
models: boolean;
|
|
279
|
-
/** Whether the default-model row travels. */
|
|
280
|
-
defaults: boolean;
|
|
281
328
|
/** Whether the installed-plugin list travels. */
|
|
282
329
|
plugins: boolean;
|
|
283
330
|
/** Whether the remaining profile-patch rows travel. */
|
|
284
331
|
patch: boolean;
|
|
332
|
+
/**
|
|
333
|
+
* Whether this computer's stored API keys travel.
|
|
334
|
+
*
|
|
335
|
+
* On by default: a provider route names its credential, so a setup synced
|
|
336
|
+
* without the keys arrives with every provider unauthenticated. Turning it off
|
|
337
|
+
* keeps this computer's keys off the server entirely — nothing is deleted
|
|
338
|
+
* anywhere, and the server's copy of the part is carried through untouched,
|
|
339
|
+
* exactly as for the three parts above.
|
|
340
|
+
*/
|
|
341
|
+
keys: boolean;
|
|
285
342
|
/**
|
|
286
343
|
* Absolute paths of the extra directories that travel with the setup, in the
|
|
287
344
|
* order the user added them. The list itself is the `dirs` part, so what is
|
|
@@ -292,10 +349,11 @@ export interface SyncConfig {
|
|
|
292
349
|
/**
|
|
293
350
|
* Whether the extra directories travel with this setup.
|
|
294
351
|
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
352
|
+
* Off is the resting state: a configuration that has not said otherwise carries
|
|
353
|
+
* no directory. The list above is kept either way: switching this off says "this
|
|
354
|
+
* computer does not carry them", not "forget them". Nothing is deleted anywhere
|
|
355
|
+
* by turning it off — the server's copy of the part is carried through
|
|
356
|
+
* untouched, exactly as for the other parts.
|
|
299
357
|
*/
|
|
300
358
|
dirsEnabled: boolean;
|
|
301
359
|
/** Whether the plugin's own address, user and password are kept for next time. */
|
|
@@ -339,9 +397,10 @@ export interface ConfigView {
|
|
|
339
397
|
passphraseSet: boolean;
|
|
340
398
|
autoEvery: number;
|
|
341
399
|
models: boolean;
|
|
342
|
-
defaults: boolean;
|
|
343
400
|
plugins: boolean;
|
|
344
401
|
patch: boolean;
|
|
402
|
+
/** Whether this computer's stored API keys travel. */
|
|
403
|
+
keys: boolean;
|
|
345
404
|
/** The extra directories that travel, as stored. Not a secret. */
|
|
346
405
|
dirs: string[];
|
|
347
406
|
/** Whether the extra directories travel with the current configuration. */
|
|
@@ -412,6 +471,13 @@ export interface StatusResult {
|
|
|
412
471
|
* this is how the card is able to say so.
|
|
413
472
|
*/
|
|
414
473
|
dirProblems?: DirProblem[];
|
|
474
|
+
/**
|
|
475
|
+
* Credentials the configuration names that could not be read here, with the
|
|
476
|
+
* reason. Present exactly when there is at least one, for the same reason as
|
|
477
|
+
* {@link StatusResult.dirProblems}: a store that cannot be read is left as the
|
|
478
|
+
* server has it for this sync rather than read as empty.
|
|
479
|
+
*/
|
|
480
|
+
keysProblems?: KeyProblem[];
|
|
415
481
|
/** The profile declares plugins the installed tree does not have yet. */
|
|
416
482
|
pendingInstall: boolean;
|
|
417
483
|
/** Which plugins those are, present exactly when {@link pendingInstall} is true. */
|
|
@@ -427,9 +493,10 @@ export interface ConfigureBody {
|
|
|
427
493
|
passphrase?: string;
|
|
428
494
|
autoEvery?: number;
|
|
429
495
|
models?: boolean;
|
|
430
|
-
defaults?: boolean;
|
|
431
496
|
plugins?: boolean;
|
|
432
497
|
patch?: boolean;
|
|
498
|
+
/** Whether this computer's stored API keys travel. */
|
|
499
|
+
keys?: boolean;
|
|
433
500
|
/** The whole list of extra directories, replacing the stored one. */
|
|
434
501
|
dirs?: string[];
|
|
435
502
|
/** Whether the extra directories travel with this setup. */
|
|
@@ -471,6 +538,13 @@ export interface SyncResult {
|
|
|
471
538
|
pendingNames?: string[];
|
|
472
539
|
/** The profile has to be reloaded for the applied configuration to take effect. */
|
|
473
540
|
reloadNeeded: boolean;
|
|
541
|
+
/**
|
|
542
|
+
* Keys the merge brought here that this computer's credential store would not
|
|
543
|
+
* take, with the reason. Present exactly when there is at least one: a key that
|
|
544
|
+
* could not be stored leaves that provider unauthenticated, and a sync that
|
|
545
|
+
* reported success would hide it.
|
|
546
|
+
*/
|
|
547
|
+
keysProblems?: KeyProblem[];
|
|
474
548
|
}
|
|
475
549
|
/** POST {@link ROUTE_CHECK} result. */
|
|
476
550
|
export interface CheckResult {
|
package/lib/wire.js
CHANGED
|
@@ -64,8 +64,19 @@ export const LEGACY_REMOTE_FILE = 'dsh-profile.dshsync';
|
|
|
64
64
|
* second computer gets both the path and what is under it. Its empty value is a
|
|
65
65
|
* real value (`{ dirs: [] }`), not an absent part, because "the list is now
|
|
66
66
|
* empty" is a change that has to travel like any other.
|
|
67
|
+
*
|
|
68
|
+
* `keys` is the one part whose content is a secret, and the one part that lives
|
|
69
|
+
* outside the profile: the API keys this computer's credential store holds for its
|
|
70
|
+
* providers. It is on by default, because a setup that travels without its keys
|
|
71
|
+
* arrives with every provider unauthenticated, and it can be switched off on its
|
|
72
|
+
* own; see {@link KeysPart}.
|
|
73
|
+
*
|
|
74
|
+
* The default model is deliberately *not* a part: which model a new session starts
|
|
75
|
+
* on is changed several times a day, and every change would push a file for
|
|
76
|
+
* something a person sets on each computer anyway. Its row stays out of `patch`
|
|
77
|
+
* too, like this plugin's own row. See `docs/decisions/0056-drop-default-model-part.md`.
|
|
67
78
|
*/
|
|
68
|
-
export const PARTS = ['models', '
|
|
79
|
+
export const PARTS = ['models', 'plugins', 'patch', 'dirs', 'keys'];
|
|
69
80
|
/**
|
|
70
81
|
* Most extra directories one configuration may name.
|
|
71
82
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-webdav-sync",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "用 WebDAV 在多台电脑之间同步 DSH
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"description": "用 WebDAV 在多台电脑之间同步 DSH 的配置:模型供应商与模型、已安装插件、其余 profile 配置、附加目录与 API 密钥。整个备份在离开本机前用口令加密(AES-256-GCM + PBKDF2),以分片三方合并的方式在服务端保留一个加密文件。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
7
7
|
"deepseek",
|