@distributionos/cli 0.1.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.
@@ -0,0 +1,259 @@
1
+ import { exec } from 'node:child_process';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ export async function runValidationPlan(plan, options = {}) {
9
+ const commands = [
10
+ ['build', plan.validation.build],
11
+ ['test', plan.validation.test],
12
+ ['lint', plan.validation.lint],
13
+ ].filter(([, command]) => Boolean(command));
14
+
15
+ const results = [];
16
+ for (const [name, command] of commands) {
17
+ results.push(await runCommand({
18
+ name,
19
+ command,
20
+ cwd: plan.cwd,
21
+ timeoutMs: options.timeoutMs ?? 120_000,
22
+ }));
23
+ }
24
+
25
+ return results;
26
+ }
27
+
28
+ export function compareValidationResults(before, after) {
29
+ const beforeByName = new Map(before.map(result => [result.name, result]));
30
+ return after.map(result => {
31
+ const baseline = beforeByName.get(result.name);
32
+ const introduced = result.exitCode !== 0 && (!baseline || baseline.exitCode === 0);
33
+ const preExisting = result.exitCode !== 0 && Boolean(baseline) && baseline.exitCode !== 0;
34
+ return {
35
+ ...result,
36
+ introducedFailure: introduced,
37
+ preExistingFailure: preExisting,
38
+ };
39
+ });
40
+ }
41
+
42
+ export async function inspectInstallArtifacts(plan) {
43
+ const results = [];
44
+ if (plan.analytics?.status !== 'enabled') {
45
+ return results;
46
+ }
47
+
48
+ const scriptUrl = plan.analytics.scriptUrl ?? scriptSrcFromTag(plan.analytics.scriptTag);
49
+ if (!scriptUrl) {
50
+ results.push({
51
+ name: 'analytics script',
52
+ status: 'failed',
53
+ message: 'Analytics was enabled, but no script URL was available for inspection.',
54
+ });
55
+ return results;
56
+ }
57
+
58
+ const layoutTarget = plan.analytics.layoutTarget;
59
+ if (!layoutTarget) {
60
+ results.push({
61
+ name: 'analytics source',
62
+ status: 'failed',
63
+ message: 'No supported shared layout was detected.',
64
+ });
65
+ return results;
66
+ }
67
+
68
+ const layoutPath = safeResolve(plan.cwd, layoutTarget);
69
+ const layout = await fs.readFile(layoutPath, 'utf8').catch(() => '');
70
+ const scriptCount = countOccurrences(layout, scriptUrl);
71
+ const hasConfig = layout.includes('window.distributionOSAnalyticsConfig');
72
+ const managedBlockCount = countOccurrences(layout, 'DISTRIBUTIONOS:START analytics');
73
+
74
+ results.push({
75
+ name: 'analytics source',
76
+ status: scriptCount === 1 && hasConfig && managedBlockCount === 1 ? 'passed' : 'failed',
77
+ message: scriptCount === 1 && hasConfig && managedBlockCount === 1
78
+ ? `${layoutTarget} contains one managed analytics install with route privacy config.`
79
+ : `${layoutTarget} should contain exactly one managed analytics script and config block.`,
80
+ });
81
+
82
+ const htmlFiles = await findBuiltHtmlFiles(plan.cwd);
83
+ if (htmlFiles.length === 0) {
84
+ results.push({
85
+ name: 'built HTML',
86
+ status: 'warning',
87
+ message: 'No built HTML files were found to inspect; source layout inspection is the local proof.',
88
+ });
89
+ } else {
90
+ const matches = [];
91
+ for (const file of htmlFiles) {
92
+ const html = await fs.readFile(path.join(plan.cwd, file), 'utf8').catch(() => '');
93
+ if (html.includes(scriptUrl) || html.includes(`/api/analytics/script/${plan.remote.analyticsContract?.siteId ?? ''}`)) {
94
+ matches.push(file);
95
+ }
96
+ }
97
+ results.push({
98
+ name: 'built HTML',
99
+ status: matches.length > 0 ? 'passed' : 'warning',
100
+ message: matches.length > 0
101
+ ? `Found analytics script in built HTML: ${matches.slice(0, 3).join(', ')}${matches.length > 3 ? ', ...' : ''}.`
102
+ : 'Built HTML files were present, but none included the analytics script. Framework output may be server-rendered; verify after deploy.',
103
+ });
104
+ }
105
+
106
+ const markerInspection = await inspectMarkers(plan.cwd, plan.repo.contentFiles);
107
+ results.push(markerInspection.content);
108
+ results.push(markerInspection.cta);
109
+ return results;
110
+ }
111
+
112
+ async function runCommand({ name, command, cwd, timeoutMs }) {
113
+ try {
114
+ const { stdout, stderr } = await execAsync(command, {
115
+ cwd,
116
+ timeout: timeoutMs,
117
+ windowsHide: true,
118
+ maxBuffer: 1024 * 1024,
119
+ });
120
+ return {
121
+ name,
122
+ command,
123
+ exitCode: 0,
124
+ stdout: tail(stdout),
125
+ stderr: tail(stderr),
126
+ };
127
+ } catch (error) {
128
+ return {
129
+ name,
130
+ command,
131
+ exitCode: typeof error.code === 'number' ? error.code : 1,
132
+ stdout: tail(error.stdout ?? ''),
133
+ stderr: tail(error.stderr ?? error.message ?? ''),
134
+ };
135
+ }
136
+ }
137
+
138
+ function tail(value, max = 4000) {
139
+ const text = redactSensitiveOutput(String(value ?? ''));
140
+ return text.length > max ? text.slice(-max) : text;
141
+ }
142
+
143
+ export function redactSensitiveOutput(value) {
144
+ const text = String(value ?? '');
145
+ let redactNextLine = false;
146
+
147
+ return text.split(/\r?\n/).map((line) => {
148
+ if (redactNextLine) {
149
+ redactNextLine = false;
150
+ return '[redacted sensitive validation output]';
151
+ }
152
+
153
+ if (PRIVATE_KEY_RE.test(line)) {
154
+ if (!/-----END [A-Z ]*PRIVATE KEY-----/i.test(line)) redactNextLine = true;
155
+ return '[redacted sensitive validation output]';
156
+ }
157
+
158
+ if (SENSITIVE_LINE_RE.test(line)) {
159
+ return redactSensitiveLine(line);
160
+ }
161
+
162
+ return line;
163
+ }).join('\n');
164
+ }
165
+
166
+ const PRIVATE_KEY_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|-----END [A-Z ]*PRIVATE KEY-----|private key starts with/i;
167
+ const SENSITIVE_LINE_RE =
168
+ /\b(private key|api key|apikey|access token|refresh token|auth token|authorization|client email|project id|password|secret|credential)\b/i;
169
+
170
+ function redactSensitiveLine(line) {
171
+ const delimiter = line.match(/^([^:=]{1,80}[:=]\s*)(.*)$/);
172
+ if (delimiter) return `${delimiter[1]}[redacted]`;
173
+ return '[redacted sensitive validation output]';
174
+ }
175
+
176
+ async function findBuiltHtmlFiles(cwd) {
177
+ const roots = ['out', 'dist', 'build', '.next/server/app', '.next/server/pages'];
178
+ const files = [];
179
+ for (const root of roots) {
180
+ await collectHtmlFiles(path.join(cwd, root), root, files);
181
+ if (files.length >= 100) break;
182
+ }
183
+ return files.slice(0, 100);
184
+ }
185
+
186
+ async function collectHtmlFiles(absoluteDir, relativeDir, files) {
187
+ if (files.length >= 100) return;
188
+ let entries = [];
189
+ try {
190
+ entries = await fs.readdir(absoluteDir, { withFileTypes: true });
191
+ } catch {
192
+ return;
193
+ }
194
+
195
+ for (const entry of entries) {
196
+ if (files.length >= 100) return;
197
+ const relativePath = toPosix(path.join(relativeDir, entry.name));
198
+ const absolutePath = path.join(absoluteDir, entry.name);
199
+ if (entry.isDirectory()) {
200
+ await collectHtmlFiles(absolutePath, relativePath, files);
201
+ } else if (entry.isFile() && /\.html$/i.test(entry.name)) {
202
+ files.push(relativePath);
203
+ }
204
+ }
205
+ }
206
+
207
+ async function inspectMarkers(cwd, contentFiles) {
208
+ let contentMarkerCount = 0;
209
+ let ctaMarkerCount = 0;
210
+ for (const file of contentFiles ?? []) {
211
+ const text = await fs.readFile(safeResolve(cwd, file), 'utf8').catch(() => '');
212
+ contentMarkerCount += countOccurrences(text, 'distributionos:content-id');
213
+ contentMarkerCount += countOccurrences(text, 'data-dos-content-id');
214
+ ctaMarkerCount += countOccurrences(text, 'data-dos-event');
215
+ ctaMarkerCount += countOccurrences(text, 'data-dos-link-id');
216
+ }
217
+
218
+ return {
219
+ content: {
220
+ name: 'content markers',
221
+ status: contentMarkerCount > 0 ? 'passed' : 'warning',
222
+ message: contentMarkerCount > 0
223
+ ? `Found ${contentMarkerCount} existing content marker${contentMarkerCount === 1 ? '' : 's'}.`
224
+ : 'No existing dosContentId markers were found in detected content files; backfill public content after artifact IDs exist.',
225
+ },
226
+ cta: {
227
+ name: 'CTA markers',
228
+ status: ctaMarkerCount > 0 ? 'passed' : 'warning',
229
+ message: ctaMarkerCount > 0
230
+ ? `Found ${ctaMarkerCount} existing CTA marker${ctaMarkerCount === 1 ? '' : 's'}.`
231
+ : 'No existing data-dos CTA markers were found; add them only to primary CTAs or tracked campaign links.',
232
+ },
233
+ };
234
+ }
235
+
236
+ function countOccurrences(value, needle) {
237
+ if (!value || !needle) return 0;
238
+ return String(value).split(needle).length - 1;
239
+ }
240
+
241
+ function scriptSrcFromTag(scriptTag) {
242
+ if (!scriptTag) return null;
243
+ const match = String(scriptTag).match(/\ssrc=["']([^"']+)["']/i);
244
+ return match?.[1] ?? null;
245
+ }
246
+
247
+ function safeResolve(cwd, relativePath) {
248
+ const root = path.resolve(cwd);
249
+ const target = path.resolve(root, relativePath);
250
+ const relative = path.relative(root, target);
251
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
252
+ throw new Error(`Refusing to inspect outside repo root: ${relativePath}`);
253
+ }
254
+ return target;
255
+ }
256
+
257
+ function toPosix(value) {
258
+ return value.split(path.sep).filter(Boolean).join('/');
259
+ }