@logictan/dsh-config-manager 0.1.60 → 0.1.61
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/adapters/index.d.ts +1 -1
- package/lib/adapters/index.js +1 -1
- package/lib/adapters/mcp.js +4 -4
- package/lib/adapters/plugins.d.ts +0 -1
- package/lib/adapters/plugins.js +35 -19
- package/lib/adapters/prompts.js +6 -6
- package/lib/adapters/test-helpers.d.ts +27 -0
- package/lib/adapters/test-helpers.js +40 -0
- package/lib/client.d.ts +1 -10
- package/lib/client.js +293 -294
- package/lib/core/backup.js +5 -3
- package/lib/core/patch-layers.d.ts +32 -0
- package/lib/core/patch-layers.js +39 -0
- package/lib/core/rollback.js +4 -4
- package/lib/index.d.ts +21 -12
- package/lib/index.js +28 -36
- package/lib/utils/env-lock.d.ts +16 -0
- package/lib/utils/env-lock.js +27 -6
- package/package.json +2 -2
- package/src/adapters/index.ts +1 -1
- package/src/adapters/mcp.ts +4 -4
- package/src/adapters/patch-layers.test.ts +201 -0
- package/src/adapters/plugins.test.ts +13 -5
- package/src/adapters/plugins.ts +33 -17
- package/src/adapters/prompts.ts +6 -6
- package/src/adapters/test-helpers.ts +48 -0
- package/src/client/api.ts +1 -10
- package/src/client/sync/sync-api.ts +0 -1
- package/src/core/backup.ts +5 -3
- package/src/core/config-lifecycle.test.ts +4 -1
- package/src/core/patch-layers.ts +42 -0
- package/src/core/rollback.ts +4 -4
- package/src/index.facade.test.ts +25 -2
- package/src/index.ts +26 -49
- package/src/utils/env-lock.test.ts +29 -0
- package/src/utils/env-lock.ts +27 -6
|
@@ -4,10 +4,16 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
|
-
import { PluginsAdapter
|
|
7
|
+
import { PluginsAdapter } from './plugins.ts';
|
|
8
8
|
import { makeContext, makeImportContext } from './test-helpers.ts';
|
|
9
|
+
import { HOME_PATCH_FILE, PROFILE_PATCH_FILE, patchLayerKey } from '../core/patch-layers.ts';
|
|
9
10
|
import type { PlanItem } from '../core/types.ts';
|
|
10
11
|
|
|
12
|
+
/** 计划项 id:层限定复合键的投影(`patch:<file>#<lineId>`)。 */
|
|
13
|
+
function patchItemId(file: string, lineId: string): string {
|
|
14
|
+
return `patch:${patchLayerKey(file, lineId)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
test('issue #35:patch 文件随分区迁移;目标缺失的 patchedDependencies 声明导入时剔除', async () => {
|
|
12
18
|
const ws = [
|
|
13
19
|
'allowBuilds:',
|
|
@@ -167,7 +173,9 @@ test('plugins: 导出清单与 patch 行', async () => {
|
|
|
167
173
|
ctx.plugins.installed.set('@linxin666/dsh-ssh', { name: '@linxin666/dsh-ssh', version: '0.1.12', enabled: true, isBundle: true, inBundles: ['@linxin666/dsh-web-ui-all'] });
|
|
168
174
|
ctx.plugins.installed.set('dsh-memory-evolve', { name: 'dsh-memory-evolve', version: '1.0.0', enabled: true, spec: 'github:csyangwen/dsh-memory-evolve' });
|
|
169
175
|
ctx.plugins.installed.set('@deepseek-ai/dsh-base', { name: '@deepseek-ai/dsh-base', version: '0.1.0-rc.6', enabled: true });
|
|
170
|
-
|
|
176
|
+
// 层寻址契约:导出同时读 home 与 profile 两层,单层的 MemPatch 对任何 file 都返回同一批行,
|
|
177
|
+
// 会让同一行被当成两层的两行。此处改用按层键控的门面,夹具才与真实门面同语义。
|
|
178
|
+
ctx.useLayeredPatch().set(HOME_PATCH_FILE, 'skill-badge', { id: 'skill-badge', disabled: true });
|
|
171
179
|
|
|
172
180
|
const adapter = new PluginsAdapter();
|
|
173
181
|
const out = await adapter.export(ctx, { includeSecrets: false });
|
|
@@ -235,11 +243,11 @@ test('plugins: 已装同版本 Skip / 未装 Install / 版本不同 Conflict / p
|
|
|
235
243
|
const byId = new Map(items.map((i) => [i.id, i]));
|
|
236
244
|
assert.equal(byId.get('plugin:pkg-a')?.kind, 'Skip');
|
|
237
245
|
assert.equal(byId.get('plugin:pkg-b')?.kind, 'Conflict');
|
|
238
|
-
assert.equal(byId.get('
|
|
239
|
-
assert.equal(byId.get('
|
|
246
|
+
assert.equal(byId.get(patchItemId(HOME_PATCH_FILE, 'my-line'))?.kind, 'Create');
|
|
247
|
+
assert.equal(byId.get(patchItemId(HOME_PATCH_FILE, 'my-line'))?.target?.ref, patchLayerKey(HOME_PATCH_FILE, 'my-line'));
|
|
240
248
|
|
|
241
249
|
// patch 行写入(Create)
|
|
242
|
-
const r = await adapter.applyItem(byId.get('
|
|
250
|
+
const r = await adapter.applyItem(byId.get(patchItemId(HOME_PATCH_FILE, 'my-line'))!, makeImportContext(dst, sections));
|
|
243
251
|
assert.equal(r.ok, true);
|
|
244
252
|
assert.equal(r.needsRestart, true);
|
|
245
253
|
assert.deepEqual(dst.patchFile.lines.get('my-line')?.raw, { id: 'my-line', name: 'pkg-c', config: { x: 1 } });
|
package/src/adapters/plugins.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* plugins 分区 adapter(设计 §3.3/§8):
|
|
3
|
-
* 数据源 = ctx.plugins.listInstalled()(插件清单)+
|
|
3
|
+
* 数据源 = ctx.plugins.listInstalled()(插件清单)+ 两个 patch 层(home 与 profile 的 cordis.patch.yml)。
|
|
4
4
|
*
|
|
5
5
|
* 安全不变量:绝不打包插件二进制;导入走 DSH 官方机制(dsh plugin CLI → needsRestart 提示)。
|
|
6
|
-
* patch
|
|
6
|
+
* patch 行导入(用户自定义行:启用/禁用/插入)写回该行自带的层(home 或 profile),同样 needsRestart。
|
|
7
7
|
*
|
|
8
8
|
* T1(本地源插件迁移):`link:` / `file:` 来源的插件指向本机路径,换机后必然不可达
|
|
9
9
|
* (曾导致插件被静默丢失)。导出时经注入的 `localPack` 执行 `npm pack`,把 tarball 作为
|
|
@@ -19,6 +19,7 @@ import { msgOf, zhMsg } from '../core/messages.ts';
|
|
|
19
19
|
import type { MsgFunc } from '../core/messages.ts';
|
|
20
20
|
import { isPathSafe, normalizePath } from '../utils/paths.ts';
|
|
21
21
|
import { PLUGIN_PATCH_REF_PREFIX } from '../core/backup.ts';
|
|
22
|
+
import { HOME_PATCH_FILE, PROFILE_PATCH_FILE, patchLayerKey, parsePatchLayerKey } from '../core/patch-layers.ts';
|
|
22
23
|
import { parsePnpmPatchedDependencies, sanitizePnpmWorkspacePatches } from './pnpm-workspace.ts';
|
|
23
24
|
import type { LocalPluginTarball, PatchLine, PluginEntry, PluginsSection, PnpmPatchFile } from '../schema/types.ts';
|
|
24
25
|
import type {
|
|
@@ -26,7 +27,6 @@ import type {
|
|
|
26
27
|
ImportContext, PlanItem, ValidationResult,
|
|
27
28
|
} from '../core/types.ts';
|
|
28
29
|
|
|
29
|
-
export const USER_PATCH_FILE = 'cordis.patch.yml';
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* 本地源插件打包钩子(由宿主注入,见 src/index.ts createAdapters)。
|
|
@@ -170,12 +170,16 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
170
170
|
} catch (err) {
|
|
171
171
|
warnings.push(msgOf(ctx)('adapter.pluginListReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
|
|
172
172
|
}
|
|
173
|
+
// 两层都读:home 层(全机偏好)与 profile 层(该 profile 专属)。缺 profile 层文件不是故障
|
|
174
|
+
// (readPatchLines 对不存在的文件返回空数组),只有真读失败才告警。
|
|
173
175
|
const patch: PatchLine[] = [];
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
for (const file of [HOME_PATCH_FILE, PROFILE_PATCH_FILE]) {
|
|
177
|
+
try {
|
|
178
|
+
const lines = await ctx.patchFile.readPatchLines(file);
|
|
179
|
+
for (const l of lines) patch.push({ file, lineId: l.lineId, raw: l.raw });
|
|
180
|
+
} catch (err) {
|
|
181
|
+
warnings.push(msgOf(ctx)('adapter.patchReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
|
|
182
|
+
}
|
|
179
183
|
}
|
|
180
184
|
// pnpm-workspace.yaml(allowBuilds / minimumReleaseAgeExclude 等):随插件分区迁移,
|
|
181
185
|
// 否则目标 profile 的 pnpm 可能因构建白名单/冷静期拒绝安装插件(§34.17 同款语义)。
|
|
@@ -414,16 +418,25 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
414
418
|
|
|
415
419
|
// 用户 patch 行:lineId 唯一键;存在且同 → Skip;存在不同 → Conflict;不存在 → Create。
|
|
416
420
|
// mcp-client 行与 systemPrompt/planMode 行由 mcp/prompts adapter 管理,此处跳过(避免重复写入覆盖)。
|
|
417
|
-
|
|
421
|
+
// 逐层读目标端:行自带的 file 决定比对哪一层(只读 home 层会让 profile 层行在目标机
|
|
422
|
+
// 已有同值时被误判成 Create,也会让目标机 profile 层的不同值判不出 Conflict)。
|
|
423
|
+
const targetLines = new Map<string, { lineId: string; raw: unknown }[]>();
|
|
424
|
+
for (const pl of data.patch) {
|
|
425
|
+
if (!targetLines.has(pl.file)) {
|
|
426
|
+
targetLines.set(pl.file, await ctx.target.patchFile.readPatchLines(pl.file));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
418
429
|
for (const pl of data.patch) {
|
|
419
430
|
if (isManagedElsewhere(pl.raw)) continue;
|
|
420
|
-
|
|
421
|
-
const
|
|
431
|
+
// 层限定复合键:两层可存在同名 lineId,裸 lineId 无法指向「哪一层的哪一行」。
|
|
432
|
+
const ref = patchLayerKey(pl.file, pl.lineId);
|
|
433
|
+
const id = `patch:${ref}`;
|
|
434
|
+
const tl = targetLines.get(pl.file)?.find((l) => l.lineId === pl.lineId);
|
|
422
435
|
if (!tl) {
|
|
423
436
|
items.push({
|
|
424
437
|
id, kind: 'Create', adapter: 'plugins',
|
|
425
438
|
description: msg('adapter.patchLineCreate', { lineId: pl.lineId }), severity: 'info',
|
|
426
|
-
target: { adapter: 'plugins', ref
|
|
439
|
+
target: { adapter: 'plugins', ref },
|
|
427
440
|
});
|
|
428
441
|
} else if (isDeepStrictEqual(tl.raw, pl.raw)) {
|
|
429
442
|
items.push({ id, kind: 'Skip', adapter: 'plugins', description: msg('adapter.patchLineSame', { lineId: pl.lineId }), severity: 'info' });
|
|
@@ -431,7 +444,7 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
431
444
|
items.push({
|
|
432
445
|
id, kind: 'Conflict', adapter: 'plugins',
|
|
433
446
|
description: msg('adapter.patchLineDiff', { lineId: pl.lineId }), severity: 'warning',
|
|
434
|
-
target: { adapter: 'plugins', ref
|
|
447
|
+
target: { adapter: 'plugins', ref },
|
|
435
448
|
});
|
|
436
449
|
}
|
|
437
450
|
}
|
|
@@ -561,13 +574,16 @@ export class PluginsAdapter implements ConfigAdapter<PluginsSection> {
|
|
|
561
574
|
// patch 行:Create → insert,Update/Conflict(useImported) → update
|
|
562
575
|
const ref = item.target?.ref;
|
|
563
576
|
if (!ref) return { ok: false, message: msg('adapter.missingTargetRef') };
|
|
577
|
+
// 复合键解析出层与 lineId:只按 lineId 查找会命中数组里先出现的 home 层行,
|
|
578
|
+
// 把 profile 层的行写进 home 层(回滚同理)。
|
|
579
|
+
const { file, lineId } = parsePatchLayerKey(ref);
|
|
564
580
|
const data = ctx.sections.get('plugins') as PluginsSection | undefined;
|
|
565
|
-
const pl = data?.patch.find((p) => p.lineId ===
|
|
581
|
+
const pl = data?.patch.find((p) => p.file === file && p.lineId === lineId);
|
|
566
582
|
if (!pl) return { ok: false, message: msg('adapter.patchMissing', { ref }) };
|
|
567
|
-
await ctx.target.patchFile.applyPatchChanges(
|
|
568
|
-
{ lineId
|
|
583
|
+
await ctx.target.patchFile.applyPatchChanges(file, [
|
|
584
|
+
{ lineId, raw: pl.raw, action: item.kind === 'Create' ? 'insert' : 'update' },
|
|
569
585
|
]);
|
|
570
|
-
return { ok: true, needsRestart: true, message: msg('adapter.patchWritten', { ref }) };
|
|
586
|
+
return { ok: true, needsRestart: true, message: msg('adapter.patchWritten', { ref: lineId }) };
|
|
571
587
|
}
|
|
572
588
|
|
|
573
589
|
async validate(data: PluginsSection, msg: MsgFunc = zhMsg): Promise<ValidationResult> {
|
package/src/adapters/prompts.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
|
|
18
18
|
ImportContext, PlanItem, ValidationResult,
|
|
19
19
|
} from '../core/types.ts';
|
|
20
|
-
import {
|
|
20
|
+
import { HOME_PATCH_FILE } from '../core/patch-layers.ts';
|
|
21
21
|
|
|
22
22
|
/** 导出记录:PromptEntry 之外记录来源行名(导入需要重建行时使用) */
|
|
23
23
|
export interface PromptExportEntry extends PromptEntry {
|
|
@@ -116,7 +116,7 @@ export class PromptsAdapter implements ConfigAdapter<PromptsExportSection> {
|
|
|
116
116
|
const warnings: string[] = [];
|
|
117
117
|
let lines: { lineId: string; raw: unknown }[] = [];
|
|
118
118
|
try {
|
|
119
|
-
lines = await ctx.patchFile.readPatchLines(
|
|
119
|
+
lines = await ctx.patchFile.readPatchLines(HOME_PATCH_FILE);
|
|
120
120
|
} catch (err) {
|
|
121
121
|
warnings.push(msgOf(ctx)('adapter.patchReadFailedPrompts', { reason: err instanceof Error ? err.message : String(err) }));
|
|
122
122
|
}
|
|
@@ -132,7 +132,7 @@ export class PromptsAdapter implements ConfigAdapter<PromptsExportSection> {
|
|
|
132
132
|
async analyzeImport(data: PromptsExportSection, ctx: ImportContext): Promise<PlanItem[]> {
|
|
133
133
|
const msg = ctx.msg;
|
|
134
134
|
const items: PlanItem[] = [];
|
|
135
|
-
const targetLines = await ctx.target.patchFile.readPatchLines(
|
|
135
|
+
const targetLines = await ctx.target.patchFile.readPatchLines(HOME_PATCH_FILE);
|
|
136
136
|
const targetPrompts = extractPrompts(targetLines);
|
|
137
137
|
for (const p of data.prompts) {
|
|
138
138
|
const id = p.id;
|
|
@@ -192,17 +192,17 @@ export class PromptsAdapter implements ConfigAdapter<PromptsExportSection> {
|
|
|
192
192
|
// Create:目标无来源行 → 用记录的行名重建 patch 行(insert)
|
|
193
193
|
if (item.kind === 'Create') {
|
|
194
194
|
const raw = buildPromptLine(ref, prompt);
|
|
195
|
-
await ctx.target.patchFile.applyPatchChanges(
|
|
195
|
+
await ctx.target.patchFile.applyPatchChanges(HOME_PATCH_FILE, [
|
|
196
196
|
{ lineId: ref, raw, action: 'insert' },
|
|
197
197
|
]);
|
|
198
198
|
return { ok: true, needsRestart: true, message: msg('adapter.promptCreated', { name: prompt.name, ref }) };
|
|
199
199
|
}
|
|
200
200
|
// Update / Conflict(useImported):合并进目标行 config
|
|
201
|
-
const lines = await ctx.target.patchFile.readPatchLines(
|
|
201
|
+
const lines = await ctx.target.patchFile.readPatchLines(HOME_PATCH_FILE);
|
|
202
202
|
const line = lines.find((l) => l.lineId === ref);
|
|
203
203
|
if (!line) return { ok: false, message: msg('adapter.patchLineMissing', { ref }) };
|
|
204
204
|
const newRaw = mergePromptIntoLine(line.raw, prompt);
|
|
205
|
-
await ctx.target.patchFile.applyPatchChanges(
|
|
205
|
+
await ctx.target.patchFile.applyPatchChanges(HOME_PATCH_FILE, [
|
|
206
206
|
{ lineId: ref, raw: newRaw, action: 'update' },
|
|
207
207
|
]);
|
|
208
208
|
return { ok: true, needsRestart: true, message: msg('adapter.promptWritten', { name: prompt.name, ref }) };
|
|
@@ -158,6 +158,46 @@ export class MemPatch implements PatchFileFacade {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* 分层 patch 门面 mock:patch 行按 `file`(层 token)分桶。
|
|
163
|
+
* 单层的 `MemPatch` 无法表达「同名 lineId 分布在两层」——而那正是层寻址契约的核心,
|
|
164
|
+
* 故另立一个按层键控的实现,供 plugins 适配器与快照/回滚的契约测试使用。
|
|
165
|
+
*/
|
|
166
|
+
export class MemLayeredPatch implements PatchFileFacade {
|
|
167
|
+
files = new Map<string, Map<string, { lineId: string; raw: unknown }>>();
|
|
168
|
+
|
|
169
|
+
/** 直接落一行(测试夹具;等价于目标机该层已存在该行)。 */
|
|
170
|
+
set(file: string, lineId: string, raw: unknown): void {
|
|
171
|
+
const layer = this.files.get(file) ?? new Map();
|
|
172
|
+
layer.set(lineId, { lineId, raw });
|
|
173
|
+
this.files.set(file, layer);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
has(file: string, lineId: string): boolean {
|
|
177
|
+
return this.files.get(file)?.has(lineId) ?? false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
rawOf(file: string, lineId: string): unknown {
|
|
181
|
+
return this.files.get(file)?.get(lineId)?.raw;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async readPatchLines(file: string): Promise<{ lineId: string; raw: unknown }[]> {
|
|
185
|
+
return [...(this.files.get(file)?.values() ?? [])];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async applyPatchChanges(
|
|
189
|
+
file: string,
|
|
190
|
+
changes: { lineId: string; raw: unknown; action: 'insert' | 'update' | 'remove' }[],
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
const layer = this.files.get(file) ?? new Map<string, { lineId: string; raw: unknown }>();
|
|
193
|
+
this.files.set(file, layer);
|
|
194
|
+
for (const c of changes) {
|
|
195
|
+
if (c.action === 'remove') layer.delete(c.lineId);
|
|
196
|
+
else layer.set(c.lineId, { lineId: c.lineId, raw: c.raw });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
161
201
|
export class MemSnapshotStore implements SnapshotStore {
|
|
162
202
|
snapshots = new Map<string, Snapshot>();
|
|
163
203
|
blobs = new Map<string, Uint8Array>();
|
|
@@ -203,6 +243,14 @@ export class MockHostContext implements HostContext {
|
|
|
203
243
|
this.fs = new MemFs(homeDir);
|
|
204
244
|
this.log = createLogger({ level: 'error', sink: () => {} });
|
|
205
245
|
}
|
|
246
|
+
|
|
247
|
+
/** 换成按层键控的 patch 门面(层寻址契约测试用)。
|
|
248
|
+
* 字段的声明类型仍是单层的 MemPatch,以免既有夹具的 `.lines` 用法失型。 */
|
|
249
|
+
useLayeredPatch(): MemLayeredPatch {
|
|
250
|
+
const layered = new MemLayeredPatch();
|
|
251
|
+
this.patchFile = layered as unknown as MemPatch;
|
|
252
|
+
return layered;
|
|
253
|
+
}
|
|
206
254
|
}
|
|
207
255
|
|
|
208
256
|
export function makeContext(platform: string, homeDir: string, profile?: string): MockHostContext {
|
package/src/client/api.ts
CHANGED
|
@@ -4,19 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { zhUiT, type UiT } from '../ui/i18n.ts'
|
|
6
6
|
|
|
7
|
-
/** Host
|
|
7
|
+
/** Host 半状态响应:设置页页脚版本行的两个字段(消费方只有 ConfigManagerSection) */
|
|
8
8
|
export interface ServiceStatus {
|
|
9
|
-
ready: boolean
|
|
10
9
|
pluginVersion: string
|
|
11
10
|
dshVersion: string
|
|
12
|
-
platform: string
|
|
13
|
-
arch: string
|
|
14
|
-
homeDir?: string
|
|
15
|
-
profile?: string
|
|
16
|
-
profileManifestReadable?: boolean
|
|
17
|
-
installedPluginCount?: number
|
|
18
|
-
installedPluginNames?: string[]
|
|
19
|
-
bundles?: string[]
|
|
20
11
|
}
|
|
21
12
|
|
|
22
13
|
export const CONFIG_MANAGER_API = {
|
|
@@ -30,7 +30,6 @@ import { zhUiT, type UiT } from '../../ui/i18n.ts';
|
|
|
30
30
|
|
|
31
31
|
/** 同步端点常量(与 Host 半 src/index.ts API 常量保持一致) */
|
|
32
32
|
export const SYNC_API = {
|
|
33
|
-
base: '/api/dsh-config-manager/sync',
|
|
34
33
|
status: '/api/dsh-config-manager/sync/status',
|
|
35
34
|
push: '/api/dsh-config-manager/sync/push',
|
|
36
35
|
pull: '/api/dsh-config-manager/sync/pull',
|
package/src/core/backup.ts
CHANGED
|
@@ -9,6 +9,7 @@ import fs from 'node:fs/promises';
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
11
|
import { parseJsonSafe } from '../utils/json.ts';
|
|
12
|
+
import { parsePatchLayerKey } from './patch-layers.ts';
|
|
12
13
|
import { sha256Hex } from '../utils/hashing.ts';
|
|
13
14
|
import { normalizePath } from '../utils/paths.ts';
|
|
14
15
|
import { atomicWriteFile } from '../utils/atomic-write.ts';
|
|
@@ -206,10 +207,11 @@ async function engineSnapshotEntry(ctx: HostContext, target: SnapshotTarget): Pr
|
|
|
206
207
|
copiedTo: `blobs/${crypto.randomUUID()}`,
|
|
207
208
|
};
|
|
208
209
|
}
|
|
209
|
-
// patchLine
|
|
210
|
-
|
|
210
|
+
// patchLine:ref 是层限定复合键(<file>#<lineId>),据此到**对应层**读原行。
|
|
211
|
+
// 旧快照的裸 lineId 由 parsePatchLayerKey 兼容为 home 层。
|
|
212
|
+
const { file, lineId } = parsePatchLayerKey(target.ref);
|
|
211
213
|
const lines = await ctx.patchFile.readPatchLines(file);
|
|
212
|
-
const line = lines.find((l) => l.lineId ===
|
|
214
|
+
const line = lines.find((l) => l.lineId === lineId);
|
|
213
215
|
return { kind: 'patchLine', adapter: target.adapter, ref: target.ref, before: line?.raw ?? null, existed: line !== undefined };
|
|
214
216
|
}
|
|
215
217
|
case 'skills':
|
|
@@ -474,7 +474,10 @@ test('自动快照端到端:真实变更 → 防抖到期 → 落盘一份 aut
|
|
|
474
474
|
const metas = await h.lifecycle.list();
|
|
475
475
|
assert.equal(metas[0]!.kind, 'auto');
|
|
476
476
|
assert.equal(metas[0]!.trigger, 'watcher');
|
|
477
|
-
|
|
477
|
+
// 回调在 saveSnapshot 落盘**之后**才触发,所以「等文件出现」不等于「回调已跑」:
|
|
478
|
+
// 直接断言会在负载高时偶发失败。等回调本身,而不是等它的副作用。
|
|
479
|
+
const fired = await waitFor(async () => h.autoMetas.length === 1);
|
|
480
|
+
assert.equal(fired, true, '宿主回调必须被触发');
|
|
478
481
|
});
|
|
479
482
|
|
|
480
483
|
test('自动快照端到端:恢复自写文件是回声 → 不产生快照(否则会挡住重做)', async (t) => {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cordis patch 层的寻址契约(唯一真源)。
|
|
3
|
+
*
|
|
4
|
+
* 宿主组装 patch 栈的顺序是 `bundle → profile → home → --patch`(后者覆盖前者),两层分工不同:
|
|
5
|
+
* - home 层 `$DSH_HOME/cordis.patch.yml`:全机偏好,对每个 profile 生效;
|
|
6
|
+
* - profile 层 `$DSH_HOME/profiles/<profile>/cordis.patch.yml`:该 profile 专属(端口、trustedHosts、
|
|
7
|
+
* 挂载行…),换机恢复时必须跟着走。
|
|
8
|
+
*
|
|
9
|
+
* 两层的**持久化标识**是这里的两个逻辑 token,而不是相对路径:同步快照会跨机器、
|
|
10
|
+
* 跨 profile 导入,相对路径会把源机的 profile 名带过去。home 层 token 与历史快照里的
|
|
11
|
+
* `PatchLine.file` 取值逐字相同(存量快照零改动即可解析回 home 层)。
|
|
12
|
+
*
|
|
13
|
+
* 层限定复合键 `<file>#<lineId>`:两层可以存在同名 `lineId`,而计划项 id / `target.ref` /
|
|
14
|
+
* 快照条目必须唯一指向「哪一层的哪一行」,否则应用与回滚会写错层。两个 token 与 lineId 都不含
|
|
15
|
+
* `#`(后者由 `readPatchLines` 保证非空字符串,此处不额外校验),分隔符无歧义。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** home 层 token(`$DSH_HOME/cordis.patch.yml`);取值与历史快照的 file 字段一致。 */
|
|
19
|
+
export const HOME_PATCH_FILE = 'cordis.patch.yml';
|
|
20
|
+
|
|
21
|
+
/** profile 层 token(`$DSH_HOME/profiles/<profile>/cordis.patch.yml`)。 */
|
|
22
|
+
export const PROFILE_PATCH_FILE = 'profile:cordis.patch.yml';
|
|
23
|
+
|
|
24
|
+
/** 层限定复合键的分隔符(两个层 token 与 lineId 都不含它)。 */
|
|
25
|
+
const PATCH_LAYER_SEPARATOR = '#';
|
|
26
|
+
|
|
27
|
+
/** 层限定复合键:`<file>#<lineId>`。 */
|
|
28
|
+
export function patchLayerKey(file: string, lineId: string): string {
|
|
29
|
+
return `${file}${PATCH_LAYER_SEPARATOR}${lineId}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 解析层限定复合键。
|
|
34
|
+
*
|
|
35
|
+
* 兼容分支:旧快照的 `target.ref` / `SnapshotEntry.ref` 是裸 `lineId`(当时只有 home 层),
|
|
36
|
+
* 不含 `#` 即视作 home 层。lineId 本身不含 `#`,因此该分支无歧义。
|
|
37
|
+
*/
|
|
38
|
+
export function parsePatchLayerKey(ref: string): { file: string; lineId: string } {
|
|
39
|
+
const at = ref.indexOf(PATCH_LAYER_SEPARATOR);
|
|
40
|
+
if (at < 0) return { file: HOME_PATCH_FILE, lineId: ref };
|
|
41
|
+
return { file: ref.slice(0, at), lineId: ref.slice(at + 1) };
|
|
42
|
+
}
|
package/src/core/rollback.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - settings 回滚仍走 expectedRevision 乐观锁,避免覆盖导入后用户的新修改。
|
|
9
9
|
*/
|
|
10
10
|
import { resolveFileTarget } from './backup.ts';
|
|
11
|
+
import { parsePatchLayerKey } from './patch-layers.ts';
|
|
11
12
|
import { msgOf } from './messages.ts';
|
|
12
13
|
import type {
|
|
13
14
|
ConfigAdapter, HostContext, RollbackReport, Snapshot, SnapshotEntry, SnapshotStore,
|
|
@@ -71,10 +72,9 @@ async function compensateOne(
|
|
|
71
72
|
}
|
|
72
73
|
case 'patchLine': {
|
|
73
74
|
try {
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
const file =
|
|
77
|
-
const lineId = entry.ref;
|
|
75
|
+
// 快照条目的 ref 是层限定复合键(<file>#<lineId>)——必须写回**同一层**,
|
|
76
|
+
// 否则 profile 层的原值会被写进 home 层。旧快照的裸 lineId 兼容为 home 层。
|
|
77
|
+
const { file, lineId } = parsePatchLayerKey(entry.ref);
|
|
78
78
|
await ctx.patchFile.applyPatchChanges(file, [
|
|
79
79
|
{ lineId, raw: entry.before, action: entry.before === null ? 'remove' : 'update' },
|
|
80
80
|
]);
|
package/src/index.facade.test.ts
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import test from 'node:test';
|
|
12
12
|
import assert from 'node:assert/strict';
|
|
13
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
|
-
import { DshPluginsFacade, ensureActivationRow } from './index.ts';
|
|
17
|
+
import { DshPatchFileFacade, DshPluginsFacade, ensureActivationRow } from './index.ts';
|
|
18
18
|
import { resolveProfileDir } from './core/plugin-cli.ts';
|
|
19
19
|
import type { DshPluginResult } from './core/plugin-cli.ts';
|
|
20
20
|
import type { PatchChange, PatchFileFacade } from './core/types.ts';
|
|
@@ -250,6 +250,29 @@ test('ensureActivationRow: bundle 包跳过(reconcile 已维护 bundles)', a
|
|
|
250
250
|
}
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
+
test('ensureActivationRow: 非 bundle 插件补行落在 profile 层(home 层文件不受影响)', async () => {
|
|
254
|
+
const { homeDir, profileDir, cleanup } = makeTempProfile();
|
|
255
|
+
try {
|
|
256
|
+
writeInstalledPkg(profileDir, 'pkg-a', '1.0.0');
|
|
257
|
+
const patchFile = new DshPatchFileFacade(homeDir, 'web');
|
|
258
|
+
const homePatch = join(homeDir, 'cordis.patch.yml');
|
|
259
|
+
const profilePatch = join(profileDir, 'cordis.patch.yml');
|
|
260
|
+
writeFileSync(homePatch, '- id: home-line\n', 'utf8');
|
|
261
|
+
const homeBefore = readFileSync(homePatch, 'utf8');
|
|
262
|
+
|
|
263
|
+
await ensureActivationRow(patchFile, join(profileDir, 'node_modules', 'pkg-a'), 'pkg-a');
|
|
264
|
+
|
|
265
|
+
assert.match(
|
|
266
|
+
readFileSync(profilePatch, 'utf8'),
|
|
267
|
+
/id: pm-pkg-a/,
|
|
268
|
+
'激活行必须落在 profile 层(home 层对每个 profile 生效,会让只在 web 装过的插件污染其他 profile)',
|
|
269
|
+
);
|
|
270
|
+
assert.equal(readFileSync(homePatch, 'utf8'), homeBefore, 'home 层文件必须原样不变');
|
|
271
|
+
} finally {
|
|
272
|
+
cleanup();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
253
276
|
test('ensureActivationRow: scope 包名 slug 形态正确(@scope/name → pm-scope-name)', async () => {
|
|
254
277
|
const { profileDir, cleanup } = makeTempProfile();
|
|
255
278
|
try {
|
package/src/index.ts
CHANGED
|
@@ -84,7 +84,7 @@ import { makeMsg, msgOf, zhMsg } from './core/messages.ts'
|
|
|
84
84
|
import type { MsgFunc } from './core/messages.ts'
|
|
85
85
|
import {
|
|
86
86
|
cleanupAbortedInstall, hasDshBundlePatch, installErrorFor, installSpecFor, listInstalledPlugins,
|
|
87
|
-
resolveProfileDir, resolveProfileNameFromArgv,
|
|
87
|
+
resolveProfileDir, resolveProfileNameFromArgv, runDshPlugin, validateProfileName,
|
|
88
88
|
} from './core/plugin-cli.ts'
|
|
89
89
|
import type {
|
|
90
90
|
ConfigAdapter, CredentialsFacade, FileSystemFacade, HostContext, ImportDecisions,
|
|
@@ -92,7 +92,8 @@ import type {
|
|
|
92
92
|
SettingsFacade, Snapshot, WorkspaceFacade,
|
|
93
93
|
} from './core/types.ts'
|
|
94
94
|
import { ImportNotConfirmedError, ImportUserSkippedError } from './core/types.ts'
|
|
95
|
-
import { createAdapters
|
|
95
|
+
import { createAdapters } from './adapters/index.ts'
|
|
96
|
+
import { HOME_PATCH_FILE, PROFILE_PATCH_FILE } from './core/patch-layers.ts'
|
|
96
97
|
import { createLocalPluginPackHook } from './core/local-plugin-host.ts'
|
|
97
98
|
import { createEncryptionProvider, decryptCredentials, decryptArchive, SecurityError, encryptArchive, isArchiveBlob, verifyEncryptedBlob } from './security/index.ts'
|
|
98
99
|
import { createHardenedZipParser } from './security/zip-security.ts'
|
|
@@ -156,7 +157,7 @@ export const name = 'config-manager'
|
|
|
156
157
|
export const inject = ['settings', 'credentials']
|
|
157
158
|
|
|
158
159
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
159
|
-
const PLUGIN_VERSION = '0.1.
|
|
160
|
+
const PLUGIN_VERSION = '0.1.61'
|
|
160
161
|
|
|
161
162
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
162
163
|
const PLUGIN_NAME = 'dsh-config-manager'
|
|
@@ -220,6 +221,8 @@ export interface Config {
|
|
|
220
221
|
|
|
221
222
|
/** Route family — must match the browser half's CONFIG_MANAGER_API exactly. */
|
|
222
223
|
const API = {
|
|
224
|
+
// 设置页页脚版本行(pluginVersion / dshVersion)。只读,loopback fence。
|
|
225
|
+
status: '/api/dsh-config-manager/status',
|
|
223
226
|
// P2-⑫:导出前只读预览(不落盘 ZIP;返回各分区 counts + 估算大小)
|
|
224
227
|
// P1-⑧:快照管理(手动删除 + 置顶豁免自动清理)
|
|
225
228
|
// m-backup-schedule:定时全量备份(读/存 backup-schedule.json + 立即执行一次)
|
|
@@ -644,12 +647,12 @@ class DshWorkspaceFacade implements WorkspaceFacade {
|
|
|
644
647
|
}
|
|
645
648
|
}
|
|
646
649
|
|
|
647
|
-
/** Profile 目录内的 patch 文件(非 bundle 插件激活行写入处,marketplace 同款路径)。 */
|
|
648
|
-
const PROFILE_PATCH_FILE = 'cordis.patch.yml'
|
|
649
|
-
|
|
650
650
|
/** Patch-file facade:用户 patch 层($DSH_HOME/cordis.patch.yml)+ profile patch 层
|
|
651
|
-
* ($DSH_HOME/profiles/<name>/cordis.patch.yml),两者都在 home 根内。
|
|
652
|
-
|
|
651
|
+
* ($DSH_HOME/profiles/<name>/cordis.patch.yml),两者都在 home 根内。
|
|
652
|
+
*
|
|
653
|
+
* 导出仅为让测试能用**真实门面**(而非 mock)钉住层寻址——mock 会让两个层 token 的取值
|
|
654
|
+
* 撞车无处暴露(DshPluginsFacade 同款做法)。 */
|
|
655
|
+
export class DshPatchFileFacade implements PatchFileFacade {
|
|
653
656
|
private readonly homeDir: string
|
|
654
657
|
private readonly profile: string
|
|
655
658
|
private readonly msg: MsgFunc
|
|
@@ -661,9 +664,9 @@ class DshPatchFileFacade implements PatchFileFacade {
|
|
|
661
664
|
}
|
|
662
665
|
|
|
663
666
|
private patchPath(file: string): string {
|
|
664
|
-
if (file ===
|
|
665
|
-
if (file === PROFILE_PATCH_FILE) return join(this.homeDir, 'profiles', this.profile,
|
|
666
|
-
throw new Error(this.msg('host.patchUnsupported', { user:
|
|
667
|
+
if (file === HOME_PATCH_FILE) return join(this.homeDir, HOME_PATCH_FILE)
|
|
668
|
+
if (file === PROFILE_PATCH_FILE) return join(this.homeDir, 'profiles', this.profile, HOME_PATCH_FILE)
|
|
669
|
+
throw new Error(this.msg('host.patchUnsupported', { user: HOME_PATCH_FILE, profile: PROFILE_PATCH_FILE, file }))
|
|
667
670
|
}
|
|
668
671
|
|
|
669
672
|
async readPatchLines(file: string): Promise<{ lineId: string; raw: unknown }[]> {
|
|
@@ -1423,44 +1426,6 @@ export function isGitHubAuthMissing(error: unknown): boolean {
|
|
|
1423
1426
|
return error instanceof GitHubAuthError && (error.code === 'unauthorized' || error.code === 'no_token')
|
|
1424
1427
|
}
|
|
1425
1428
|
|
|
1426
|
-
/** /status 的插件诊断位(issue #28)。仅回非敏感元信息:目录、profile 名、计数。 */
|
|
1427
|
-
export interface PluginDiagnostics {
|
|
1428
|
-
homeDir: string
|
|
1429
|
-
profile: string
|
|
1430
|
-
/** profile 目录的 package.json 是否可读(不可读 → 清单必然为空) */
|
|
1431
|
-
profileManifestReadable: boolean
|
|
1432
|
-
/** 插件清单来源 = package.json 的 dependencies 里非 in-box 的包 */
|
|
1433
|
-
installedPluginCount: number
|
|
1434
|
-
installedPluginNames: string[]
|
|
1435
|
-
/** dsh.profile.bundles 声明(非空即「替换默认插件栈」) */
|
|
1436
|
-
bundles: string[]
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
/**
|
|
1440
|
-
* 读取插件诊断信息(issue #28):把「插件到底读了哪个目录 / 哪个 profile / 看到什么」变成
|
|
1441
|
-
* 用户可自查的数据——此前只存在于宿主内部,导致「装了插件却识别不到」无从定位。
|
|
1442
|
-
* best-effort:失败不抛出(诊断位缺失不应拖垮 /status)。
|
|
1443
|
-
*/
|
|
1444
|
-
async function readPluginDiagnostics(host: HostContext): Promise<Partial<PluginDiagnostics>> {
|
|
1445
|
-
try {
|
|
1446
|
-
const profileDir = resolveProfileDir(host.homeDir, host.profile ?? 'web')
|
|
1447
|
-
const manifest = readProfileManifest(profileDir)
|
|
1448
|
-
const installed = await host.plugins.listInstalled()
|
|
1449
|
-
const bundles = manifest?.dsh?.profile?.bundles
|
|
1450
|
-
return {
|
|
1451
|
-
homeDir: host.homeDir,
|
|
1452
|
-
profile: host.profile ?? 'web',
|
|
1453
|
-
profileManifestReadable: manifest !== null,
|
|
1454
|
-
installedPluginCount: installed.length,
|
|
1455
|
-
installedPluginNames: installed.map((p) => p.name),
|
|
1456
|
-
bundles: Array.isArray(bundles) ? bundles : [],
|
|
1457
|
-
}
|
|
1458
|
-
} catch (err) {
|
|
1459
|
-
host.log.warn(`plugin diagnostics unavailable: ${err instanceof Error ? err.message : String(err)}`)
|
|
1460
|
-
return {}
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
1429
|
/** Build the /api/dsh-config-manager route family. */
|
|
1465
1430
|
function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSyncScheduler; makeSyncEngine: (cfg: SyncConfig) => SyncEngine; lifecycle: ConfigLifecycle } {
|
|
1466
1431
|
const { host, adapters, exportsDir, tmpDir, snapshotsDir, runs, syncDir, dataDir, credentials, githubClientId, githubClientSecret, history } = deps
|
|
@@ -1856,6 +1821,18 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
|
|
|
1856
1821
|
|
|
1857
1822
|
const routesList: WebRoute[] = [
|
|
1858
1823
|
// ------------------------------------------------------------- status
|
|
1824
|
+
// 设置页页脚版本行:插件版本 + DSH 版本。只读、无 secret,loopback fence。
|
|
1825
|
+
{
|
|
1826
|
+
kind: 'exact',
|
|
1827
|
+
path: API.status,
|
|
1828
|
+
handler: async (req, res) => {
|
|
1829
|
+
if (!guard(req, res, 'GET')) return
|
|
1830
|
+
writeJson(res, 200, {
|
|
1831
|
+
pluginVersion: PLUGIN_VERSION,
|
|
1832
|
+
dshVersion: host.dshVersion,
|
|
1833
|
+
})
|
|
1834
|
+
},
|
|
1835
|
+
},
|
|
1859
1836
|
// ------------------------------------------------------------- export
|
|
1860
1837
|
// ---------------------------------------------------- export-preview
|
|
1861
1838
|
// P2-⑫:导出前只读预览(不落盘 ZIP):对选中分区逐个 adapter.export 收集 counts
|
|
@@ -22,6 +22,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
22
22
|
import {
|
|
23
23
|
EnvironmentLockManager,
|
|
24
24
|
EnvironmentLockIOError,
|
|
25
|
+
parseLinuxProcStartTime,
|
|
25
26
|
EnvironmentLockOwnedByAnotherError,
|
|
26
27
|
EnvironmentLockUnavailableError,
|
|
27
28
|
runWithMutationLock,
|
|
@@ -281,6 +282,34 @@ process.exit(1); // 期望被拒:非 0 指示「未获得锁」;父进程根
|
|
|
281
282
|
await parent.release(pres.token!);
|
|
282
283
|
});
|
|
283
284
|
|
|
285
|
+
test('parseLinuxProcStartTime:取字段 22(starttime),不得取字段 24(rss)', () => {
|
|
286
|
+
// 真实 /proc/<pid>/stat 形状;comm 含空格与括号(按最后一个 ')' 切片才正确)。
|
|
287
|
+
// 字段:pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt
|
|
288
|
+
// cmajflt utime stime cutime cstime priority nice num_threads itrealvalue
|
|
289
|
+
// starttime vsize rss ...
|
|
290
|
+
const line =
|
|
291
|
+
'12345 (node (worker)) S 1 12345 12345 0 -1 4194560 1000 0 0 0 5 3 0 0 20 0 11 0 999999 123456789 456 ' +
|
|
292
|
+
'18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0';
|
|
293
|
+
assert.equal(
|
|
294
|
+
parseLinuxProcStartTime(line),
|
|
295
|
+
'999999',
|
|
296
|
+
'starttime 是字段 22;切片后 index 0 = 字段 3,故 index = 22-3 = 19。' +
|
|
297
|
+
'取 index 21 会得到字段 24(rss)—— 该值随进程内存占用变化,' +
|
|
298
|
+
'会让同一进程的两次读取得到不同身份,被误判为 PID 复用(STALE_LOCK_DETECTED)。',
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('parseLinuxProcStartTime:comm 含 ")" 时仍取最后一个 ")" 之后的字段', () => {
|
|
303
|
+
const line = '7 (a)b)c) R 1 7 7 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 424242 100 200';
|
|
304
|
+
assert.equal(parseLinuxProcStartTime(line), '424242');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('parseLinuxProcStartTime:畸形输入返回 null(不得抛出,也不得编造身份)', () => {
|
|
308
|
+
assert.equal(parseLinuxProcStartTime(''), null);
|
|
309
|
+
assert.equal(parseLinuxProcStartTime('no-parens-here'), null);
|
|
310
|
+
assert.equal(parseLinuxProcStartTime('1 (x) S'), null, '字段不足 → null');
|
|
311
|
+
});
|
|
312
|
+
|
|
284
313
|
test('§11.1-c4 release:close→unlink;unlink 失败抛 EnvironmentLockIOError 且保留 activeToken(可重试)', async (t) => {
|
|
285
314
|
const locksDir = tmp(t);
|
|
286
315
|
const ctl = makeIo();
|