@shidesheng0218/dsh-plugin-lint 0.0.0-stage → 0.4.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.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +734 -2
  4. package/bin/dsh-plugin-analyze.mjs +378 -0
  5. package/bin/dsh-plugin-lint.mjs +381 -0
  6. package/bin/dsh-plugin-matrix.mjs +78 -0
  7. package/bin/dsh-plugin-upgrade-plan.mjs +180 -0
  8. package/package.json +62 -4
  9. package/schema/dsh-manifest.schema.json +134 -0
  10. package/scripts/lib/probe.mjs +34 -0
  11. package/scripts/probe-boot.mjs +123 -0
  12. package/src/composition.js +1 -0
  13. package/src/core/baseline.js +98 -0
  14. package/src/core/changed.js +41 -0
  15. package/src/core/load.js +145 -0
  16. package/src/core/mask.js +207 -0
  17. package/src/core/pack.js +95 -0
  18. package/src/core/packlist.js +186 -0
  19. package/src/core/peer.js +39 -0
  20. package/src/core/profile-scan.js +121 -0
  21. package/src/core/report.js +174 -0
  22. package/src/core/rules.js +581 -0
  23. package/src/core/semver.js +192 -0
  24. package/src/core/tarball.js +90 -0
  25. package/src/data/event-types.json +393 -0
  26. package/src/experimental/conflicts.js +315 -0
  27. package/src/experimental/index.js +4 -0
  28. package/src/experimental/performance.js +515 -0
  29. package/src/experimental/security.js +462 -0
  30. package/src/index.js +74 -0
  31. package/src/load.js +1 -0
  32. package/src/mask.js +1 -0
  33. package/src/matrix/cache.js +19 -0
  34. package/src/matrix/cell.js +79 -0
  35. package/src/matrix/cells.js +58 -0
  36. package/src/matrix/conflicts.js +210 -0
  37. package/src/matrix/export.js +155 -0
  38. package/src/matrix/index.js +71 -0
  39. package/src/matrix/manifest.js +17 -0
  40. package/src/matrix/migrate.js +41 -0
  41. package/src/matrix/run.js +241 -0
  42. package/src/matrix/sanitize.js +42 -0
  43. package/src/matrix/schema.js +178 -0
  44. package/src/matrix/site.js +61 -0
  45. package/src/matrix/sync.js +72 -0
  46. package/src/matrix/verify.js +152 -0
  47. package/src/performance.js +1 -0
  48. package/src/probe/artifact-resolver.js +161 -0
  49. package/src/probe/classify.js +8 -0
  50. package/src/probe/dsh-adapter.js +212 -0
  51. package/src/probe/environment/docker.js +270 -0
  52. package/src/probe/environment/host-unsafe.js +39 -0
  53. package/src/probe/environment/materialize.js +22 -0
  54. package/src/probe/environment.js +50 -0
  55. package/src/probe/installed-artifact.js +17 -0
  56. package/src/probe/probe.js +278 -0
  57. package/src/report.js +1 -0
  58. package/src/rules.js +1 -0
  59. package/src/security.js +1 -0
  60. package/src/shared/errors.js +7 -0
  61. package/src/shared/hash.js +31 -0
  62. package/src/shared/status.js +12 -0
  63. package/src/shared/validate.js +14 -0
  64. package/src/shared/version.js +4 -0
  65. package/src/upgrade/compatibility.js +14 -0
  66. package/src/upgrade/planner.js +119 -0
  67. package/src/upgrade/profile.js +82 -0
