@distributionos/cli 0.1.10 → 0.1.14
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/package.json +1 -1
- package/src/api.js +38 -2
- package/src/bootstrap.js +4 -3
- package/src/cli.js +145 -63
- package/src/constants.js +1 -0
- package/src/mcp-config.js +187 -0
- package/src/mutate.js +13 -9
- package/src/plan.js +76 -42
- package/src/setup-report.js +222 -0
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -42,7 +42,7 @@ export async function resolveAuthToken({ env, apiBase, appId, fetchImpl }) {
|
|
|
42
42
|
: null;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export async function fetchDistributionOsSetupContext(input) {
|
|
45
|
+
export async function fetchDistributionOsSetupContext(input) {
|
|
46
46
|
const token = await resolveAuthToken({
|
|
47
47
|
env: input.env,
|
|
48
48
|
apiBase: input.apiBase ?? DEFAULT_API_BASE,
|
|
@@ -61,6 +61,7 @@ export async function fetchDistributionOsSetupContext(input) {
|
|
|
61
61
|
usage: null,
|
|
62
62
|
instructions: null,
|
|
63
63
|
analyticsContract: null,
|
|
64
|
+
setupContract: null,
|
|
64
65
|
errors: [],
|
|
65
66
|
};
|
|
66
67
|
}
|
|
@@ -76,7 +77,7 @@ export async function fetchDistributionOsSetupContext(input) {
|
|
|
76
77
|
return null;
|
|
77
78
|
};
|
|
78
79
|
|
|
79
|
-
const [instructions, analyticsContract, usage] = await Promise.all([
|
|
80
|
+
const [instructions, analyticsContract, usage, setupContract] = await Promise.all([
|
|
80
81
|
fetchJson({
|
|
81
82
|
fetchImpl: input.fetch,
|
|
82
83
|
apiBase,
|
|
@@ -95,6 +96,12 @@ export async function fetchDistributionOsSetupContext(input) {
|
|
|
95
96
|
token: token.value,
|
|
96
97
|
path: `/api/v1/apps/${encodeURIComponent(input.appId)}/usage`,
|
|
97
98
|
}).catch(capture('Usage fetch')),
|
|
99
|
+
fetchJson({
|
|
100
|
+
fetchImpl: input.fetch,
|
|
101
|
+
apiBase,
|
|
102
|
+
token: token.value,
|
|
103
|
+
path: `/api/v1/apps/${encodeURIComponent(input.appId)}/setup/contract`,
|
|
104
|
+
}).catch(capture('Setup contract fetch')),
|
|
98
105
|
]);
|
|
99
106
|
|
|
100
107
|
return {
|
|
@@ -108,9 +115,38 @@ export async function fetchDistributionOsSetupContext(input) {
|
|
|
108
115
|
usage,
|
|
109
116
|
instructions,
|
|
110
117
|
analyticsContract,
|
|
118
|
+
setupContract,
|
|
111
119
|
errors,
|
|
112
120
|
};
|
|
113
121
|
}
|
|
122
|
+
|
|
123
|
+
export async function reportSetupInstallation(input) {
|
|
124
|
+
const token = await resolveAuthToken({
|
|
125
|
+
env: input.env,
|
|
126
|
+
apiBase: input.apiBase ?? DEFAULT_API_BASE,
|
|
127
|
+
appId: input.appId,
|
|
128
|
+
fetchImpl: input.fetch,
|
|
129
|
+
});
|
|
130
|
+
if (!token) {
|
|
131
|
+
throw new Error('No stored OAuth token or env token was available.');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const apiBase = normalizeApiBase(input.apiBase ?? DEFAULT_API_BASE);
|
|
135
|
+
const response = await input.fetch(`${apiBase}/api/v1/apps/${encodeURIComponent(input.appId)}/setup/report`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: {
|
|
138
|
+
Authorization: `Bearer ${token.value}`,
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
Accept: 'application/json',
|
|
141
|
+
},
|
|
142
|
+
body: JSON.stringify(input.report),
|
|
143
|
+
});
|
|
144
|
+
const body = await response.json().catch(() => null);
|
|
145
|
+
if (!response.ok || body?.success === false) {
|
|
146
|
+
throw new Error(body?.error || `Setup report failed with HTTP ${response.status}`);
|
|
147
|
+
}
|
|
148
|
+
return body?.data ?? body;
|
|
149
|
+
}
|
|
114
150
|
|
|
115
151
|
export async function ensureAnalyticsSite(input) {
|
|
116
152
|
const token = await resolveAuthToken({
|
package/src/bootstrap.js
CHANGED
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
const BOOTSTRAP_BLOCK_RE =
|
|
8
8
|
/<!--\s*DISTRIBUTIONOS:START[\s\S]*?DISTRIBUTIONOS:END\s*-->/i;
|
|
9
9
|
|
|
10
|
-
export function buildSetupCommand(appId, packageName = CLI_PACKAGE_NAME) {
|
|
11
|
-
|
|
12
|
-
}
|
|
10
|
+
export function buildSetupCommand(appId, packageName = CLI_PACKAGE_NAME) {
|
|
11
|
+
const packageSpec = packageName === CLI_PACKAGE_NAME ? `${packageName}@latest` : packageName;
|
|
12
|
+
return `npx --yes ${packageSpec} setup --app ${shellToken(appId)}`;
|
|
13
|
+
}
|
|
13
14
|
|
|
14
15
|
export function buildLocalBootstrapBlock(version = CURRENT_BOOTSTRAP_VERSION, appId) {
|
|
15
16
|
const appIdAttribute = appId ? ` appId=${appId}` : '';
|
package/src/cli.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { CLI_LOCAL_VERSION, DEFAULT_API_BASE } from './constants.js';
|
|
2
|
-
import {
|
|
3
|
-
ensureAnalyticsSite,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { CLI_LOCAL_VERSION, DEFAULT_API_BASE } from './constants.js';
|
|
2
|
+
import {
|
|
3
|
+
ensureAnalyticsSite,
|
|
4
|
+
reportSetupInstallation,
|
|
5
|
+
reportImplementation,
|
|
6
|
+
resolveAuthToken,
|
|
7
|
+
verifyAnalyticsInstall,
|
|
8
|
+
} from './api.js';
|
|
8
9
|
import { loginWithOAuth } from './oauth.js';
|
|
9
10
|
import { createSetupPlan, formatPlanText } from './plan.js';
|
|
10
11
|
import { applySetupPlan } from './mutate.js';
|
|
11
|
-
import { submitInitialization } from './initialization.js';
|
|
12
|
-
import { compareValidationResults, inspectInstallArtifacts, runValidationPlan } from './validation.js';
|
|
12
|
+
import { submitInitialization } from './initialization.js';
|
|
13
|
+
import { compareValidationResults, inspectInstallArtifacts, runValidationPlan } from './validation.js';
|
|
14
|
+
import { buildSetupReport } from './setup-report.js';
|
|
13
15
|
|
|
14
16
|
export async function runCli(argv, runtime) {
|
|
15
17
|
const stdout = runtime.stdout;
|
|
@@ -139,15 +141,21 @@ export async function runCli(argv, runtime) {
|
|
|
139
141
|
if (!authReady) return 1;
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
let plan = await createSetupPlan({
|
|
143
|
-
appId: args.appId,
|
|
144
|
-
cwd: args.cwd ?? runtime.cwd,
|
|
145
|
-
apiBase: args.apiBase ?? DEFAULT_API_BASE,
|
|
144
|
+
let plan = await createSetupPlan({
|
|
145
|
+
appId: args.appId,
|
|
146
|
+
cwd: args.cwd ?? runtime.cwd,
|
|
147
|
+
apiBase: args.apiBase ?? DEFAULT_API_BASE,
|
|
146
148
|
env: runtime.env,
|
|
147
149
|
fetch: runtime.fetch,
|
|
148
|
-
noFetch: args.noFetch,
|
|
149
|
-
apply: args.apply,
|
|
150
|
-
});
|
|
150
|
+
noFetch: args.noFetch,
|
|
151
|
+
apply: args.apply,
|
|
152
|
+
});
|
|
153
|
+
plan.setupReport = await submitSetupReportSafely({
|
|
154
|
+
args,
|
|
155
|
+
runtime,
|
|
156
|
+
plan,
|
|
157
|
+
phase: 'plan',
|
|
158
|
+
});
|
|
151
159
|
|
|
152
160
|
if (!args.apply && !args.yes) {
|
|
153
161
|
stdout.write(args.json ? `${JSON.stringify(plan, null, 2)}\n` : formatPlanText(plan));
|
|
@@ -185,15 +193,21 @@ export async function runCli(argv, runtime) {
|
|
|
185
193
|
}
|
|
186
194
|
}
|
|
187
195
|
|
|
188
|
-
plan = await createSetupPlan({
|
|
189
|
-
appId: args.appId,
|
|
190
|
-
cwd: args.cwd ?? runtime.cwd,
|
|
191
|
-
apiBase: args.apiBase ?? DEFAULT_API_BASE,
|
|
196
|
+
plan = await createSetupPlan({
|
|
197
|
+
appId: args.appId,
|
|
198
|
+
cwd: args.cwd ?? runtime.cwd,
|
|
199
|
+
apiBase: args.apiBase ?? DEFAULT_API_BASE,
|
|
192
200
|
env: runtime.env,
|
|
193
201
|
fetch: runtime.fetch,
|
|
194
|
-
noFetch: args.noFetch,
|
|
195
|
-
apply: args.apply,
|
|
196
|
-
});
|
|
202
|
+
noFetch: args.noFetch,
|
|
203
|
+
apply: args.apply,
|
|
204
|
+
});
|
|
205
|
+
plan.setupReport = await submitSetupReportSafely({
|
|
206
|
+
args,
|
|
207
|
+
runtime,
|
|
208
|
+
plan,
|
|
209
|
+
phase: args.apply ? 'apply_started' : 'plan',
|
|
210
|
+
});
|
|
197
211
|
|
|
198
212
|
if (!args.apply) {
|
|
199
213
|
stdout.write(args.json ? `${JSON.stringify(plan, null, 2)}\n` : formatPlanText(plan));
|
|
@@ -205,36 +219,99 @@ export async function runCli(argv, runtime) {
|
|
|
205
219
|
const changes = await applySetupPlan(plan, {
|
|
206
220
|
allowDirty: args.allowDirty,
|
|
207
221
|
skipAnalytics: args.skipAnalytics,
|
|
222
|
+
});
|
|
223
|
+
const postValidation = args.skipValidate ? [] : await runValidationPlan(plan);
|
|
224
|
+
const validation = compareValidationResults(baselineValidation, postValidation);
|
|
225
|
+
const inspection = args.skipAnalytics ? [] : await inspectInstallArtifacts(plan);
|
|
226
|
+
const completedPlan = await createSetupPlan({
|
|
227
|
+
appId: args.appId,
|
|
228
|
+
cwd: args.cwd ?? runtime.cwd,
|
|
229
|
+
apiBase: args.apiBase ?? DEFAULT_API_BASE,
|
|
230
|
+
env: runtime.env,
|
|
231
|
+
fetch: runtime.fetch,
|
|
232
|
+
noFetch: args.noFetch,
|
|
233
|
+
apply: true,
|
|
234
|
+
});
|
|
235
|
+
completedPlan.setupReport = await submitSetupReportSafely({
|
|
236
|
+
args,
|
|
237
|
+
runtime,
|
|
238
|
+
plan: completedPlan,
|
|
239
|
+
phase: 'apply_completed',
|
|
240
|
+
details: {
|
|
241
|
+
changes,
|
|
242
|
+
validation,
|
|
243
|
+
inspection,
|
|
244
|
+
requiresAgentRestart: changes.some((change) => change.type === 'mcp' && change.changed),
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
const token = await resolveAuthToken({
|
|
248
|
+
env: runtime.env,
|
|
249
|
+
apiBase: args.apiBase ?? DEFAULT_API_BASE,
|
|
250
|
+
appId: args.appId,
|
|
251
|
+
fetchImpl: runtime.fetch,
|
|
208
252
|
});
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
apiBase: args.apiBase ?? DEFAULT_API_BASE,
|
|
223
|
-
fetchImpl: runtime.fetch,
|
|
224
|
-
domain: args.domain,
|
|
225
|
-
})
|
|
226
|
-
: null;
|
|
227
|
-
|
|
228
|
-
stdout.write(args.json
|
|
229
|
-
? `${JSON.stringify({ plan, changes, initialization, validation, inspection }, null, 2)}\n`
|
|
230
|
-
: formatApplyResult(plan, changes, initialization, validation, inspection, args.skipValidate, args.apiBase ?? DEFAULT_API_BASE));
|
|
253
|
+
const initialization = token
|
|
254
|
+
? await submitInitialization({
|
|
255
|
+
plan: completedPlan,
|
|
256
|
+
token: token.value,
|
|
257
|
+
apiBase: args.apiBase ?? DEFAULT_API_BASE,
|
|
258
|
+
fetchImpl: runtime.fetch,
|
|
259
|
+
domain: args.domain,
|
|
260
|
+
})
|
|
261
|
+
: null;
|
|
262
|
+
|
|
263
|
+
stdout.write(args.json
|
|
264
|
+
? `${JSON.stringify({ plan: completedPlan, changes, initialization, validation, inspection }, null, 2)}\n`
|
|
265
|
+
: formatApplyResult(completedPlan, changes, initialization, validation, inspection, args.skipValidate, args.apiBase ?? DEFAULT_API_BASE));
|
|
231
266
|
return hasBlockingApplyFailure(validation, inspection) ? 1 : 0;
|
|
232
|
-
} catch (error) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
267
|
+
} catch (error) {
|
|
268
|
+
await submitSetupReportSafely({
|
|
269
|
+
args,
|
|
270
|
+
runtime,
|
|
271
|
+
plan,
|
|
272
|
+
phase: 'apply_failed',
|
|
273
|
+
details: {
|
|
274
|
+
applyResult: {
|
|
275
|
+
validation: [{
|
|
276
|
+
name: 'apply',
|
|
277
|
+
status: 'failed',
|
|
278
|
+
}],
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
stderr.write(`${error instanceof Error ? error.message : 'Setup apply failed'}\n`);
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function submitSetupReportSafely({ args, runtime, plan, phase, details = {} }) {
|
|
288
|
+
if (args.noFetch || args.skipSetupReport) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const report = await buildSetupReport(plan, phase, details);
|
|
294
|
+
const result = await reportSetupInstallation({
|
|
295
|
+
appId: args.appId,
|
|
296
|
+
apiBase: args.apiBase ?? DEFAULT_API_BASE,
|
|
297
|
+
env: runtime.env,
|
|
298
|
+
fetch: runtime.fetch,
|
|
299
|
+
report,
|
|
300
|
+
});
|
|
301
|
+
return {
|
|
302
|
+
status: 'submitted',
|
|
303
|
+
phase,
|
|
304
|
+
result,
|
|
305
|
+
};
|
|
306
|
+
} catch (error) {
|
|
307
|
+
return {
|
|
308
|
+
status: 'failed',
|
|
309
|
+
phase,
|
|
310
|
+
error: error instanceof Error ? error.message : 'Setup report failed',
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
238
315
|
function hasBlockingApplyFailure(validation, inspection) {
|
|
239
316
|
return validation.some((result) => result.introducedFailure) ||
|
|
240
317
|
inspection.some((result) => result.status === 'failed');
|
|
@@ -266,10 +343,11 @@ export function parseArgs(rawArgs) {
|
|
|
266
343
|
apply: false,
|
|
267
344
|
allowDirty: false,
|
|
268
345
|
skipAnalytics: false,
|
|
269
|
-
skipValidate: false,
|
|
270
|
-
noOpen: false,
|
|
271
|
-
noLogin: false,
|
|
272
|
-
|
|
346
|
+
skipValidate: false,
|
|
347
|
+
noOpen: false,
|
|
348
|
+
noLogin: false,
|
|
349
|
+
skipSetupReport: false,
|
|
350
|
+
yes: false,
|
|
273
351
|
url: null,
|
|
274
352
|
contentId: null,
|
|
275
353
|
contractVersion: null,
|
|
@@ -301,10 +379,11 @@ export function parseArgs(rawArgs) {
|
|
|
301
379
|
else if (arg === '--apply') parsed.apply = true;
|
|
302
380
|
else if (arg === '--allow-dirty') parsed.allowDirty = true;
|
|
303
381
|
else if (arg === '--skip-analytics') parsed.skipAnalytics = true;
|
|
304
|
-
else if (arg === '--skip-validate') parsed.skipValidate = true;
|
|
305
|
-
else if (arg === '--no-open') parsed.noOpen = true;
|
|
306
|
-
else if (arg === '--no-login') parsed.noLogin = true;
|
|
307
|
-
else if (arg === '--
|
|
382
|
+
else if (arg === '--skip-validate') parsed.skipValidate = true;
|
|
383
|
+
else if (arg === '--no-open') parsed.noOpen = true;
|
|
384
|
+
else if (arg === '--no-login') parsed.noLogin = true;
|
|
385
|
+
else if (arg === '--skip-setup-report') parsed.skipSetupReport = true;
|
|
386
|
+
else if (arg === '--yes' || arg === '-y') parsed.yes = true;
|
|
308
387
|
else if (arg === '--app') parsed.appId = args[++index] ?? null;
|
|
309
388
|
else if (arg.startsWith('--app=')) parsed.appId = arg.slice('--app='.length);
|
|
310
389
|
else if (arg === '--cwd') parsed.cwd = args[++index] ?? null;
|
|
@@ -337,9 +416,9 @@ function helpText() {
|
|
|
337
416
|
return [
|
|
338
417
|
'DistributionOS CLI',
|
|
339
418
|
'',
|
|
340
|
-
'Usage:',
|
|
341
|
-
' distributionos setup --app <appId> [--api-base <url>] [--cwd <path>] [--json] [--no-fetch]',
|
|
342
|
-
' distributionos setup --app <appId> --apply [--domain <domain>] [--allow-dirty]',
|
|
419
|
+
'Usage:',
|
|
420
|
+
' distributionos setup --app <appId> [--api-base <url>] [--cwd <path>] [--json] [--no-fetch]',
|
|
421
|
+
' distributionos setup --app <appId> --apply [--domain <domain>] [--allow-dirty] [--skip-setup-report]',
|
|
343
422
|
' distributionos login --app <appId> [--api-base <url>]',
|
|
344
423
|
' distributionos verify --app <appId> --url <liveUrl> [--content-id <id>]',
|
|
345
424
|
' distributionos report-implementation --app <appId> --artifact <artifactId> [--url <liveUrl>] [--summary <text>]',
|
|
@@ -507,7 +586,10 @@ function formatApplyResult(plan, changes, initialization, validation, inspection
|
|
|
507
586
|
lines.push(
|
|
508
587
|
'- Open this DistributionOS review page now:',
|
|
509
588
|
` ${reviewUrl}`,
|
|
510
|
-
'-
|
|
589
|
+
'- If .mcp.json was created or updated, restart or reconnect your agent session so it can load the DistributionOS MCP server.',
|
|
590
|
+
'- On the review page, copy the verification prompt and ask your agent to run it.',
|
|
591
|
+
'- DistributionOS will wait for a successful app-scoped MCP/API call before you continue to Brain Doc review.',
|
|
592
|
+
'- Verification prompt: "Check DistributionOS connection, fetch the latest agent instructions, then tell me today\'s analytics summary for this app."',
|
|
511
593
|
'- After review, come back to this repo and inspect git diff before committing, pushing, or deploying.',
|
|
512
594
|
);
|
|
513
595
|
} else {
|
package/src/constants.js
CHANGED
|
@@ -8,6 +8,7 @@ export const DEFAULT_API_BASE = 'https://distributionos.dev';
|
|
|
8
8
|
export const MCP_SERVER_URL = 'https://distributionos.dev/api/mcp';
|
|
9
9
|
export const CURRENT_BOOTSTRAP_VERSION = '2026.06.14';
|
|
10
10
|
export const CURRENT_ANALYTICS_CONTRACT_VERSION = '2026.06.14';
|
|
11
|
+
export const CURRENT_SETUP_CONTRACT_VERSION = '2026.06.18';
|
|
11
12
|
|
|
12
13
|
export const TOKEN_ENV_NAMES = [
|
|
13
14
|
'DISTRIBUTIONOS_API_KEY',
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { MCP_SERVER_URL } from './constants.js';
|
|
4
|
+
import { pathExists } from './detect.js';
|
|
5
|
+
|
|
6
|
+
export const PROJECT_MCP_CONFIG_PATH = '.mcp.json';
|
|
7
|
+
export const DISTRIBUTIONOS_MCP_SERVER_NAME = 'distributionos';
|
|
8
|
+
|
|
9
|
+
export async function buildProjectMcpConfigPlan(cwd, serverUrl = MCP_SERVER_URL) {
|
|
10
|
+
const target = safeResolve(cwd, PROJECT_MCP_CONFIG_PATH);
|
|
11
|
+
const base = {
|
|
12
|
+
path: PROJECT_MCP_CONFIG_PATH,
|
|
13
|
+
serverName: DISTRIBUTIONOS_MCP_SERVER_NAME,
|
|
14
|
+
serverUrl,
|
|
15
|
+
authMode: 'oauth',
|
|
16
|
+
requiresRestart: true,
|
|
17
|
+
writesSecrets: false,
|
|
18
|
+
recommendedConfig: recommendedMcpConfig(serverUrl),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (!(await pathExists(target))) {
|
|
22
|
+
return {
|
|
23
|
+
...base,
|
|
24
|
+
supported: true,
|
|
25
|
+
action: 'create',
|
|
26
|
+
reason: 'Create a project-local MCP config with the DistributionOS remote server URL only.',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(await fs.readFile(target, 'utf8'));
|
|
33
|
+
} catch {
|
|
34
|
+
return {
|
|
35
|
+
...base,
|
|
36
|
+
supported: false,
|
|
37
|
+
action: 'manual',
|
|
38
|
+
reason: 'Existing .mcp.json is not valid JSON, so the CLI will not edit it automatically.',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!isRecord(parsed)) {
|
|
43
|
+
return {
|
|
44
|
+
...base,
|
|
45
|
+
supported: false,
|
|
46
|
+
action: 'manual',
|
|
47
|
+
reason: 'Existing .mcp.json is not an object, so the CLI will not edit it automatically.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (containsSecretLikeConfig(parsed)) {
|
|
52
|
+
return {
|
|
53
|
+
...base,
|
|
54
|
+
supported: false,
|
|
55
|
+
action: 'manual',
|
|
56
|
+
reason: 'Existing .mcp.json appears to contain secret headers, tokens, or env values. The CLI will not rewrite it.',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const servers = isRecord(parsed.mcpServers) ? parsed.mcpServers : {};
|
|
61
|
+
const existing = servers[DISTRIBUTIONOS_MCP_SERVER_NAME];
|
|
62
|
+
const existingUrl = isRecord(existing) && typeof existing.url === 'string'
|
|
63
|
+
? existing.url
|
|
64
|
+
: null;
|
|
65
|
+
const existingType = isRecord(existing) && typeof existing.type === 'string'
|
|
66
|
+
? existing.type
|
|
67
|
+
: null;
|
|
68
|
+
|
|
69
|
+
if (existingUrl === serverUrl && existingType === 'http') {
|
|
70
|
+
return {
|
|
71
|
+
...base,
|
|
72
|
+
supported: true,
|
|
73
|
+
action: 'none',
|
|
74
|
+
reason: 'Project-local MCP config already includes the DistributionOS server.',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (existingUrl === serverUrl && !existingType) {
|
|
79
|
+
return {
|
|
80
|
+
...base,
|
|
81
|
+
supported: true,
|
|
82
|
+
action: 'update',
|
|
83
|
+
reason: 'Add the Claude-compatible HTTP transport type to the DistributionOS MCP server entry.',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (existingUrl === serverUrl && existingType !== 'http') {
|
|
88
|
+
return {
|
|
89
|
+
...base,
|
|
90
|
+
supported: false,
|
|
91
|
+
action: 'manual',
|
|
92
|
+
reason: 'Existing distributionos MCP server uses a non-HTTP transport. The CLI will not overwrite it automatically.',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (existingUrl) {
|
|
97
|
+
return {
|
|
98
|
+
...base,
|
|
99
|
+
supported: false,
|
|
100
|
+
action: 'manual',
|
|
101
|
+
reason: 'Existing distributionos MCP server points to a different URL. The CLI will not overwrite it automatically.',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
...base,
|
|
107
|
+
supported: true,
|
|
108
|
+
action: 'update',
|
|
109
|
+
reason: 'Add the DistributionOS remote MCP server to the existing project-local MCP config.',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function applyProjectMcpConfig(cwd, plan) {
|
|
114
|
+
if (!plan || plan.action === 'none') {
|
|
115
|
+
return { type: 'mcp', changed: false, file: plan?.path ?? PROJECT_MCP_CONFIG_PATH, reason: plan?.reason ?? 'No MCP config change planned.' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (plan.action === 'manual' || !plan.supported) {
|
|
119
|
+
return { type: 'mcp', changed: false, file: plan.path, reason: plan.reason };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const target = safeResolve(cwd, plan.path);
|
|
123
|
+
let existing = {};
|
|
124
|
+
if (await pathExists(target)) {
|
|
125
|
+
existing = JSON.parse(await fs.readFile(target, 'utf8'));
|
|
126
|
+
}
|
|
127
|
+
if (!isRecord(existing)) {
|
|
128
|
+
return { type: 'mcp', changed: false, file: plan.path, reason: 'Existing MCP config was not an object.' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const next = {
|
|
132
|
+
...existing,
|
|
133
|
+
mcpServers: {
|
|
134
|
+
...(isRecord(existing.mcpServers) ? existing.mcpServers : {}),
|
|
135
|
+
[DISTRIBUTIONOS_MCP_SERVER_NAME]: {
|
|
136
|
+
type: 'http',
|
|
137
|
+
url: plan.serverUrl ?? MCP_SERVER_URL,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
143
|
+
await fs.writeFile(target, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
144
|
+
return {
|
|
145
|
+
type: 'mcp',
|
|
146
|
+
changed: true,
|
|
147
|
+
file: plan.path,
|
|
148
|
+
action: plan.action === 'create' ? 'created' : 'updated',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function recommendedMcpConfig(serverUrl) {
|
|
153
|
+
return {
|
|
154
|
+
mcpServers: {
|
|
155
|
+
[DISTRIBUTIONOS_MCP_SERVER_NAME]: {
|
|
156
|
+
type: 'http',
|
|
157
|
+
url: serverUrl,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function containsSecretLikeConfig(value) {
|
|
164
|
+
if (Array.isArray(value)) return value.some(containsSecretLikeConfig);
|
|
165
|
+
if (!isRecord(value)) return false;
|
|
166
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
167
|
+
if (/authorization|api[-_]?key|token|secret|password|credential|headers|env/i.test(key)) {
|
|
168
|
+
if (nested !== null && nested !== undefined && nested !== '') return true;
|
|
169
|
+
}
|
|
170
|
+
if (containsSecretLikeConfig(nested)) return true;
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isRecord(value) {
|
|
176
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function safeResolve(cwd, relativePath) {
|
|
180
|
+
const root = path.resolve(cwd);
|
|
181
|
+
const target = path.resolve(root, relativePath);
|
|
182
|
+
const relative = path.relative(root, target);
|
|
183
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
184
|
+
throw new Error(`Refusing to write outside repo root: ${relativePath}`);
|
|
185
|
+
}
|
|
186
|
+
return target;
|
|
187
|
+
}
|
package/src/mutate.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { applyProjectMcpConfig } from './mcp-config.js';
|
|
3
4
|
|
|
4
5
|
const BOOTSTRAP_BLOCK_RE =
|
|
5
6
|
/<!--\s*DISTRIBUTIONOS:START[\s\S]*?DISTRIBUTIONOS:END\s*-->/i;
|
|
@@ -17,13 +18,16 @@ export async function applySetupPlan(plan, options = {}) {
|
|
|
17
18
|
const bootstrap = await applyBootstrap(plan.cwd, plan.bootstrap);
|
|
18
19
|
if (bootstrap.changed) changes.push(bootstrap);
|
|
19
20
|
|
|
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
|
-
|
|
26
|
-
|
|
21
|
+
if (!options.skipAnalytics) {
|
|
22
|
+
const analytics = await applyAnalytics(plan.cwd, plan.analytics);
|
|
23
|
+
if (analytics.changed || analytics.reason) changes.push(analytics);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mcp = await applyProjectMcpConfig(plan.cwd, plan.mcp);
|
|
27
|
+
if (mcp.changed || mcp.reason) changes.push(mcp);
|
|
28
|
+
|
|
29
|
+
return changes;
|
|
30
|
+
}
|
|
27
31
|
|
|
28
32
|
async function applyBootstrap(cwd, bootstrap) {
|
|
29
33
|
const targetPath = safeResolve(cwd, bootstrap.target);
|
package/src/plan.js
CHANGED
|
@@ -10,11 +10,16 @@ import {
|
|
|
10
10
|
buildSetupCommand,
|
|
11
11
|
getBootstrapAction,
|
|
12
12
|
} from './bootstrap.js';
|
|
13
|
-
import { scanRepo } from './detect.js';
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
import { scanRepo } from './detect.js';
|
|
14
|
+
import { buildProjectMcpConfigPlan } from './mcp-config.js';
|
|
15
|
+
import {
|
|
16
|
+
buildAnalyticsRouteConfigScript,
|
|
17
|
+
buildAnalyticsRouteConfigSnippet,
|
|
18
|
+
} from './privacy.js';
|
|
19
|
+
import {
|
|
20
|
+
extractBootstrapMetadata,
|
|
21
|
+
formatSetupReportSummary,
|
|
22
|
+
} from './setup-report.js';
|
|
18
23
|
|
|
19
24
|
export async function createSetupPlan(options) {
|
|
20
25
|
const cwd = options.cwd;
|
|
@@ -33,10 +38,11 @@ export async function createSetupPlan(options) {
|
|
|
33
38
|
remote.instructions?.recommendedBootstrapBlock ??
|
|
34
39
|
buildLocalBootstrapBlock(instructionPackVersion, appId);
|
|
35
40
|
const bootstrap = getBootstrapAction(repo.instructionFiles, recommendedBootstrapBlock);
|
|
36
|
-
const analyticsContract = remote.analyticsContract;
|
|
37
|
-
const planRepo = sanitizeRepoForPlan(repo);
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
const analyticsContract = remote.analyticsContract;
|
|
42
|
+
const planRepo = sanitizeRepoForPlan(repo);
|
|
43
|
+
const mcp = await buildProjectMcpConfigPlan(cwd);
|
|
44
|
+
|
|
45
|
+
return {
|
|
40
46
|
appId,
|
|
41
47
|
cwd,
|
|
42
48
|
mode: options.apply ? 'apply' : 'review',
|
|
@@ -47,35 +53,41 @@ export async function createSetupPlan(options) {
|
|
|
47
53
|
repo: planRepo,
|
|
48
54
|
remote,
|
|
49
55
|
instructionPackVersion,
|
|
50
|
-
bootstrap,
|
|
51
|
-
|
|
56
|
+
bootstrap,
|
|
57
|
+
mcp,
|
|
58
|
+
analytics: buildAnalyticsPlan({ analyticsContract, repo: planRepo, instructionPackVersion }),
|
|
52
59
|
fallbackInstructions: buildFallbackMcpInstructions(appId),
|
|
53
60
|
warnings: buildWarnings({ repo: planRepo, remote }),
|
|
54
61
|
validation: buildValidationPlan(planRepo),
|
|
55
|
-
deploymentChecklist: [
|
|
56
|
-
'Review this plan and any proposed file edits.',
|
|
57
|
-
'Apply reviewed bootstrap and
|
|
58
|
-
'
|
|
62
|
+
deploymentChecklist: [
|
|
63
|
+
'Review this plan and any proposed file edits.',
|
|
64
|
+
'Apply reviewed bootstrap, analytics, and non-secret project MCP config changes only.',
|
|
65
|
+
'Restart or reconnect your agent if the project-local MCP config changed.',
|
|
66
|
+
'Return to the DistributionOS onboarding page and verify the agent MCP connection before reviewing generated context.',
|
|
67
|
+
'Run baseline tests before mutation when the worktree is messy.',
|
|
59
68
|
'Run build and test commands after mutation.',
|
|
60
69
|
'Do not deploy until a human reviews the diff.',
|
|
61
70
|
'After deployment, call verify_analytics_install on representative public URLs.',
|
|
62
|
-
'Call report_implementation for shipped artifacts or analytics opt-outs.',
|
|
63
|
-
],
|
|
64
|
-
|
|
65
|
-
}
|
|
71
|
+
'Call report_implementation for shipped artifacts or analytics opt-outs.',
|
|
72
|
+
],
|
|
73
|
+
setupReport: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
66
76
|
|
|
67
77
|
function sanitizeRepoForPlan(repo) {
|
|
68
78
|
return {
|
|
69
79
|
...repo,
|
|
70
80
|
packageJson: sanitizePackageJson(repo.packageJson),
|
|
71
|
-
instructionFiles: (repo.instructionFiles ?? []).map(file => ({
|
|
72
|
-
path: file.path,
|
|
73
|
-
priority: file.priority,
|
|
74
|
-
exists: file.exists,
|
|
75
|
-
hasManagedDistributionOsBlock:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
instructionFiles: (repo.instructionFiles ?? []).map(file => ({
|
|
82
|
+
path: file.path,
|
|
83
|
+
priority: file.priority,
|
|
84
|
+
exists: file.exists,
|
|
85
|
+
hasManagedDistributionOsBlock: extractBootstrapMetadata(file.content).hasManagedDistributionOsBlock,
|
|
86
|
+
managedDistributionOsVersion: extractBootstrapMetadata(file.content).version,
|
|
87
|
+
managedDistributionOsAppId: extractBootstrapMetadata(file.content).appId,
|
|
88
|
+
contentOmitted: true,
|
|
89
|
+
})),
|
|
90
|
+
};
|
|
79
91
|
}
|
|
80
92
|
|
|
81
93
|
function sanitizePackageJson(packageJson) {
|
|
@@ -113,11 +125,12 @@ export function formatPlanText(plan) {
|
|
|
113
125
|
...plan.remote.errors.map(error => `- ${error}`),
|
|
114
126
|
'',
|
|
115
127
|
'Planned changes',
|
|
116
|
-
`1. Agent bootstrap: ${plan.bootstrap.action} ${plan.bootstrap.target}`,
|
|
117
|
-
` ${plan.bootstrap.reason}`,
|
|
118
|
-
`2. Agent instructions: ${plan.remote.instructions ? 'use fetched current instruction pack' : 'use fallback MCP/OAuth instructions until authenticated'}`,
|
|
119
|
-
`3. Analytics: ${analyticsSummary(plan.analytics)}`,
|
|
120
|
-
|
|
128
|
+
`1. Agent bootstrap: ${plan.bootstrap.action} ${plan.bootstrap.target}`,
|
|
129
|
+
` ${plan.bootstrap.reason}`,
|
|
130
|
+
`2. Agent instructions: ${plan.remote.instructions ? 'use fetched current instruction pack' : 'use fallback MCP/OAuth instructions until authenticated'}`,
|
|
131
|
+
`3. Analytics: ${analyticsSummary(plan.analytics)}`,
|
|
132
|
+
`4. Agent MCP config: ${mcpSummary(plan.mcp)}`,
|
|
133
|
+
'',
|
|
121
134
|
'Route privacy review',
|
|
122
135
|
`- Allowed public candidates: ${formatList(plan.repo.routePrivacy.allowed, 8)}`,
|
|
123
136
|
`- Blocked private candidates: ${formatList(plan.repo.routePrivacy.blocked, 8)}`,
|
|
@@ -128,17 +141,29 @@ export function formatPlanText(plan) {
|
|
|
128
141
|
`- Test: ${plan.validation.test ?? 'not detected'}`,
|
|
129
142
|
`- Lint: ${plan.validation.lint ?? 'not detected'}`,
|
|
130
143
|
'',
|
|
131
|
-
'Fallback MCP/OAuth instructions',
|
|
132
|
-
...plan.fallbackInstructions.map(item => `- ${item}`),
|
|
133
|
-
'',
|
|
144
|
+
'Fallback MCP/OAuth instructions',
|
|
145
|
+
...plan.fallbackInstructions.map(item => `- ${item}`),
|
|
146
|
+
'',
|
|
147
|
+
'Agent live-data connection',
|
|
148
|
+
`- Project config: ${plan.mcp.path}`,
|
|
149
|
+
`- Planned action: ${plan.mcp.action}`,
|
|
150
|
+
`- Auth mode: ${plan.mcp.authMode}; no API keys or OAuth tokens are written.`,
|
|
151
|
+
`- ${plan.mcp.reason}`,
|
|
152
|
+
'- Verification is separate from install: after apply, restart or reconnect your agent and ask it to call check_distributionos_connection.',
|
|
153
|
+
'',
|
|
134
154
|
'Deployment checklist',
|
|
135
155
|
...plan.deploymentChecklist.map(item => `- ${item}`),
|
|
136
156
|
];
|
|
137
157
|
|
|
138
|
-
if (plan.analytics.configSnippet) {
|
|
139
|
-
lines.push('', 'Future-compatible route gate config', plan.analytics.configSnippet);
|
|
140
|
-
}
|
|
141
|
-
|
|
158
|
+
if (plan.analytics.configSnippet) {
|
|
159
|
+
lines.push('', 'Future-compatible route gate config', plan.analytics.configSnippet);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const setupReportLines = formatSetupReportSummary(plan.setupReport);
|
|
163
|
+
if (setupReportLines.length > 0) {
|
|
164
|
+
lines.push('', ...setupReportLines);
|
|
165
|
+
}
|
|
166
|
+
|
|
142
167
|
if (plan.warnings.length > 0) {
|
|
143
168
|
lines.push('', 'Warnings', ...plan.warnings.map(item => `- ${item}`));
|
|
144
169
|
}
|
|
@@ -175,7 +200,8 @@ function nextStepLines(plan) {
|
|
|
175
200
|
|
|
176
201
|
lines.push(
|
|
177
202
|
'',
|
|
178
|
-
'DistributionOS expects to edit only the managed agent instruction block and
|
|
203
|
+
'DistributionOS expects to edit only the managed agent instruction block, supported analytics layout, and non-secret project MCP config. Review git diff before committing or deploying.',
|
|
204
|
+
'After apply, return to the DistributionOS onboarding page. It will wait for a real MCP/API call before letting you continue.',
|
|
179
205
|
);
|
|
180
206
|
|
|
181
207
|
return lines;
|
|
@@ -307,7 +333,7 @@ function meterText(value) {
|
|
|
307
333
|
return `${value.used ?? 0}/${limit}`;
|
|
308
334
|
}
|
|
309
335
|
|
|
310
|
-
function analyticsSummary(analytics) {
|
|
336
|
+
function analyticsSummary(analytics) {
|
|
311
337
|
if (analytics.status === 'enabled') {
|
|
312
338
|
return `contract ${analytics.contractVersion}; install script in ${analytics.layoutTarget ?? 'reviewed shared layout'}`;
|
|
313
339
|
}
|
|
@@ -315,7 +341,15 @@ function analyticsSummary(analytics) {
|
|
|
315
341
|
return 'tracker will be created or fetched during apply before analytics is installed';
|
|
316
342
|
}
|
|
317
343
|
return 'contract not fetched; print manual guidance only';
|
|
318
|
-
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function mcpSummary(mcp) {
|
|
347
|
+
if (!mcp) return 'manual instructions only';
|
|
348
|
+
if (mcp.action === 'none') return `${mcp.path} already includes DistributionOS`;
|
|
349
|
+
if (mcp.action === 'create') return `create ${mcp.path} with DistributionOS MCP server URL`;
|
|
350
|
+
if (mcp.action === 'update') return `update ${mcp.path} with DistributionOS MCP server URL`;
|
|
351
|
+
return `manual setup needed for ${mcp.path}`;
|
|
352
|
+
}
|
|
319
353
|
|
|
320
354
|
function formatList(values, limit = 5) {
|
|
321
355
|
if (!values || values.length === 0) return 'none';
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
CLI_LOCAL_VERSION,
|
|
5
|
+
CURRENT_ANALYTICS_CONTRACT_VERSION,
|
|
6
|
+
CURRENT_BOOTSTRAP_VERSION,
|
|
7
|
+
CURRENT_SETUP_CONTRACT_VERSION,
|
|
8
|
+
MCP_SERVER_URL,
|
|
9
|
+
} from './constants.js';
|
|
10
|
+
|
|
11
|
+
const BOOTSTRAP_BLOCK_RE =
|
|
12
|
+
/<!--\s*DISTRIBUTIONOS:START([^>]*)-->[\s\S]*?<!--\s*DISTRIBUTIONOS:END\s*-->/i;
|
|
13
|
+
const ANALYTICS_BLOCK_RE =
|
|
14
|
+
/DISTRIBUTIONOS:START analytics[\s\S]*?DISTRIBUTIONOS:END analytics/i;
|
|
15
|
+
|
|
16
|
+
export async function buildSetupReport(plan, phase = 'plan', details = {}) {
|
|
17
|
+
const detectedCapabilities = new Set([
|
|
18
|
+
'analytics.verificationReady',
|
|
19
|
+
'setup.reporting',
|
|
20
|
+
]);
|
|
21
|
+
const managedFiles = [];
|
|
22
|
+
let blockedReason = null;
|
|
23
|
+
|
|
24
|
+
const bootstrapVersion = detectBootstrapVersion(plan);
|
|
25
|
+
if (bootstrapVersion && plan.bootstrap.action === 'none') {
|
|
26
|
+
detectedCapabilities.add('agent.bootstrap');
|
|
27
|
+
detectedCapabilities.add('agent.liveInstructions');
|
|
28
|
+
}
|
|
29
|
+
managedFiles.push({
|
|
30
|
+
path: plan.bootstrap.target,
|
|
31
|
+
type: 'agent.bootstrap',
|
|
32
|
+
action: plan.bootstrap.action,
|
|
33
|
+
status: plan.bootstrap.action === 'none' ? 'current' : 'planned',
|
|
34
|
+
reason: plan.bootstrap.reason,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (plan.mcp?.action === 'none') {
|
|
38
|
+
detectedCapabilities.add('agent.mcp.remoteConfig');
|
|
39
|
+
} else if (plan.mcp?.action === 'manual') {
|
|
40
|
+
blockedReason = plan.mcp.reason;
|
|
41
|
+
}
|
|
42
|
+
if (plan.mcp) {
|
|
43
|
+
managedFiles.push({
|
|
44
|
+
path: plan.mcp.path,
|
|
45
|
+
type: 'agent.mcp.remoteConfig',
|
|
46
|
+
action: plan.mcp.action,
|
|
47
|
+
status: plan.mcp.action === 'none' ? 'current' : plan.mcp.action === 'manual' ? 'blocked_manual' : 'planned',
|
|
48
|
+
reason: plan.mcp.reason,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const analytics = await inspectAnalyticsInstall(plan);
|
|
53
|
+
if (analytics.publicTracker) detectedCapabilities.add('analytics.publicTracker');
|
|
54
|
+
if (analytics.routePrivacy) detectedCapabilities.add('analytics.routePrivacy');
|
|
55
|
+
if (plan.analytics.layoutTarget) {
|
|
56
|
+
managedFiles.push({
|
|
57
|
+
path: plan.analytics.layoutTarget,
|
|
58
|
+
type: 'analytics.publicTracker',
|
|
59
|
+
action: analytics.publicTracker && analytics.routePrivacy ? 'none' : 'update',
|
|
60
|
+
status: analytics.publicTracker && analytics.routePrivacy ? 'current' : 'planned',
|
|
61
|
+
reason: analytics.reason,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (plan.remote?.tokenName && /oauth/i.test(plan.remote.tokenName)) {
|
|
66
|
+
detectedCapabilities.add('agent.oauthReady');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const applyResult = details.applyResult ?? buildApplyResult(details);
|
|
70
|
+
return {
|
|
71
|
+
phase,
|
|
72
|
+
cliVersion: CLI_LOCAL_VERSION,
|
|
73
|
+
localContractVersion: CURRENT_SETUP_CONTRACT_VERSION,
|
|
74
|
+
detectedCapabilities: [...detectedCapabilities].sort(),
|
|
75
|
+
...(blockedReason ? { blockedReason } : {}),
|
|
76
|
+
requiresAgentRestart: Boolean(details.requiresAgentRestart),
|
|
77
|
+
managedFiles: managedFiles.map(sanitizeManagedFile),
|
|
78
|
+
repo: {
|
|
79
|
+
framework: plan.repo.framework,
|
|
80
|
+
packageManager: plan.repo.packageManager,
|
|
81
|
+
layoutTarget: plan.analytics.layoutTarget ?? null,
|
|
82
|
+
routeCounts: {
|
|
83
|
+
allowed: plan.repo.routePrivacy.allowed.length,
|
|
84
|
+
blocked: plan.repo.routePrivacy.blocked.length,
|
|
85
|
+
review: plan.repo.routePrivacy.review.length,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
...(applyResult ? { applyResult } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatSetupReportSummary(setupReport) {
|
|
93
|
+
if (!setupReport) return [];
|
|
94
|
+
const localSetup = setupReport.result?.localSetup ?? null;
|
|
95
|
+
const lines = [
|
|
96
|
+
'Local setup contract',
|
|
97
|
+
`- CLI version: ${CLI_LOCAL_VERSION}`,
|
|
98
|
+
`- Local contract: ${CURRENT_SETUP_CONTRACT_VERSION}`,
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
if (setupReport.status === 'skipped') {
|
|
102
|
+
lines.push('- Server report: skipped by --skip-setup-report');
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (setupReport.status === 'failed') {
|
|
107
|
+
lines.push(`- Server report: failed (${setupReport.error})`);
|
|
108
|
+
lines.push('- Local file changes are still reviewable; DistributionOS may not show the latest install state until reporting succeeds.');
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!localSetup) {
|
|
113
|
+
lines.push('- Server report: pending');
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
lines.push(`- Server status: ${localSetup.status}`);
|
|
118
|
+
lines.push(`- Missing capabilities: ${formatList(localSetup.missingCapabilities)}`);
|
|
119
|
+
if (localSetup.status !== 'current') {
|
|
120
|
+
lines.push(`- Review command: ${localSetup.updateCommand}`);
|
|
121
|
+
lines.push(`- Apply command: ${localSetup.applyCommand}`);
|
|
122
|
+
}
|
|
123
|
+
if (localSetup.requiresAgentRestart) {
|
|
124
|
+
lines.push('- Agent restart: required after apply so MCP changes load.');
|
|
125
|
+
}
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function detectBootstrapVersion(plan) {
|
|
130
|
+
const current = (plan.repo.instructionFiles ?? []).find((file) => file.hasManagedDistributionOsBlock);
|
|
131
|
+
return current?.managedDistributionOsVersion ??
|
|
132
|
+
(plan.bootstrap.action === 'none' ? CURRENT_BOOTSTRAP_VERSION : null);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function inspectAnalyticsInstall(plan) {
|
|
136
|
+
const target = plan.analytics.layoutTarget;
|
|
137
|
+
if (!target) {
|
|
138
|
+
return {
|
|
139
|
+
publicTracker: false,
|
|
140
|
+
routePrivacy: false,
|
|
141
|
+
reason: 'No supported shared analytics layout was detected.',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let content = '';
|
|
146
|
+
try {
|
|
147
|
+
content = await fs.readFile(path.join(plan.cwd, target), 'utf8');
|
|
148
|
+
} catch {
|
|
149
|
+
return {
|
|
150
|
+
publicTracker: false,
|
|
151
|
+
routePrivacy: false,
|
|
152
|
+
reason: 'Analytics layout could not be read.',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const managed = ANALYTICS_BLOCK_RE.test(content);
|
|
157
|
+
const publicTracker = managed && content.includes(`${new URL(MCP_SERVER_URL).origin}/api/analytics/script/`);
|
|
158
|
+
const contractVersion = plan.analytics.contractVersion || CURRENT_ANALYTICS_CONTRACT_VERSION;
|
|
159
|
+
const routePrivacy =
|
|
160
|
+
managed &&
|
|
161
|
+
content.includes('distributionOSAnalyticsConfig') &&
|
|
162
|
+
content.includes(contractVersion) &&
|
|
163
|
+
content.includes('/workflow/**') &&
|
|
164
|
+
content.includes('/workflows/**');
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
publicTracker,
|
|
168
|
+
routePrivacy,
|
|
169
|
+
reason: publicTracker && routePrivacy
|
|
170
|
+
? 'Managed analytics install is current.'
|
|
171
|
+
: 'Managed analytics install is missing or stale.',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildApplyResult(details) {
|
|
176
|
+
if (!details.changes && !details.validation && !details.inspection) return null;
|
|
177
|
+
return {
|
|
178
|
+
changedFiles: [...new Set((details.changes ?? [])
|
|
179
|
+
.filter((change) => change.changed && change.file)
|
|
180
|
+
.map((change) => change.file))],
|
|
181
|
+
validation: (details.validation ?? []).map((result) => ({
|
|
182
|
+
name: result.name,
|
|
183
|
+
status: result.exitCode === 0
|
|
184
|
+
? 'passed'
|
|
185
|
+
: result.introducedFailure
|
|
186
|
+
? 'failed_after_cli_changes'
|
|
187
|
+
: result.preExistingFailure
|
|
188
|
+
? 'pre_existing_failure'
|
|
189
|
+
: 'failed',
|
|
190
|
+
})),
|
|
191
|
+
inspection: (details.inspection ?? []).map((result) => ({
|
|
192
|
+
name: result.name,
|
|
193
|
+
status: result.status,
|
|
194
|
+
})),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function sanitizeManagedFile(file) {
|
|
199
|
+
return {
|
|
200
|
+
path: file.path,
|
|
201
|
+
type: file.type,
|
|
202
|
+
action: file.action,
|
|
203
|
+
status: file.status,
|
|
204
|
+
reason: file.reason,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatList(values) {
|
|
209
|
+
if (!Array.isArray(values) || values.length === 0) return 'none';
|
|
210
|
+
return values.join(', ');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function extractBootstrapMetadata(content) {
|
|
214
|
+
const match = String(content ?? '').match(BOOTSTRAP_BLOCK_RE);
|
|
215
|
+
if (!match) return { hasManagedDistributionOsBlock: false, version: null, appId: null };
|
|
216
|
+
const attrs = match[1] ?? '';
|
|
217
|
+
return {
|
|
218
|
+
hasManagedDistributionOsBlock: true,
|
|
219
|
+
version: attrs.match(/\bversion=([^\s>]+)/i)?.[1] ?? null,
|
|
220
|
+
appId: attrs.match(/\bappId=([^\s>]+)/i)?.[1] ?? null,
|
|
221
|
+
};
|
|
222
|
+
}
|