@distributionos/cli 0.1.11 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@distributionos/cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.14",
4
4
  "description": "DistributionOS repo setup CLI for agent onboarding and first-party analytics.",
5
5
  "homepage": "https://distributionos.dev",
6
6
  "repository": {
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
- return `npx ${packageName} setup --app ${shellToken(appId)}`;
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
- reportImplementation,
5
- resolveAuthToken,
6
- verifyAnalyticsInstall,
7
- } from './api.js';
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 postValidation = args.skipValidate ? [] : await runValidationPlan(plan);
210
- const validation = compareValidationResults(baselineValidation, postValidation);
211
- const inspection = args.skipAnalytics ? [] : await inspectInstallArtifacts(plan);
212
- const token = await resolveAuthToken({
213
- env: runtime.env,
214
- apiBase: args.apiBase ?? DEFAULT_API_BASE,
215
- appId: args.appId,
216
- fetchImpl: runtime.fetch,
217
- });
218
- const initialization = token
219
- ? await submitInitialization({
220
- plan,
221
- token: token.value,
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
- stderr.write(`${error instanceof Error ? error.message : 'Setup apply failed'}\n`);
234
- return 1;
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
- yes: false,
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 === '--yes' || arg === '-y') parsed.yes = true;
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,9 +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
- '- Review the generated Brain Doc and Brain Vault context before asking your agent to keep working.',
511
589
  '- If .mcp.json was created or updated, restart or reconnect your agent session so it can load the DistributionOS MCP server.',
512
- '- After reconnecting, ask your agent: "Check DistributionOS connection and tell me today\'s analytics summary."',
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."',
513
593
  '- After review, come back to this repo and inspect git diff before committing, pushing, or deploying.',
514
594
  );
515
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',
package/src/mcp-config.js CHANGED
@@ -62,8 +62,11 @@ export async function buildProjectMcpConfigPlan(cwd, serverUrl = MCP_SERVER_URL)
62
62
  const existingUrl = isRecord(existing) && typeof existing.url === 'string'
63
63
  ? existing.url
64
64
  : null;
65
+ const existingType = isRecord(existing) && typeof existing.type === 'string'
66
+ ? existing.type
67
+ : null;
65
68
 
66
- if (existingUrl === serverUrl) {
69
+ if (existingUrl === serverUrl && existingType === 'http') {
67
70
  return {
68
71
  ...base,
69
72
  supported: true,
@@ -72,6 +75,24 @@ export async function buildProjectMcpConfigPlan(cwd, serverUrl = MCP_SERVER_URL)
72
75
  };
73
76
  }
74
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
+
75
96
  if (existingUrl) {
76
97
  return {
77
98
  ...base,
@@ -112,6 +133,7 @@ export async function applyProjectMcpConfig(cwd, plan) {
112
133
  mcpServers: {
113
134
  ...(isRecord(existing.mcpServers) ? existing.mcpServers : {}),
114
135
  [DISTRIBUTIONOS_MCP_SERVER_NAME]: {
136
+ type: 'http',
115
137
  url: plan.serverUrl ?? MCP_SERVER_URL,
116
138
  },
117
139
  },
@@ -131,6 +153,7 @@ function recommendedMcpConfig(serverUrl) {
131
153
  return {
132
154
  mcpServers: {
133
155
  [DISTRIBUTIONOS_MCP_SERVER_NAME]: {
156
+ type: 'http',
134
157
  url: serverUrl,
135
158
  },
136
159
  },
package/src/plan.js CHANGED
@@ -15,7 +15,11 @@ import { buildProjectMcpConfigPlan } from './mcp-config.js';
15
15
  import {
16
16
  buildAnalyticsRouteConfigScript,
17
17
  buildAnalyticsRouteConfigSnippet,
18
- } from './privacy.js';
18
+ } from './privacy.js';
19
+ import {
20
+ extractBootstrapMetadata,
21
+ formatSetupReportSummary,
22
+ } from './setup-report.js';
19
23
 
20
24
  export async function createSetupPlan(options) {
21
25
  const cwd = options.cwd;
@@ -55,31 +59,35 @@ export async function createSetupPlan(options) {
55
59
  fallbackInstructions: buildFallbackMcpInstructions(appId),
56
60
  warnings: buildWarnings({ repo: planRepo, remote }),
57
61
  validation: buildValidationPlan(planRepo),
58
- deploymentChecklist: [
59
- 'Review this plan and any proposed file edits.',
62
+ deploymentChecklist: [
63
+ 'Review this plan and any proposed file edits.',
60
64
  'Apply reviewed bootstrap, analytics, and non-secret project MCP config changes only.',
61
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.',
62
67
  'Run baseline tests before mutation when the worktree is messy.',
63
68
  'Run build and test commands after mutation.',
64
69
  'Do not deploy until a human reviews the diff.',
65
70
  'After deployment, call verify_analytics_install on representative public URLs.',
66
- 'Call report_implementation for shipped artifacts or analytics opt-outs.',
67
- ],
68
- };
69
- }
71
+ 'Call report_implementation for shipped artifacts or analytics opt-outs.',
72
+ ],
73
+ setupReport: null,
74
+ };
75
+ }
70
76
 
71
77
  function sanitizeRepoForPlan(repo) {
72
78
  return {
73
79
  ...repo,
74
80
  packageJson: sanitizePackageJson(repo.packageJson),
75
- instructionFiles: (repo.instructionFiles ?? []).map(file => ({
76
- path: file.path,
77
- priority: file.priority,
78
- exists: file.exists,
79
- hasManagedDistributionOsBlock: /<!--\s*DISTRIBUTIONOS:START[\s\S]*?DISTRIBUTIONOS:END\s*-->/i.test(file.content ?? ''),
80
- contentOmitted: true,
81
- })),
82
- };
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
+ };
83
91
  }
84
92
 
85
93
  function sanitizePackageJson(packageJson) {
@@ -141,15 +149,21 @@ export function formatPlanText(plan) {
141
149
  `- Planned action: ${plan.mcp.action}`,
142
150
  `- Auth mode: ${plan.mcp.authMode}; no API keys or OAuth tokens are written.`,
143
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.',
144
153
  '',
145
154
  'Deployment checklist',
146
155
  ...plan.deploymentChecklist.map(item => `- ${item}`),
147
156
  ];
148
157
 
149
- if (plan.analytics.configSnippet) {
150
- lines.push('', 'Future-compatible route gate config', plan.analytics.configSnippet);
151
- }
152
-
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
+
153
167
  if (plan.warnings.length > 0) {
154
168
  lines.push('', 'Warnings', ...plan.warnings.map(item => `- ${item}`));
155
169
  }
@@ -187,6 +201,7 @@ function nextStepLines(plan) {
187
201
  lines.push(
188
202
  '',
189
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.',
190
205
  );
191
206
 
192
207
  return lines;
@@ -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
+ }