@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.
- package/README.md +38 -0
- package/bin/distributionos.js +14 -0
- package/package.json +27 -0
- package/src/api.js +209 -0
- package/src/auth-store.js +52 -0
- package/src/bootstrap.js +107 -0
- package/src/cli.js +487 -0
- package/src/constants.js +60 -0
- package/src/detect.js +279 -0
- package/src/initialization.js +148 -0
- package/src/mutate.js +193 -0
- package/src/oauth.js +238 -0
- package/src/plan.js +265 -0
- package/src/privacy.js +62 -0
- package/src/validation.js +259 -0
package/src/oauth.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { storeCredential } from './auth-store.js';
|
|
5
|
+
import { DEFAULT_API_BASE } from './constants.js';
|
|
6
|
+
|
|
7
|
+
export async function loginWithOAuth(options) {
|
|
8
|
+
const apiBase = normalizeApiBase(options.apiBase ?? DEFAULT_API_BASE);
|
|
9
|
+
const callback = await createCallbackServer();
|
|
10
|
+
const redirectUri = `http://127.0.0.1:${callback.port}/callback`;
|
|
11
|
+
const verifier = randomSecret(48);
|
|
12
|
+
const challenge = pkceChallenge(verifier);
|
|
13
|
+
const state = randomSecret(24);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const client = await registerClient({
|
|
17
|
+
fetchImpl: options.fetch,
|
|
18
|
+
apiBase,
|
|
19
|
+
redirectUri,
|
|
20
|
+
});
|
|
21
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
22
|
+
apiBase,
|
|
23
|
+
appId: options.appId,
|
|
24
|
+
clientId: client.client_id,
|
|
25
|
+
redirectUri,
|
|
26
|
+
challenge,
|
|
27
|
+
state,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
options.stdout?.write([
|
|
31
|
+
'Open this DistributionOS authorization URL:',
|
|
32
|
+
authorizeUrl,
|
|
33
|
+
'',
|
|
34
|
+
].join('\n'));
|
|
35
|
+
if (options.openBrowser !== false) openUrl(authorizeUrl);
|
|
36
|
+
|
|
37
|
+
const authResult = await callback.waitForCallback();
|
|
38
|
+
if (authResult.state !== state) throw new Error('OAuth callback state did not match.');
|
|
39
|
+
if (authResult.error) throw new Error(`OAuth failed: ${authResult.error}`);
|
|
40
|
+
if (!authResult.code) throw new Error('OAuth callback did not include an authorization code.');
|
|
41
|
+
|
|
42
|
+
const tokens = await exchangeCode({
|
|
43
|
+
fetchImpl: options.fetch,
|
|
44
|
+
apiBase,
|
|
45
|
+
clientId: client.client_id,
|
|
46
|
+
redirectUri,
|
|
47
|
+
verifier,
|
|
48
|
+
code: authResult.code,
|
|
49
|
+
});
|
|
50
|
+
const credential = credentialFromTokenResponse(tokens);
|
|
51
|
+
await storeCredential({
|
|
52
|
+
apiBase,
|
|
53
|
+
appId: options.appId,
|
|
54
|
+
credential: {
|
|
55
|
+
type: 'oauth',
|
|
56
|
+
clientId: client.client_id,
|
|
57
|
+
...credential,
|
|
58
|
+
},
|
|
59
|
+
env: options.env,
|
|
60
|
+
});
|
|
61
|
+
return credential;
|
|
62
|
+
} finally {
|
|
63
|
+
await callback.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function refreshOAuthCredential({ credential, apiBase, appId, fetchImpl, env }) {
|
|
68
|
+
if (!credential?.refreshToken) return null;
|
|
69
|
+
const response = await postForm(fetchImpl, `${normalizeApiBase(apiBase)}/oauth/token`, {
|
|
70
|
+
grant_type: 'refresh_token',
|
|
71
|
+
refresh_token: credential.refreshToken,
|
|
72
|
+
scope: 'read write',
|
|
73
|
+
});
|
|
74
|
+
const next = {
|
|
75
|
+
...credential,
|
|
76
|
+
...credentialFromTokenResponse(response),
|
|
77
|
+
clientId: credential.clientId,
|
|
78
|
+
type: 'oauth',
|
|
79
|
+
};
|
|
80
|
+
await storeCredential({ apiBase, appId, credential: next, env });
|
|
81
|
+
return next;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isCredentialFresh(credential, skewMs = 5 * 60 * 1000) {
|
|
85
|
+
if (!credential?.accessToken || !credential.expiresAt) return false;
|
|
86
|
+
return new Date(credential.expiresAt).getTime() - skewMs > Date.now();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function credentialFromTokenResponse(tokens) {
|
|
90
|
+
const expiresAt = new Date(Date.now() + Number(tokens.expires_in ?? 0) * 1000).toISOString();
|
|
91
|
+
return {
|
|
92
|
+
accessToken: tokens.access_token,
|
|
93
|
+
refreshToken: tokens.refresh_token,
|
|
94
|
+
tokenType: tokens.token_type ?? 'Bearer',
|
|
95
|
+
scope: tokens.scope ?? 'read write',
|
|
96
|
+
expiresAt,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function registerClient({ fetchImpl, apiBase, redirectUri }) {
|
|
101
|
+
const response = await fetchImpl(`${apiBase}/oauth/register`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
client_name: 'DistributionOS CLI',
|
|
106
|
+
redirect_uris: [redirectUri],
|
|
107
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
108
|
+
response_types: ['code'],
|
|
109
|
+
token_endpoint_auth_method: 'none',
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
const body = await response.json().catch(() => null);
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error(body?.error_description || body?.error || `Client registration failed with HTTP ${response.status}`);
|
|
115
|
+
}
|
|
116
|
+
return body;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildAuthorizeUrl({ apiBase, appId, clientId, redirectUri, challenge, state }) {
|
|
120
|
+
const url = new URL(`${apiBase}/oauth/authorize`);
|
|
121
|
+
url.searchParams.set('response_type', 'code');
|
|
122
|
+
url.searchParams.set('client_id', clientId);
|
|
123
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
124
|
+
url.searchParams.set('code_challenge', challenge);
|
|
125
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
126
|
+
url.searchParams.set('scope', 'read write');
|
|
127
|
+
url.searchParams.set('resource', `${apiBase}/api/mcp`);
|
|
128
|
+
url.searchParams.set('state', state);
|
|
129
|
+
url.searchParams.set('appId', appId);
|
|
130
|
+
return url.toString();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function exchangeCode({ fetchImpl, apiBase, clientId, redirectUri, verifier, code }) {
|
|
134
|
+
return postForm(fetchImpl, `${apiBase}/oauth/token`, {
|
|
135
|
+
grant_type: 'authorization_code',
|
|
136
|
+
code,
|
|
137
|
+
client_id: clientId,
|
|
138
|
+
redirect_uri: redirectUri,
|
|
139
|
+
code_verifier: verifier,
|
|
140
|
+
resource: `${apiBase}/api/mcp`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function postForm(fetchImpl, url, fields) {
|
|
145
|
+
const response = await fetchImpl(url, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
148
|
+
body: new URLSearchParams(fields),
|
|
149
|
+
});
|
|
150
|
+
const body = await response.json().catch(() => null);
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
throw new Error(body?.error_description || body?.error || `OAuth token request failed with HTTP ${response.status}`);
|
|
153
|
+
}
|
|
154
|
+
return body;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function createCallbackServer() {
|
|
158
|
+
let resolveCallback;
|
|
159
|
+
let rejectCallback;
|
|
160
|
+
const callbackPromise = new Promise((resolve, reject) => {
|
|
161
|
+
resolveCallback = resolve;
|
|
162
|
+
rejectCallback = reject;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const server = http.createServer((request, response) => {
|
|
166
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
167
|
+
if (url.pathname !== '/callback') {
|
|
168
|
+
response.writeHead(404).end('Not found');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const payload = {
|
|
172
|
+
code: url.searchParams.get('code'),
|
|
173
|
+
state: url.searchParams.get('state'),
|
|
174
|
+
error: url.searchParams.get('error'),
|
|
175
|
+
};
|
|
176
|
+
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
177
|
+
response.end('<h1>DistributionOS CLI connected</h1><p>You can return to your terminal.</p>');
|
|
178
|
+
resolveCallback(payload);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
server.on('error', rejectCallback);
|
|
182
|
+
await new Promise((resolve, reject) => {
|
|
183
|
+
server.listen(0, '127.0.0.1', error => {
|
|
184
|
+
if (error) reject(error);
|
|
185
|
+
else resolve();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const address = server.address();
|
|
190
|
+
const timeout = setTimeout(() => {
|
|
191
|
+
rejectCallback(new Error('OAuth login timed out.'));
|
|
192
|
+
}, 5 * 60 * 1000);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
port: address.port,
|
|
196
|
+
waitForCallback: async () => {
|
|
197
|
+
try {
|
|
198
|
+
return await callbackPromise;
|
|
199
|
+
} finally {
|
|
200
|
+
clearTimeout(timeout);
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
close: () => new Promise(resolve => server.close(resolve)),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function openUrl(url) {
|
|
208
|
+
const command = process.platform === 'win32'
|
|
209
|
+
? 'cmd'
|
|
210
|
+
: process.platform === 'darwin'
|
|
211
|
+
? 'open'
|
|
212
|
+
: 'xdg-open';
|
|
213
|
+
const args = process.platform === 'win32'
|
|
214
|
+
? ['/c', 'start', '""', url]
|
|
215
|
+
: [url];
|
|
216
|
+
try {
|
|
217
|
+
const child = spawn(command, args, {
|
|
218
|
+
detached: true,
|
|
219
|
+
stdio: 'ignore',
|
|
220
|
+
windowsHide: true,
|
|
221
|
+
});
|
|
222
|
+
child.unref();
|
|
223
|
+
} catch {
|
|
224
|
+
// The printed URL is the reliable fallback.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function pkceChallenge(verifier) {
|
|
229
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function randomSecret(bytes) {
|
|
233
|
+
return randomBytes(bytes).toString('base64url');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normalizeApiBase(value) {
|
|
237
|
+
return String(value || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
238
|
+
}
|
package/src/plan.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { fetchDistributionOsSetupContext } from './api.js';
|
|
2
|
+
import {
|
|
3
|
+
CLI_PACKAGE_NAME,
|
|
4
|
+
CURRENT_BOOTSTRAP_VERSION,
|
|
5
|
+
DEFAULT_API_BASE,
|
|
6
|
+
} from './constants.js';
|
|
7
|
+
import {
|
|
8
|
+
buildFallbackMcpInstructions,
|
|
9
|
+
buildLocalBootstrapBlock,
|
|
10
|
+
buildSetupCommand,
|
|
11
|
+
getBootstrapAction,
|
|
12
|
+
} from './bootstrap.js';
|
|
13
|
+
import { scanRepo } from './detect.js';
|
|
14
|
+
import {
|
|
15
|
+
buildAnalyticsRouteConfigScript,
|
|
16
|
+
buildAnalyticsRouteConfigSnippet,
|
|
17
|
+
} from './privacy.js';
|
|
18
|
+
|
|
19
|
+
export async function createSetupPlan(options) {
|
|
20
|
+
const cwd = options.cwd;
|
|
21
|
+
const appId = options.appId;
|
|
22
|
+
const repo = await scanRepo(cwd);
|
|
23
|
+
const remote = await fetchDistributionOsSetupContext({
|
|
24
|
+
appId,
|
|
25
|
+
apiBase: options.apiBase ?? DEFAULT_API_BASE,
|
|
26
|
+
env: options.env,
|
|
27
|
+
fetch: options.fetch,
|
|
28
|
+
noFetch: options.noFetch,
|
|
29
|
+
});
|
|
30
|
+
const instructionPackVersion =
|
|
31
|
+
remote.instructions?.instructionPackVersion ?? CURRENT_BOOTSTRAP_VERSION;
|
|
32
|
+
const recommendedBootstrapBlock =
|
|
33
|
+
remote.instructions?.recommendedBootstrapBlock ??
|
|
34
|
+
buildLocalBootstrapBlock(instructionPackVersion, appId);
|
|
35
|
+
const bootstrap = getBootstrapAction(repo.instructionFiles, recommendedBootstrapBlock);
|
|
36
|
+
const analyticsContract = remote.analyticsContract;
|
|
37
|
+
const planRepo = sanitizeRepoForPlan(repo);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
appId,
|
|
41
|
+
cwd,
|
|
42
|
+
mode: options.apply ? 'apply' : 'dry-run',
|
|
43
|
+
packageName: CLI_PACKAGE_NAME,
|
|
44
|
+
futureCommand: buildSetupCommand(appId),
|
|
45
|
+
localCommand: `npm run cli:setup -- --app ${appId}`,
|
|
46
|
+
apiBase: options.apiBase ?? DEFAULT_API_BASE,
|
|
47
|
+
repo: planRepo,
|
|
48
|
+
remote,
|
|
49
|
+
instructionPackVersion,
|
|
50
|
+
bootstrap,
|
|
51
|
+
analytics: buildAnalyticsPlan({ analyticsContract, repo: planRepo, instructionPackVersion }),
|
|
52
|
+
fallbackInstructions: buildFallbackMcpInstructions(appId),
|
|
53
|
+
warnings: buildWarnings({ repo: planRepo, remote }),
|
|
54
|
+
validation: buildValidationPlan(planRepo),
|
|
55
|
+
deploymentChecklist: [
|
|
56
|
+
'Review this plan and any proposed file edits.',
|
|
57
|
+
'Apply reviewed bootstrap and analytics changes only.',
|
|
58
|
+
'Run baseline tests before mutation when the worktree is messy.',
|
|
59
|
+
'Run build and test commands after mutation.',
|
|
60
|
+
'Do not deploy until a human reviews the diff.',
|
|
61
|
+
'After deployment, call verify_analytics_install on representative public URLs.',
|
|
62
|
+
'Call report_implementation for shipped artifacts or analytics opt-outs.',
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sanitizeRepoForPlan(repo) {
|
|
68
|
+
return {
|
|
69
|
+
...repo,
|
|
70
|
+
packageJson: sanitizePackageJson(repo.packageJson),
|
|
71
|
+
instructionFiles: (repo.instructionFiles ?? []).map(file => ({
|
|
72
|
+
path: file.path,
|
|
73
|
+
priority: file.priority,
|
|
74
|
+
exists: file.exists,
|
|
75
|
+
hasManagedDistributionOsBlock: /<!--\s*DISTRIBUTIONOS:START[\s\S]*?DISTRIBUTIONOS:END\s*-->/i.test(file.content ?? ''),
|
|
76
|
+
contentOmitted: true,
|
|
77
|
+
})),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sanitizePackageJson(packageJson) {
|
|
82
|
+
if (!packageJson) return null;
|
|
83
|
+
return {
|
|
84
|
+
name: typeof packageJson.name === 'string' ? packageJson.name : undefined,
|
|
85
|
+
version: typeof packageJson.version === 'string' ? packageJson.version : undefined,
|
|
86
|
+
private: typeof packageJson.private === 'boolean' ? packageJson.private : undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function formatPlanText(plan) {
|
|
91
|
+
const lines = [
|
|
92
|
+
'DistributionOS CLI setup plan',
|
|
93
|
+
`App: ${plan.appId}`,
|
|
94
|
+
plan.mode === 'apply'
|
|
95
|
+
? 'Mode: apply'
|
|
96
|
+
: 'Mode: dry-run (no files changed)',
|
|
97
|
+
`Package: ${plan.packageName}`,
|
|
98
|
+
`Future command: ${plan.futureCommand}`,
|
|
99
|
+
`Local command: ${plan.localCommand}`,
|
|
100
|
+
'',
|
|
101
|
+
'Repository',
|
|
102
|
+
`- Root: ${plan.cwd}`,
|
|
103
|
+
`- Git: ${plan.repo.git.isGitRepo ? (plan.repo.git.dirty ? 'dirty worktree' : 'clean worktree') : 'not detected'}`,
|
|
104
|
+
`- Framework: ${plan.repo.framework}`,
|
|
105
|
+
`- Package manager: ${plan.repo.packageManager}`,
|
|
106
|
+
`- Layout candidates: ${formatList(plan.repo.layoutCandidates)}`,
|
|
107
|
+
`- Content files found: ${plan.repo.contentFiles.length}`,
|
|
108
|
+
`- Deploy hints: ${formatList(plan.repo.deployHints)}`,
|
|
109
|
+
'',
|
|
110
|
+
'DistributionOS access',
|
|
111
|
+
remoteSummary(plan),
|
|
112
|
+
...plan.remote.errors.map(error => `- ${error}`),
|
|
113
|
+
'',
|
|
114
|
+
'Planned changes',
|
|
115
|
+
`1. Agent bootstrap: ${plan.bootstrap.action} ${plan.bootstrap.target}`,
|
|
116
|
+
` ${plan.bootstrap.reason}`,
|
|
117
|
+
`2. Agent instructions: ${plan.remote.instructions ? 'use fetched current instruction pack' : 'use fallback MCP/OAuth instructions until authenticated'}`,
|
|
118
|
+
`3. Analytics: ${analyticsSummary(plan.analytics)}`,
|
|
119
|
+
'',
|
|
120
|
+
'Route privacy review',
|
|
121
|
+
`- Allowed public candidates: ${formatList(plan.repo.routePrivacy.allowed, 8)}`,
|
|
122
|
+
`- Blocked private candidates: ${formatList(plan.repo.routePrivacy.blocked, 8)}`,
|
|
123
|
+
`- Needs review: ${formatList(plan.repo.routePrivacy.review, 8)}`,
|
|
124
|
+
'',
|
|
125
|
+
'Validation',
|
|
126
|
+
`- Build: ${plan.validation.build ?? 'not detected'}`,
|
|
127
|
+
`- Test: ${plan.validation.test ?? 'not detected'}`,
|
|
128
|
+
`- Lint: ${plan.validation.lint ?? 'not detected'}`,
|
|
129
|
+
'',
|
|
130
|
+
'Fallback MCP/OAuth instructions',
|
|
131
|
+
...plan.fallbackInstructions.map(item => `- ${item}`),
|
|
132
|
+
'',
|
|
133
|
+
'Deployment checklist',
|
|
134
|
+
...plan.deploymentChecklist.map(item => `- ${item}`),
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
if (plan.analytics.configSnippet) {
|
|
138
|
+
lines.push('', 'Future-compatible route gate config', plan.analytics.configSnippet);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (plan.warnings.length > 0) {
|
|
142
|
+
lines.push('', 'Warnings', ...plan.warnings.map(item => `- ${item}`));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return `${lines.join('\n')}\n`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildAnalyticsPlan({ analyticsContract, repo, instructionPackVersion }) {
|
|
149
|
+
const layoutTarget = selectAnalyticsLayoutTarget(repo);
|
|
150
|
+
if (!analyticsContract) {
|
|
151
|
+
return {
|
|
152
|
+
status: 'not_fetched',
|
|
153
|
+
scriptTag: null,
|
|
154
|
+
scriptUrl: null,
|
|
155
|
+
layoutTarget,
|
|
156
|
+
contractVersion: null,
|
|
157
|
+
configSnippet: buildAnalyticsRouteConfigSnippet(instructionPackVersion),
|
|
158
|
+
configScript: buildAnalyticsRouteConfigScript(instructionPackVersion),
|
|
159
|
+
notes: [
|
|
160
|
+
'No authenticated analytics contract was fetched.',
|
|
161
|
+
'Create or fetch the tracker in DistributionOS before applying analytics changes.',
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (analyticsContract.status !== 'enabled') {
|
|
167
|
+
return {
|
|
168
|
+
status: analyticsContract.status,
|
|
169
|
+
scriptTag: null,
|
|
170
|
+
scriptUrl: null,
|
|
171
|
+
layoutTarget,
|
|
172
|
+
contractVersion: analyticsContract.version ?? null,
|
|
173
|
+
configSnippet: buildAnalyticsRouteConfigSnippet(analyticsContract.version),
|
|
174
|
+
configScript: buildAnalyticsRouteConfigScript(analyticsContract.version),
|
|
175
|
+
notes: analyticsContract.installInstructions ?? [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
status: 'enabled',
|
|
181
|
+
scriptTag: analyticsContract.scriptTag ?? null,
|
|
182
|
+
scriptUrl: analyticsContract.scriptUrl ?? null,
|
|
183
|
+
layoutTarget,
|
|
184
|
+
contractVersion: analyticsContract.version ?? null,
|
|
185
|
+
configSnippet: buildAnalyticsRouteConfigSnippet(analyticsContract.version),
|
|
186
|
+
configScript: buildAnalyticsRouteConfigScript(analyticsContract.version),
|
|
187
|
+
notes: [
|
|
188
|
+
'Install the fetched script exactly once in the shared public layout.',
|
|
189
|
+
'Preserve clean canonical URLs.',
|
|
190
|
+
'Backfill public blog and landing pages with stable dosContentId markers.',
|
|
191
|
+
'Add data-dos-event and data-dos-link-id only to primary CTAs or campaign links.',
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function selectAnalyticsLayoutTarget(repo) {
|
|
197
|
+
const candidates = repo.layoutCandidates ?? [];
|
|
198
|
+
if (repo.framework === 'next') {
|
|
199
|
+
return candidates.find(file => file.endsWith('app/layout.tsx')) ??
|
|
200
|
+
candidates.find(file => file.endsWith('pages/_app.tsx')) ??
|
|
201
|
+
candidates[0] ??
|
|
202
|
+
null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (['vite', 'vite-react', 'create-react-app', 'react'].includes(repo.framework)) {
|
|
206
|
+
return candidates.find(file => file === 'index.html') ?? candidates[0] ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return candidates[0] ?? null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildWarnings({ repo, remote }) {
|
|
213
|
+
const warnings = [];
|
|
214
|
+
if (repo.git.dirty) {
|
|
215
|
+
warnings.push('Worktree has existing changes. Keep baseline failures separate before applying edits.');
|
|
216
|
+
}
|
|
217
|
+
if (!remote.instructions) {
|
|
218
|
+
warnings.push('Live DistributionOS instructions were not fetched. Connect MCP OAuth or provide a safe env token before applying app-specific setup.');
|
|
219
|
+
}
|
|
220
|
+
if (!remote.analyticsContract) {
|
|
221
|
+
warnings.push('Analytics contract was not fetched. Do not install tracker code from stale/manual instructions.');
|
|
222
|
+
}
|
|
223
|
+
if (repo.routePrivacy.review.length > 0) {
|
|
224
|
+
warnings.push('Some routes need human review before analytics markers are applied.');
|
|
225
|
+
}
|
|
226
|
+
if (repo.framework === 'unknown') {
|
|
227
|
+
warnings.push('Framework was not recognized. Use printed manual steps instead of automatic mutation.');
|
|
228
|
+
}
|
|
229
|
+
return warnings;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildValidationPlan(repo) {
|
|
233
|
+
return {
|
|
234
|
+
build: repo.commands.build,
|
|
235
|
+
test: repo.commands.test,
|
|
236
|
+
lint: repo.commands.lint,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function remoteSummary(plan) {
|
|
241
|
+
if (plan.remote.status === 'fetched') {
|
|
242
|
+
return `- Fetched live instructions and analytics contract using ${plan.remote.tokenName}.`;
|
|
243
|
+
}
|
|
244
|
+
if (plan.remote.status === 'partial') {
|
|
245
|
+
return `- Used ${plan.remote.tokenName}, but one or more DistributionOS requests failed.`;
|
|
246
|
+
}
|
|
247
|
+
return '- No env token found. Dry-run used local fallback instructions and did not call DistributionOS APIs.';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function analyticsSummary(analytics) {
|
|
251
|
+
if (analytics.status === 'enabled') {
|
|
252
|
+
return `contract ${analytics.contractVersion}; install script in ${analytics.layoutTarget ?? 'reviewed shared layout'}`;
|
|
253
|
+
}
|
|
254
|
+
if (analytics.status === 'disabled') {
|
|
255
|
+
return 'tracker is not enabled yet; create tracker in DistributionOS first';
|
|
256
|
+
}
|
|
257
|
+
return 'contract not fetched; print manual guidance only';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function formatList(values, limit = 5) {
|
|
261
|
+
if (!values || values.length === 0) return 'none';
|
|
262
|
+
const shown = values.slice(0, limit).join(', ');
|
|
263
|
+
const remaining = values.length - limit;
|
|
264
|
+
return remaining > 0 ? `${shown}, +${remaining} more` : shown;
|
|
265
|
+
}
|
package/src/privacy.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CURRENT_ANALYTICS_CONTRACT_VERSION,
|
|
3
|
+
PRIVATE_ROUTE_PATTERNS,
|
|
4
|
+
PUBLIC_ROUTE_PATTERNS,
|
|
5
|
+
} from './constants.js';
|
|
6
|
+
|
|
7
|
+
const privateRegexes = PRIVATE_ROUTE_PATTERNS.map(patternToRegex);
|
|
8
|
+
const publicRegexes = PUBLIC_ROUTE_PATTERNS.map(patternToRegex);
|
|
9
|
+
|
|
10
|
+
export function classifyRoute(route) {
|
|
11
|
+
const normalized = normalizeRoute(route);
|
|
12
|
+
if (privateRegexes.some((regex) => regex.test(normalized))) return 'blocked';
|
|
13
|
+
if (publicRegexes.some((regex) => regex.test(normalized))) return 'allowed';
|
|
14
|
+
return 'review';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function groupRoutesByPrivacy(routes) {
|
|
18
|
+
const grouped = {
|
|
19
|
+
allowed: [],
|
|
20
|
+
blocked: [],
|
|
21
|
+
review: [],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
for (const route of routes) {
|
|
25
|
+
grouped[classifyRoute(route)].push(route);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return grouped;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildAnalyticsRouteConfigSnippet(version = CURRENT_ANALYTICS_CONTRACT_VERSION) {
|
|
32
|
+
return [
|
|
33
|
+
'<script>',
|
|
34
|
+
buildAnalyticsRouteConfigScript(version),
|
|
35
|
+
'</script>',
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildAnalyticsRouteConfigScript(version = CURRENT_ANALYTICS_CONTRACT_VERSION) {
|
|
40
|
+
return [
|
|
41
|
+
'window.distributionOSAnalyticsConfig = window.distributionOSAnalyticsConfig || {};',
|
|
42
|
+
'window.distributionOSAnalyticsConfig.contractVersion = ' + JSON.stringify(version) + ';',
|
|
43
|
+
'window.distributionOSAnalyticsConfig.routePrivacy = {',
|
|
44
|
+
` allow: ${JSON.stringify(PUBLIC_ROUTE_PATTERNS)},`,
|
|
45
|
+
` block: ${JSON.stringify(PRIVATE_ROUTE_PATTERNS)}`,
|
|
46
|
+
'};',
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeRoute(route) {
|
|
51
|
+
const trimmed = String(route || '').trim();
|
|
52
|
+
if (!trimmed || trimmed === '/') return '/';
|
|
53
|
+
return `/${trimmed.replace(/^\/+/, '').replace(/\/+$/, '')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function patternToRegex(pattern) {
|
|
57
|
+
if (pattern === '/') return /^\/$/;
|
|
58
|
+
const escaped = pattern
|
|
59
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
60
|
+
.replace(/\/\*\*$/g, '(?:/.*)?');
|
|
61
|
+
return new RegExp(`^${escaped}$`);
|
|
62
|
+
}
|