@max-null/dsh-plugin-center 0.1.7 → 0.2.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/dist/toggle.js ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Hot disable/enable through the profile's user patch layer
3
+ * (`<profileDir>/cordis.patch.yml`) — the mechanism dsh-market ports from
4
+ * dsh-plugin-hub: a patch row `- id: X` + `disabled: true` stops that loader
5
+ * entry, `disabled: false` force-enables one a lower layer disabled. The
6
+ * official web profile re-composes via HMR (~1s, no restart); SSiD applies
7
+ * the same file on every boot, so the choice survives restarts there.
8
+ *
9
+ * Writes are line-level (the patch dialect is simple for toggles: a row id
10
+ * followed by an optional `disabled:` line), serialized so concurrent
11
+ * toggles cannot interleave a read-modify-write, refused when the file is
12
+ * not a plain entry list, and protected for host-infrastructure rows.
13
+ */
14
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ /** Host infrastructure rows: disabling any of these breaks the very chain
17
+ * the patch layer runs on. Same list dsh-market uses. */
18
+ const PROTECTED_PATTERNS = [
19
+ /^cordis:/u,
20
+ /^@deepseek-ai\/cordis-plugin-/u,
21
+ /^@deepseek-ai\/dsh-host-/u,
22
+ /^@deepseek-ai\/dsh-client-modules$/u,
23
+ /^@deepseek-ai\/dsh-client-connection$/u,
24
+ /^@deepseek-ai\/dsh-client-hmr$/u,
25
+ /^@deepseek-ai\/dsh-client-runtime$/u,
26
+ /^@deepseek-ai\/dsh-client-locale$/u,
27
+ /^@deepseek-ai\/dsh-client-web/u,
28
+ /^@deepseek-ai\/dsh-web-frontend$/u,
29
+ /^@deepseek-ai\/dsh-app-boot$/u,
30
+ /^@deepseek-ai\/dsh-base$/u,
31
+ /^@deepseek-ai\/dsh-web-app$/u,
32
+ ];
33
+ function isProtected(id) {
34
+ return PROTECTED_PATTERNS.some(pattern => pattern.test(id));
35
+ }
36
+ /** Official bundle entry ids (timer/llm/session/… from dsh-base and
37
+ * dsh-web-app patch inserts): disabling these breaks the core chain, so
38
+ * they refuse to toggle alongside the pattern-based protection above. */
39
+ function officialEntryIds(profileDir) {
40
+ const ids = new Set();
41
+ for (const pkg of ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) {
42
+ try {
43
+ const patchPath = join(profileDir, 'node_modules', pkg, 'cordis.patch.yml');
44
+ if (!existsSync(patchPath))
45
+ continue;
46
+ const text = readFileSync(patchPath, 'utf8');
47
+ for (const match of text.matchAll(/^- id:\s*(\S+)\s*$/gmu))
48
+ ids.add(match[1]);
49
+ }
50
+ catch { /* best-effort */ }
51
+ }
52
+ return ids;
53
+ }
54
+ /** What the user patch layer currently says about every row id. */
55
+ export function readDisabledState(patchPath) {
56
+ const state = new Map();
57
+ let text = '';
58
+ try {
59
+ text = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
60
+ }
61
+ catch {
62
+ return state;
63
+ }
64
+ let current = null;
65
+ for (const line of text.split('\n')) {
66
+ const row = /^- id:\s*(\S+)\s*$/u.exec(line);
67
+ if (row !== null) {
68
+ current = row[1];
69
+ continue;
70
+ }
71
+ if (current === null)
72
+ continue;
73
+ const disabled = /^ {2}disabled:\s*(true|false)\s*$/u.exec(line);
74
+ if (disabled !== null) {
75
+ state.set(current, disabled[1] === 'true');
76
+ current = null;
77
+ }
78
+ }
79
+ return state;
80
+ }
81
+ /** Serialize toggles so concurrent writes cannot interleave. */
82
+ let toggleChain = Promise.resolve();
83
+ /**
84
+ * Set one entry's disabled stance in the profile patch layer. The file is
85
+ * only touched when the stance changes; a malformed file (not a plain
86
+ * entry list) is reported instead of being made worse.
87
+ * @param profileDir - the profile directory holding cordis.patch.yml.
88
+ * @param id - the loader entry id to toggle.
89
+ * @param disabled - the target stance.
90
+ * @returns the outcome; `nowDisabled` mirrors the stance or null when refused.
91
+ */
92
+ export function setDisabled(profileDir, id, disabled) {
93
+ const run = toggleChain.then(async () => {
94
+ if (isProtected(id) || officialEntryIds(profileDir).has(id)) {
95
+ return { ok: false, detail: `"${id}" is host infrastructure and cannot be disabled`, nowDisabled: null };
96
+ }
97
+ const patchPath = join(profileDir, 'cordis.patch.yml');
98
+ const current = readDisabledState(patchPath);
99
+ if (current.get(id) === disabled) {
100
+ return { ok: true, detail: `"${id}" is already ${disabled ? 'disabled' : 'enabled'}`, nowDisabled: disabled };
101
+ }
102
+ let text = '';
103
+ try {
104
+ text = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
105
+ }
106
+ catch (error) {
107
+ return { ok: false, detail: `read ${patchPath} failed: ${String(error)}`, nowDisabled: null };
108
+ }
109
+ // Malformed guard: skip comment/blank lines first — the shipped profile
110
+ // patch file opens with a comment block and an empty `[]` array, both of
111
+ // which are legal. A non-empty significant first line that is neither an
112
+ // entry (`- …`) nor the empty array means a file we must not touch.
113
+ const significant = text.split('\n').map(line => line.trim()).filter(line => line !== '' && !line.startsWith('#'));
114
+ if (significant.length > 0 && !significant[0].startsWith('- ') && significant[0] !== '[]') {
115
+ return { ok: false, detail: 'cordis.patch.yml is not a plain entry list; refusing to modify it', nowDisabled: null };
116
+ }
117
+ const lines = text === '' ? [] : text.split('\n');
118
+ const out = [];
119
+ let patched = false;
120
+ for (let i = 0; i < lines.length; i++) {
121
+ const line = lines[i];
122
+ // The empty-array placeholder: appending entries to a file that still
123
+ // holds `[]` would produce invalid YAML, so the placeholder is dropped
124
+ // once the first real entry lands.
125
+ if (line.trim() === '[]')
126
+ continue;
127
+ const row = /^- id:\s*(\S+)\s*$/u.exec(line);
128
+ if (row !== null && row[1] === id) {
129
+ // Find the row's disabled line (immediately after, if present) and
130
+ // replace it; otherwise insert one right after the id line.
131
+ out.push(line);
132
+ const next = lines[i + 1];
133
+ if (next !== undefined && /^ {2}disabled:\s*(true|false)\s*$/u.test(next)) {
134
+ out.push(` disabled: ${String(disabled)}`);
135
+ i++;
136
+ }
137
+ else {
138
+ out.push(` disabled: ${String(disabled)}`);
139
+ }
140
+ patched = true;
141
+ continue;
142
+ }
143
+ out.push(line);
144
+ }
145
+ if (!patched) {
146
+ // Append a new row (the file ends with a newline when non-empty).
147
+ const tail = out.length > 0 && out[out.length - 1] !== '' ? '\n' : '';
148
+ out.push(`${tail}- id: ${id}\n disabled: ${String(disabled)}`);
149
+ }
150
+ try {
151
+ writeFileSync(patchPath, out.join('\n') + '\n');
152
+ }
153
+ catch (error) {
154
+ return { ok: false, detail: `write ${patchPath} failed: ${String(error)}`, nowDisabled: null };
155
+ }
156
+ return { ok: true, detail: '', nowDisabled: disabled };
157
+ });
158
+ toggleChain = run.catch(() => { });
159
+ return run;
160
+ }
package/dist/update.d.ts CHANGED
@@ -21,15 +21,27 @@ export declare function detectUpdate(name: string, localVersion: string, repoUrl
21
21
  export interface PnpmResult {
22
22
  ok: boolean;
23
23
  detail: string;
24
+ /** Wall-clock duration of the pnpm process (slow-but-successful is common). */
25
+ durationMs: number;
24
26
  }
27
+ /**
28
+ * Append one pnpm operation to `<profileDir>/plugin-center-pnpm.log`: time,
29
+ * command, cwd, exit, duration, and the captured output tail. Every install
30
+ * and update lands here regardless of success, so a problem on any machine
31
+ * can be diagnosed by copying the log (2026-08-22: SSiD 更新慢/失败复盘需要
32
+ * 现场证据;日志写失败绝不影响主流程)。
33
+ */
34
+ export declare function logPnpm(profileDir: string, args: readonly string[], result: PnpmResult): void;
25
35
  /** pnpm 候选命令:GUI 进程 PATH 常缺用户级 npm 全局目录;且 Windows 上
26
36
  * CreateProcess 只找 pnpm.exe(.cmd/.ps1 必须经 shell)——2026-08-17 实测
27
37
  * spawn('pnpm', shell:false) 直接 ENOENT,更新永远假成功。 */
28
38
  export declare function pnpmCandidates(): string[];
29
39
  /**
30
40
  * Run pnpm in the profile directory, trying each candidate command in turn.
31
- * Output inherits the process stdio (no pipe capture — the host sandbox
32
- * forbids named-pipe stdio); the exit code is the only result this layer needs.
41
+ * Output is captured (no named-pipe stdio — the host process is the web or
42
+ * electron main process, not the DSH tool sandbox); the exit code and the
43
+ * captured tail are the result this layer returns, and every attempt is
44
+ * appended to the profile's plugin-center-pnpm.log.
33
45
  */
34
46
  export declare function runPnpm(args: readonly string[], cwd: string): Promise<PnpmResult>;
35
47
  /** Install a package into the web profile, mirroring `dsh plugin add` semantics. */
package/dist/update.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * first (many community repos ship no release/tag/CHANGELOG — verified §7.2).
5
5
  */
6
6
  import { spawn } from 'node:child_process';
7
- import { existsSync } from 'node:fs';
7
+ import { appendFileSync, existsSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { compareVersions, satisfies } from "./semver.js";
@@ -70,6 +70,28 @@ export async function detectUpdate(name, localVersion, repoUrl, compatRange, loc
70
70
  compatRange,
71
71
  };
72
72
  }
73
+ /**
74
+ * Append one pnpm operation to `<profileDir>/plugin-center-pnpm.log`: time,
75
+ * command, cwd, exit, duration, and the captured output tail. Every install
76
+ * and update lands here regardless of success, so a problem on any machine
77
+ * can be diagnosed by copying the log (2026-08-22: SSiD 更新慢/失败复盘需要
78
+ * 现场证据;日志写失败绝不影响主流程)。
79
+ */
80
+ export function logPnpm(profileDir, args, result) {
81
+ try {
82
+ const file = join(profileDir, 'plugin-center-pnpm.log');
83
+ const line = [
84
+ `${new Date().toISOString()} $ pnpm ${args.join(' ')}`,
85
+ ` cwd=${profileDir}`,
86
+ ` ${result.ok ? 'ok' : 'FAIL'} duration=${result.durationMs}ms`,
87
+ result.detail === '' ? '' : ` ${result.detail.split('\n').map(s => ` ${s}`).join('\n')}`,
88
+ '---',
89
+ '',
90
+ ].join('\n');
91
+ appendFileSync(file, line);
92
+ }
93
+ catch { /* logging must never break the install path */ }
94
+ }
73
95
  /** pnpm 候选命令:GUI 进程 PATH 常缺用户级 npm 全局目录;且 Windows 上
74
96
  * CreateProcess 只找 pnpm.exe(.cmd/.ps1 必须经 shell)——2026-08-17 实测
75
97
  * spawn('pnpm', shell:false) 直接 ENOENT,更新永远假成功。 */
@@ -89,28 +111,53 @@ function runOne(command, args, cwd) {
89
111
  try {
90
112
  // shell: true —— Windows 下 .cmd shim 必须经 shell 才能 spawn;
91
113
  // windowsHide —— GUI 宿主下不弹 cmd 窗口(2026-08-18 用户实测闪烁)。
92
- child = spawn(command, [...args], { cwd, stdio: 'inherit', shell: true, windowsHide: true });
114
+ // stdio 走默认 pipe:捕获 pnpm 输出,失败时把尾部输出放进 detail
115
+ // (2026-08-22 起——此前 inherit 只有 exit code,SSiD 下更新失败
116
+ // 无法诊断;宿主进程(web/electron)非 DSH 工具沙箱,pipe 可用)。
117
+ child = spawn(command, [...args], { cwd, shell: true, windowsHide: true });
93
118
  }
94
119
  catch (e) {
95
- resolve({ ok: false, detail: e instanceof Error ? e.message : String(e) });
120
+ resolve({ ok: false, detail: e instanceof Error ? e.message : String(e), durationMs: 0 });
96
121
  return;
97
122
  }
98
- child.on('error', e => resolve({ ok: false, detail: e.message }));
99
- child.on('close', code => resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: `exit code ${code ?? 1}` }));
123
+ const started = Date.now();
124
+ // 15 分钟硬超时:pnpm 可能卡在 supply-chain 全量验证/网络重试上
125
+ // (2026-08-22 SSiD 下「更新中」长时间不结束的防御),超时杀掉并报错。
126
+ let timedOut = false;
127
+ const timer = setTimeout(() => {
128
+ timedOut = true;
129
+ child.kill();
130
+ }, 15 * 60_000);
131
+ let out = '';
132
+ child.stdout?.on('data', (d) => { out += d.toString(); });
133
+ child.stderr?.on('data', (d) => { out += d.toString(); });
134
+ child.on('error', e => { clearTimeout(timer); resolve({ ok: false, detail: e.message, durationMs: Date.now() - started }); });
135
+ child.on('close', code => {
136
+ clearTimeout(timer);
137
+ const durationMs = Date.now() - started;
138
+ if (code === 0)
139
+ resolve({ ok: true, detail: '', durationMs });
140
+ const tail = out.trim();
141
+ const header = timedOut ? 'timed out after 15 minutes' : `exit code ${code ?? 1}`;
142
+ resolve({ ok: false, detail: `${header}${tail === '' ? '' : `\n${tail.slice(-4000)}`}`, durationMs });
143
+ });
100
144
  });