@@ -0,0 +1,241 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { ToolError } from '../shared/errors.js';
4
+ import { PROBE_VERSION } from '../shared/version.js';
5
+ import { sha256 } from '../shared/hash.js';
6
+ import { resolveArtifact } from '../probe/artifact-resolver.js';
7
+ import { createProbeEnvironment } from '../probe/environment.js';
8
+ import { probePackage } from '../probe/probe.js';
9
+ import { startStub as startHostStub } from '../probe/dsh-adapter.js';
10
+ import { cacheKeyFor } from './cache.js';
11
+ import { createCell } from './cell.js';
12
+ import { cellFilePath, writeCell } from './cells.js';
13
+ import { readCellAny } from './migrate.js';
14
+ import { buildManifest } from './manifest.js';
15
+ import { validateCell } from './schema.js';
16
+
17
+ const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
18
+ const productionServices = {
19
+ resolveArtifact,
20
+ createProbeEnvironment,
21
+ probePackage,
22
+ startStub: async ({ environment }) => environment.startStub ? environment.startStub() : startHostStub(),
23
+ };
24
+
25
+ function splitSpec(value) {
26
+ const text = String(value);
27
+ const split = text.startsWith('@') ? text.lastIndexOf('@') > text.indexOf('/') : text.lastIndexOf('@') > 0;
28
+ if (!split) return { name: text, version: 'latest' };
29
+ const index = text.lastIndexOf('@');
30
+ return { name: text.slice(0, index), version: text.slice(index + 1) || 'latest' };
31
+ }
32
+
33
+ function inputFor(target, targetMode) {
34
+ if (targetMode === 'refresh-latest') {
35
+ if (typeof target === 'string' && existsSync(target)) return target;
36
+ if (target && typeof target === 'object' && (target.local || target.path)) return target;
37
+ return typeof target === 'string' ? splitSpec(target).name : target.name;
38
+ }
39
+ if ((typeof target === 'string' && existsSync(target)) || (target && typeof target === 'object' && (target.local || target.path))) return target;
40
+ if (target && typeof target === 'object') {
41
+ if (!EXACT_VERSION.test(target.version || '') || typeof target.integrity !== 'string') {
42
+ throw new ToolError('ARTIFACT_SPEC_UNSUPPORTED', 'Pinned matrix targets need an exact version and registry integrity.');
43
+ }
44
+ return target;
45
+ }
46
+ const spec = splitSpec(target);
47
+ if (!EXACT_VERSION.test(spec.version)) {
48
+ throw new ToolError('ARTIFACT_SPEC_UNSUPPORTED', 'Pinned matrix targets cannot re-resolve latest or a dist-tag.');
49
+ }
50
+ return target;
51
+ }
52
+
53
+ function publicError(error) {
54
+ return {
55
+ code: error?.code || 'MATRIX_INFRASTRUCTURE_ERROR',
56
+ message: error?.code ? error.message : 'The matrix cell could not complete because of an internal or host failure.',
57
+ };
58
+ }
59
+
60
+ export async function runMatrix({
61
+ targets,
62
+ targetMode = 'pinned',
63
+ dshVersions,
64
+ compositions = ['headless', 'web'],
65
+ outDir,
66
+ cacheRoot,
67
+ concurrency = 4,
68
+ force = false,
69
+ keep = false,
70
+ useTools = true,
71
+ backend = 'auto',
72
+ services = productionServices,
73
+ runId = 'local-matrix',
74
+ }) {
75
+ if (!['pinned', 'refresh-latest'].includes(targetMode)) throw new ToolError('MATRIX_INPUT_INVALID', 'targetMode must be pinned or refresh-latest.');
76
+ if (!Array.isArray(targets) || targets.length === 0) throw new ToolError('MATRIX_INPUT_INVALID', 'At least one target is required.');
77
+ if (!Array.isArray(dshVersions) || dshVersions.length === 0) throw new ToolError('MATRIX_INPUT_INVALID', 'At least one DSH version is required.');
78
+ if (!Array.isArray(compositions) || compositions.length === 0 || compositions.some((mode) => !['headless', 'web'].includes(mode))) {
79
+ throw new ToolError('MATRIX_INPUT_INVALID', 'compositions must contain only headless and/or web.');
80
+ }
81
+ if (!outDir || !cacheRoot) throw new ToolError('MATRIX_INPUT_INVALID', 'outDir and cacheRoot are required.');
82
+ const resolved = [];
83
+ for (const target of targets) {
84
+ const artifact = await services.resolveArtifact(inputFor(target, targetMode));
85
+ resolved.push({
86
+ artifact,
87
+ input: {
88
+ name: artifact.name,
89
+ requestedVersion: artifact.requestedVersion,
90
+ resolvedVersion: artifact.resolvedVersion,
91
+ // A local artifact has integrity: undefined. JSON has no undefined, and
92
+ // stableStringify rightly rejects it; omitting the key keeps the manifest
93
+ // hashable instead of aborting every local-directory run.
94
+ ...(artifact.integrity !== undefined ? { integrity: artifact.integrity } : {}),
95
+ ...(artifact.sourceDigest !== undefined ? { sourceDigest: artifact.sourceDigest } : {}),
96
+ local: artifact.local,
97
+ },
98
+ });
99
+ }
100
+ const tasks = [];
101
+ for (const { artifact } of resolved) {
102
+ for (const version of dshVersions) {
103
+ for (const composition of compositions) tasks.push({ artifact, version, composition });
104
+ }
105
+ }
106
+ const summary = {
107
+ total: tasks.length,
108
+ completed: 0,
109
+ errors: 0,
110
+ cached: 0,
111
+ invalidCache: 0,
112
+ targetMode,
113
+ inputs: resolved.map((entry) => entry.input),
114
+ statuses: {},
115
+ outDir,
116
+ cacheRoot,
117
+ };
118
+ const written = [];
119
+ let invalidCache = 0;
120
+
121
+ function cachedCell(task, cacheKey) {
122
+ if (force) return undefined;
123
+ const path = cellFilePath(cacheRoot, {
124
+ plugin: task.artifact,
125
+ dsh: { version: task.version, composition: task.composition },
126
+ cacheKey,
127
+ });
128
+ if (!existsSync(path)) return undefined;
129
+ try {
130
+ const { kind, cell } = readCellAny(JSON.parse(readFileSync(path, 'utf8')));
131
+ if (kind !== 'v2' || cell.cacheKey !== cacheKey || cell.run.outcome !== 'completed' || !validateCell(cell).ok) {
132
+ invalidCache += 1;
133
+ return undefined;
134
+ }
135
+ writeCell(outDir, cell);
136
+ return { cell, path: cellFilePath(outDir, cell), cached: true };
137
+ } catch {
138
+ invalidCache += 1;
139
+ return undefined;
140
+ }
141
+ }
142
+
143
+ async function execute(task) {
144
+ const cacheKey = cacheKeyFor({ artifact: task.artifact, dshVersion: task.version, composition: task.composition, probeVersion: PROBE_VERSION });
145
+ const cached = cachedCell(task, cacheKey);
146
+ if (cached) return cached;
147
+ let environment;
148
+ let stub;
149
+ let observation;
150
+ try {
151
+ environment = await services.createProbeEnvironment({
152
+ backend,
153
+ workDir: cacheRoot,
154
+ dshDir: task.version !== 'desktop' ? join(cacheRoot, 'dsh', task.version) : undefined,
155
+ });
156
+ stub = useTools && task.composition !== 'web' ? await services.startStub({ environment, composition: task.composition }) : undefined;
157
+ const dsh = { kind: task.version === 'desktop' ? 'desktop' : 'npm', version: task.version, source: task.version === 'desktop' ? 'desktop' : 'npm', composition: task.composition };
158
+ observation = await services.probePackage({ artifact: task.artifact, dsh, environment, stub, tools: useTools, keep, composition: task.composition, audit: true });
159
+ } catch (error) {
160
+ observation = { outcome: 'error', error: publicError(error), entries: [], durationMs: 0 };
161
+ if (environment) await environment.destroy();
162
+ } finally {
163
+ if (stub?.server) stub.server.close();
164
+ }
165
+ let cell;
166
+ try {
167
+ cell = createCell({
168
+ artifact: task.artifact,
169
+ dsh: { version: task.version, source: task.version === 'desktop' ? 'desktop' : 'npm', composition: task.composition },
170
+ observation,
171
+ isolation: environment?.isolation || backend,
172
+ probeVersion: PROBE_VERSION,
173
+ });
174
+ } catch (error) {
175
+ // A cell that cannot be represented is an infrastructure error for that
176
+ // cell, not a reason to abort the remaining queue.
177
+ cell = createCell({
178
+ artifact: task.artifact,
179
+ dsh: { version: task.version, source: task.version === 'desktop' ? 'desktop' : 'npm', composition: task.composition },
180
+ observation: {
181
+ outcome: 'error',
182
+ error: { code: 'CELL_BUILD_FAILED', message: String(error?.message || error) },
183
+ entries: [],
184
+ durationMs: observation?.durationMs || 0,
185
+ },
186
+ isolation: environment?.isolation || backend,
187
+ probeVersion: PROBE_VERSION,
188
+ });
189
+ }
190
+ const path = writeCell(outDir, cell);
191
+ if (cell.run.outcome === 'completed' && cacheRoot !== outDir) writeCell(cacheRoot, cell);
192
+ return { cell, path, cached: false };
193
+ }
194
+
195
+ const queue = [...tasks];
196
+ const workers = Array.from({ length: Math.min(Math.max(1, concurrency), queue.length) }, async () => {
197
+ while (queue.length > 0) {
198
+ const result = await execute(queue.shift());
199
+ written.push(result);
200
+ }
201
+ });
202
+ await Promise.all(workers);
203
+ written.sort((a, b) => a.cell.plugin.name.localeCompare(b.cell.plugin.name)
204
+ || a.cell.plugin.resolvedVersion.localeCompare(b.cell.plugin.resolvedVersion, undefined, { numeric: true })
205
+ || a.cell.dsh.version.localeCompare(b.cell.dsh.version, undefined, { numeric: true })
206
+ || a.cell.dsh.composition.localeCompare(b.cell.dsh.composition));
207
+
208
+ for (const { cell, cached } of written) {
209
+ if (cached) summary.cached += 1;
210
+ if (cell.run.outcome === 'completed') {
211
+ summary.completed += 1;
212
+ summary.statuses[cell.result.status] = (summary.statuses[cell.result.status] || 0) + 1;
213
+ } else {
214
+ summary.errors += 1;
215
+ }
216
+ }
217
+ summary.invalidCache = invalidCache;
218
+ const manifest = buildManifest({
219
+ runId,
220
+ probeVersion: PROBE_VERSION,
221
+ inputs: {
222
+ targetMode,
223
+ targets: summary.inputs,
224
+ dshVersions,
225
+ compositions,
226
+ probeVersion: PROBE_VERSION,
227
+ },
228
+ artifacts: written.map(({ path }) => {
229
+ const bytes = readFileSync(path);
230
+ return { path: relative(outDir, path).replace(/\\/g, '/'), bytes: bytes.length, sha256: sha256(bytes) };
231
+ }),
232
+ });
233
+ manifest.targetMode = targetMode;
234
+ manifest.inputs = summary.inputs;
235
+ manifest.cellCount = written.length;
236
+ manifest.errorCount = summary.errors;
237
+ const manifestPath = `${outDir}/run-manifest.json`;
238
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
239
+ summary.manifestPath = manifestPath;
240
+ return { cells: written.map((entry) => entry.cell), summary };
241
+ }
@@ -0,0 +1,42 @@
1
+ import { ToolError } from '../shared/errors.js';
2
+ import { validateCell } from './schema.js';
3
+
4
+ export function sanitizeText(value) {
5
+ return String(value)
6
+ .replace(/token=[^\s&"']+/gi, 'token=<redacted>')
7
+ .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1<redacted>')
8
+ .replace(/(https?:\/\/)[^/\s:@]+:[^/\s@]+@/gi, '$1<redacted>@')
9
+ .replace(/((?:api[_-]?key|api[_-]?token|access[_-]?token|secret|password|npm_token|probe_api_key)\s*[:=]\s*["']?)[^\s,"'}]+/gi, '$1<redacted>')
10
+ .replace(/\/(Users|home)\/[^/\s]+/g, '/$1/<redacted>');
11
+ }
12
+
13
+ export function sanitizeCellForPublish(cell) {
14
+ if (cell?.schemaVersion !== 2 || cell.run?.outcome !== 'completed') {
15
+ throw new ToolError('EXPORT_INPUT_INVALID', 'Only completed v2 cells can be published.');
16
+ }
17
+ const validation = validateCell(cell);
18
+ if (!validation.ok) throw new ToolError('EXPORT_INPUT_INVALID', validation.errors.join('; '));
19
+ const publicCell = structuredClone(cell);
20
+ delete publicCell.plugin.path;
21
+ delete publicCell.installOutput;
22
+ delete publicCell.stdout;
23
+ delete publicCell.stderr;
24
+ delete publicCell.env;
25
+ if (publicCell.evidence) publicCell.evidence.note = sanitizeText(publicCell.evidence.note || '');
26
+ if (publicCell.reproduce?.command) publicCell.reproduce.command = sanitizeText(publicCell.reproduce.command);
27
+ return publicCell;
28
+ }
29
+
30
+ export function sanitizeForPublish(value) {
31
+ if (Array.isArray(value)) return value.map(sanitizeForPublish);
32
+ if (value && typeof value === 'object') {
33
+ if (value.schemaVersion === 2 && value.plugin && value.run) return sanitizeCellForPublish(value);
34
+ const output = {};
35
+ for (const [key, entry] of Object.entries(value)) {
36
+ if (['stdout', 'stderr', 'installOutput', 'env', 'cause', 'rawOutput'].includes(key)) continue;
37
+ output[key] = sanitizeForPublish(entry);
38
+ }
39
+ return output;
40
+ }
41
+ return typeof value === 'string' ? sanitizeText(value) : value;
42
+ }
@@ -0,0 +1,178 @@
1
+ import { isPlainObject } from '../shared/validate.js';
2
+ import { cacheKeyFor } from './cache.js';
3
+
4
+ const HEX64 = /^[0-9a-f]{64}$/;
5
+ const TRUST = new Set(['self-reported', 'verified', 'disputed']);
6
+ const OUTCOME = new Set(['completed', 'error']);
7
+
8
+ function object(value, path, errors) {
9
+ if (!isPlainObject(value)) errors.push(`${path} must be an object`);
10
+ return isPlainObject(value);
11
+ }
12
+
13
+ function string(value, path, errors, { allowEmpty = false } = {}) {
14
+ if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) errors.push(`${path} must be a non-empty string`);
15
+ }
16
+
17
+ function date(value, path, errors) {
18
+ if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) errors.push(`${path} must be an ISO date`);
19
+ }
20
+
21
+ function verificationFields(run, probeVersion, errors) {
22
+ if (run.trust === 'verified') {
23
+ if (!object(run.verification, 'run.verification', errors)) return;
24
+ for (const field of ['workflow', 'runId', 'url']) string(run.verification[field], `run.verification.${field}`, errors);
25
+ if (run.verification.probeVersion !== probeVersion) errors.push('run.verification.probeVersion must match probe.version');
26
+ }
27
+ if (run.trust === 'disputed') {
28
+ if (!object(run.dispute, 'run.dispute', errors)) return;
29
+ string(run.dispute.reported, 'run.dispute.reported', errors);
30
+ string(run.dispute.observed, 'run.dispute.observed', errors);
31
+ }
32
+ }
33
+
34
+ export function validateCell(value) {
35
+ const errors = [];
36
+ if (!object(value, 'cell', errors)) return { ok: false, errors };
37
+ if (value.schemaVersion !== 2) errors.push('schemaVersion must be 2');
38
+ if (!HEX64.test(value.cacheKey || '')) errors.push('cacheKey must be a sha256 hex digest');
39
+
40
+ if (object(value.plugin, 'plugin', errors)) {
41
+ for (const field of ['name', 'requestedVersion', 'resolvedVersion']) string(value.plugin[field], `plugin.${field}`, errors);
42
+ if (typeof value.plugin.local !== 'boolean') errors.push('plugin.local must be boolean');
43
+ if (value.plugin.local === false && typeof value.plugin.integrity !== 'string') errors.push('registry integrity is required');
44
+ if (value.plugin.local === true && typeof value.plugin.sourceDigest !== 'string') errors.push('local sourceDigest is required');
45
+ // A plugin the host rejected was never loaded, so there is no loaded version
46
+ // to record. Requiring one here made createCell throw and aborted the whole
47
+ // matrix run on the first rejection.
48
+ if (value.run?.outcome === 'completed' && value.result?.status !== 'rejected-by-host') {
49
+ string(value.plugin.loadedVersion, 'plugin.loadedVersion', errors);
50
+ }
51
+ if (value.plugin.loadedVersion !== undefined && value.plugin.loadedVersion !== value.plugin.resolvedVersion) {
52
+ errors.push('plugin.loadedVersion must equal resolvedVersion');
53
+ }
54
+ }
55
+
56
+ if (object(value.dsh, 'dsh', errors)) {
57
+ string(value.dsh.version, 'dsh.version', errors);
58
+ string(value.dsh.source, 'dsh.source', errors);
59
+ if (!['headless', 'web'].includes(value.dsh.composition)) errors.push('dsh.composition must be headless or web');
60
+ }
61
+ if (object(value.probe, 'probe', errors)) {
62
+ string(value.probe.version, 'probe.version', errors);
63
+ string(value.probe.isolation, 'probe.isolation', errors);
64
+ if (object(value.probe.host, 'probe.host', errors)) {
65
+ string(value.probe.host.os, 'probe.host.os', errors);
66
+ string(value.probe.host.node, 'probe.host.node', errors);
67
+ }
68
+ }
69
+
70
+ if (object(value.run, 'run', errors)) {
71
+ if (!OUTCOME.has(value.run.outcome)) errors.push('run.outcome must be completed or error');
72
+ if (!TRUST.has(value.run.trust)) errors.push('run.trust is invalid');
73
+ date(value.run.at, 'run.at', errors);
74
+ if (!Number.isFinite(value.run.durationMs) || value.run.durationMs < 0) errors.push('run.durationMs must be non-negative');
75
+ verificationFields(value.run, value.probe?.version, errors);
76
+ if (value.run.outcome === 'error') {
77
+ if (value.result !== undefined) errors.push('error cells must not contain a plugin verdict');
78
+ if (object(value.run.error, 'run.error', errors)) {
79
+ string(value.run.error.code, 'run.error.code', errors);
80
+ string(value.run.error.message, 'run.error.message', errors);
81
+ }
82
+ }
83
+ }
84
+
85
+ if (value.run?.outcome === 'completed') {
86
+ if (object(value.result, 'result', errors)) {
87
+ string(value.result.status, 'result.status', errors);
88
+ if (!Array.isArray(value.result.entries)) errors.push('result.entries must be an array');
89
+ if (!Array.isArray(value.result.awaitedServices)) errors.push('result.awaitedServices must be an array');
90
+ string(value.result.clientHalf, 'result.clientHalf', errors);
91
+ if (value.result.composition !== value.dsh?.composition) errors.push('result.composition must match dsh.composition');
92
+ }
93
+ if (object(value.evidence, 'evidence', errors)) {
94
+ string(value.evidence.note, 'evidence.note', errors, { allowEmpty: true });
95
+ if (!HEX64.test(value.evidence.outputSha256 || '')) errors.push('evidence.outputSha256 must be a sha256 hex digest');
96
+ }
97
+ if (object(value.reproduce, 'reproduce', errors)) string(value.reproduce.command, 'reproduce.command', errors);
98
+ }
99
+
100
+ if (HEX64.test(value.cacheKey || '') && value.plugin?.resolvedVersion && value.dsh?.composition && value.probe?.version) {
101
+ try {
102
+ const expected = cacheKeyFor({
103
+ artifact: value.plugin,
104
+ dshVersion: value.dsh.version,
105
+ composition: value.dsh.composition,
106
+ probeVersion: value.probe.version,
107
+ });
108
+ if (expected !== value.cacheKey) errors.push('cacheKey does not match the artifact inputs');
109
+ } catch (error) {
110
+ errors.push(error.message);
111
+ }
112
+ }
113
+ return { ok: errors.length === 0, errors };
114
+ }
115
+
116
+ export function validateIndex(value) {
117
+ const errors = [];
118
+ if (!object(value, 'index', errors)) return { ok: false, errors };
119
+ if (value.schemaVersion !== 2) errors.push('index.schemaVersion must be 2');
120
+ date(value.generatedAt, 'index.generatedAt', errors);
121
+ if (!Array.isArray(value.dshVersions) || value.dshVersions.some((entry) => typeof entry !== 'string')) errors.push('index.dshVersions must be strings');
122
+ if (!Array.isArray(value.plugins)) {
123
+ errors.push('index.plugins must be an array');
124
+ return { ok: false, errors };
125
+ }
126
+ for (const [pluginIndex, plugin] of value.plugins.entries()) {
127
+ string(plugin.name, `plugins[${pluginIndex}].name`, errors);
128
+ if (!Array.isArray(plugin.versions)) {
129
+ errors.push(`plugins[${pluginIndex}].versions must be an array`);
130
+ continue;
131
+ }
132
+ for (const [versionIndex, version] of plugin.versions.entries()) {
133
+ string(version.version, `plugins[${pluginIndex}].versions[${versionIndex}].version`, errors);
134
+ if (!Array.isArray(version.cells)) {
135
+ errors.push(`plugins[${pluginIndex}].versions[${versionIndex}].cells must be an array`);
136
+ continue;
137
+ }
138
+ for (const [cellIndex, cell] of version.cells.entries()) {
139
+ const path = `plugins[${pluginIndex}].versions[${versionIndex}].cells[${cellIndex}]`;
140
+ if (!HEX64.test(cell.cacheKey || '')) errors.push(`${path}.cacheKey is invalid`);
141
+ string(cell.dsh, `${path}.dsh`, errors);
142
+ if (!['headless', 'web'].includes(cell.composition)) errors.push(`${path}.composition is invalid`);
143
+ string(cell.status, `${path}.status`, errors);
144
+ string(cell.trust, `${path}.trust`, errors);
145
+ date(cell.at, `${path}.at`, errors);
146
+ if (!Array.isArray(cell.toolsAdded)) errors.push(`${path}.toolsAdded must be an array`);
147
+ string(cell.reproduce, `${path}.reproduce`, errors);
148
+ }
149
+ }
150
+ }
151
+ return { ok: errors.length === 0, errors };
152
+ }
153
+
154
+ export function validateManifest(value) {
155
+ const errors = [];
156
+ if (!object(value, 'manifest', errors)) return { ok: false, errors };
157
+ if (value.schemaVersion !== 2) errors.push('manifest.schemaVersion must be 2');
158
+ string(value.runId, 'manifest.runId', errors);
159
+ date(value.generatedAt, 'manifest.generatedAt', errors);
160
+ string(value.probeVersion, 'manifest.probeVersion', errors);
161
+ if (!HEX64.test(value.inputsSha256 || '')) errors.push('manifest.inputsSha256 is invalid');
162
+ if (!Array.isArray(value.artifacts)) {
163
+ errors.push('manifest.artifacts must be an array');
164
+ return { ok: false, errors };
165
+ }
166
+ const paths = new Set();
167
+ for (const [index, artifact] of value.artifacts.entries()) {
168
+ const path = artifact.path;
169
+ if (typeof path !== 'string' || path.startsWith('/') || path.includes('\\') || path === '..' || path.startsWith('../') || path.includes('/../') || path.includes('\0')) {
170
+ errors.push(`artifacts[${index}].path is not a safe relative path`);
171
+ }
172
+ if (paths.has(path)) errors.push(`artifacts[${index}].path is duplicated`);
173
+ paths.add(path);
174
+ if (!Number.isInteger(artifact.bytes) || artifact.bytes < 0) errors.push(`artifacts[${index}].bytes must be non-negative`);
175
+ if (!HEX64.test(artifact.sha256 || '')) errors.push(`artifacts[${index}].sha256 is invalid`);
176
+ }
177
+ return { ok: errors.length === 0, errors };
178
+ }
@@ -0,0 +1,61 @@
1
+ const COLORS = {
2
+ active: '#1a7f37',
3
+ 'inactive-error': '#cf222e',
4
+ 'rejected-by-host': '#cf222e',
5
+ 'awaiting-service': '#bf8700',
6
+ 'install-failed': '#8250df',
7
+ 'boot-failed': '#cf222e',
8
+ 'template-failed': '#57606a',
9
+ };
10
+
11
+ export function escapeHtml(value) {
12
+ return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
13
+ }
14
+
15
+ function renderCell(cell) {
16
+ const status = escapeHtml(cell.status);
17
+ const note = cell.note || cell.title;
18
+ const title = escapeHtml(note || cell.status);
19
+ const color = COLORS[cell.status] || '#57606a';
20
+ const tools = cell.toolsAdded?.length ? `<div class="tools">+${cell.toolsAdded.length} tools</div>` : '';
21
+ const services = cell.awaitedServices?.length ? `<div class="meta">needs ${escapeHtml(cell.awaitedServices.join(', '))}</div>` : '';
22
+ const detail = note ? `<div class="note">${escapeHtml(note)}</div>` : '';
23
+ return `<div class="observation"><span class="pill" style="background:${color}" title="${title}">${status}</span>${tools}${services}<div class="meta">${escapeHtml(cell.composition)} · ${escapeHtml(cell.trust)}</div>${detail}</div>`;
24
+ }
25
+
26
+ export function renderSite(index) {
27
+ const dshVersions = index.dshVersions || [];
28
+ const plugins = index.plugins || [];
29
+ const cells = plugins.flatMap((plugin) => plugin.versions.flatMap((version) => version.cells));
30
+ const tally = {};
31
+ for (const cell of cells) tally[cell.status] = (tally[cell.status] || 0) + 1;
32
+ const rows = plugins.map((plugin) => plugin.versions.map((version) => {
33
+ const columns = dshVersions.map((dsh) => {
34
+ const observations = version.cells.filter((cell) => cell.dsh === dsh)
35
+ .sort((a, b) => a.composition.localeCompare(b.composition) || a.at.localeCompare(b.at));
36
+ return `<td>${observations.length ? observations.map(renderCell).join('') : '<span class="none">—</span>'}</td>`;
37
+ }).join('');
38
+ return `<tr><th>${escapeHtml(plugin.name)}<div class="ver">${escapeHtml(version.version)}</div></th>${columns}</tr>`;
39
+ }).join('\n')).join('\n');
40
+ const source = index.source?.runId || index.sourceRunId || 'unknown';
41
+ return [
42
+ '<!doctype html>',
43
+ '<html lang="en"><head><meta charset="utf-8">',
44
+ '<title>DSH plugin compatibility matrix</title>',
45
+ '<style>',
46
+ 'body{font:14px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;margin:2rem;color:#1f2328}',
47
+ 'h1{font-size:1.4rem;margin:0 0 .25rem}.sub{color:#57606a;margin-bottom:1.5rem}',
48
+ 'table{border-collapse:collapse}th,td{border:1px solid #d0d7de;padding:.5rem .75rem;text-align:left;vertical-align:top}',
49
+ 'th{background:#f6f8fa;font-weight:600}.ver{color:#57606a;font-weight:400;font-size:.8rem}',
50
+ '.observation{margin-bottom:.65rem}.pill{display:inline-block;color:#fff;border-radius:999px;padding:.1rem .5rem;font-size:.75rem}',
51
+ '.tools{color:#1a7f37;font-size:.75rem;margin-top:.25rem}.meta{color:#8c959f;font-size:.7rem;margin-top:.2rem}',
52
+ '.note{margin-top:1rem;color:#57606a;font-size:.78rem;max-width:60rem}.none{color:#d0d7de;text-align:center}',
53
+ '</style></head><body>',
54
+ '<h1>DSH plugin compatibility matrix</h1>',
55
+ `<div class="sub">${cells.length} cells · ${plugins.length} plugins · ${dshVersions.length} DSH versions · generated ${escapeHtml(index.generatedAt)} · source ${escapeHtml(source)}</div>`,
56
+ `<table><thead><tr><th>Plugin</th>${dshVersions.map((version) => `<th>${escapeHtml(version)}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table>`,
57
+ '<div class="note"><strong>Read "awaiting service" as untested, not as broken.</strong> Each composition is shown separately; no best result is selected across headless and web. Host-half probes do not prove browser client-half behavior or security.</div>',
58
+ `<div class="note"><strong>Statuses:</strong> ${Object.entries(tally).sort((a, b) => b[1] - a[1]).map(([status, count]) => `${escapeHtml(status)} ${count}`).join(' · ')}</div>`,
59
+ '</body></html>',
60
+ ].join('\n');
61
+ }
@@ -0,0 +1,72 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve, sep } from 'node:path';
3
+ import { ToolError } from '../shared/errors.js';
4
+ import { sha256 } from '../shared/hash.js';
5
+ import { validateManifest } from './schema.js';
6
+ import { verifyPublicArtifact } from './export.js';
7
+
8
+ const MAX_FILES = 10000;
9
+ const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
10
+
11
+ function safeRelative(path) {
12
+ return typeof path === 'string' && !path.startsWith('/') && !path.includes('\\') && path !== '..' && !path.startsWith('../') && !path.includes('/../') && !path.includes('\0');
13
+ }
14
+
15
+ export async function syncMatrix({ source, outDir, fetchImpl = fetch }) {
16
+ if (!source) throw new ToolError('MATRIX_SOURCE_REQUIRED', 'An explicit matrix source is required.');
17
+ if (!outDir) throw new ToolError('MATRIX_OUTPUT_REQUIRED', 'An explicit sync output directory is required.');
18
+ const remote = /^https:\/\//.test(source);
19
+ const base = remote ? new URL(source.endsWith('/') ? source : source + '/') : realpathSync(source);
20
+ async function readSource(path) {
21
+ if (!safeRelative(path)) throw new ToolError('MANIFEST_INVALID', 'The source contains an unsafe artifact path.');
22
+ if (remote) {
23
+ const url = new URL(path, base);
24
+ if (url.protocol !== 'https:') throw new ToolError('MATRIX_SOURCE_UNSUPPORTED', 'Only HTTPS matrix sources are supported.');
25
+ const response = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(10000) });
26
+ if (!response.ok) throw new ToolError('MATRIX_SOURCE_UNAVAILABLE', `The matrix source returned HTTP ${response.status}.`);
27
+ const bytes = Buffer.from(await response.arrayBuffer());
28
+ if (bytes.length > MAX_ARTIFACT_BYTES) throw new ToolError('ARTIFACT_TOO_LARGE', 'A matrix artifact exceeds the size limit.');
29
+ return bytes;
30
+ }
31
+ const pathOnDisk = realpathSync(resolve(base, path));
32
+ if (pathOnDisk !== base && !pathOnDisk.startsWith(base + sep)) throw new ToolError('MANIFEST_INVALID', 'A source file escapes the source directory.');
33
+ const bytes = readFileSync(pathOnDisk);
34
+ if (bytes.length > MAX_ARTIFACT_BYTES) throw new ToolError('ARTIFACT_TOO_LARGE', 'A matrix artifact exceeds the size limit.');
35
+ return bytes;
36
+ }
37
+
38
+ const manifest = JSON.parse((await readSource('manifest.json')).toString('utf8'));
39
+ const validation = validateManifest(manifest);
40
+ if (!validation.ok) throw new ToolError('MANIFEST_INVALID', validation.errors.join('; '));
41
+ if (manifest.artifacts.length > MAX_FILES) throw new ToolError('ARTIFACT_TOO_LARGE', 'The matrix declares too many files.');
42
+ const parent = dirname(outDir);
43
+ mkdirSync(parent, { recursive: true });
44
+ const staging = mkdtempSync(join(parent, '.sync-'));
45
+ try {
46
+ for (const artifact of manifest.artifacts) {
47
+ const bytes = await readSource(artifact.path);
48
+ if (bytes.length !== artifact.bytes || sha256(bytes) !== artifact.sha256) {
49
+ throw new ToolError('ARTIFACT_INTEGRITY_MISMATCH', `Artifact integrity mismatch: ${artifact.path}`);
50
+ }
51
+ const destination = join(staging, artifact.path);
52
+ mkdirSync(dirname(destination), { recursive: true });
53
+ writeFileSync(destination, bytes);
54
+ }
55
+ writeFileSync(join(staging, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
56
+ const verification = await verifyPublicArtifact({ dir: staging });
57
+ if (!verification.ok) throw new ToolError('ARTIFACT_INTEGRITY_MISMATCH', verification.errors.join('; '));
58
+ const backup = `${outDir}.backup-${Date.now()}`;
59
+ if (existsSync(outDir)) renameSync(outDir, backup);
60
+ try {
61
+ renameSync(staging, outDir);
62
+ rmSync(backup, { recursive: true, force: true });
63
+ } catch (error) {
64
+ if (existsSync(backup)) renameSync(backup, outDir);
65
+ throw error;
66
+ }
67
+ return { manifest, filesWritten: manifest.artifacts.length + 1 };
68
+ } catch (error) {
69
+ rmSync(staging, { recursive: true, force: true });
70
+ throw error;
71
+ }
72
+ }