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/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 };
@@ -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>;