@distributionos/cli 0.1.15 → 0.1.17

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/src/mcp-config.js CHANGED
@@ -1,187 +1,319 @@
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
- }
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 CODEX_MCP_CONFIG_PATH = '.codex/config.toml';
8
+ export const DISTRIBUTIONOS_MCP_SERVER_NAME = 'distributionos';
9
+
10
+ export async function buildProjectMcpConfigPlan(cwd, serverUrl = MCP_SERVER_URL) {
11
+ const target = safeResolve(cwd, PROJECT_MCP_CONFIG_PATH);
12
+ const base = {
13
+ path: PROJECT_MCP_CONFIG_PATH,
14
+ serverName: DISTRIBUTIONOS_MCP_SERVER_NAME,
15
+ serverUrl,
16
+ authMode: 'oauth',
17
+ requiresRestart: true,
18
+ writesSecrets: false,
19
+ recommendedConfig: recommendedMcpConfig(serverUrl),
20
+ };
21
+
22
+ if (!(await pathExists(target))) {
23
+ return {
24
+ ...base,
25
+ supported: true,
26
+ action: 'create',
27
+ reason: 'Create a project-local MCP config with the DistributionOS remote server URL only.',
28
+ };
29
+ }
30
+
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(await fs.readFile(target, 'utf8'));
34
+ } catch {
35
+ return {
36
+ ...base,
37
+ supported: false,
38
+ action: 'manual',
39
+ reason: 'Existing .mcp.json is not valid JSON, so the CLI will not edit it automatically.',
40
+ };
41
+ }
42
+
43
+ if (!isRecord(parsed)) {
44
+ return {
45
+ ...base,
46
+ supported: false,
47
+ action: 'manual',
48
+ reason: 'Existing .mcp.json is not an object, so the CLI will not edit it automatically.',
49
+ };
50
+ }
51
+
52
+ if (containsSecretLikeConfig(parsed)) {
53
+ return {
54
+ ...base,
55
+ supported: false,
56
+ action: 'manual',
57
+ reason: 'Existing .mcp.json appears to contain secret headers, tokens, or env values. The CLI will not rewrite it.',
58
+ };
59
+ }
60
+
61
+ const servers = isRecord(parsed.mcpServers) ? parsed.mcpServers : {};
62
+ const existing = servers[DISTRIBUTIONOS_MCP_SERVER_NAME];
63
+ const existingUrl = isRecord(existing) && typeof existing.url === 'string'
64
+ ? existing.url
65
+ : null;
66
+ const existingType = isRecord(existing) && typeof existing.type === 'string'
67
+ ? existing.type
68
+ : null;
69
+
70
+ if (existingUrl === serverUrl && existingType === 'http') {
71
+ return {
72
+ ...base,
73
+ supported: true,
74
+ action: 'none',
75
+ reason: 'Project-local MCP config already includes the DistributionOS server.',
76
+ };
77
+ }
78
+
79
+ if (existingUrl === serverUrl && !existingType) {
80
+ return {
81
+ ...base,
82
+ supported: true,
83
+ action: 'update',
84
+ reason: 'Add the Claude-compatible HTTP transport type to the DistributionOS MCP server entry.',
85
+ };
86
+ }
87
+
88
+ if (existingUrl === serverUrl && existingType !== 'http') {
89
+ return {
90
+ ...base,
91
+ supported: false,
92
+ action: 'manual',
93
+ reason: 'Existing distributionos MCP server uses a non-HTTP transport. The CLI will not overwrite it automatically.',
94
+ };
95
+ }
96
+
97
+ if (existingUrl) {
98
+ return {
99
+ ...base,
100
+ supported: false,
101
+ action: 'manual',
102
+ reason: 'Existing distributionos MCP server points to a different URL. The CLI will not overwrite it automatically.',
103
+ };
104
+ }
105
+
106
+ return {
107
+ ...base,
108
+ supported: true,
109
+ action: 'update',
110
+ reason: 'Add the DistributionOS remote MCP server to the existing project-local MCP config.',
111
+ };
112
+ }
113
+
114
+ export async function applyProjectMcpConfig(cwd, plan) {
115
+ if (!plan || plan.action === 'none') {
116
+ return { type: 'mcp', changed: false, file: plan?.path ?? PROJECT_MCP_CONFIG_PATH, reason: plan?.reason ?? 'No MCP config change planned.' };
117
+ }
118
+
119
+ if (plan.action === 'manual' || !plan.supported) {
120
+ return { type: 'mcp', changed: false, file: plan.path, reason: plan.reason };
121
+ }
122
+
123
+ const target = safeResolve(cwd, plan.path);
124
+ let existing = {};
125
+ if (await pathExists(target)) {
126
+ existing = JSON.parse(await fs.readFile(target, 'utf8'));
127
+ }
128
+ if (!isRecord(existing)) {
129
+ return { type: 'mcp', changed: false, file: plan.path, reason: 'Existing MCP config was not an object.' };
130
+ }
131
+
132
+ const next = {
133
+ ...existing,
134
+ mcpServers: {
135
+ ...(isRecord(existing.mcpServers) ? existing.mcpServers : {}),
136
+ [DISTRIBUTIONOS_MCP_SERVER_NAME]: {
137
+ type: 'http',
138
+ url: plan.serverUrl ?? MCP_SERVER_URL,
139
+ },
140
+ },
141
+ };
142
+
143
+ await fs.mkdir(path.dirname(target), { recursive: true });
144
+ await fs.writeFile(target, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
145
+ return {
146
+ type: 'mcp',
147
+ changed: true,
148
+ file: plan.path,
149
+ action: plan.action === 'create' ? 'created' : 'updated',
150
+ };
151
+ }
152
+
153
+ export async function buildCodexMcpConfigPlan(cwd, serverUrl = MCP_SERVER_URL) {
154
+ const target = safeResolve(cwd, CODEX_MCP_CONFIG_PATH);
155
+ const base = {
156
+ path: CODEX_MCP_CONFIG_PATH,
157
+ serverName: DISTRIBUTIONOS_MCP_SERVER_NAME,
158
+ serverUrl,
159
+ authMode: 'oauth',
160
+ requiresRestart: true,
161
+ writesSecrets: false,
162
+ recommendedConfig: recommendedCodexConfig(serverUrl),
163
+ };
164
+
165
+ if (!(await pathExists(target))) {
166
+ return {
167
+ ...base,
168
+ supported: true,
169
+ action: 'create',
170
+ reason: 'Create a Codex project MCP config with the DistributionOS remote server URL only.',
171
+ };
172
+ }
173
+
174
+ const existing = await fs.readFile(target, 'utf8');
175
+ const section = findCodexMcpSection(existing);
176
+
177
+ if (!section) {
178
+ return {
179
+ ...base,
180
+ supported: true,
181
+ action: 'update',
182
+ reason: 'Add the DistributionOS MCP server to the existing Codex project config.',
183
+ };
184
+ }
185
+
186
+ if (section.url === serverUrl && section.enabled !== false) {
187
+ return {
188
+ ...base,
189
+ supported: true,
190
+ action: 'none',
191
+ reason: 'Codex project config already includes the DistributionOS server.',
192
+ };
193
+ }
194
+
195
+ if (section.url === serverUrl && section.enabled === false) {
196
+ return {
197
+ ...base,
198
+ supported: true,
199
+ action: 'update',
200
+ reason: 'Enable the existing DistributionOS MCP server in the Codex project config.',
201
+ };
202
+ }
203
+
204
+ return {
205
+ ...base,
206
+ supported: false,
207
+ action: 'manual',
208
+ reason: 'Existing Codex DistributionOS MCP server points to a different URL or is incomplete. The CLI will not overwrite it automatically.',
209
+ };
210
+ }
211
+
212
+ export async function applyCodexMcpConfig(cwd, plan) {
213
+ if (!plan || plan.action === 'none') {
214
+ return { type: 'codex-mcp', changed: false, file: plan?.path ?? CODEX_MCP_CONFIG_PATH, reason: plan?.reason ?? 'No Codex MCP config change planned.' };
215
+ }
216
+
217
+ if (plan.action === 'manual' || !plan.supported) {
218
+ return { type: 'codex-mcp', changed: false, file: plan.path, reason: plan.reason };
219
+ }
220
+
221
+ const target = safeResolve(cwd, plan.path);
222
+ const block = recommendedCodexConfig(plan.serverUrl ?? MCP_SERVER_URL);
223
+ const existing = await fs.readFile(target, 'utf8').catch(() => '');
224
+ const section = findCodexMcpSection(existing);
225
+ let next;
226
+
227
+ if (!existing.trim()) {
228
+ next = `${block}\n`;
229
+ } else if (!section) {
230
+ next = `${existing.replace(/\s*$/, '\n\n')}${block}\n`;
231
+ } else if (section.url === (plan.serverUrl ?? MCP_SERVER_URL) && section.enabled === false) {
232
+ const currentSection = existing.slice(section.start, section.end);
233
+ const updatedSection = currentSection.replace(/^\s*enabled\s*=\s*false\s*$/m, 'enabled = true');
234
+ next = existing.slice(0, section.start) + updatedSection + existing.slice(section.end);
235
+ if (!next.endsWith('\n')) next += '\n';
236
+ } else {
237
+ next = existing.slice(0, section.start) + block + existing.slice(section.end);
238
+ if (!next.endsWith('\n')) next += '\n';
239
+ }
240
+
241
+ if (next === existing) {
242
+ return { type: 'codex-mcp', changed: false, file: plan.path, reason: 'No Codex MCP config text change needed.' };
243
+ }
244
+
245
+ await fs.mkdir(path.dirname(target), { recursive: true });
246
+ await fs.writeFile(target, next, 'utf8');
247
+ return {
248
+ type: 'codex-mcp',
249
+ changed: true,
250
+ file: plan.path,
251
+ action: plan.action === 'create' ? 'created' : 'updated',
252
+ };
253
+ }
254
+
255
+ function recommendedMcpConfig(serverUrl) {
256
+ return {
257
+ mcpServers: {
258
+ [DISTRIBUTIONOS_MCP_SERVER_NAME]: {
259
+ type: 'http',
260
+ url: serverUrl,
261
+ },
262
+ },
263
+ };
264
+ }
265
+
266
+ function recommendedCodexConfig(serverUrl) {
267
+ return [
268
+ `[mcp_servers.${DISTRIBUTIONOS_MCP_SERVER_NAME}]`,
269
+ 'enabled = true',
270
+ `url = ${JSON.stringify(serverUrl)}`,
271
+ ].join('\n');
272
+ }
273
+
274
+ function findCodexMcpSection(content) {
275
+ const text = String(content ?? '');
276
+ const sectionRe = /^\s*\[mcp_servers\.distributionos\]\s*$/m;
277
+ const match = sectionRe.exec(text);
278
+ if (!match || match.index === undefined) return null;
279
+
280
+ const start = match.index;
281
+ const afterHeader = start + match[0].length;
282
+ const nextSection = text.slice(afterHeader).search(/^\s*\[/m);
283
+ const end = nextSection === -1 ? text.length : afterHeader + nextSection;
284
+ const body = text.slice(start, end);
285
+ const url = body.match(/^\s*url\s*=\s*"([^"]+)"\s*$/m)?.[1] ?? null;
286
+ const enabledValue = body.match(/^\s*enabled\s*=\s*(true|false)\s*$/m)?.[1];
287
+ return {
288
+ start,
289
+ end,
290
+ url,
291
+ enabled: enabledValue === undefined ? null : enabledValue === 'true',
292
+ };
293
+ }
294
+
295
+ function containsSecretLikeConfig(value) {
296
+ if (Array.isArray(value)) return value.some(containsSecretLikeConfig);
297
+ if (!isRecord(value)) return false;
298
+ for (const [key, nested] of Object.entries(value)) {
299
+ if (/authorization|api[-_]?key|token|secret|password|credential|headers|env/i.test(key)) {
300
+ if (nested !== null && nested !== undefined && nested !== '') return true;
301
+ }
302
+ if (containsSecretLikeConfig(nested)) return true;
303
+ }
304
+ return false;
305
+ }
306
+
307
+ function isRecord(value) {
308
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
309
+ }
310
+
311
+ function safeResolve(cwd, relativePath) {
312
+ const root = path.resolve(cwd);
313
+ const target = path.resolve(root, relativePath);
314
+ const relative = path.relative(root, target);
315
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
316
+ throw new Error(`Refusing to write outside repo root: ${relativePath}`);
317
+ }
318
+ return target;
319
+ }
package/src/mutate.js CHANGED
@@ -1,6 +1,6 @@
1
- import { promises as fs } from 'node:fs';
2
- import path from 'node:path';
3
- import { applyProjectMcpConfig } from './mcp-config.js';
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { applyCodexMcpConfig, applyProjectMcpConfig } from './mcp-config.js';
4
4
 
