dsh-webdav-sync 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +298 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +9 -0
- package/lib/client.js +1144 -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/index.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-webdav-sync host half.
|
|
3
|
+
*
|
|
4
|
+
* The plugin's whole job is done by two halves that never both run in the same
|
|
5
|
+
* process: this one, inside the DSH host, reads and writes the profile's own
|
|
6
|
+
* files (`cordis.patch.yml` for the model catalog and the other rows,
|
|
7
|
+
* `package.json` for the installed plugins), talks to the WebDAV server, and
|
|
8
|
+
* serves the routes the browser half fetches; the browser half is a settings
|
|
9
|
+
* card that shows what is where and asks for a sync.
|
|
10
|
+
*
|
|
11
|
+
* Nothing about the WebDAV account is stored in the composition: the profile
|
|
12
|
+
* patch is itself one of the things that travels, so a password put there would
|
|
13
|
+
* be uploaded by the next sync. It lives in the profile's
|
|
14
|
+
* `.dsh-webdav-sync/sync.json`, mode 0600, beside the state and the cache.
|
|
15
|
+
*/
|
|
16
|
+
import type { Context, Volatile } from '@deepseek-ai/cordis';
|
|
17
|
+
import z from '@deepseek-ai/schemastery';
|
|
18
|
+
import { type ConfigureBody } from './wire.js';
|
|
19
|
+
import { sameAccount, usable, type Engine } from './engine.js';
|
|
20
|
+
import { defaultConfig, normaliseConfig } from './state.js';
|
|
21
|
+
export declare const name = "dsh-webdav-sync";
|
|
22
|
+
export declare const inject: never[];
|
|
23
|
+
/** The settings namespace this plugin serves; its browser card spells the same string. */
|
|
24
|
+
export declare const WEBDAV_SYNC_NS = "dsh-webdav-sync";
|
|
25
|
+
export interface Config {
|
|
26
|
+
/** Profile directory to sync; absent selects the running profile. */
|
|
27
|
+
profileDir?: Volatile<string | undefined> | string;
|
|
28
|
+
/** How often the host syncs on its own, in minutes; `-1` (the default) is only when asked. */
|
|
29
|
+
autoEvery?: Volatile<number> | number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Cordis resolves the composition entry's config through this schema before
|
|
33
|
+
* `apply` runs. In dsh 0.1.7+, fields marked with `.volatile()` are projected
|
|
34
|
+
* by the settings service into live configuration forms.
|
|
35
|
+
*/
|
|
36
|
+
export declare const Config: z;
|
|
37
|
+
/** Reject stale or misspelled config keys before defaults can hide them. */
|
|
38
|
+
export declare function validateConfig(config: Config): void;
|
|
39
|
+
/** The settings-facing subset of the config. */
|
|
40
|
+
export interface SectionConfig {
|
|
41
|
+
profileDir?: string;
|
|
42
|
+
autoEvery: number;
|
|
43
|
+
}
|
|
44
|
+
/** The section-shaped view of a config: absent keys stay absent. */
|
|
45
|
+
export declare function sectionOf(config: Config): SectionConfig;
|
|
46
|
+
/**
|
|
47
|
+
* Apply one configuration change from the card.
|
|
48
|
+
*
|
|
49
|
+
* A blank secret leaves the stored one alone rather than clearing it: the card
|
|
50
|
+
* is given only whether a secret is set, so it can never send back what it was
|
|
51
|
+
* never shown, and a form submitted without touching the password field must not
|
|
52
|
+
* wipe it. The way to clear a stored password is to turn sync off.
|
|
53
|
+
*
|
|
54
|
+
* The passphrase is refused when it is the WebDAV password: the server is sent
|
|
55
|
+
* the password, so a file sealed with it could be opened by whoever runs the
|
|
56
|
+
* server, which is the opposite of what sealing it is for.
|
|
57
|
+
*/
|
|
58
|
+
export declare function configure(engine: Engine, body: ConfigureBody): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Plugin apply: mount the settings presentation policy, the HTTP routes and the
|
|
61
|
+
* auto-sync timer on any webServer that comes and goes.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Passwords that are in use but deliberately not on disk.
|
|
65
|
+
*
|
|
66
|
+
* "Remember the password" off means it must not be written to `sync.json`, and
|
|
67
|
+
* it does not mean it should stop working: every route rebuilds its engine from
|
|
68
|
+
* that file, so a password emptied there would be one no request could carry and
|
|
69
|
+
* sync would be unable to sign in until the process restarted. It is held for as
|
|
70
|
+
* long as the host runs, keyed by the state directory so two profiles do not
|
|
71
|
+
* share one, and put back into the configuration each time it is read.
|
|
72
|
+
*/
|
|
73
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
|
74
|
+
export { defaultConfig, normaliseConfig, sameAccount, usable };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-webdav-sync host half.
|
|
3
|
+
*
|
|
4
|
+
* The plugin's whole job is done by two halves that never both run in the same
|
|
5
|
+
* process: this one, inside the DSH host, reads and writes the profile's own
|
|
6
|
+
* files (`cordis.patch.yml` for the model catalog and the other rows,
|
|
7
|
+
* `package.json` for the installed plugins), talks to the WebDAV server, and
|
|
8
|
+
* serves the routes the browser half fetches; the browser half is a settings
|
|
9
|
+
* card that shows what is where and asks for a sync.
|
|
10
|
+
*
|
|
11
|
+
* Nothing about the WebDAV account is stored in the composition: the profile
|
|
12
|
+
* patch is itself one of the things that travels, so a password put there would
|
|
13
|
+
* be uploaded by the next sync. It lives in the profile's
|
|
14
|
+
* `.dsh-webdav-sync/sync.json`, mode 0600, beside the state and the cache.
|
|
15
|
+
*/
|
|
16
|
+
import z from '@deepseek-ai/schemastery';
|
|
17
|
+
import { AUTO_CHOICES, ROUTE_CHECK, ROUTE_CONFIG, ROUTE_DISMISS, ROUTE_INSTALL, ROUTE_OFF, ROUTE_PREVIEW, ROUTE_RESTORE, ROUTE_STATUS, ROUTE_SYNC, ROUTE_UNDO, ROUTE_UPLOAD, } from './wire.js';
|
|
18
|
+
import { validateProfileDir, resolveFromEnv } from './paths.js';
|
|
19
|
+
import { check, dismiss, engineFor, loadConfig, preview, restore, sameAccount, saveConfig, status, sync, turnOff, undo, upload, usable, } from './engine.js';
|
|
20
|
+
import { installPlugins } from './install.js';
|
|
21
|
+
import { sameSecret } from './crypto.js';
|
|
22
|
+
import { defaultConfig, normaliseConfig, heldPassword, holdPassword, releasePassword } from './state.js';
|
|
23
|
+
export const name = 'dsh-webdav-sync';
|
|
24
|
+
export const inject = [];
|
|
25
|
+
/** Largest accepted request body (bytes) — these payloads are a few strings. */
|
|
26
|
+
const BODY_LIMIT = 64 * 1024;
|
|
27
|
+
/** The settings namespace this plugin serves; its browser card spells the same string. */
|
|
28
|
+
export const WEBDAV_SYNC_NS = 'dsh-webdav-sync';
|
|
29
|
+
/** How long the auto-sync timer waits before its first attempt. */
|
|
30
|
+
const FIRST_DELAY_MS = 20_000;
|
|
31
|
+
function readVolatile(value) {
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (typeof value === 'object' && value !== null && 'get' in value && typeof value.get === 'function') {
|
|
35
|
+
return value.get();
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Cordis resolves the composition entry's config through this schema before
|
|
41
|
+
* `apply` runs. In dsh 0.1.7+, fields marked with `.volatile()` are projected
|
|
42
|
+
* by the settings service into live configuration forms.
|
|
43
|
+
*/
|
|
44
|
+
export const Config = z.object({
|
|
45
|
+
profileDir: z.string().volatile(),
|
|
46
|
+
autoEvery: z.number().default(-1).volatile(),
|
|
47
|
+
});
|
|
48
|
+
/** Reject stale or misspelled config keys before defaults can hide them. */
|
|
49
|
+
export function validateConfig(config) {
|
|
50
|
+
const unknown = Object.keys(config).find(key => key !== 'profileDir' && key !== 'autoEvery');
|
|
51
|
+
if (unknown !== undefined) {
|
|
52
|
+
throw new Error(`WebdavSyncConfig:不认识的配置项「${unknown}」`);
|
|
53
|
+
}
|
|
54
|
+
const dir = readVolatile(config.profileDir);
|
|
55
|
+
if (dir !== undefined && dir !== '')
|
|
56
|
+
validateProfileDir(dir);
|
|
57
|
+
const auto = readVolatile(config.autoEvery);
|
|
58
|
+
if (auto !== undefined && !AUTO_CHOICES.includes(auto)) {
|
|
59
|
+
throw new Error(`WebdavSyncConfig:「autoEvery」只能是 ${AUTO_CHOICES.join('、')} 之一`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The section-shaped view of a config: absent keys stay absent. */
|
|
63
|
+
export function sectionOf(config) {
|
|
64
|
+
const profileDir = readVolatile(config.profileDir);
|
|
65
|
+
const autoEvery = readVolatile(config.autoEvery);
|
|
66
|
+
return {
|
|
67
|
+
...(profileDir === undefined || profileDir === '' ? {} : { profileDir }),
|
|
68
|
+
autoEvery: autoEvery ?? -1,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Apply one configuration change from the card.
|
|
73
|
+
*
|
|
74
|
+
* A blank secret leaves the stored one alone rather than clearing it: the card
|
|
75
|
+
* is given only whether a secret is set, so it can never send back what it was
|
|
76
|
+
* never shown, and a form submitted without touching the password field must not
|
|
77
|
+
* wipe it. The way to clear a stored password is to turn sync off.
|
|
78
|
+
*
|
|
79
|
+
* The passphrase is refused when it is the WebDAV password: the server is sent
|
|
80
|
+
* the password, so a file sealed with it could be opened by whoever runs the
|
|
81
|
+
* server, which is the opposite of what sealing it is for.
|
|
82
|
+
*/
|
|
83
|
+
export async function configure(engine, body) {
|
|
84
|
+
const next = { ...engine.config };
|
|
85
|
+
const gavePassword = typeof body.password === 'string' && body.password !== '';
|
|
86
|
+
if (typeof body.url === 'string')
|
|
87
|
+
next.url = body.url.trim().replace(/\/+$/, '');
|
|
88
|
+
if (typeof body.user === 'string')
|
|
89
|
+
next.user = body.user.trim();
|
|
90
|
+
if (gavePassword)
|
|
91
|
+
next.password = body.password;
|
|
92
|
+
if (typeof body.passphrase === 'string' && body.passphrase !== '')
|
|
93
|
+
next.passphrase = body.passphrase;
|
|
94
|
+
if (typeof body.models === 'boolean')
|
|
95
|
+
next.models = body.models;
|
|
96
|
+
if (typeof body.defaults === 'boolean')
|
|
97
|
+
next.defaults = body.defaults;
|
|
98
|
+
if (typeof body.plugins === 'boolean')
|
|
99
|
+
next.plugins = body.plugins;
|
|
100
|
+
if (typeof body.patch === 'boolean')
|
|
101
|
+
next.patch = body.patch;
|
|
102
|
+
if (typeof body.rememberPassword === 'boolean')
|
|
103
|
+
next.rememberPassword = body.rememberPassword;
|
|
104
|
+
if (typeof body.autoEvery === 'number' && AUTO_CHOICES.includes(body.autoEvery)) {
|
|
105
|
+
next.autoEvery = body.autoEvery;
|
|
106
|
+
}
|
|
107
|
+
// The password this computer would actually send: one typed into this submit, or
|
|
108
|
+
// the one already in hand for this account. What is written down is only part of
|
|
109
|
+
// that — a password the user chose not to remember is held in memory for the run
|
|
110
|
+
// — and this refusal is about the file on the server being openable by whoever
|
|
111
|
+
// runs it, which is just as true of the one that would be sent. A changed
|
|
112
|
+
// account has no password in hand at all: the carried one is dropped below, so
|
|
113
|
+
// it cannot be what seals a file this submit is about to write.
|
|
114
|
+
const inHand = gavePassword || sameAccount(engine.config, next);
|
|
115
|
+
const sent = inHand ? (next.password !== '' ? next.password : (heldPassword(engine.paths) ?? '')) : '';
|
|
116
|
+
if (next.passphrase !== '' && sent !== '' && sameSecret(next.passphrase, sent)) {
|
|
117
|
+
throw new Error('口令不能和 WebDAV 密码相同:密码是要发给服务器的,服务器可以拿它打开文件。请另设一个口令');
|
|
118
|
+
}
|
|
119
|
+
// A blank password field means "keep the stored one", which is only a faithful
|
|
120
|
+
// reading while the stored one belongs to the same account. Pointing this
|
|
121
|
+
// computer at another server or user while carrying the old password along
|
|
122
|
+
// would send a credential for one service to another, so the carried-over one
|
|
123
|
+
// is dropped rather than reused. A password typed in this same submit is kept:
|
|
124
|
+
// it was given for the address being saved. The card reads "a password is set"
|
|
125
|
+
// from the stored value, so it stops saying so once this takes effect.
|
|
126
|
+
if (!gavePassword && !sameAccount(engine.config, next))
|
|
127
|
+
next.password = '';
|
|
128
|
+
// Turning remembering off is about the file, not about this run: the card says
|
|
129
|
+
// the password "is used for this run only", and a password emptied here would
|
|
130
|
+
// be one no request could ever carry, leaving sync unable to sign in until the
|
|
131
|
+
// process restarts and the user retypes it. It stays in the engine — which is
|
|
132
|
+
// what the running half talks to the server with — and is left out of what is
|
|
133
|
+
// written down.
|
|
134
|
+
if (next.rememberPassword) {
|
|
135
|
+
await saveConfig(engine.paths, next);
|
|
136
|
+
releasePassword(engine.paths);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
await saveConfig(engine.paths, { ...next, password: '' });
|
|
140
|
+
holdPassword(engine.paths, next.password);
|
|
141
|
+
}
|
|
142
|
+
engine.config = next;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Plugin apply: mount the settings presentation policy, the HTTP routes and the
|
|
146
|
+
* auto-sync timer on any webServer that comes and goes.
|
|
147
|
+
*/
|
|
148
|
+
/**
|
|
149
|
+
* Passwords that are in use but deliberately not on disk.
|
|
150
|
+
*
|
|
151
|
+
* "Remember the password" off means it must not be written to `sync.json`, and
|
|
152
|
+
* it does not mean it should stop working: every route rebuilds its engine from
|
|
153
|
+
* that file, so a password emptied there would be one no request could carry and
|
|
154
|
+
* sync would be unable to sign in until the process restarted. It is held for as
|
|
155
|
+
* long as the host runs, keyed by the state directory so two profiles do not
|
|
156
|
+
* share one, and put back into the configuration each time it is read.
|
|
157
|
+
*/
|
|
158
|
+
export function apply(ctx, config = {}) {
|
|
159
|
+
validateConfig(config);
|
|
160
|
+
const currentConfig = () => ctx.fiber?.config ?? config;
|
|
161
|
+
/** Resolve the profile and read the stored sync configuration, always afresh. */
|
|
162
|
+
const engine = async () => {
|
|
163
|
+
const paths = resolveFromEnv(readVolatile(currentConfig().profileDir));
|
|
164
|
+
const { config: stored } = await loadConfig(paths);
|
|
165
|
+
const secret = stored.rememberPassword ? undefined : heldPassword(paths);
|
|
166
|
+
return engineFor(paths, secret === undefined || secret === '' ? stored : { ...stored, password: secret });
|
|
167
|
+
};
|
|
168
|
+
// In dsh 0.1.7+, the settings service projects volatile Config fields into
|
|
169
|
+
// forms. auto: false tells the host this plugin ships its own card, which the
|
|
170
|
+
// browser half registers as `plugins.bundle.config`.
|
|
171
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
172
|
+
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber), 'webdav-sync: settings presentation policy');
|
|
173
|
+
});
|
|
174
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
175
|
+
const send = (res, status, body) => {
|
|
176
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
177
|
+
res.end(JSON.stringify(body));
|
|
178
|
+
};
|
|
179
|
+
const readJson = (req) => new Promise((resolvePromise, rejectPromise) => {
|
|
180
|
+
const chunks = [];
|
|
181
|
+
let size = 0;
|
|
182
|
+
req.on('data', (chunk) => {
|
|
183
|
+
size += chunk.length;
|
|
184
|
+
if (size > BODY_LIMIT) {
|
|
185
|
+
req.destroy();
|
|
186
|
+
rejectPromise(new Error('请求内容过大'));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
chunks.push(chunk);
|
|
190
|
+
});
|
|
191
|
+
req.on('end', () => {
|
|
192
|
+
if (chunks.length === 0) {
|
|
193
|
+
resolvePromise({});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
resolvePromise(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
rejectPromise(new Error(`请求内容不是合法的 JSON:${error instanceof Error ? error.message : String(error)}`));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
req.on('error', rejectPromise);
|
|
204
|
+
});
|
|
205
|
+
/**
|
|
206
|
+
* One exact route. A failure answers 400 with its own message, which is what
|
|
207
|
+
* the card shows: the messages this plugin raises are written to be read by
|
|
208
|
+
* a person ("服务器上没有目录 … 请先在那边建好"), not to be mapped
|
|
209
|
+
* onto status codes.
|
|
210
|
+
*/
|
|
211
|
+
const route = (path, label, handle) => {
|
|
212
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
213
|
+
kind: 'exact',
|
|
214
|
+
path,
|
|
215
|
+
handler: async (req, res) => {
|
|
216
|
+
try {
|
|
217
|
+
await handle(req, res, req.method === 'GET' ? {} : await readJson(req));
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
221
|
+
send(res, 400, { error: text });
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
}), `webdav-sync: ${label} route`);
|
|
225
|
+
};
|
|
226
|
+
route(ROUTE_STATUS, 'status', async (_req, res) => {
|
|
227
|
+
const result = await status(await engine());
|
|
228
|
+
send(res, 200, result);
|
|
229
|
+
});
|
|
230
|
+
route(ROUTE_CONFIG, 'config', async (_req, res, body) => {
|
|
231
|
+
const current = await engine();
|
|
232
|
+
await configure(current, (body ?? {}));
|
|
233
|
+
await saveStateIfUsable(current);
|
|
234
|
+
send(res, 200, { message: '已保存' });
|
|
235
|
+
});
|
|
236
|
+
route(ROUTE_CHECK, 'check', async (_req, res) => {
|
|
237
|
+
const current = await engine();
|
|
238
|
+
if (!usable(current.config) && current.config.url.trim() === '') {
|
|
239
|
+
throw new Error('请先填写同步地址');
|
|
240
|
+
}
|
|
241
|
+
send(res, 200, await check(current));
|
|
242
|
+
});
|
|
243
|
+
route(ROUTE_SYNC, 'sync', async (req, res, body) => {
|
|
244
|
+
const current = await engine();
|
|
245
|
+
const force = body?.force === true || new URL(req.url ?? '/', 'http://x').searchParams.get('force') === '1';
|
|
246
|
+
send(res, 200, await sync(current, { force }));
|
|
247
|
+
});
|
|
248
|
+
route(ROUTE_PREVIEW, 'preview', async (_req, res) => {
|
|
249
|
+
send(res, 200, await preview(await engine()));
|
|
250
|
+
});
|
|
251
|
+
route(ROUTE_RESTORE, 'restore', async (_req, res) => {
|
|
252
|
+
send(res, 200, await restore(await engine()));
|
|
253
|
+
});
|
|
254
|
+
route(ROUTE_UNDO, 'undo', async (_req, res) => {
|
|
255
|
+
send(res, 200, await undo(await engine()));
|
|
256
|
+
});
|
|
257
|
+
route(ROUTE_UPLOAD, 'upload', async (_req, res) => {
|
|
258
|
+
send(res, 200, await upload(await engine()));
|
|
259
|
+
});
|
|
260
|
+
route(ROUTE_DISMISS, 'dismiss', async (_req, res) => {
|
|
261
|
+
await dismiss(await engine());
|
|
262
|
+
send(res, 200, { message: '已关闭提示' });
|
|
263
|
+
});
|
|
264
|
+
route(ROUTE_OFF, 'off', async (_req, res) => {
|
|
265
|
+
await turnOff(await engine());
|
|
266
|
+
send(res, 200, { message: '已关闭 WebDAV 同步;服务器上的文件保持原样' });
|
|
267
|
+
});
|
|
268
|
+
route(ROUTE_INSTALL, 'install', async (_req, res) => {
|
|
269
|
+
const current = await engine();
|
|
270
|
+
send(res, 200, await installPlugins(current.paths));
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
startTimer(ctx, currentConfig);
|
|
274
|
+
}
|
|
275
|
+
/** Write a state file that records the new configuration's key, if it can be written. */
|
|
276
|
+
async function saveStateIfUsable(engine) {
|
|
277
|
+
try {
|
|
278
|
+
const { ensureStateDir } = await import('./state.js');
|
|
279
|
+
await ensureStateDir(engine.paths);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Not being able to make the directory is reported by the first sync, which
|
|
283
|
+
// needs it far more than a configuration change does.
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Sync on a timer, when the stored configuration asks for it.
|
|
288
|
+
*
|
|
289
|
+
* The interval is read from the stored configuration before every attempt rather
|
|
290
|
+
* than captured once, so changing it in the card takes effect without a reload.
|
|
291
|
+
* A failure is recorded in the state by `sync` itself and changes nothing here:
|
|
292
|
+
* the next tick tries again, which is the whole point of a timer.
|
|
293
|
+
*/
|
|
294
|
+
function startTimer(ctx, currentConfig) {
|
|
295
|
+
let timer;
|
|
296
|
+
let stopped = false;
|
|
297
|
+
const tick = async () => {
|
|
298
|
+
if (stopped)
|
|
299
|
+
return;
|
|
300
|
+
let wait = 0;
|
|
301
|
+
try {
|
|
302
|
+
const paths = resolveFromEnv(readVolatile(currentConfig().profileDir));
|
|
303
|
+
const { config: stored } = await loadConfig(paths);
|
|
304
|
+
const minutes = stored.autoEvery;
|
|
305
|
+
if (usable(stored) && minutes > 0) {
|
|
306
|
+
await sync(engineFor(paths, stored)).catch(() => undefined);
|
|
307
|
+
wait = minutes * 60_000;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// A configuration that cannot be read yet is not an error worth logging on
|
|
312
|
+
// every tick: the card is where it gets set.
|
|
313
|
+
}
|
|
314
|
+
if (stopped)
|
|
315
|
+
return;
|
|
316
|
+
// Nothing to do is polled for slowly instead of not at all, so turning sync
|
|
317
|
+
// on in the card starts it without a reload.
|
|
318
|
+
timer = setTimeout(() => void tick(), wait === 0 ? 60_000 : wait);
|
|
319
|
+
timer.unref?.();
|
|
320
|
+
};
|
|
321
|
+
timer = setTimeout(() => void tick(), FIRST_DELAY_MS);
|
|
322
|
+
timer.unref?.();
|
|
323
|
+
ctx.effect(() => () => {
|
|
324
|
+
stopped = true;
|
|
325
|
+
if (timer !== undefined)
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
}, 'webdav-sync: auto-sync timer');
|
|
328
|
+
}
|
|
329
|
+
export { defaultConfig, normaliseConfig, sameAccount, usable };
|
package/lib/install.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installing the plugins a synced profile declares.
|
|
3
|
+
*
|
|
4
|
+
* A sync can bring in a `package.json` naming plugins this computer has never
|
|
5
|
+
* installed. The profile's `node_modules` is not synced — it is a build product,
|
|
6
|
+
* tens of megabytes of it — so the declaration travels and the packages are
|
|
7
|
+
* fetched here instead.
|
|
8
|
+
*
|
|
9
|
+
* The command is `pnpm install` in the profile, which is what dsh's own plugin
|
|
10
|
+
* manager runs; the packages come from the same registries, so nothing about
|
|
11
|
+
* supply-chain rules changes because this plugin asked for them.
|
|
12
|
+
*
|
|
13
|
+
* The child's output goes to an open file, never a pipe. A pipe is a restricted
|
|
14
|
+
* operation inside dsh's file sandbox, and this code runs wherever the harness
|
|
15
|
+
* put the plugin: a file descriptor always works, and the tail of the file is
|
|
16
|
+
* what the card shows anyway.
|
|
17
|
+
*/
|
|
18
|
+
import type { InstallResult } from './wire.js';
|
|
19
|
+
import type { ResolvedPaths } from './paths.js';
|
|
20
|
+
/**
|
|
21
|
+
* Run `pnpm install` in the profile directory.
|
|
22
|
+
*
|
|
23
|
+
* A nonzero exit is not a thrown error: the card shows what pnpm said, which is
|
|
24
|
+
* the only thing that tells a user whether a package name was wrong or the
|
|
25
|
+
* network was down. A pnpm that cannot be started at all is the one case that
|
|
26
|
+
* throws, because there is nothing to show.
|
|
27
|
+
*/
|
|
28
|
+
export declare function installPlugins(paths: ResolvedPaths): Promise<InstallResult>;
|