dsh-panel-control 0.3.12 → 0.3.13
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/package.json +3 -2
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-panel-control",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.13",
|
|
4
4
|
"type": "module",
|
|
5
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
6
|
"keywords": [
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"docs",
|
|
25
25
|
"icon.svg",
|
|
26
26
|
"index.js",
|
|
27
|
-
"screenshots.json"
|
|
27
|
+
"screenshots.json",
|
|
28
|
+
"scripts"
|
|
28
29
|
],
|
|
29
30
|
"dsh": {
|
|
30
31
|
"bundle": {
|
|
@@ -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);
|