5
5
  const BOOTSTRAP_BLOCK_RE =
6
6
  /<!--\s*DISTRIBUTIONOS:START[\s\S]*?DISTRIBUTIONOS:END\s*-->/i;
@@ -18,16 +18,19 @@ export async function applySetupPlan(plan, options = {}) {
18
18
  const bootstrap = await applyBootstrap(plan.cwd, plan.bootstrap);
19
19
  if (bootstrap.changed) changes.push(bootstrap);
20
20
 
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
- }
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 codexMcp = await applyCodexMcpConfig(plan.cwd, plan.codexMcp);
27
+ if (codexMcp.changed || codexMcp.reason) changes.push(codexMcp);
28
+
29
+ const mcp = await applyProjectMcpConfig(plan.cwd, plan.mcp);
30
+ if (mcp.changed || mcp.reason) changes.push(mcp);
31
+
32
+ return changes;
33
+ }
31
34
 
32
35
  async function applyBootstrap(cwd, bootstrap) {
33
36
  const targetPath = safeResolve(cwd, bootstrap.target);
@@ -95,9 +98,9 @@ async function applyNextAppRouterAnalytics(cwd, analytics, scriptSrc) {
95
98
  contractVersion: analytics.contractVersion,
96
99
  configScript: analytics.configScript,
97
100
  });
