dsh-webdav-sync 1.0.3 → 1.0.4

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/engine.js CHANGED
@@ -22,14 +22,15 @@ import { dirsDiffer, dirsPartOf, readDirs, writeDirs } from './dirs.js';
22
22
  import { applyManifest, applyPatch, collect, isMissing, keepAside, message, missingPlugins, readManifest, readPatch, writeIfChanged, } from './store.js';
23
23
  import { cached, ensureStateDir, keptDir, loadConfig, loadState, normaliseUrl, releasePassword, remember, sameAccount, saveConfig, saveState, stateKey, syncsPart, usable, } from './state.js';
24
24
  import { resolvePaths } from './paths.js';
25
+ import { applyKeys, readKeys } from './keys.js';
25
26
  /**
26
27
  * This computer's whole setup, as parts.
27
28
  *
28
- * The four profile parts are read once; the extra directories have to be walked,
29
- * so they arrive separately and are put back together here. `carried` is the
30
- * server's `dirs` part, and it is used for the directories this computer could not
31
- * read — see {@link dirsPartOf} for why carrying those beats reading them as
32
- * empty ones.
29
+ * The profile parts are read once, and the keys with them; the extra directories
30
+ * have to be walked, so they arrive separately and are put back together here.
31
+ * `carried` is the server's `dirs` part, and it is used for the directories this
32
+ * computer could not read — see {@link dirsPartOf} for why carrying those beats
33
+ * reading them as empty ones.
33
34
  */
