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/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
- dirsEnabled: true,
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 four parts above:
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 DefaultsPart, type ModelsPart, type PatchEntry, type PatchPart, type PluginsPart } from './wire.js';
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
- * Model and default rows are replaced in place when they exist and appended when
77
- * they do not; the remaining rows are replaced wholesale by the merged list, with
78
- * the model rows kept where they were. A no-op merge leaves the bytes untouched,
79
- * so a sync that changed nothing does not rewrite the file.
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
- * Model and default rows are replaced in place when they exist and appended when
156
- * they do not; the remaining rows are replaced wholesale by the merged list, with
157
- * the model rows kept where they were. A no-op merge leaves the bytes untouched,
158
- * so a sync that changed nothing does not rewrite the file.
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 two id-targeted model rows.
191
- for (const [id, part] of [
192
- [MODELS_ID, parts.models],
193
- [DEFAULTS_ID, parts.defaults],
194
- ]) {
195
- if (part === undefined)
196
- continue;
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(part.config).length > 0) {
221
- existing.node.set('config', document.createNode(part.config));
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, part.config)) {
225
- existing.node.set('config', mergeInto(document, config, part.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: defaultNameFor(id), config: part.config });
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", "defaults", "plugins", "patch", "dirs"];
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
- * The list above is kept either way: switching this off says "this computer does
296
- * not carry them", not "forget them". Nothing is deleted anywhere by turning it
297
- * off — the server's copy of the part is carried through untouched, exactly as
298
- * for the four parts above.
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', 'defaults', 'plugins', 'patch', 'dirs'];
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.3",
4
- "description": "用 WebDAV 在多台电脑之间同步 DSH 的配置:模型供应商与模型、默认模型、已安装插件。整个备份在离开本机前用口令加密(AES-256-GCM + PBKDF2),以分片三方合并的方式在服务端保留一个加密文件。",
3
+ "version": "1.0.5",
4
+ "description": "用 WebDAV 在多台电脑之间同步 DSH 的配置:模型供应商与模型、已安装插件、其余 profile 配置、附加目录与 API 密钥。整个备份在离开本机前用口令加密(AES-256-GCM + PBKDF2),以分片三方合并的方式在服务端保留一个加密文件。",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "deepseek",