98
- const next = ANALYTICS_BLOCK_RE.test(existing)
99
- ? existing.replace(ANALYTICS_BLOCK_RE, `\n${block}\n`)
100
- : insertIntoNextAppRouterLayout(existing, block);
101
+ const next = ANALYTICS_BLOCK_RE.test(existing)
102
+ ? existing.replace(ANALYTICS_BLOCK_RE, `\n${block}\n`)
103
+ : insertIntoNextAppRouterLayout(existing, block);
101
104
 
102
105
  if (next === existing) {
103
106
  return { type: 'analytics', changed: false, file: analytics.layoutTarget, reason: 'No analytics change needed.' };
@@ -160,33 +163,33 @@ function buildHtmlAnalyticsBlock({ scriptSrc, contractVersion, configScript }) {
160
163
  ].join('\n');
161
164
  }
162
165
 
163
- function insertBeforeHeadClose(content, block) {
164
- const headClose = content.match(/^(\s*)<\/head>/im);
165
- if (headClose?.index === undefined) {
166
- throw new Error('Could not find </head> in shared layout.');
167
- }
168
- return `${content.slice(0, headClose.index)}${block}\n${content.slice(headClose.index)}`;
169
- }
170
-
171
- function insertIntoNextAppRouterLayout(content, block) {
172
- const headClose = content.match(/^(\s*)<\/head>/im);
173
- if (headClose?.index !== undefined) {
174
- return `${content.slice(0, headClose.index)}${block}\n${content.slice(headClose.index)}`;
175
- }
176
-
177
- const bodyOpen = content.match(/^(\s*)<body(\s|>)/im);
178
- if (bodyOpen?.index === undefined) {
179
- throw new Error('Could not find <body> in shared layout.');
180
- }
181
-
182
- const indent = bodyOpen[1] || ' ';
183
- const headBlock = [
184
- `${indent}<head>`,
185
- block,
186
- `${indent}</head>`,
187
- ].join('\n');
188
- return `${content.slice(0, bodyOpen.index)}${headBlock}\n${content.slice(bodyOpen.index)}`;
189
- }
166
+ function insertBeforeHeadClose(content, block) {
167
+ const headClose = content.match(/^(\s*)<\/head>/im);
168
+ if (headClose?.index === undefined) {
169
+ throw new Error('Could not find </head> in shared layout.');
170
+ }
171
+ return `${content.slice(0, headClose.index)}${block}\n${content.slice(headClose.index)}`;
172
+ }
173
+
174
+ function insertIntoNextAppRouterLayout(content, block) {
175
+ const headClose = content.match(/^(\s*)<\/head>/im);
176
+ if (headClose?.index !== undefined) {
177
+ return `${content.slice(0, headClose.index)}${block}\n${content.slice(headClose.index)}`;
178
+ }
179
+
180
+ const bodyOpen = content.match(/^(\s*)<body(\s|>)/im);
181
+ if (bodyOpen?.index === undefined) {
182
+ throw new Error('Could not find <body> in shared layout.');
183
+ }
184
+
185
+ const indent = bodyOpen[1] || ' ';
186
+ const headBlock = [
187
+ `${indent}<head>`,
188
+ block,
189
+ `${indent}</head>`,
190
+ ].join('\n');
191
+ return `${content.slice(0, bodyOpen.index)}${headBlock}\n${content.slice(bodyOpen.index)}`;
192
+ }
190
193
 
191
194
  function indentLines(value, prefix) {
192
195
  return String(value).split(/\r?\n/).map(line => `${prefix}${line}`).join('\n');