34
35
  function partsOf(snapshot, carried) {
35
36
  const dirs = dirsPartOf(snapshot.dirs, carried);
@@ -54,9 +55,9 @@ function serially(work) {
54
55
  queue = next.then(() => undefined, () => undefined);
55
56
  return next;
56
57
  }
57
- /** The engine for a profile directory and a configuration. */
58
- export function engineFor(paths, config) {
59
- return { paths, config };
58
+ /** The engine for a profile directory, a configuration, and the host's credential store. */
59
+ export function engineFor(paths, config, credentials) {
60
+ return { paths, config, credentials };
60
61
  }
61
62
  // ---------------------------------------------------------------------------
62
63
  // Sync
@@ -162,7 +163,7 @@ async function syncOnce(engine, state, force) {
162
163
  }
163
164
  state.blind = undefined;
164
165
  }
165
- const snapshot = await take(paths, config);
166
+ const snapshot = await take(paths, config, engine.credentials);
166
167
  let remote = await openRemote(engine, state, read);
167
168
  const first = state.local === undefined;
168
169
  // A directory could not be read this run: its part stands in for the server's
@@ -330,7 +331,7 @@ async function syncOnce(engine, state, force) {
330
331
  // Read back rather than assume: a directory whose file was just written — or
331
332
  // removed — has to be hashed as it now is, or the next sync would see an edit
332
333
  // that this one made itself and call it a conflict.
333
- state.local = hashesOf(partsOf(await take(paths, config), undefined));
334
+ state.local = hashesOf(partsOf(await take(paths, config, engine.credentials), undefined));
334
335
  // A part can come in without the file needing to be written: when only the
335
336
  // server moved, the merge agrees with it. The person still has to be told
336
337
  // what changed here, so the notice is recorded on this path too — clearing
@@ -350,6 +351,7 @@ async function syncOnce(engine, state, force) {
350
351
  ...(saved === undefined ? {} : { saved }),
351
352
  pushed: false,
352
353
  reloadNeeded: written.reloadNeeded,
354
+ ...keyProblems(written.keysProblems),
353
355
  ...(await pendings(engine, applied.plugins)),
354
356
  };
355
357
  }
@@ -411,7 +413,7 @@ function unreadablePart(bundle) {
411
413
  if (!isObject(value))
412
414
  return part;
413
415
  const record = value;
414
- if (part === 'models' || part === 'defaults') {
416
+ if (part === 'models') {
415
417
  if (!isObject(record.config))
416
418
  return part;
417
419
  continue;
@@ -427,6 +429,17 @@ function unreadablePart(bundle) {
427
429
  return part;
428
430
  continue;
429
431
  }
432
+ if (part === 'keys') {
433
+ // Ref names and their values, both strings: anything else is a file this
434
+ // plugin did not write, and reading it as empty would be a lie about what
435
+ // this computer holds.
436
+ const refs = record.refs;
437
+ if (!isObject(refs))
438
+ return part;
439
+ if (!Object.entries(refs).every(([ref, value]) => ref !== '' && typeof value === 'string'))
440
+ return part;
441
+ continue;
442
+ }
430
443
  // The remaining part is the leftover patch rows.
431
444
  if (!Array.isArray(record.entries))
432
445
  return part;
@@ -543,7 +556,7 @@ async function push(engine, state, snapshot, merged) {
543
556
  // failed upload never leaves this computer holding another computer's setup
544
557
  // with nothing to say where its own went.
545
558
  const applied = await applyMerged(engine, snapshot, merged.applied);
546
- const now = hashesOf(partsOf(await take(paths, config), undefined));
559
+ const now = hashesOf(partsOf(await take(paths, config, engine.credentials), undefined));
547
560
  state.server = written;
548
561
  state.sum = sum(sealed);
549
562
  state.remote = hashesOf(bundle);
@@ -566,6 +579,7 @@ async function push(engine, state, snapshot, merged) {
566
579
  ...(merged.saved === undefined ? {} : { saved: merged.saved }),
567
580
  pushed: true,
568
581
  reloadNeeded: applied.reloadNeeded,
582
+ ...keyProblems(applied.keysProblems),
569
583
  ...(await pendings(engine, merged.merged.plugins)),
570
584
  };
571
585
  }
@@ -608,13 +622,12 @@ async function applyMerged(engine, snapshot, applied) {
608
622
  reloadNeeded = true;
609
623
  }
610
624
  }
611
- if (applied.models !== undefined || applied.defaults !== undefined || applied.patch !== undefined) {
625
+ if (applied.models !== undefined || applied.patch !== undefined) {
612
626
  // `null` means "take this away", which is how a removal made on another
613
627
  // computer reaches this one. The patch part has no row of its own to remove,
614
628
  // so it is emptied instead.
615
629
  const next = applyPatch(snapshot.patch, {
616
630
  models: applied.models,
617
- defaults: applied.defaults,
618
631
  ...(applied.patch === null ? { patch: { entries: [] } } : { patch: applied.patch }),
619
632
  });
620
633
  if (next !== snapshot.patch.text) {
@@ -635,7 +648,30 @@ async function applyMerged(engine, snapshot, applied) {
635
648
  reloadNeeded = true;
636
649
  }
637
650
  }
638
- return { reloadNeeded };
651
+ // The keys go last and through the host's credential service rather than a file
652
+ // of this plugin's own ({@link applyKeys}): each one is a call on a store another
653
+ // package owns, so a refusal there leaves the profile's files written and the key
654
+ // reported. Nothing here reloads, either — DSH resolves a credential per call, so
655
+ // the next provider request already sees the new value.
656
+ let keysProblems = [];
657
+ if (applied.keys !== undefined) {
658
+ keysProblems = await applyKeys(engine.credentials, applied.keys, async () => {
659
+ if (kept)
660
+ return;
661
+ kept = true;
662
+ await keepBefore(engine, snapshot);
663
+ });
664
+ }
665
+ return { reloadNeeded, keysProblems };
666
+ }
667
+ /**
668
+ * The `keysProblems` field of a result, present only when there is one.
669
+ *
670
+ * The card reads an absent field as "every key went in", so an empty list is left
671
+ * off rather than sent as an empty one to be filtered on the other side.
672
+ */
673
+ function keyProblems(problems) {
674
+ return problems.length === 0 ? {} : { keysProblems: problems };
639
675
  }
640
676
  /**
641
677
  * Seal this computer's setup into `sync/before-apply` before a merge writes.
@@ -752,7 +788,7 @@ export async function restore(engine) {
752
788
  const remote = await openRemote(engine, state, { ...read, notModified: false });
753
789
  if (remote.empty)
754
790
  throw new Error('服务器上还没有 dsh 配置:没有可恢复的内容');
755
- const snapshot = await take(paths, config);
791
+ const snapshot = await take(paths, config, engine.credentials);
756
792
  // What is here *on disk* is what the notice is about, so the directories are
757
793
  // hashed without standing in the server's copy for the ones this computer could
758
794
  // not read: a folder that is merely absent reads as nothing here, differs from
@@ -782,7 +818,7 @@ export async function restore(engine) {
782
818
  const written = await applyMerged(engine, snapshot, applied);
783
819
  state.undo = kept ?? '';
784
820
  state.remote = remote.hashes;
785
- state.local = hashesOf(partsOf(await take(paths, config), undefined));
821
+ state.local = hashesOf(partsOf(await take(paths, config, engine.credentials), undefined));
786
822
  state.sum = read.data === undefined ? state.sum : sum(read.data);
787
823
  state.server = read.version;
788
824
  state.blind = undefined;
@@ -802,6 +838,7 @@ export async function restore(engine) {
802
838
  ...(kept === undefined ? {} : { saved: keptDir(paths) }),
803
839
  pushed: false,
804
840
  reloadNeeded: written.reloadNeeded,
841
+ ...keyProblems(written.keysProblems),
805
842
  ...(await pendings(engine, applied.plugins)),
806
843
  };
807
844
  });
@@ -842,7 +879,7 @@ export async function upload(engine) {
842
879
  }
843
880
  await keepAside(keptDir(paths), 'server-replaced', read.data);
844
881
  }
845
- const snapshot = await take(paths, config);
882
+ const snapshot = await take(paths, config, engine.credentials);
846
883
  const result = await push(engine, state, snapshot, {
847
884
  merged: { ...partsOf(snapshot, undefined) },
848
885
  applied: {},
@@ -876,7 +913,7 @@ export async function undo(engine) {
876
913
  throw new Error('恢复之前留下的那份副本已经不在了,没有东西可以放回去');
877
914
  }
878
915
  const bundle = (await open(new Uint8Array(data), config.passphrase));
879
- const snapshot = await take(paths, config);
916
+ const snapshot = await take(paths, config, engine.credentials);
880
917
  const merged = {};
881
918
  const applied = {};
882
919
  const back = [];
@@ -902,6 +939,7 @@ export async function undo(engine) {
902
939
  there: [],
903
940
  pushed: false,
904
941
  reloadNeeded: written.reloadNeeded,
942
+ ...keyProblems(written.keysProblems),
905
943
  ...(await pendings(engine, merged.plugins)),
906
944
  };
907
945
  });
@@ -922,7 +960,7 @@ export async function status(engine) {
922
960
  const { paths, config } = engine;
923
961
  const state = await loadState(paths);
924
962
  const fresh = state.key === stateKey(config);
925
- const snapshot = await take(paths, config).catch(() => undefined);
963
+ const snapshot = await take(paths, config, engine.credentials).catch(() => undefined);
926
964
  const local = snapshot === undefined ? {} : hashesOf(partsOf(snapshot, undefined));
927
965
  const parts = [];
928
966
  const known = fresh ? state.local : undefined;
@@ -983,6 +1021,10 @@ export async function status(engine) {
983
1021
  ...(snapshot === undefined || snapshot.dirs.problems.length === 0
984
1022
  ? {}
985
1023
  : { dirProblems: snapshot.dirs.problems }),
1024
+ // The same, for a key that could not be read: a store that cannot be read must
1025
+ // not look like a store with nothing in it, or this computer would look like
1026
+ // one with no keys and the next sync would believe it.
1027
+ ...(snapshot === undefined ? {} : keyProblems(snapshot.keysProblems)),
986
1028
  undoable: fresh && state.undo !== undefined && state.undo !== '',
987
1029
  };
988
1030
  }
@@ -1030,9 +1072,9 @@ export function viewOf(config) {
1030
1072
  passphraseSet: config.passphrase !== '',
1031
1073
  autoEvery: config.autoEvery,
1032
1074
  models: config.models,
1033
- defaults: config.defaults,
1034
1075
  plugins: config.plugins,
1035
1076
  patch: config.patch,
1077
+ keys: config.keys,
1036
1078
  dirs: [...config.dirs],
1037
1079
  dirsEnabled: config.dirsEnabled,
1038
1080
  rememberPassword: config.rememberPassword,
@@ -1103,7 +1145,7 @@ export async function preview(engine) {
1103
1145
  const dav = davOf(config);
1104
1146
  const read = await dav.get(state.server);
1105
1147
  const remote = await openRemote(engine, state, read);
1106
- const snapshot = await take(paths, config);
1148
+ const snapshot = await take(paths, config, engine.credentials);
1107
1149
  const local = hashesOf(partsOf(snapshot, remote.part('dirs')));
1108
1150
  const first = state.local === undefined;
1109
1151
  const changes = [];
@@ -1117,6 +1159,13 @@ export async function preview(engine) {
1117
1159
  if (!changedHere && !changedThere)
1118
1160
  continue;
1119
1161
  if (changedHere && changedThere) {
1162
+ // A credential is the one part whose own copy the merge takes outright, since
1163
+ // there is no local time to weigh against the server's stamp — the same rule
1164
+ // {@link localChangedAt} states for it, said here as the answer the card shows.
1165
+ if (part === 'keys') {
1166
+ changes.push({ part, take: 'local' });
1167
+ continue;
1168
+ }
1120
1169
  const serverAt = Date.parse(remote.created);
1121
1170
  const ours = localChangedAt(snapshot, part);
1122
1171
  changes.push({ part, take: !Number.isFinite(serverAt) || ours >= serverAt ? 'newer-local' : 'newer-remote' });
@@ -1130,7 +1179,7 @@ export async function preview(engine) {
1130
1179
  // Small helpers
1131
1180
  // ---------------------------------------------------------------------------
1132
1181
  /** Read this computer's setup and the files it came from, in one pass. */
1133
- export async function take(paths, config) {
1182
+ export async function take(paths, config, credentials) {
1134
1183
  const patch = await readPatch(paths);
1135
1184
  const manifest = await readManifest(paths);
1136
1185
  // Switched off, the directories are not walked at all: reading them is the one
@@ -1138,7 +1187,14 @@ export async function take(paths, config) {
1138
1187
  // does not carry has nothing to say. An empty read still tells the merge there is
1139
1188
  // nothing here to compare, which is exactly true.
1140
1189
  const dirs = await readDirs(config.dirsEnabled ? config.dirs : []);
1141
- return { patch, manifest, parts: collect(patch, manifest), dirs };
1190
+ const parts = collect(patch, manifest);
1191
+ // The keys, read from the names the parts above just named. Switched off, they
1192
+ // are not read at all — the same "nothing to say" the directories get — and a
1193
+ // host with no credential store resolves nothing rather than reading as empty.
1194
+ const keys = config.keys ? await readKeys(credentials, parts) : { problems: [] };
1195
+ if (keys.part !== undefined)
1196
+ parts.keys = keys.part;
1197
+ return { patch, manifest, parts, dirs, keysProblems: keys.problems };
1142
1198
  }
1143
1199
  /** Per-part hashes of a setup; a part that is not there has none. */
1144
1200
  export function hashesOf(parts) {
@@ -1169,6 +1225,14 @@ export function localChangedAt(snapshot, part) {
1169
1225
  // file inside any of them stands for the whole part.
1170
1226
  if (part === 'dirs')
1171
1227
  return snapshot.dirs.mtimeMs;
1228
+ // A credential has neither a file of this plugin's own nor a time the host's
1229
+ // store reports, so there is nothing to weigh the server's stamp against. This
1230
+ // computer's copy wins instead — it is the one that authenticates the requests
1231
+ // made here, and it may have been stored a moment ago through the Models page,
1232
+ // which touches no profile file at all. The copy that loses is archived by
1233
+ // {@link keepConflict} like any other. See `docs/decisions/0054-sync-api-keys.md`.
1234
+ if (part === 'keys')
1235
+ return Number.POSITIVE_INFINITY;
1172
1236
  return snapshot.patch.mtimeMs;
1173
1237
  }
1174
1238
  /** The top-level packages present in the profile's node_modules. */
package/lib/index.js CHANGED
@@ -20,6 +20,7 @@ import { validateProfileDir, resolveFromEnv } from './paths.js';
20
20
  import { RelayServer, makeRelayPassword, makeRelayUser } from './relay.js';
21
21
  import { check, dismiss, engineFor, loadConfig, preview, restore, sameAccount, status, sync, turnOff, undo, upload, usable, } from './engine.js';
22
22
  import { installPlugins } from './install.js';
23
+ import { keysPort } from './keys.js';
23
24
  import { sameSecret } from './crypto.js';
24
25
  import { normaliseDirs } from './dirs.js';
25
26
  import { parseHeaders } from './dav.js';
@@ -90,12 +91,12 @@ export async function configure(engine, body) {
90
91
  next.passphrase = body.passphrase;
91
92
  if (typeof body.models === 'boolean')
92
93
  next.models = body.models;
93
- if (typeof body.defaults === 'boolean')
94
- next.defaults = body.defaults;
95
94
  if (typeof body.plugins === 'boolean')
96
95
  next.plugins = body.plugins;
97
96
  if (typeof body.patch === 'boolean')
98
97
  next.patch = body.patch;
98
+ if (typeof body.keys === 'boolean')
99
+ next.keys = body.keys;
99
100
  if (typeof body.rememberPassword === 'boolean')
100
101
  next.rememberPassword = body.rememberPassword;
101
102
  if (typeof body.headers === 'string') {
@@ -176,6 +177,23 @@ export async function configure(engine, body) {
176
177
  await persistConfig(engine.paths, next);
177
178
  engine.config = next;
178
179
  }
180
+ /**
181
+ * The host's credential service, in the shape the sync uses it, or `undefined` on
182
+ * a host that has none.
183
+ *
184
+ * A lookup rather than an injected dependency: a host without the service is a
185
+ * working state — the `keys` part then reads nothing, writes nothing and says so —
186
+ * and, like every other service here, it can come and go while the plugin is
187
+ * mounted. `ctx.get` is the untyped accessor, so the shape is checked rather than
188
+ * trusted from a type this plugin cannot import (it depends on no DSH package).
189
+ */
190
+ function credentialPort(ctx) {
191
+ const service = ctx.get('credentials');
192
+ if (service === undefined || typeof service.resolve !== 'function' || typeof service.set !== 'function') {
193
+ return undefined;
194
+ }
195
+ return keysPort(service);
196
+ }
179
197
  /**
180
198
  * Plugin apply: mount the settings presentation policy, the HTTP routes and the
181
199
  * auto-sync timer on any webServer that comes and goes.
@@ -196,7 +214,8 @@ export function apply(ctx, config = {}) {
196
214
  const paths = resolveFromEnv(readVolatile(currentConfig().profileDir));
197
215
  const { config: stored } = await loadConfig(paths);
198
216
  const secret = stored.rememberPassword ? undefined : heldPassword(paths);
199
- return engineFor(paths, secret === undefined || secret === '' ? stored : { ...stored, password: secret });
217
+ const config = secret === undefined || secret === '' ? stored : { ...stored, password: secret };
218
+ return engineFor(paths, config, credentialPort(ctx));
200
219
  };
201
220
  // In dsh 0.1.7+, the settings service projects volatile Config fields into
202
221
  // forms. auto: false tells the host this plugin ships its own card, which the
package/lib/keys.d.ts ADDED
@@ -0,0 +1,125 @@
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 type { KeyProblem, KeysPart, Part } from './wire.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 declare const CREDENTIAL_FIELDS: readonly string[];
36
+ /** One reference's value as the credential service resolved it. */
37
+ export interface ResolvedKey {
38
+ /** The non-empty secret. */
39
+ value: string;
40
+ /**
41
+ * Which layer supplied it: `env`, `file`, `project-env` or `user-env`.
42
+ *
43
+ * Only `file` — the provider-managed store, the one layer `set` writes to —
44
+ * travels. See {@link readKeys}.
45
+ */
46
+ source: string;
47
+ }
48
+ /**
49
+ * DSH's credential service, as far as this plugin uses it.
50
+ *
51
+ * Structural on purpose: the service belongs to another package, and this plugin
52
+ * neither depends on it nor requires it to be there. The two methods are `resolve`
53
+ * and `set` on `CredentialProvider`; `store` is the name this module gives the
54
+ * write, so a reader of the engine never has to know which word DSH picked.
55
+ */
56
+ export interface CredentialService {
57
+ resolve(ref: string): Promise<ResolvedKey | undefined>;
58
+ set(ref: string, value: string): Promise<void>;
59
+ }
60
+ /** The two operations the sync needs: what is behind a name, and how to store one. */
61
+ export interface KeysPort {
62
+ resolve(ref: string): Promise<ResolvedKey | undefined>;
63
+ store(ref: string, value: string): Promise<void>;
64
+ }
65
+ /** What one read of this computer's keys produced. */
66
+ export interface KeysRead {
67
+ /** What travels, absent when this computer has no value for any name it knows. */
68
+ part?: KeysPart;
69
+ /** References that could not be read, with the reason. */
70
+ problems: KeyProblem[];
71
+ }
72
+ /**
73
+ * The credential service in the shape this plugin uses it.
74
+ *
75
+ * `set` rejects while a read-only layer shadows the reference, which is exactly
76
+ * the fact that matters here: a value that would not take effect is not one to
77
+ * report as stored. {@link applyKeys} therefore asks first and writes second.
78
+ */
79
+ export declare function keysPort(service: CredentialService): KeysPort;
80
+ /**
81
+ * Every credential-reference name the given parts name, deduplicated and sorted.
82
+ *
83
+ * Walks the parts as they are — no schema, no interpretation — because the value
84
+ * of a reference field is a name wherever it sits, and the shape of a provider
85
+ * config is the provider plugin's to decide, not this one's.
86
+ */
87
+ export declare function credentialRefs(parts: Partial<Record<Part, unknown>>): string[];
88
+ /**
89
+ * Read the keys this computer holds for the names in hand.
90
+ *
91
+ * Only the managed store travels: a name the launching environment supplies is
92
+ * left out, and so is one that falls through to a `.env`. Two reasons, and they
93
+ * agree — the store is the one layer this plugin can write back to, and an
94
+ * environment export belongs to whoever started DSH, not to this computer's
95
+ * stored setup. The environment-wins rule is the service's own
96
+ * (`resolve().source` is `'env'` whenever the inherited environment has the name,
97
+ * and a write is refused while it does), so a name that is both exported and
98
+ * stored is read as an export and left here; unsetting it in the shell is what
99
+ * makes the stored value the one in force, and the one that travels.
100
+ *
101
+ * A name with no stored value is skipped, not reported: plenty of profiles name a
102
+ * provider nobody has signed in to, and "this key is not here either" is not a
103
+ * failure of the sync. A name that cannot be resolved *at all* is different, and
104
+ * is reported — a store that cannot be read must not look like a store with
105
+ * nothing in it, or a sync would read it as "this computer has no keys".
106
+ */
107
+ export declare function readKeys(port: KeysPort | undefined, parts: Partial<Record<Part, unknown>>): Promise<KeysRead>;
108
+ /**
109
+ * Put the merged keys part into this computer's store, and report what would not
110
+ * go in.
111
+ *
112
+ * A value already in force here is left alone. That is not an optimisation: the
113
+ * service refuses a write while a read-only layer shadows the reference, and a
114
+ * value that is already the one in force — the same key, exported and stored —
115
+ * would be reported as a failure for saying so twice.
116
+ *
117
+ * `beforeWrite` is called at most once, just before the first value that really
118
+ * replaces something. A credential has no copy kept by the caller any other way,
119
+ * so it is the callback's job to seal one: overwriting a key without keeping the
120
+ * value it had would leave undo with nothing to put back.
121
+ *
122
+ * `null` — the merge's way of saying the part was taken away — writes nothing. No
123
+ * credential is ever removed by a sync.
124
+ */
125
+ export declare function applyKeys(port: KeysPort | undefined, part: KeysPart | null | undefined, beforeWrite?: () => Promise<void>): Promise<KeyProblem[]>;
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 密钥不在这里读写';