@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/detect.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { execFile } 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
|
+
import { groupRoutesByPrivacy } from './privacy.js';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
const SKIP_DIRS = new Set([
|
|
10
|
+
'.git',
|
|
11
|
+
'.next',
|
|
12
|
+
'.turbo',
|
|
13
|
+
'.vercel',
|
|
14
|
+
'build',
|
|
15
|
+
'coverage',
|
|
16
|
+
'dist',
|
|
17
|
+
'node_modules',
|
|
18
|
+
'out',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const SKIP_FILE_PATTERNS = [
|
|
22
|
+
/(^|\/)\.env($|\.)/i,
|
|
23
|
+
/(^|\/)id_rsa($|\.)/i,
|
|
24
|
+
/(^|\/)id_dsa($|\.)/i,
|
|
25
|
+
/(^|\/)id_ecdsa($|\.)/i,
|
|
26
|
+
/(^|\/)id_ed25519($|\.)/i,
|
|
27
|
+
/(^|\/)[^/]*(secret|secrets|credential|credentials|private-key|private_key|service-account|serviceAccount|token|tokens)[^/]*$/i,
|
|
28
|
+
/\.(pem|key|p12|pfx)$/i,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const INSTRUCTION_FILE_CANDIDATES = [
|
|
32
|
+
{ path: 'AGENTS.md', priority: 1 },
|
|
33
|
+
{ path: 'CLAUDE.md', priority: 2 },
|
|
34
|
+
{ path: '.cursor/rules/distributionos.md', priority: 3 },
|
|
35
|
+
{ path: '.cursorrules', priority: 4 },
|
|
36
|
+
{ path: 'DEVIN.md', priority: 5 },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export async function scanRepo(cwd) {
|
|
40
|
+
const [packageJson, files, git] = await Promise.all([
|
|
41
|
+
readPackageJson(cwd),
|
|
42
|
+
listFiles(cwd),
|
|
43
|
+
detectGitState(cwd),
|
|
44
|
+
]);
|
|
45
|
+
const framework = detectFramework({ files, packageJson });
|
|
46
|
+
const packageManager = detectPackageManager(files);
|
|
47
|
+
const routes = detectRoutes(files);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
cwd,
|
|
51
|
+
packageJson,
|
|
52
|
+
files,
|
|
53
|
+
git,
|
|
54
|
+
framework,
|
|
55
|
+
packageManager,
|
|
56
|
+
commands: detectCommands(packageJson, packageManager),
|
|
57
|
+
routes,
|
|
58
|
+
routePrivacy: groupRoutesByPrivacy(routes),
|
|
59
|
+
layoutCandidates: detectLayoutCandidates(files),
|
|
60
|
+
instructionFiles: await readInstructionFiles(cwd),
|
|
61
|
+
contentFiles: detectContentFiles(files),
|
|
62
|
+
deployHints: detectDeployHints(files, packageJson),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function pathExists(filePath) {
|
|
67
|
+
try {
|
|
68
|
+
await fs.access(filePath);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readPackageJson(cwd) {
|
|
76
|
+
const filePath = path.join(cwd, 'package.json');
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function detectPackageManager(files) {
|
|
85
|
+
const fileSet = new Set(files);
|
|
86
|
+
if (fileSet.has('pnpm-lock.yaml')) return 'pnpm';
|
|
87
|
+
if (fileSet.has('yarn.lock')) return 'yarn';
|
|
88
|
+
if (fileSet.has('bun.lockb') || fileSet.has('bun.lock')) return 'bun';
|
|
89
|
+
if (fileSet.has('package-lock.json')) return 'npm';
|
|
90
|
+
return 'unknown';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function detectFramework({ files, packageJson }) {
|
|
94
|
+
const deps = {
|
|
95
|
+
...(packageJson?.dependencies ?? {}),
|
|
96
|
+
...(packageJson?.devDependencies ?? {}),
|
|
97
|
+
};
|
|
98
|
+
const fileSet = new Set(files);
|
|
99
|
+
if (deps.next || fileSet.has('next.config.ts') || fileSet.has('next.config.js')) {
|
|
100
|
+
return 'next';
|
|
101
|
+
}
|
|
102
|
+
if (deps.vite || fileSet.has('vite.config.ts') || fileSet.has('vite.config.js')) {
|
|
103
|
+
return deps.react || deps['@vitejs/plugin-react'] ? 'vite-react' : 'vite';
|
|
104
|
+
}
|
|
105
|
+
if (deps['react-scripts']) return 'create-react-app';
|
|
106
|
+
if (deps.react) return 'react';
|
|
107
|
+
return 'unknown';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function detectCommands(packageJson, packageManager) {
|
|
111
|
+
const scripts = packageJson?.scripts ?? {};
|
|
112
|
+
const run = commandName => packageManager === 'unknown'
|
|
113
|
+
? `npm run ${commandName}`
|
|
114
|
+
: `${packageManager} run ${commandName}`;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
build: scripts.build ? run('build') : null,
|
|
118
|
+
test: scripts.test ? run('test') : null,
|
|
119
|
+
lint: scripts.lint ? run('lint') : null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function detectGitState(cwd) {
|
|
124
|
+
try {
|
|
125
|
+
const [{ stdout: root }, { stdout: status }, branchResult] = await Promise.all([
|
|
126
|
+
execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 }),
|
|
127
|
+
execFileAsync('git', ['status', '--short'], { cwd, timeout: 5000 }),
|
|
128
|
+
execFileAsync('git', ['branch', '--show-current'], { cwd, timeout: 5000 }).catch(() => ({ stdout: '' })),
|
|
129
|
+
]);
|
|
130
|
+
const lines = status.split(/\r?\n/).map(line => line.trimEnd()).filter(Boolean);
|
|
131
|
+
return {
|
|
132
|
+
isGitRepo: true,
|
|
133
|
+
root: root.trim(),
|
|
134
|
+
branch: branchResult.stdout.trim() || null,
|
|
135
|
+
dirty: lines.length > 0,
|
|
136
|
+
statusLines: lines,
|
|
137
|
+
};
|
|
138
|
+
} catch {
|
|
139
|
+
return {
|
|
140
|
+
isGitRepo: false,
|
|
141
|
+
root: null,
|
|
142
|
+
branch: null,
|
|
143
|
+
dirty: false,
|
|
144
|
+
statusLines: [],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function listFiles(cwd, maxFiles = 4000) {
|
|
150
|
+
const results = [];
|
|
151
|
+
|
|
152
|
+
async function walk(relativeDir) {
|
|
153
|
+
if (results.length >= maxFiles) return;
|
|
154
|
+
const absoluteDir = path.join(cwd, relativeDir);
|
|
155
|
+
let entries = [];
|
|
156
|
+
try {
|
|
157
|
+
entries = await fs.readdir(absoluteDir, { withFileTypes: true });
|
|
158
|
+
} catch {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
if (results.length >= maxFiles) return;
|
|
164
|
+
const relativePath = toPosix(path.join(relativeDir, entry.name));
|
|
165
|
+
if (entry.isDirectory()) {
|
|
166
|
+
if (!SKIP_DIRS.has(entry.name)) await walk(relativePath);
|
|
167
|
+
} else if (entry.isFile() && !shouldSkipFile(relativePath)) {
|
|
168
|
+
results.push(relativePath);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await walk('');
|
|
174
|
+
return results.sort();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function shouldSkipFile(relativePath) {
|
|
178
|
+
const normalized = toPosix(relativePath);
|
|
179
|
+
return SKIP_FILE_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function detectRoutes(files) {
|
|
183
|
+
const routes = new Set();
|
|
184
|
+
for (const file of files) {
|
|
185
|
+
const appRoute = routeFromAppRouterFile(file);
|
|
186
|
+
if (appRoute) routes.add(appRoute);
|
|
187
|
+
|
|
188
|
+
const pageRoute = routeFromPagesRouterFile(file);
|
|
189
|
+
if (pageRoute) routes.add(pageRoute);
|
|
190
|
+
}
|
|
191
|
+
return [...routes].sort(routeSort);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function routeFromAppRouterFile(file) {
|
|
195
|
+
if (!/(^|\/)page\.(jsx?|tsx?|mdx)$/.test(file)) return null;
|
|
196
|
+
const parts = file.split('/');
|
|
197
|
+
const appIndex = parts[0] === 'app' ? 0 : parts[0] === 'src' && parts[1] === 'app' ? 1 : -1;
|
|
198
|
+
if (appIndex < 0) return null;
|
|
199
|
+
|
|
200
|
+
const routeParts = parts
|
|
201
|
+
.slice(appIndex + 1, -1)
|
|
202
|
+
.filter(segment => segment && !segment.startsWith('(') && !segment.startsWith('@'));
|
|
203
|
+
if (routeParts[0] === 'api') return null;
|
|
204
|
+
return routeParts.length ? `/${routeParts.join('/')}` : '/';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function routeFromPagesRouterFile(file) {
|
|
208
|
+
if (!/(^|\/)[^/]+\.(jsx?|tsx?|mdx)$/.test(file)) return null;
|
|
209
|
+
const parts = file.split('/');
|
|
210
|
+
const pagesIndex = parts[0] === 'pages' ? 0 : parts[0] === 'src' && parts[1] === 'pages' ? 1 : -1;
|
|
211
|
+
if (pagesIndex < 0) return null;
|
|
212
|
+
|
|
213
|
+
const routeParts = parts.slice(pagesIndex + 1);
|
|
214
|
+
if (routeParts[0] === 'api') return null;
|
|
215
|
+
const last = routeParts[routeParts.length - 1].replace(/\.(jsx?|tsx?|mdx)$/, '');
|
|
216
|
+
routeParts[routeParts.length - 1] = last;
|
|
217
|
+
if (routeParts[0]?.startsWith('_')) return null;
|
|
218
|
+
const cleaned = routeParts.filter(segment => segment !== 'index');
|
|
219
|
+
return cleaned.length ? `/${cleaned.join('/')}` : '/';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function detectLayoutCandidates(files) {
|
|
223
|
+
const preferred = [
|
|
224
|
+
'src/app/layout.tsx',
|
|
225
|
+
'app/layout.tsx',
|
|
226
|
+
'src/pages/_app.tsx',
|
|
227
|
+
'pages/_app.tsx',
|
|
228
|
+
'src/main.tsx',
|
|
229
|
+
'src/App.tsx',
|
|
230
|
+
'index.html',
|
|
231
|
+
];
|
|
232
|
+
const fileSet = new Set(files);
|
|
233
|
+
return preferred.filter(file => fileSet.has(file));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function readInstructionFiles(cwd) {
|
|
237
|
+
const files = [];
|
|
238
|
+
for (const candidate of INSTRUCTION_FILE_CANDIDATES) {
|
|
239
|
+
const absolutePath = path.join(cwd, candidate.path);
|
|
240
|
+
const exists = await pathExists(absolutePath);
|
|
241
|
+
files.push({
|
|
242
|
+
...candidate,
|
|
243
|
+
exists,
|
|
244
|
+
content: exists ? await fs.readFile(absolutePath, 'utf8').catch(() => '') : '',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return files.sort((a, b) => {
|
|
248
|
+
if (a.exists !== b.exists) return a.exists ? -1 : 1;
|
|
249
|
+
return a.priority - b.priority;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function detectContentFiles(files) {
|
|
254
|
+
return files.filter(file => (
|
|
255
|
+
/\.(md|mdx)$/.test(file) &&
|
|
256
|
+
/(^|\/)(blog|blogs|posts|articles|content|docs|guides|resources)(\/|$)/.test(file)
|
|
257
|
+
)).slice(0, 50);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function detectDeployHints(files, packageJson) {
|
|
261
|
+
const hints = [];
|
|
262
|
+
const fileSet = new Set(files);
|
|
263
|
+
if (fileSet.has('vercel.json')) hints.push('vercel.json');
|
|
264
|
+
if (fileSet.has('netlify.toml')) hints.push('netlify.toml');
|
|
265
|
+
if (fileSet.has('wrangler.toml')) hints.push('wrangler.toml');
|
|
266
|
+
if (fileSet.has('firebase.json')) hints.push('firebase.json');
|
|
267
|
+
if (packageJson?.scripts?.deploy) hints.push('package.json scripts.deploy');
|
|
268
|
+
return hints;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function routeSort(a, b) {
|
|
272
|
+
if (a === '/') return -1;
|
|
273
|
+
if (b === '/') return 1;
|
|
274
|
+
return a.localeCompare(b);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function toPosix(value) {
|
|
278
|
+
return value.split(path.sep).filter(Boolean).join('/');
|
|
279
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function buildInitializationPayload(plan, options = {}) {
|
|
5
|
+
const appName = titleFromPackageName(plan.repo.packageJson?.name ?? plan.appId);
|
|
6
|
+
const docs = await readProjectDocs(plan.cwd);
|
|
7
|
+
const publicBlogRoute = plan.repo.routes.find(route => route === '/blog' || route.startsWith('/blog/'));
|
|
8
|
+
const claims = [
|
|
9
|
+
{
|
|
10
|
+
category: 'repo',
|
|
11
|
+
claim: `The repo package name is ${plan.repo.packageJson?.name ?? appName}.`,
|
|
12
|
+
confidence: 'high',
|
|
13
|
+
sources: [{ type: 'file', reference: 'package.json' }],
|
|
14
|
+
needsReview: false,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
category: 'stack',
|
|
18
|
+
claim: `The repo appears to use ${plan.repo.framework}.`,
|
|
19
|
+
confidence: plan.repo.framework === 'unknown' ? 'low' : 'high',
|
|
20
|
+
sources: [{ type: 'file', reference: 'package.json' }],
|
|
21
|
+
needsReview: plan.repo.framework === 'unknown',
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
if (publicBlogRoute) {
|
|
26
|
+
claims.push({
|
|
27
|
+
category: 'distribution',
|
|
28
|
+
claim: `The app has a public blog route at ${publicBlogRoute}.`,
|
|
29
|
+
confidence: 'high',
|
|
30
|
+
sources: [{ type: 'file', reference: routeReference(publicBlogRoute, plan.repo.files) }],
|
|
31
|
+
needsReview: false,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (docs.overview) {
|
|
36
|
+
claims.push({
|
|
37
|
+
category: 'positioning',
|
|
38
|
+
claim: docs.overview.claim,
|
|
39
|
+
confidence: 'medium',
|
|
40
|
+
sources: [{ type: 'file', reference: docs.overview.file, excerpt: docs.overview.excerpt }],
|
|
41
|
+
needsReview: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
appId: plan.appId,
|
|
47
|
+
client: 'distributionos-cli',
|
|
48
|
+
instructionPackVersion: plan.instructionPackVersion,
|
|
49
|
+
sourceSummary: 'DistributionOS CLI inspected package metadata, routes, content files, deploy hints, and agent instruction files. It did not read .env files or secrets.',
|
|
50
|
+
appIdentity: {
|
|
51
|
+
name: appName,
|
|
52
|
+
domain: options.domain,
|
|
53
|
+
description: docs.overview?.claim,
|
|
54
|
+
},
|
|
55
|
+
repo: {
|
|
56
|
+
root: path.basename(plan.cwd),
|
|
57
|
+
defaultBranch: plan.repo.git.branch ?? null,
|
|
58
|
+
frameworks: [plan.repo.framework].filter(value => value && value !== 'unknown'),
|
|
59
|
+
languages: ['TypeScript', 'JavaScript'],
|
|
60
|
+
packageManagers: [plan.repo.packageManager].filter(value => value && value !== 'unknown'),
|
|
61
|
+
notableFiles: [
|
|
62
|
+
'package.json',
|
|
63
|
+
...plan.repo.layoutCandidates,
|
|
64
|
+
...plan.repo.contentFiles.slice(0, 12),
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
distribution: {
|
|
68
|
+
blogRoute: publicBlogRoute ?? undefined,
|
|
69
|
+
analytics: plan.analytics.status === 'enabled'
|
|
70
|
+
? ['DistributionOS first-party analytics contract was available during setup.']
|
|
71
|
+
: [],
|
|
72
|
+
},
|
|
73
|
+
claims,
|
|
74
|
+
unknowns: [
|
|
75
|
+
'CLI setup did not verify live traffic, revenue, pricing, or customer claims.',
|
|
76
|
+
...(plan.analytics.status === 'enabled' ? [] : ['Analytics tracker was not enabled or fetched during initialization.']),
|
|
77
|
+
],
|
|
78
|
+
risks: [
|
|
79
|
+
'Review generated Brain Doc positioning before DistributionOS uses this context for public distribution work.',
|
|
80
|
+
],
|
|
81
|
+
nextSteps: [
|
|
82
|
+
'Review the generated Brain Doc in DistributionOS.',
|
|
83
|
+
'Connect Search Console if it is not connected.',
|
|
84
|
+
'Verify analytics after deploying the tracker to a public URL.',
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function submitInitialization({ plan, token, apiBase, fetchImpl, domain }) {
|
|
90
|
+
const payload = await buildInitializationPayload(plan, { domain });
|
|
91
|
+
const response = await fetchImpl(`${apiBase.replace(/\/+$/, '')}/api/v1/apps/${encodeURIComponent(plan.appId)}/onboarding/initialize`, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: `Bearer ${token}`,
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
Accept: 'application/json',
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify(payload),
|
|
99
|
+
});
|
|
100
|
+
const body = await response.json().catch(() => null);
|
|
101
|
+
if (!response.ok || body?.success === false) {
|
|
102
|
+
throw new Error(body?.error || `Initialization submit failed with HTTP ${response.status}`);
|
|
103
|
+
}
|
|
104
|
+
return body?.data ?? body;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readProjectDocs(cwd) {
|
|
108
|
+
for (const file of ['README.md', 'CLAUDE.md', 'AGENTS.md']) {
|
|
109
|
+
const text = await fs.readFile(path.join(cwd, file), 'utf8').catch(() => '');
|
|
110
|
+
const excerpt = extractOverviewExcerpt(text);
|
|
111
|
+
if (excerpt) {
|
|
112
|
+
return {
|
|
113
|
+
overview: {
|
|
114
|
+
file,
|
|
115
|
+
excerpt,
|
|
116
|
+
claim: excerpt.replace(/\s+/g, ' ').slice(0, 500),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractOverviewExcerpt(text) {
|
|
125
|
+
const normalized = text.replace(/\r\n/g, '\n');
|
|
126
|
+
const overviewIndex = normalized.search(/(^|\n)#{1,3}\s+(Project Overview|Overview|Solution|What it does)\s*\n/i);
|
|
127
|
+
const source = overviewIndex >= 0 ? normalized.slice(overviewIndex) : normalized;
|
|
128
|
+
return source
|
|
129
|
+
.split(/\n{2,}/)
|
|
130
|
+
.map(block => block.replace(/^#{1,6}\s+[^\n]+\n?/, '').trim())
|
|
131
|
+
.find(block => block.length >= 80 && !/secret|api key|environment variables/i.test(block))
|
|
132
|
+
?.slice(0, 700) ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function routeReference(route, files) {
|
|
136
|
+
if (route === '/blog' && files.includes('src/app/blog/page.tsx')) return 'src/app/blog/page.tsx';
|
|
137
|
+
if (route.startsWith('/blog/') && files.includes('src/app/blog/[slug]/page.tsx')) return 'src/app/blog/[slug]/page.tsx';
|
|
138
|
+
return route;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function titleFromPackageName(value) {
|
|
142
|
+
return String(value)
|
|
143
|
+
.replace(/^@[^/]+\//, '')
|
|
144
|
+
.split(/[-_\s]+/)
|
|
145
|
+
.filter(Boolean)
|
|
146
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
147
|
+
.join(' ') || 'App';
|
|
148
|
+
}
|
package/src/mutate.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const BOOTSTRAP_BLOCK_RE =
|
|
5
|
+
/<!--\s*DISTRIBUTIONOS:START[\s\S]*?DISTRIBUTIONOS:END\s*-->/i;
|
|
6
|
+
const ANALYTICS_BLOCK_RE =
|
|
7
|
+
/\s*{\s*\/\*\s*DISTRIBUTIONOS:START analytics[\s\S]*?DISTRIBUTIONOS:END analytics\s*\*\/\s*}\s*/i;
|
|
8
|
+
const HTML_ANALYTICS_BLOCK_RE =
|
|
9
|
+
/\s*<!--\s*DISTRIBUTIONOS:START analytics\s*-->[\s\S]*?<!--\s*DISTRIBUTIONOS:END analytics\s*-->\s*/i;
|
|
10
|
+
|
|
11
|
+
export async function applySetupPlan(plan, options = {}) {
|
|
12
|
+
if (plan.repo.git.dirty && !options.allowDirty) {
|
|
13
|
+
throw new Error('Refusing to apply changes to a dirty worktree. Commit/stash changes or pass --allow-dirty.');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const changes = [];
|
|
17
|
+
const bootstrap = await applyBootstrap(plan.cwd, plan.bootstrap);
|
|
18
|
+
if (bootstrap.changed) changes.push(bootstrap);
|
|
19
|
+
|
|
20
|
+
if (!options.skipAnalytics) {
|
|
21
|
+
const analytics = await applyAnalytics(plan.cwd, plan.analytics);
|
|
22
|
+
if (analytics.changed || analytics.reason) changes.push(analytics);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return changes;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function applyBootstrap(cwd, bootstrap) {
|
|
29
|
+
const targetPath = safeResolve(cwd, bootstrap.target);
|
|
30
|
+
const block = bootstrap.recommendedBlock.trim();
|
|
31
|
+
|
|
32
|
+
if (bootstrap.action === 'none') {
|
|
33
|
+
return { type: 'bootstrap', changed: false, file: bootstrap.target, reason: 'Already current.' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (bootstrap.action === 'create') {
|
|
37
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
38
|
+
await fs.writeFile(targetPath, `${block}\n`, 'utf8');
|
|
39
|
+
return { type: 'bootstrap', changed: true, file: bootstrap.target, action: 'created' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const existing = await fs.readFile(targetPath, 'utf8').catch(() => '');
|
|
43
|
+
const next = bootstrap.action === 'update'
|
|
44
|
+
? existing.replace(BOOTSTRAP_BLOCK_RE, block)
|
|
45
|
+
: `${existing.replace(/\s*$/, '\n\n')}${block}\n`;
|
|
46
|
+
|
|
47
|
+
if (next !== existing) {
|
|
48
|
+
await fs.writeFile(targetPath, next, 'utf8');
|
|
49
|
+
return { type: 'bootstrap', changed: true, file: bootstrap.target, action: bootstrap.action };
|
|
50
|
+
}
|
|
51
|
+
return { type: 'bootstrap', changed: false, file: bootstrap.target, reason: 'No text change needed.' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function applyAnalytics(cwd, analytics) {
|
|
55
|
+
if (analytics.status !== 'enabled') {
|
|
56
|
+
return { type: 'analytics', changed: false, file: null, reason: 'Analytics contract is not enabled.' };
|
|
57
|
+
}
|
|
58
|
+
if (!analytics.layoutTarget) {
|
|
59
|
+
return { type: 'analytics', changed: false, file: null, reason: 'No supported shared layout was detected.' };
|
|
60
|
+
}
|
|
61
|
+
const scriptSrc = analytics.scriptUrl ?? scriptSrcFromTag(analytics.scriptTag);
|
|
62
|
+
if (!scriptSrc) {
|
|
63
|
+
return { type: 'analytics', changed: false, file: analytics.layoutTarget, reason: 'Analytics contract did not include a script URL.' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (analytics.layoutTarget.endsWith('app/layout.tsx')) {
|
|
67
|
+
return applyNextAppRouterAnalytics(cwd, analytics, scriptSrc);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (analytics.layoutTarget === 'index.html') {
|
|
71
|
+
return applyHtmlAnalytics(cwd, analytics, scriptSrc);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
type: 'analytics',
|
|
76
|
+
changed: false,
|
|
77
|
+
file: analytics.layoutTarget,
|
|
78
|
+
reason: 'Only Next App Router layout.tsx and index.html are auto-supported in this MVP.',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function applyNextAppRouterAnalytics(cwd, analytics, scriptSrc) {
|
|
83
|
+
const targetPath = safeResolve(cwd, analytics.layoutTarget);
|
|
84
|
+
const existing = await fs.readFile(targetPath, 'utf8');
|
|
85
|
+
if (existing.includes(scriptSrc) && !ANALYTICS_BLOCK_RE.test(existing)) {
|
|
86
|
+
return { type: 'analytics', changed: false, file: analytics.layoutTarget, reason: 'Analytics script URL already exists outside the managed block.' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const block = buildNextAnalyticsBlock({
|
|
90
|
+
scriptSrc,
|
|
91
|
+
contractVersion: analytics.contractVersion,
|
|
92
|
+
configScript: analytics.configScript,
|
|
93
|
+
});
|
|
94
|
+
const next = ANALYTICS_BLOCK_RE.test(existing)
|
|
95
|
+
? existing.replace(ANALYTICS_BLOCK_RE, `\n${block}\n`)
|
|
96
|
+
: insertBeforeHeadClose(existing, block);
|
|
97
|
+
|
|
98
|
+
if (next === existing) {
|
|
99
|
+
return { type: 'analytics', changed: false, file: analytics.layoutTarget, reason: 'No analytics change needed.' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await fs.writeFile(targetPath, next, 'utf8');
|
|
103
|
+
return { type: 'analytics', changed: true, file: analytics.layoutTarget, action: 'installed' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function applyHtmlAnalytics(cwd, analytics, scriptSrc) {
|
|
107
|
+
const targetPath = safeResolve(cwd, analytics.layoutTarget);
|
|
108
|
+
const existing = await fs.readFile(targetPath, 'utf8');
|
|
109
|
+
if (existing.includes(scriptSrc) && !HTML_ANALYTICS_BLOCK_RE.test(existing)) {
|
|
110
|
+
return { type: 'analytics', changed: false, file: analytics.layoutTarget, reason: 'Analytics script URL already exists outside the managed block.' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const block = buildHtmlAnalyticsBlock({
|
|
114
|
+
scriptSrc,
|
|
115
|
+
contractVersion: analytics.contractVersion,
|
|
116
|
+
configScript: analytics.configScript,
|
|
117
|
+
});
|
|
118
|
+
const next = HTML_ANALYTICS_BLOCK_RE.test(existing)
|
|
119
|
+
? existing.replace(HTML_ANALYTICS_BLOCK_RE, `\n${block}\n`)
|
|
120
|
+
: insertBeforeHeadClose(existing, block);
|
|
121
|
+
|
|
122
|
+
if (next === existing) {
|
|
123
|
+
return { type: 'analytics', changed: false, file: analytics.layoutTarget, reason: 'No analytics change needed.' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await fs.writeFile(targetPath, next, 'utf8');
|
|
127
|
+
return { type: 'analytics', changed: true, file: analytics.layoutTarget, action: 'installed' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildNextAnalyticsBlock({ scriptSrc, contractVersion, configScript }) {
|
|
131
|
+
const config = configScript || `window.distributionOSAnalyticsConfig = { contractVersion: ${JSON.stringify(contractVersion)} };`;
|
|
132
|
+
return [
|
|
133
|
+
' {/* DISTRIBUTIONOS:START analytics */}',
|
|
134
|
+
' <script',
|
|
135
|
+
' dangerouslySetInnerHTML={{',
|
|
136
|
+
` __html: ${JSON.stringify(config)},`,
|
|
137
|
+
' }}',
|
|
138
|
+
' />',
|
|
139
|
+
' <script',
|
|
140
|
+
' async',
|
|
141
|
+
` src=${JSON.stringify(scriptSrc)}`,
|
|
142
|
+
' />',
|
|
143
|
+
' {/* DISTRIBUTIONOS:END analytics */}',
|
|
144
|
+
].join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildHtmlAnalyticsBlock({ scriptSrc, contractVersion, configScript }) {
|
|
148
|
+
const config = configScript || `window.distributionOSAnalyticsConfig = { contractVersion: ${JSON.stringify(contractVersion)} };`;
|
|
149
|
+
return [
|
|
150
|
+
' <!-- DISTRIBUTIONOS:START analytics -->',
|
|
151
|
+
' <script>',
|
|
152
|
+
indentLines(config, ' '),
|
|
153
|
+
' </script>',
|
|
154
|
+
` <script async src="${escapeHtmlAttribute(scriptSrc)}"></script>`,
|
|
155
|
+
' <!-- DISTRIBUTIONOS:END analytics -->',
|
|
156
|
+
].join('\n');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function insertBeforeHeadClose(content, block) {
|
|
160
|
+
const headClose = content.match(/^(\s*)<\/head>/im);
|
|
161
|
+
if (headClose?.index === undefined) {
|
|
162
|
+
throw new Error('Could not find </head> in shared layout.');
|
|
163
|
+
}
|
|
164
|
+
return `${content.slice(0, headClose.index)}${block}\n${content.slice(headClose.index)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function indentLines(value, prefix) {
|
|
168
|
+
return String(value).split(/\r?\n/).map(line => `${prefix}${line}`).join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function escapeHtmlAttribute(value) {
|
|
172
|
+
return String(value)
|
|
173
|
+
.replace(/&/g, '&')
|
|
174
|
+
.replace(/"/g, '"')
|
|
175
|
+
.replace(/</g, '<')
|
|
176
|
+
.replace(/>/g, '>');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function scriptSrcFromTag(scriptTag) {
|
|
180
|
+
if (!scriptTag) return null;
|
|
181
|
+
const match = String(scriptTag).match(/\ssrc=["']([^"']+)["']/i);
|
|
182
|
+
return match?.[1] ?? null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function safeResolve(cwd, relativePath) {
|
|
186
|
+
const root = path.resolve(cwd);
|
|
187
|
+
const target = path.resolve(root, relativePath);
|
|
188
|
+
const relative = path.relative(root, target);
|
|
189
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
190
|
+
throw new Error(`Refusing to write outside repo root: ${relativePath}`);
|
|
191
|
+
}
|
|
192
|
+
return target;
|
|
193
|
+
}
|