dsh-panel-control 0.3.12 → 0.3.14
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/README.md +2 -0
- package/client.js +0 -1
- package/index.js +60 -4
- package/package.json +62 -57
- package/scripts/enable-local.mjs +120 -0
- package/scripts/host-smoke.mjs +226 -0
- package/scripts/profile-doctor.mjs +146 -0
package/README.md
CHANGED
|
@@ -157,6 +157,8 @@ npm run enable -- --disable # 三步一起撤掉
|
|
|
157
157
|
npm run doctor # 只检查:每个 bundle 是否都能解析
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
+
(这三个脚本随包发布,装成 npm 包后也能直接跑;`.scratch/harness.html` 只在仓库里。)
|
|
161
|
+
|
|
160
162
|
`doctor` 也会揪出别的断链插件(指向已删除目录的 `link:` 依赖、名字与 `package.json` 不符的包),它只读真实 profile,不改任何东西。
|
|
161
163
|
|
|
162
164
|
**二、Node 定时器里的回调没有错误边界。** `setTimeout` / `Promise` 回调不在 Cordis 的 `apply` 调用栈里,那里抛出的异常没人接,就是进程级 fatal。第一个版本正是在 20 秒看门狗里调了一个已被删掉的函数,启动 20 秒后必崩,日志里只留一行「客户端半没上报」。
|
package/client.js
CHANGED
package/index.js
CHANGED
|
@@ -44,7 +44,20 @@ const LOG_KEEP_BYTES = 256 * 1024;
|
|
|
44
44
|
const CLIENT_FILE = fileURLToPath(new URL('./client.js', import.meta.url));
|
|
45
45
|
|
|
46
46
|
/** Set `DPC_NUDGE=off` to keep the Host strictly read-only about its own files. */
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The nudge is opt-in, and that default is deliberate.
|
|
49
|
+
*
|
|
50
|
+
* Rewriting `client.js` makes the loader hot-swap this module into the live
|
|
51
|
+
* page. That re-evaluation runs the whole slot reconcile again, and a plugin
|
|
52
|
+
* whose registration is not idempotent then throws on its second
|
|
53
|
+
* `register({ id: 'workspace-control' })` and loses its sidebar entry: healing
|
|
54
|
+
* this plugin broke a neighbour's. A page reload fixes the frozen list without
|
|
55
|
+
* touching anyone else, so the safe path is the banner, and the nudge stays
|
|
56
|
+
* behind DPC_NUDGE=on for people who need it and accept the risk.
|
|
57
|
+
*/
|
|
58
|
+
const NUDGE_ENABLED = ['on', '1', 'true', 'yes'].includes(
|
|
59
|
+
String(process.env.DPC_NUDGE || '').trim().toLowerCase(),
|
|
60
|
+
);
|
|
48
61
|
|
|
49
62
|
/** Set once the nudge below actually rewrote the Client file in this process. */
|
|
50
63
|
let recoveredAt = 0;
|
|
@@ -503,6 +516,32 @@ function inventorySnapshot(scope) {
|
|
|
503
516
|
};
|
|
504
517
|
}
|
|
505
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Write one line per apply that answers the only question a cold start leaves
|
|
521
|
+
* open: did this package even reach the Host's plugin graph?
|
|
522
|
+
*
|
|
523
|
+
* The page can only load what the graph advertises, so "missing from the list"
|
|
524
|
+
* and "listed but never fetched" have completely different fixes. One line,
|
|
525
|
+
* once per apply, settles it from `host.log` alone.
|
|
526
|
+
*/
|
|
527
|
+
function logInventoryOnApply(scope) {
|
|
528
|
+
try {
|
|
529
|
+
const snapshot = inventorySnapshot(scope);
|
|
530
|
+
if (!snapshot.ok) {
|
|
531
|
+
logHost(`graph: unreadable (${String(snapshot.error && snapshot.error.message)})`);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const value = snapshot.value;
|
|
535
|
+
const listed = value.ids.includes(PACKAGE);
|
|
536
|
+
logHost(
|
|
537
|
+
`graph: ${listed ? 'lists' : 'does not list'} ${PACKAGE} · rev ${value.rev} · ` +
|
|
538
|
+
`${value.ids.length} client entr${value.ids.length === 1 ? 'y' : 'ies'}`,
|
|
539
|
+
);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
logHost(`graph: probe failed (${describeError(error)})`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
506
545
|
/**
|
|
507
546
|
* Say it out loud when the page never came back.
|
|
508
547
|
*
|
|
@@ -545,17 +584,33 @@ async function watchFirstClientBoot(startedAt) {
|
|
|
545
584
|
// while developing) must not be reported as "the window never got the code".
|
|
546
585
|
if (newest >= startedAt - 120_000) return;
|
|
547
586
|
logHost(
|
|
548
|
-
'client half did not report within
|
|
587
|
+
'client half did not report within 30s: the window is running a plugin list from before this code ' +
|
|
588
|
+
'(a rebuilt interface can take this long to finish loading; press the refresh button if the entry is missing)',
|
|
549
589
|
);
|
|
550
590
|
// The page will not ask for a script that is not in its frozen list, but it
|
|
551
591
|
// does watch this package's files: one byte-level change to the Client entry
|
|
552
592
|
// makes the loader hand the module to the live page. Flip the trailing
|
|
553
593
|
// newline, so the file stays byte-stable instead of growing on every launch.
|
|
554
594
|
if (!NUDGE_ENABLED) {
|
|
555
|
-
logHost('recovery nudge
|
|
595
|
+
logHost('recovery nudge stayed off (opt in with DPC_NUDGE=on); a page reload is the safe fix');
|
|
556
596
|
return;
|
|
557
597
|
}
|
|
558
598
|
try {
|
|
599
|
+
// The page may have reported while this timer was waiting: never rewrite a
|
|
600
|
+
// file the running page just loaded, because the resulting hot swap is
|
|
601
|
+
// what re-runs everybody's registration.
|
|
602
|
+
const late = await fs.readFile(CLIENT_LOG, 'utf8').catch(() => '');
|
|
603
|
+
const quiet = late
|
|
604
|
+
.split(String.fromCharCode(10))
|
|
605
|
+
.some((line) => {
|
|
606
|
+
if (!line.includes('"kind":"boot"')) return false;
|
|
607
|
+
const stamp = Date.parse(line.slice(0, line.indexOf(' ')));
|
|
608
|
+
return Number.isFinite(stamp) && stamp >= since;
|
|
609
|
+
});
|
|
610
|
+
if (quiet) {
|
|
611
|
+
logHost('recovery nudge skipped: the page reported while the watchdog was waiting');
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
559
614
|
const source = await fs.readFile(CLIENT_FILE, 'utf8');
|
|
560
615
|
const next = source.endsWith(NL + NL) ? source.slice(0, -1) : source + NL;
|
|
561
616
|
await fs.writeFile(CLIENT_FILE, next, 'utf8');
|
|
@@ -607,6 +662,7 @@ export function apply(ctx) {
|
|
|
607
662
|
}
|
|
608
663
|
if (dispose) {
|
|
609
664
|
logHost(`route mounted at ${ROUTE_PATH} (attempt ${attempts})`);
|
|
665
|
+
logInventoryOnApply(scope);
|
|
610
666
|
const startedAt = Date.now();
|
|
611
667
|
// A timer callback that throws is fatal for the Host: contain it here and
|
|
612
668
|
// again inside the async body.
|
|
@@ -614,7 +670,7 @@ export function apply(ctx) {
|
|
|
614
670
|
void watchFirstClientBoot(startedAt).catch((error) =>
|
|
615
671
|
logHost(`client boot watchdog failed: ${describeError(error)}`),
|
|
616
672
|
);
|
|
617
|
-
},
|
|
673
|
+
}, 30_000);
|
|
618
674
|
if (typeof watchdog.unref === 'function') watchdog.unref();
|
|
619
675
|
return;
|
|
620
676
|
}
|
package/package.json
CHANGED
|
@@ -1,57 +1,62 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-panel-control",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "Manage the global panel navigation at the top of the DSH sidebar: reorder, hide and group every entry, with the layout persisted per installation.",
|
|
6
|
-
"keywords": [
|
|
7
|
-
"dsh",
|
|
8
|
-
"dsh-plugin",
|
|
9
|
-
"deepseek-harness",
|
|
10
|
-
"sidebar",
|
|
11
|
-
"panel"
|
|
12
|
-
],
|
|
13
|
-
"exports": {
|
|
14
|
-
".": "./index.js",
|
|
15
|
-
"./client": "./client.js",
|
|
16
|
-
"./icon": "./icon.svg"
|
|
17
|
-
},
|
|
18
|
-
"files": [
|
|
19
|
-
"LICENSE",
|
|
20
|
-
"README.md",
|
|
21
|
-
"assets",
|
|
22
|
-
"client.js",
|
|
23
|
-
"cordis.patch.yml",
|
|
24
|
-
"docs",
|
|
25
|
-
"icon.svg",
|
|
26
|
-
"index.js",
|
|
27
|
-
"screenshots.json"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-panel-control",
|
|
3
|
+
"version": "0.3.14",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Manage the global panel navigation at the top of the DSH sidebar: reorder, hide and group every entry, with the layout persisted per installation.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"dsh",
|
|
8
|
+
"dsh-plugin",
|
|
9
|
+
"deepseek-harness",
|
|
10
|
+
"sidebar",
|
|
11
|
+
"panel"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./client": "./client.js",
|
|
16
|
+
"./icon": "./icon.svg"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.md",
|
|
21
|
+
"assets",
|
|
22
|
+
"client.js",
|
|
23
|
+
"cordis.patch.yml",
|
|
24
|
+
"docs",
|
|
25
|
+
"icon.svg",
|
|
26
|
+
"index.js",
|
|
27
|
+
"screenshots.json",
|
|
28
|
+
"scripts"
|
|
29
|
+
],
|
|
30
|
+
"dsh": {
|
|
31
|
+
"bundle": {
|
|
32
|
+
"patch": "./cordis.patch.yml"
|
|
33
|
+
},
|
|
34
|
+
"client": {
|
|
35
|
+
"platform": "web",
|
|
36
|
+
"immediately": true,
|
|
37
|
+
"inject": []
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"author": {
|
|
42
|
+
"name": "QIUZAIYOU"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/QIUZAIYOU/dsh-panel-control.git"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/QIUZAIYOU/dsh-panel-control#readme",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/QIUZAIYOU/dsh-panel-control/issues"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"check": "node --check index.js && node --check client.js && node scripts/host-smoke.mjs",
|
|
54
|
+
"smoke": "node scripts/host-smoke.mjs",
|
|
55
|
+
"doctor": "node scripts/profile-doctor.mjs",
|
|
56
|
+
"enable": "node scripts/enable-local.mjs"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"registry": "https://registry.npmjs.org/",
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Installs this plugin into a DSH profile in the one order that cannot leave a
|
|
4
|
+
* half-configured profile behind: dependency → node_modules link → bundle list.
|
|
5
|
+
*
|
|
6
|
+
* A name in `dsh.profile.bundles` that nothing can resolve is fatal at startup,
|
|
7
|
+
* and the Host hits it outside any per-plugin error boundary, so the app dies
|
|
8
|
+
* instead of skipping the plugin. Adding the three parts in any other order
|
|
9
|
+
* (or only some of them) is what produces that state.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/enable-local.mjs # the desktop profile
|
|
13
|
+
* node scripts/enable-local.mjs --profile <dir>
|
|
14
|
+
* node scripts/enable-local.mjs --disable # remove all three parts again
|
|
15
|
+
*
|
|
16
|
+
* Every step is verified before the next one runs, and a failure rolls the
|
|
17
|
+
* profile back to what it was.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { execFileSync } from 'node:child_process';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { dirname, join, resolve } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const pluginDir = resolve(here, '..');
|
|
27
|
+
const pluginName = JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8')).name;
|
|
28
|
+
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
const disable = args.includes('--disable');
|
|
31
|
+
const profileFlag = args.indexOf('--profile');
|
|
32
|
+
const profileDir =
|
|
33
|
+
profileFlag >= 0 ? resolve(args[profileFlag + 1]) : join(homedir(), '.dsh', 'profiles', 'desktop');
|
|
34
|
+
const manifestPath = join(profileDir, 'package.json');
|
|
35
|
+
|
|
36
|
+
if (!existsSync(manifestPath)) {
|
|
37
|
+
console.error(`no profile at ${profileDir}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const original = readFileSync(manifestPath, 'utf8');
|
|
42
|
+
const manifest = JSON.parse(original);
|
|
43
|
+
manifest.dsh ||= {};
|
|
44
|
+
manifest.dsh.profile ||= {};
|
|
45
|
+
const bundles = Array.isArray(manifest.dsh.profile.bundles) ? manifest.dsh.profile.bundles : [];
|
|
46
|
+
const linkPath = join(profileDir, 'node_modules', pluginName);
|
|
47
|
+
const spec = `link:${pluginDir.replace(/\\/g, '/')}`;
|
|
48
|
+
|
|
49
|
+
function linkExists() {
|
|
50
|
+
try {
|
|
51
|
+
return lstatSync(linkPath).isSymbolicLink() || lstatSync(linkPath).isDirectory();
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeManifest() {
|
|
58
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rollback(reason) {
|
|
62
|
+
writeFileSync(manifestPath, original, 'utf8');
|
|
63
|
+
console.error(`rolled back: ${reason}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function doctor() {
|
|
68
|
+
try {
|
|
69
|
+
const out = execFileSync(process.execPath, [join(here, 'profile-doctor.mjs'), '--profile', profileDir], {
|
|
70
|
+
encoding: 'utf8',
|
|
71
|
+
});
|
|
72
|
+
process.stdout.write(out);
|
|
73
|
+
return true;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
process.stdout.write(error.stdout || '');
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (disable) {
|
|
81
|
+
manifest.dependencies && delete manifest.dependencies[pluginName];
|
|
82
|
+
manifest.dsh.profile.bundles = bundles.filter((name) => name !== pluginName);
|
|
83
|
+
writeManifest();
|
|
84
|
+
if (linkExists()) unlinkSync(linkPath);
|
|
85
|
+
console.log(`disabled ${pluginName} in ${profileDir}`);
|
|
86
|
+
doctor();
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 1. dependency
|
|
91
|
+
manifest.dependencies ||= {};
|
|
92
|
+
manifest.dependencies[pluginName] = spec;
|
|
93
|
+
writeManifest();
|
|
94
|
+
|
|
95
|
+
// 2. link — same shape as every other local plugin in the profile
|
|
96
|
+
if (!existsSync(linkPath)) {
|
|
97
|
+
const target = process.platform === 'win32' ? 'junction' : 'dir';
|
|
98
|
+
try {
|
|
99
|
+
symlinkSync(pluginDir, linkPath, target);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
rollback(`could not link ${linkPath}: ${(error && error.message) || error}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!existsSync(join(linkPath, 'package.json'))) {
|
|
105
|
+
rollback(`${linkPath} does not resolve to a package`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 3. bundle list, last, and only once 1 and 2 are true
|
|
109
|
+
if (!bundles.includes(pluginName)) {
|
|
110
|
+
const anchor = bundles.indexOf('dsh-workspace-control');
|
|
111
|
+
if (anchor >= 0) bundles.splice(anchor, 0, pluginName);
|
|
112
|
+
else bundles.push(pluginName);
|
|
113
|
+
}
|
|
114
|
+
manifest.dsh.profile.bundles = bundles;
|
|
115
|
+
writeManifest();
|
|
116
|
+
|
|
117
|
+
console.log(`enabled ${pluginName} in ${profileDir} (bundle #${bundles.indexOf(pluginName) + 1} of ${bundles.length})`);
|
|
118
|
+
if (!doctor()) {
|
|
119
|
+
rollback('the profile still has an unresolvable bundle');
|
|
120
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-half smoke test.
|
|
3
|
+
*
|
|
4
|
+
* The Host half runs inside the app process, so a mistake here is a crash, not
|
|
5
|
+
* a red console line: one uncaught ReferenceError in a timer took the whole app
|
|
6
|
+
* down once. This harness imports the real module, calls `apply` with a fake
|
|
7
|
+
* Cordis scope, runs every deferred callback it scheduled, and drives the
|
|
8
|
+
* registered route. It fails on any thrown error.
|
|
9
|
+
*
|
|
10
|
+
* node .scratch/host-smoke.mjs
|
|
11
|
+
*/
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
// `npm run smoke -- <file>` points the harness at another copy, which is how
|
|
17
|
+
// a regression (or the broken revision that shipped once) gets reproduced.
|
|
18
|
+
const override = process.argv[2];
|
|
19
|
+
const MODULE = pathToFileURL(override ? path.resolve(override) : path.join(here, '..', 'index.js')).href;
|
|
20
|
+
|
|
21
|
+
// The recovery nudge rewrites the real client.js when a page never reports; the
|
|
22
|
+
// harness must stay read-only about the checkout.
|
|
23
|
+
process.env.DPC_NUDGE = 'off';
|
|
24
|
+
|
|
25
|
+
const timers = [];
|
|
26
|
+
const realSetTimeout = globalThis.setTimeout;
|
|
27
|
+
globalThis.setTimeout = (fn, ms, ...rest) => {
|
|
28
|
+
const handle = { fn, ms, cleared: false };
|
|
29
|
+
timers.push(handle);
|
|
30
|
+
return handle;
|
|
31
|
+
};
|
|
32
|
+
globalThis.clearTimeout = (handle) => {
|
|
33
|
+
if (handle && typeof handle === 'object') handle.cleared = true;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const failures = [];
|
|
37
|
+
function check(name, ok, detail) {
|
|
38
|
+
if (ok) {
|
|
39
|
+
console.log(` ok ${name}`);
|
|
40
|
+
} else {
|
|
41
|
+
failures.push(`${name}${detail ? `: ${detail}` : ''}`);
|
|
42
|
+
console.log(` FAIL ${name}${detail ? `: ${detail}` : ''}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const registrations = [];
|
|
47
|
+
const scope = {
|
|
48
|
+
get(key) {
|
|
49
|
+
if (key === 'connection') {
|
|
50
|
+
return {
|
|
51
|
+
fetch: {
|
|
52
|
+
register(options) {
|
|
53
|
+
registrations.push(options);
|
|
54
|
+
return () => {};
|
|
55
|
+
},
|
|
56
|
+
get() {},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (key === 'clientModules') {
|
|
61
|
+
return { graph: () => ({ rev: 'rev-test', release: 'rel', entries: [{ id: 'a' }, { id: 'b' }], batches: [] }) };
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const effects = [];
|
|
68
|
+
const ctx = {
|
|
69
|
+
inject(deps, cb) {
|
|
70
|
+
cb(scope);
|
|
71
|
+
},
|
|
72
|
+
effect(factory) {
|
|
73
|
+
const dispose = factory();
|
|
74
|
+
effects.push(dispose);
|
|
75
|
+
return () => {};
|
|
76
|
+
},
|
|
77
|
+
get(key) {
|
|
78
|
+
return scope.get(key);
|
|
79
|
+
},
|
|
80
|
+
on() {},
|
|
81
|
+
logger: { error: () => {}, warn: () => {}, info: () => {} },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
console.log('host-smoke');
|
|
85
|
+
let mod;
|
|
86
|
+
try {
|
|
87
|
+
mod = await import(MODULE);
|
|
88
|
+
check('module imported', typeof mod.apply === 'function');
|
|
89
|
+
} catch (error) {
|
|
90
|
+
check('module imported', false, error.message);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
mod.apply(ctx);
|
|
96
|
+
check('apply() did not throw', true);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
check('apply() did not throw', false, error.message);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
check('route registered', registrations.length === 1, `got ${registrations.length}`);
|
|
102
|
+
|
|
103
|
+
// A hot replace runs `apply` again in the same process, before the old half is
|
|
104
|
+
// disposed: the second run must be as harmless as the first.
|
|
105
|
+
try {
|
|
106
|
+
mod.apply(ctx);
|
|
107
|
+
check('apply() again did not throw', true);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
check('apply() again did not throw', false, error.message);
|
|
110
|
+
}
|
|
111
|
+
const route = registrations[0];
|
|
112
|
+
check(
|
|
113
|
+
'route path',
|
|
114
|
+
route && route.path === '/api/dsh-panel-control/classify',
|
|
115
|
+
route && route.path,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Every deferred callback must survive being run: a timer body is outside every
|
|
119
|
+
// error boundary the Host has.
|
|
120
|
+
for (const timer of timers) {
|
|
121
|
+
if (timer.cleared) continue;
|
|
122
|
+
try {
|
|
123
|
+
timer.fn();
|
|
124
|
+
check(`timer(${timer.ms}ms) survived`, true);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
check(`timer(${timer.ms}ms) survived`, false, error.message);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// The nudge is the one timer body that writes to the checkout: prove that the
|
|
130
|
+
// harness, and any Host started with DPC_NUDGE=off, leaves client.js alone.
|
|
131
|
+
{
|
|
132
|
+
const fs = await import('node:fs/promises');
|
|
133
|
+
const clientPath = path.join(here, '..', 'client.js');
|
|
134
|
+
const before = await fs.readFile(clientPath, 'utf8');
|
|
135
|
+
await new Promise((resolve) => realSetTimeout(resolve, 120));
|
|
136
|
+
const after = await fs.readFile(clientPath, 'utf8');
|
|
137
|
+
check('client.js untouched with DPC_NUDGE=off', before === after, `${before.length} -> ${after.length}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Give the async timer bodies a beat to settle.
|
|
141
|
+
await new Promise((resolve) => realSetTimeout(resolve, 150));
|
|
142
|
+
|
|
143
|
+
if (route) {
|
|
144
|
+
const request = (payload, headers) => ({
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: new Headers({ host: '127.0.0.1:1234', origin: 'http://127.0.0.1:1234', 'content-type': 'application/json', ...headers }),
|
|
147
|
+
text: async () => JSON.stringify(payload),
|
|
148
|
+
json: async () => payload,
|
|
149
|
+
});
|
|
150
|
+
const envelope = (method) => ({
|
|
151
|
+
type: 'client-request',
|
|
152
|
+
rpcId: 'r1',
|
|
153
|
+
method: 'dsh-panel-control/classify',
|
|
154
|
+
payload: { method, payload: {} },
|
|
155
|
+
});
|
|
156
|
+
try {
|
|
157
|
+
const response = await route.fetch(request(envelope('inventory')));
|
|
158
|
+
const body = await response.json();
|
|
159
|
+
check('inventory answers', body && body.result && body.result.ok === true, JSON.stringify(body).slice(0, 160));
|
|
160
|
+
check('inventory rev matches graph', body.result.value.rev === 'rev-test', body.result.value && body.result.value.rev);
|
|
161
|
+
check('inventory ids', Array.isArray(body.result.value.ids) && body.result.value.ids.join() === 'a,b');
|
|
162
|
+
} catch (error) {
|
|
163
|
+
check('inventory answers', false, error.message);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const response = await route.fetch(request(envelope('report')));
|
|
167
|
+
check('report answers', response.ok === true);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
check('report answers', false, error.message);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
const response = await route.fetch({
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: new Headers({ host: 'example.com', 'content-type': 'application/json' }),
|
|
175
|
+
text: async () => JSON.stringify(envelope('inventory')),
|
|
176
|
+
});
|
|
177
|
+
check('non-loopback refused', response.status === 403, String(response.status));
|
|
178
|
+
} catch (error) {
|
|
179
|
+
check('non-loopback refused', false, error.message);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Same machine, different port: a dev server page must not rewrite the store.
|
|
183
|
+
const foreign = [
|
|
184
|
+
['other port refused', { origin: 'http://127.0.0.1:3000' }],
|
|
185
|
+
['same-site fetch refused', { 'sec-fetch-site': 'same-site' }],
|
|
186
|
+
];
|
|
187
|
+
for (const [label, headers] of foreign) {
|
|
188
|
+
try {
|
|
189
|
+
const response = await route.fetch(request(envelope('inventory'), headers));
|
|
190
|
+
check(label, response.status === 403, String(response.status));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
check(label, false, error.message);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// The desktop shell serves the page from a custom protocol; that one must pass.
|
|
196
|
+
try {
|
|
197
|
+
const response = await route.fetch(request(envelope('inventory'), { origin: 'dsh-app://app' }));
|
|
198
|
+
const body = await response.json();
|
|
199
|
+
check('custom-protocol page allowed', response.ok === true && body.result && body.result.ok === true);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
check('custom-protocol page allowed', false, error.message);
|
|
202
|
+
}
|
|
203
|
+
// An oversized body is refused before it is parsed.
|
|
204
|
+
try {
|
|
205
|
+
const huge = request(envelope('inventory'));
|
|
206
|
+
huge.text = async () => 'x'.repeat(300 * 1024);
|
|
207
|
+
const response = await route.fetch(huge);
|
|
208
|
+
check('oversized body refused', response.status === 413, String(response.status));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
check('oversized body refused', false, error.message);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const dispose of effects) {
|
|
215
|
+
if (typeof dispose === 'function') {
|
|
216
|
+
try {
|
|
217
|
+
dispose();
|
|
218
|
+
} catch (error) {
|
|
219
|
+
check('dispose() survived', false, error.message);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
globalThis.setTimeout = realSetTimeout;
|
|
225
|
+
console.log(failures.length === 0 ? 'PASS' : `FAIL (${failures.length})`);
|
|
226
|
+
process.exit(failures.length === 0 ? 0 : 1);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Checks one DSH profile for the configuration that crashes it at startup.
|
|
4
|
+
*
|
|
5
|
+
* A bundle listed in `dsh.profile.bundles` whose package cannot be resolved is
|
|
6
|
+
* fatal: the Host loads bundles in order and the failure happens outside any
|
|
7
|
+
* per-plugin error boundary. The same is true of a half-installed plugin —
|
|
8
|
+
* a name in `bundles` without the matching `dependencies` entry and
|
|
9
|
+
* `node_modules` link.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/profile-doctor.mjs # every profile
|
|
13
|
+
* node scripts/profile-doctor.mjs --profile <dir> # one profile
|
|
14
|
+
* node scripts/profile-doctor.mjs --json
|
|
15
|
+
*
|
|
16
|
+
* Exit code 0 means every bundle resolves; 1 means at least one problem.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const json = args.includes('--json');
|
|
24
|
+
const profileFlag = args.indexOf('--profile');
|
|
25
|
+
const requested = profileFlag >= 0 ? args[profileFlag + 1] : '';
|
|
26
|
+
|
|
27
|
+
const profilesRoot = join(homedir(), '.dsh', 'profiles');
|
|
28
|
+
|
|
29
|
+
function profileDirs() {
|
|
30
|
+
if (requested) return [requested];
|
|
31
|
+
if (!existsSync(profilesRoot)) return [];
|
|
32
|
+
return readdirSync(profilesRoot)
|
|
33
|
+
.map((name) => join(profilesRoot, name))
|
|
34
|
+
.filter((dir) => existsSync(join(dir, 'package.json')));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readJson(path) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function inspect(dir) {
|
|
46
|
+
const manifestPath = join(dir, 'package.json');
|
|
47
|
+
const manifest = readJson(manifestPath);
|
|
48
|
+
if (!manifest) return { dir, error: `${manifestPath} is not readable JSON` };
|
|
49
|
+
|
|
50
|
+
const bundles = manifest?.dsh?.profile?.bundles;
|
|
51
|
+
if (!Array.isArray(bundles)) return { dir, error: 'dsh.profile.bundles is not an array' };
|
|
52
|
+
|
|
53
|
+
const declared = manifest.dependencies || {};
|
|
54
|
+
const problems = [];
|
|
55
|
+
const warnings = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
|
|
58
|
+
for (const name of bundles) {
|
|
59
|
+
if (typeof name !== 'string' || !name) {
|
|
60
|
+
problems.push({ kind: 'bad-entry', name: String(name), detail: 'bundle name is not a string' });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (seen.has(name)) problems.push({ kind: 'duplicate', name, detail: 'listed more than once' });
|
|
64
|
+
seen.add(name);
|
|
65
|
+
|
|
66
|
+
const pkgDir = join(dir, 'node_modules', name);
|
|
67
|
+
if (!existsSync(pkgDir)) {
|
|
68
|
+
// Plugins that ship inside the app never appear in node_modules. Only a
|
|
69
|
+
// declared dependency that is not installed is a half-finished install.
|
|
70
|
+
if (declared[name] === undefined) {
|
|
71
|
+
warnings.push({
|
|
72
|
+
kind: 'not-in-profile',
|
|
73
|
+
name,
|
|
74
|
+
detail: 'not installed in this profile — assumed to ship with the app',
|
|
75
|
+
});
|
|
76
|
+
} else {
|
|
77
|
+
problems.push({
|
|
78
|
+
kind: 'missing-link',
|
|
79
|
+
name,
|
|
80
|
+
detail: 'dependency is declared but node_modules has no link for it',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const entry = join(pkgDir, 'package.json');
|
|
87
|
+
if (!existsSync(entry)) {
|
|
88
|
+
problems.push({ kind: 'missing-manifest', name, detail: `${entry} does not exist` });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const pkg = readJson(entry);
|
|
92
|
+
if (!pkg) {
|
|
93
|
+
problems.push({ kind: 'broken-manifest', name, detail: `${entry} is not readable JSON` });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (pkg.name !== name) {
|
|
97
|
+
problems.push({ kind: 'name-mismatch', name, detail: `package.json says "${pkg.name}"` });
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
statSync(realpathSync(pkgDir));
|
|
101
|
+
} catch {
|
|
102
|
+
problems.push({ kind: 'broken-link', name, detail: 'node_modules link points at a missing directory' });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const [name, spec] of Object.entries(declared)) {
|
|
107
|
+
if (typeof spec === 'string' && spec.startsWith('link:') && !existsSync(spec.slice(5))) {
|
|
108
|
+
problems.push({ kind: 'dangling-dependency', name, detail: `${spec} does not exist` });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { dir, bundles: bundles.length, problems, warnings };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const reports = profileDirs().map((dir) => {
|
|
116
|
+
try {
|
|
117
|
+
return inspect(dir);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return { dir, error: (error && error.message) || String(error) };
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (json) {
|
|
124
|
+
process.stdout.write(`${JSON.stringify(reports, null, 2)}\n`);
|
|
125
|
+
} else {
|
|
126
|
+
for (const report of reports) {
|
|
127
|
+
if (report.error) {
|
|
128
|
+
console.log(`FAIL ${report.dir}\n ${report.error}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const ok = report.problems.length === 0;
|
|
132
|
+
console.log(`${ok ? 'ok ' : 'FAIL'} ${report.dir} — ${report.bundles} bundle(s)`);
|
|
133
|
+
for (const problem of report.problems) {
|
|
134
|
+
console.log(` - ${problem.kind}: ${problem.name} — ${problem.detail}`);
|
|
135
|
+
}
|
|
136
|
+
for (const warning of report.warnings || []) {
|
|
137
|
+
console.log(` . ${warning.kind}: ${warning.name} — ${warning.detail}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const failed = reports.some((report) => report.error || report.problems?.length);
|
|
143
|
+
if (!reports.length) {
|
|
144
|
+
console.log('no profile found');
|
|
145
|
+
}
|
|
146
|
+
process.exit(failed ? 1 : 0);
|