dsh-webdav-sync 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +298 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +9 -0
- package/lib/client.js +1117 -0
- package/lib/crypto.d.ts +64 -0
- package/lib/crypto.js +185 -0
- package/lib/dav.d.ts +137 -0
- package/lib/dav.js +548 -0
- package/lib/engine.d.ts +100 -0
- package/lib/engine.js +1042 -0
- package/lib/index.d.ts +74 -0
- package/lib/index.js +329 -0
- package/lib/install.d.ts +28 -0
- package/lib/install.js +269 -0
- package/lib/paths.d.ts +57 -0
- package/lib/paths.js +79 -0
- package/lib/state.d.ts +93 -0
- package/lib/state.js +253 -0
- package/lib/store.d.ts +147 -0
- package/lib/store.js +560 -0
- package/lib/wire.d.ts +346 -0
- package/lib/wire.js +71 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +107 -3
package/lib/state.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin's own files, all of them inside the profile's `.dsh-webdav-sync`:
|
|
3
|
+
* the configuration (which holds the server password and the passphrase, so it is
|
|
4
|
+
* written 0600), the sync state, and a cached copy of what the server held when
|
|
5
|
+
* it was last read.
|
|
6
|
+
*
|
|
7
|
+
* The cache exists for one reason. A sync that finds the server unchanged still
|
|
8
|
+
* has to merge, and merging is three-way: it needs the version of each part as
|
|
9
|
+
* the server last held it. That is in the state, but the parts themselves are
|
|
10
|
+
* needed to compute what a part *became* on the server. Keeping the last body
|
|
11
|
+
* means a sync that only changed something here never downloads the file it is
|
|
12
|
+
* about to write over.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { AUTO_CHOICES, AUTO_MANUAL, BACKUP_EXT, } from './wire.js';
|
|
18
|
+
import { sum } from './crypto.js';
|
|
19
|
+
import { isMissing, message, writeAtomic } from './store.js';
|
|
20
|
+
/** Name of the configuration file inside the state directory. */
|
|
21
|
+
export const CONFIG_NAME = 'sync.json';
|
|
22
|
+
/** Name of the sync state file. */
|
|
23
|
+
export const STATE_NAME = 'sync-state.json';
|
|
24
|
+
/** Name of the cached copy of the server's file. */
|
|
25
|
+
export const CACHE_NAME = `sync-server${BACKUP_EXT}`;
|
|
26
|
+
/** Name of the directory holding copies the sync replaced. */
|
|
27
|
+
export const KEPT_DIR = 'sync';
|
|
28
|
+
/** Mode for anything holding a secret, and for the directory around it. */
|
|
29
|
+
const SECRET_MODE = 0o600;
|
|
30
|
+
const DIR_MODE = 0o700;
|
|
31
|
+
/** The configuration as first written, before anything is known. */
|
|
32
|
+
export function defaultConfig() {
|
|
33
|
+
return {
|
|
34
|
+
url: '',
|
|
35
|
+
user: '',
|
|
36
|
+
password: '',
|
|
37
|
+
passphrase: '',
|
|
38
|
+
autoEvery: AUTO_MANUAL,
|
|
39
|
+
models: true,
|
|
40
|
+
defaults: true,
|
|
41
|
+
plugins: true,
|
|
42
|
+
patch: true,
|
|
43
|
+
rememberPassword: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** The configuration file's path. */
|
|
47
|
+
export function configFile(paths) {
|
|
48
|
+
return join(paths.stateDir, CONFIG_NAME);
|
|
49
|
+
}
|
|
50
|
+
/** The sync state file's path. */
|
|
51
|
+
export function stateFile(paths) {
|
|
52
|
+
return join(paths.stateDir, STATE_NAME);
|
|
53
|
+
}
|
|
54
|
+
/** The cached server copy's path. */
|
|
55
|
+
export function cacheFile(paths) {
|
|
56
|
+
return join(paths.stateDir, CACHE_NAME);
|
|
57
|
+
}
|
|
58
|
+
/** The folder replaced copies are kept in. */
|
|
59
|
+
export function keptDir(paths) {
|
|
60
|
+
return join(paths.stateDir, KEPT_DIR);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Passwords that are in use but deliberately not written down.
|
|
64
|
+
*
|
|
65
|
+
* "Remember the password" off means it must not reach `sync.json`; it does not
|
|
66
|
+
* mean it should stop working. Every route rebuilds its engine from that file,
|
|
67
|
+
* so a password emptied there is one no request can carry and sync could not
|
|
68
|
+
* sign in at all until the host restarted. It is held here for as long as the
|
|
69
|
+
* host runs — keyed by state directory, so two profiles keep their own — and
|
|
70
|
+
* handed back whenever the stored configuration is read.
|
|
71
|
+
*/
|
|
72
|
+
const held = new Map();
|
|
73
|
+
/** Keep a password in memory only, for the profile it was given for. */
|
|
74
|
+
export function holdPassword(paths, password) {
|
|
75
|
+
if (password === '')
|
|
76
|
+
held.delete(paths.stateDir);
|
|
77
|
+
else
|
|
78
|
+
held.set(paths.stateDir, password);
|
|
79
|
+
}
|
|
80
|
+
/** Forget the in-memory password for a profile, on turning sync off or remembering again. */
|
|
81
|
+
export function releasePassword(paths) {
|
|
82
|
+
held.delete(paths.stateDir);
|
|
83
|
+
}
|
|
84
|
+
/** The in-memory password for a profile, if one is being held. */
|
|
85
|
+
export function heldPassword(paths) {
|
|
86
|
+
return held.get(paths.stateDir);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Read the configuration, with anything unset filled in from the defaults, so a
|
|
90
|
+
* file written by an older build never reads as a missing field.
|
|
91
|
+
*/
|
|
92
|
+
export async function loadConfig(paths) {
|
|
93
|
+
const raw = await readJson(configFile(paths));
|
|
94
|
+
if (raw === undefined)
|
|
95
|
+
return { config: defaultConfig(), found: false };
|
|
96
|
+
return { config: normaliseConfig(raw), found: true };
|
|
97
|
+
}
|
|
98
|
+
/** Read the sync state; an empty one only when there is none. */
|
|
99
|
+
export async function loadState(paths) {
|
|
100
|
+
const raw = await readJson(stateFile(paths));
|
|
101
|
+
if (raw === undefined)
|
|
102
|
+
return {};
|
|
103
|
+
return raw;
|
|
104
|
+
}
|
|
105
|
+
/** Write the configuration, 0600 because it holds two secrets. */
|
|
106
|
+
export async function saveConfig(paths, config) {
|
|
107
|
+
await writeAtomic(configFile(paths), `${JSON.stringify(config, null, 2)}\n`, SECRET_MODE);
|
|
108
|
+
}
|
|
109
|
+
/** Write the state. It holds no secret, but it does name a folder, so it is 0600 too. */
|
|
110
|
+
export async function saveState(paths, state) {
|
|
111
|
+
await writeAtomic(stateFile(paths), `${JSON.stringify(state, null, 2)}\n`, SECRET_MODE);
|
|
112
|
+
}
|
|
113
|
+
/** Write the state directory itself, 0700, before anything goes in it. */
|
|
114
|
+
export async function ensureStateDir(paths) {
|
|
115
|
+
await writeAtomicStateDir(paths);
|
|
116
|
+
}
|
|
117
|
+
async function writeAtomicStateDir(paths) {
|
|
118
|
+
const { mkdir, chmod } = await import('node:fs/promises');
|
|
119
|
+
await mkdir(paths.stateDir, { recursive: true, mode: DIR_MODE });
|
|
120
|
+
await chmod(paths.stateDir, DIR_MODE).catch(() => undefined);
|
|
121
|
+
}
|
|
122
|
+
/** Keep the body just read or written, so the next sync can merge without it. */
|
|
123
|
+
export async function remember(paths, data) {
|
|
124
|
+
await writeAtomic(cacheFile(paths), data, SECRET_MODE);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The cached body, but only when it hashes to what is expected. A cache that
|
|
128
|
+
* does not match is not the version the state describes, so it is not one the
|
|
129
|
+
* merge may stand in for.
|
|
130
|
+
*/
|
|
131
|
+
export async function cached(paths, want) {
|
|
132
|
+
if (want === undefined || want === '')
|
|
133
|
+
return undefined;
|
|
134
|
+
let data;
|
|
135
|
+
try {
|
|
136
|
+
data = await readFile(cacheFile(paths));
|
|
137
|
+
}
|
|
138
|
+
catch (cause) {
|
|
139
|
+
if (isMissing(cause))
|
|
140
|
+
return undefined;
|
|
141
|
+
throw cause;
|
|
142
|
+
}
|
|
143
|
+
return sum(data) === want ? new Uint8Array(data) : undefined;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Identifies the thing a state describes: one server, one account, one
|
|
147
|
+
* passphrase. A change to any of them resets the state, because the hashes in it
|
|
148
|
+
* are about a file that is no longer the one being synced.
|
|
149
|
+
*
|
|
150
|
+
* The password is deliberately not here: it opens the same file rather than
|
|
151
|
+
* pointing at a different one, and dropping a usable baseline whenever someone
|
|
152
|
+
* rotates a password would make this computer adopt the server wholesale on the
|
|
153
|
+
* next sync. See `docs/decisions/0026-password-not-in-state-key.md`.
|
|
154
|
+
*/
|
|
155
|
+
export function stateKey(config) {
|
|
156
|
+
return createHash('sha256')
|
|
157
|
+
.update([config.url.trim().replace(/\/+$/, ''), '\u0000', config.user, '\u0000', config.passphrase].join(''))
|
|
158
|
+
.digest('hex');
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Whether two configurations point at the same account. A password is only ever
|
|
162
|
+
* reused for the server and user it was given for: keeping one across an address
|
|
163
|
+
* change is how a password for one service gets sent to another.
|
|
164
|
+
*/
|
|
165
|
+
export function sameAccount(a, b) {
|
|
166
|
+
return normaliseUrl(a.url) === normaliseUrl(b.url) && a.user.trim() === b.user.trim();
|
|
167
|
+
}
|
|
168
|
+
/** An address with its trailing slashes gone, for comparing two of them. */
|
|
169
|
+
export function normaliseUrl(url) {
|
|
170
|
+
return url.trim().replace(/\/+$/, '');
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Whether the configuration is complete enough to reach a server. The
|
|
174
|
+
* passphrase is trimmed for the same reason `seal` refuses a blank one: a
|
|
175
|
+
* passphrase of spaces would seal nothing, so a timer must not treat it as
|
|
176
|
+
* ready and start syncs that can only fail.
|
|
177
|
+
*/
|
|
178
|
+
export function usable(config) {
|
|
179
|
+
return normaliseUrl(config.url) !== '' && config.passphrase.trim() !== '';
|
|
180
|
+
}
|
|
181
|
+
/** Whether a part travels with this configuration. */
|
|
182
|
+
export function syncsPart(config, part) {
|
|
183
|
+
switch (part) {
|
|
184
|
+
case 'models':
|
|
185
|
+
return config.models;
|
|
186
|
+
case 'defaults':
|
|
187
|
+
return config.defaults;
|
|
188
|
+
case 'plugins':
|
|
189
|
+
return config.plugins;
|
|
190
|
+
case 'patch':
|
|
191
|
+
return config.patch;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Fill in defaults and coerce the several fields that come from a browser. */
|
|
195
|
+
export function normaliseConfig(raw) {
|
|
196
|
+
const base = defaultConfig();
|
|
197
|
+
const text = (key) => {
|
|
198
|
+
const value = raw[key];
|
|
199
|
+
return typeof value === 'string' ? value : base[key];
|
|
200
|
+
};
|
|
201
|
+
const flag = (key) => {
|
|
202
|
+
const value = raw[key];
|
|
203
|
+
return typeof value === 'boolean' ? value : base[key];
|
|
204
|
+
};
|
|
205
|
+
const auto = raw.autoEvery;
|
|
206
|
+
const autoEvery = typeof auto === 'number' && Number.isInteger(auto) && AUTO_CHOICES.includes(auto) ? auto : AUTO_MANUAL;
|
|
207
|
+
return {
|
|
208
|
+
url: text('url'),
|
|
209
|
+
user: text('user'),
|
|
210
|
+
password: text('password'),
|
|
211
|
+
passphrase: text('passphrase'),
|
|
212
|
+
autoEvery,
|
|
213
|
+
models: flag('models'),
|
|
214
|
+
defaults: flag('defaults'),
|
|
215
|
+
plugins: flag('plugins'),
|
|
216
|
+
patch: flag('patch'),
|
|
217
|
+
rememberPassword: flag('rememberPassword'),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Read a JSON object out of one of the plugin's own files.
|
|
222
|
+
*
|
|
223
|
+
* Only a file that is *not there* reads as nothing. A file that is there but
|
|
224
|
+
* unreadable is a different thing and used to be treated as the same one: the
|
|
225
|
+
* configuration fell back to defaults, so the address and passphrase silently
|
|
226
|
+
* became empty, and the state read as a fresh one, so every part looked like a
|
|
227
|
+
* part the server had changed and the server's copy came over the top of this
|
|
228
|
+
* computer's work. Both files here are written whole by `writeAtomic`, so one
|
|
229
|
+
* that cannot be parsed was damaged from outside — worth saying so, with its
|
|
230
|
+
* name, rather than pretending it was never written.
|
|
231
|
+
*/
|
|
232
|
+
async function readJson(file) {
|
|
233
|
+
let text;
|
|
234
|
+
try {
|
|
235
|
+
text = await readFile(file, 'utf8');
|
|
236
|
+
}
|
|
237
|
+
catch (cause) {
|
|
238
|
+
if (isMissing(cause))
|
|
239
|
+
return undefined;
|
|
240
|
+
throw cause;
|
|
241
|
+
}
|
|
242
|
+
let value;
|
|
243
|
+
try {
|
|
244
|
+
value = JSON.parse(text);
|
|
245
|
+
}
|
|
246
|
+
catch (cause) {
|
|
247
|
+
throw new Error(`${file} 不是可读的 JSON:${message(cause)}`);
|
|
248
|
+
}
|
|
249
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
250
|
+
throw new Error(`${file} 里不是 JSON 对象`);
|
|
251
|
+
}
|
|
252
|
+
return value;
|
|
253
|
+
}
|
package/lib/store.d.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and writing a DSH profile.
|
|
3
|
+
*
|
|
4
|
+
* Two files matter. `cordis.patch.yml` is the profile's own patch layer — a
|
|
5
|
+
* top-level YAML array of loader rows, each either an id-targeted override or an
|
|
6
|
+
* `insert:` block that pushes new rows in. The model catalog is one of those rows
|
|
7
|
+
* (`llm-pi-ai`), the default model is another (`agent-default-model`), and
|
|
8
|
+
* everything else — MCP servers, permission presets, UI settings, inserts — is
|
|
9
|
+
* the rest of them. `package.json` names the installed plugins.
|
|
10
|
+
*
|
|
11
|
+
* Writes go through the `yaml` Document API rather than a stringify of a parsed
|
|
12
|
+
* object, so the file's own comments and layout survive a sync that only meant to
|
|
13
|
+
* change one provider.
|
|
14
|
+
*/
|
|
15
|
+
import type { Document } from 'yaml';
|
|
16
|
+
import { type DefaultsPart, type ModelsPart, type PatchEntry, type PatchPart, type PluginsPart } from './wire.js';
|
|
17
|
+
import type { ResolvedPaths } from './paths.js';
|
|
18
|
+
/** The profile patch row holding the model catalog. */
|
|
19
|
+
export declare const MODELS_ID = "llm-pi-ai";
|
|
20
|
+
/** The profile patch row holding the default model. */
|
|
21
|
+
export declare const DEFAULTS_ID = "agent-default-model";
|
|
22
|
+
/** Package whose patch row the plugin adds for itself; never carried as a part. */
|
|
23
|
+
export declare const SELF_ID = "dsh-webdav-sync";
|
|
24
|
+
/** One row of the profile patch, as it is carried between computers. */
|
|
25
|
+
export interface PatchRow {
|
|
26
|
+
/** Stable identity: `id:<id>` for a row, `insert:<inner ids>` for an insert block. */
|
|
27
|
+
key: string;
|
|
28
|
+
/** The row as plain JSON. */
|
|
29
|
+
value: PatchEntry;
|
|
30
|
+
}
|
|
31
|
+
/** A profile patch, parsed and kept as a document so a write preserves the file. */
|
|
32
|
+
export interface PatchFile {
|
|
33
|
+
document: Document;
|
|
34
|
+
rows: PatchRow[];
|
|
35
|
+
/** The bytes read, for the changed-since check and for keeping a copy. */
|
|
36
|
+
text: string;
|
|
37
|
+
/** The file's modification time, used as the merge's tie-breaker. */
|
|
38
|
+
mtimeMs: number;
|
|
39
|
+
}
|
|
40
|
+
/** The profile manifest, kept as plain JSON with its original key order. */
|
|
41
|
+
export interface ManifestFile {
|
|
42
|
+
value: Record<string, unknown>;
|
|
43
|
+
text: string;
|
|
44
|
+
mtimeMs: number;
|
|
45
|
+
}
|
|
46
|
+
/** Read and parse the profile patch. A missing file reads as an empty patch. */
|
|
47
|
+
export declare function readPatch(paths: ResolvedPaths): Promise<PatchFile>;
|
|
48
|
+
/** Read the profile manifest. A missing file reads as an empty manifest. */
|
|
49
|
+
export declare function readManifest(paths: ResolvedPaths): Promise<ManifestFile>;
|
|
50
|
+
/** Every part of a profile this plugin can carry, as collected from disk. */
|
|
51
|
+
export interface Collected {
|
|
52
|
+
models?: ModelsPart;
|
|
53
|
+
defaults?: DefaultsPart;
|
|
54
|
+
plugins?: PluginsPart;
|
|
55
|
+
patch?: PatchPart;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Gather the parts from one profile.
|
|
59
|
+
*
|
|
60
|
+
* A part is left out entirely when the profile holds nothing for it, so a
|
|
61
|
+
* computer that has never configured models never claims the model part — which
|
|
62
|
+
* is what lets the other computer's catalog come in untouched on a first sync.
|
|
63
|
+
*/
|
|
64
|
+
export declare function collect(patch: PatchFile, manifest: ManifestFile): Collected;
|
|
65
|
+
/** `dsh.profile.bundles` from a manifest, as strings. */
|
|
66
|
+
export declare function readBundles(manifest: Record<string, unknown>): string[];
|
|
67
|
+
/**
|
|
68
|
+
* The plugins a manifest declares that a set of installed package names does not
|
|
69
|
+
* have. Built-in shell packages are skipped: they ship with DSH, not with the
|
|
70
|
+
* profile.
|
|
71
|
+
*/
|
|
72
|
+
export declare function missingPlugins(plugins: PluginsPart | undefined, installed: readonly string[]): string[];
|
|
73
|
+
/**
|
|
74
|
+
* Rewrite the profile patch so it holds the merged parts.
|
|
75
|
+
*
|
|
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.
|
|
80
|
+
*/
|
|
81
|
+
export declare function applyPatch(patch: PatchFile, parts: {
|
|
82
|
+
models?: ModelsPart | null;
|
|
83
|
+
defaults?: DefaultsPart | null;
|
|
84
|
+
patch?: PatchPart;
|
|
85
|
+
}): string;
|
|
86
|
+
/**
|
|
87
|
+
* Rewrite the profile manifest with merged plugin declarations, two-space
|
|
88
|
+
* indented.
|
|
89
|
+
*
|
|
90
|
+
* The declaration is mirrored rather than merged: a plugin another computer
|
|
91
|
+
* removed goes from here too, or the two manifests never converge and every sync
|
|
92
|
+
* would push again. Nothing is uninstalled — `node_modules` is not synced — so
|
|
93
|
+
* what leaves is a line in a file, not a package.
|
|
94
|
+
*/
|
|
95
|
+
export declare function applyManifest(manifest: ManifestFile, plugins: PluginsPart): string;
|
|
96
|
+
/** Write bytes only when they differ from what is already there. */
|
|
97
|
+
export declare function writeIfChanged(file: string, data: string | Uint8Array): Promise<boolean>;
|
|
98
|
+
/**
|
|
99
|
+
* Write a file by writing a sibling and renaming it over the target, so a crash
|
|
100
|
+
* or a power cut leaves either the old file or the new one and never half of
|
|
101
|
+
* either. The temporary file is removed when the write fails.
|
|
102
|
+
*/
|
|
103
|
+
export declare function writeAtomic(file: string, data: string | Uint8Array, mode?: number): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Keep a copy of something the plugin is about to replace, in `sync/` beside its
|
|
106
|
+
* own state. The newest `keep` files matching the tag survive.
|
|
107
|
+
*/
|
|
108
|
+
export declare function keepAside(dir: string, tag: string, data: string | Uint8Array, keep?: number): Promise<string>;
|
|
109
|
+
/**
|
|
110
|
+
* A patch row's stable key.
|
|
111
|
+
*
|
|
112
|
+
* An `insert:` block is keyed by the ids of the rows it inserts, which is what
|
|
113
|
+
* makes it the same block on two computers that wrote it separately. An
|
|
114
|
+
* insert block whose rows carry no ids has no such name, and keying it by its
|
|
115
|
+
* position of `?`s would give every such block the one key `insert:?` — two
|
|
116
|
+
* different blocks would be read as one row, and a merge would write one
|
|
117
|
+
* block's contents into the other. Those are keyed by their own content
|
|
118
|
+
* instead, exactly like any other row that has neither id nor usable insert.
|
|
119
|
+
*/
|
|
120
|
+
export declare function keyOf(entry: PatchEntry): string;
|
|
121
|
+
/** `true` for a value that is a plain object rather than an array or null. */
|
|
122
|
+
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
123
|
+
/**
|
|
124
|
+
* Whether two values from a YAML document hold the same data.
|
|
125
|
+
*
|
|
126
|
+
* A node read back out of a Document is not a plain object, so this normalises
|
|
127
|
+
* through JSON before comparing. It is what lets a sync tell "the merge decided
|
|
128
|
+
* the same thing" from "the merge decided something new", and therefore skip a
|
|
129
|
+
* write that would only reformat the file.
|
|
130
|
+
*/
|
|
131
|
+
export declare function sameJson(a: unknown, b: unknown): boolean;
|
|
132
|
+
/** An object's keys in sorted order, so a rewritten manifest is stable. */
|
|
133
|
+
export declare function sortKeys(value: Record<string, unknown>): Record<string, unknown>;
|
|
134
|
+
/** A filesystem error meaning "there is nothing there". */
|
|
135
|
+
export declare function isMissing(cause: unknown): boolean;
|
|
136
|
+
/**
|
|
137
|
+
* An error's message, said in one line, with whatever it was caused by.
|
|
138
|
+
*
|
|
139
|
+
* A wrapper's own message is the vaguest part of it — "fetch failed" and
|
|
140
|
+
* "reading dsh-profile.dshsync" name the attempt, while the cause underneath
|
|
141
|
+
* says `ECONNREFUSED` or the parse error, and that is what a reader needs. The
|
|
142
|
+
* chain is followed rather than only read at the top, since the ends that were
|
|
143
|
+
* wrapped twice on the way up are the ones with the reason in them. The walk is
|
|
144
|
+
* bounded and watches for repeats, because a cause pointing back at itself would
|
|
145
|
+
* otherwise turn one sentence into a hang.
|
|
146
|
+
*/
|
|
147
|
+
export declare function message(cause: unknown): string;
|