101
145
  }
102
146
  /**
103
147
  * Run pnpm in the profile directory, trying each candidate command in turn.
104
- * Output inherits the process stdio (no pipe capture — the host sandbox
105
- * forbids named-pipe stdio); the exit code is the only result this layer needs.
148
+ * Output is captured (no named-pipe stdio — the host process is the web or
149
+ * electron main process, not the DSH tool sandbox); the exit code and the
150
+ * captured tail are the result this layer returns, and every attempt is
151
+ * appended to the profile's plugin-center-pnpm.log.
106
152
  */
107
153
  export async function runPnpm(args, cwd) {
108
- let last = { ok: false, detail: 'no pnpm candidate found' };
154
+ let last = { ok: false, detail: 'no pnpm candidate found', durationMs: 0 };
109
155
  for (const command of pnpmCandidates()) {
110
156
  last = await runOne(command, args, cwd);
111
157
  if (last.ok)
112
- return last;
158
+ break;
113
159
  }
160
+ logPnpm(cwd, args, last);
114
161
  return last;
115
162
  }
116
163
  /** Install a package into the web profile, mirroring `dsh plugin add` semantics. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-plugin-center",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "Plugin center for DeepSeek Harness 鈥?installed metadata, community market, update detection, and What's New",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -67,14 +67,14 @@
67
67
  "peerDependencies": {
68
68
  "@deepseek-ai/cordis": "^4.0.1",
69
69
  "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
70
- "@deepseek-ai/dsh-client-connection": ">=0.0.1-rc.5",
71
- "@deepseek-ai/dsh-host-apiproxy": ">=0.0.1-rc.5"
70
+ "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.1",
71
+ "@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.1"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@deepseek-ai/cordis": "^4.0.1",
75
75
  "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
76
- "@deepseek-ai/dsh-client-connection": ">=0.0.1-rc.5",
77
- "@deepseek-ai/dsh-host-apiproxy": ">=0.0.1-rc.5",
76
+ "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.1",
77
+ "@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.1",
78
78
  "@types/js-yaml": "^4.0.9",
79
79
  "@types/node": "^26.2.0",
80
80
  "@types/react": "~18.3.1",
@@ -83,4 +83,3 @@
83
83
  "typescript": "^5.5.0"
84
84
  }
85
85
  }
86
-