@flowdular/sandbox 0.2.7 → 0.2.9

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.
@@ -66,6 +66,40 @@ import { createServer } from "vite";
66
66
 
67
67
  // ../dev-console/src/index.mjs
68
68
  import { createLogger } from "vite";
69
+
70
+ // ../dev-console/src/brand.mjs
71
+ var MARK = [
72
+ " XXX XXX",
73
+ " XXX XXX",
74
+ " XXXXXXXXXXXXXXXXX",
75
+ "XXXXXXXXXXXXXXXXXXX",
76
+ " XXX XXX",
77
+ "XXXXXXXXXXXXXXXXXXX",
78
+ " XXXXXXXXXXXXXXXXX",
79
+ " XXX XXX",
80
+ " XXX XXX"
81
+ ];
82
+ function renderBrandHeader({
83
+ title = "FLOWDULAR",
84
+ subtitle = "",
85
+ color = false,
86
+ columns = process.stdout.columns ?? 80,
87
+ terminal = Boolean(process.stdout.isTTY)
88
+ } = {}) {
89
+ const brand = (text) => color ? `\x1B[1;38;5;42m${text}\x1B[0m` : text;
90
+ const muted = (text) => color ? `\x1B[90m${text}\x1B[0m` : text;
91
+ if (!terminal) {
92
+ return ` ${brand(title)}${subtitle ? ` ${muted(subtitle)}` : ""}`;
93
+ }
94
+ const heading = [
95
+ ` ${brand(title)}`,
96
+ ...subtitle ? [` ${muted(subtitle)}`] : []
97
+ ];
98
+ if (columns < 21) return heading.join("\n");
99
+ return [...MARK.map((row) => ` ${brand(row)}`), "", ...heading].join("\n");
100
+ }
101
+
102
+ // ../dev-console/src/index.mjs
69
103
  var ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
70
104
  var ansi = {
71
105
  reset: "\x1B[0m",
@@ -116,7 +150,7 @@ function statusLine(theme, label, value, tone = "text") {
116
150
  }
117
151
  function printReady({ title, subtitle, lines, theme }) {
118
152
  console.log("");
119
- console.log(` ${theme.brand(title)} ${theme.muted(subtitle)}`);
153
+ console.log(renderBrandHeader({ title, subtitle, color: theme.enabled }));
120
154
  for (const [label, value, tone = "text"] of lines) {
121
155
  if (value === null || value === void 0) continue;
122
156
  console.log(statusLine(theme, label, value, tone));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/sandbox",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "The Flowdular sandbox: chat a change, build it behind the gates, preview it in the real application, deliver it as code.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/flowdular/flowdular/tree/main/packages/sandbox#readme",
@@ -55,9 +55,9 @@
55
55
  "@octanejs/seo": "0.0.38",
56
56
  "@octanejs/vite-plugin": "0.1.51",
57
57
  "octane": "0.1.51",
58
- "segment-state": "0.2.0",
58
+ "segment-state": "0.2.1",
59
59
  "vite": "8.2.2",
60
- "@flowdular/sdk": "0.2.3"
60
+ "@flowdular/sdk": "0.2.4"
61
61
  },
62
62
  "imports": {
63
63
  "#coding-agent": "./internal/coding-agent/src/index.ts"
@@ -1,5 +1,6 @@
1
+ import { withSessionLock } from './session-lock.ts';
1
2
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
4
  import { isAbsolute, join, relative, resolve } from 'node:path';
4
5
  import {
5
6
  readSession,
@@ -210,28 +211,26 @@ export async function addAttachment(
210
211
  `Attachments are limited to ${MAX_ATTACHMENT_BYTES / (1024 * 1024)} MB.`,
211
212
  );
212
213
  }
213
- const current = await readSession(workspaceRoot, session.id);
214
- if (current.attachments.length >= MAX_ATTACHMENTS) {
215
- throw new SandboxSetupError(
216
- 'ATTACHMENT_LIMIT_REACHED',
217
- `A session may hold at most ${MAX_ATTACHMENTS} attachments.`,
218
- );
219
- }
220
- const type = resolveType(input.name, input.bytes);
221
- const meta: SessionAttachment = {
222
- id: randomUUID(),
223
- name: uniqueName(safeAttachmentName(input.name), current.attachments),
224
- kind: type.kind,
225
- size: input.bytes.byteLength,
226
- addedAt: Date.now(),
227
- };
228
- const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
229
- await mkdir(paths.attachments, { recursive: true });
230
- await mkdir(paths.workspaceAttachments, { recursive: true });
231
- await writeFile(storedPath(paths, meta), input.bytes);
232
- await writeFile(workspacePath(paths, meta), input.bytes);
233
- await updateSession(workspaceRoot, current.id, {
234
- attachments: [...current.attachments, meta],
214
+ let meta!: SessionAttachment;
215
+ await updateSession(workspaceRoot, session.id, async (current) => {
216
+ if (current.attachments.length >= MAX_ATTACHMENTS) {
217
+ throw new SandboxSetupError(
218
+ 'ATTACHMENT_LIMIT_REACHED',
219
+ `A session may hold at most ${MAX_ATTACHMENTS} attachments.`,
220
+ );
221
+ }
222
+ const type = resolveType(input.name, input.bytes);
223
+ meta = {
224
+ id: randomUUID(),
225
+ name: uniqueName(safeAttachmentName(input.name), current.attachments),
226
+ kind: type.kind,
227
+ size: input.bytes.byteLength,
228
+ addedAt: Date.now(),
229
+ };
230
+ const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
231
+ await mkdir(paths.attachments, { recursive: true });
232
+ await writeFile(storedPath(paths, meta), input.bytes);
233
+ return { attachments: [...current.attachments, meta] };
235
234
  });
236
235
  return meta;
237
236
  }
@@ -263,13 +262,15 @@ export async function removeAttachment(
263
262
  attachmentId: string,
264
263
  ): Promise<void> {
265
264
  assertAttachmentId(attachmentId);
266
- const current = await readSession(workspaceRoot, session.id);
267
- const meta = findAttachment(current.attachments, attachmentId);
268
- const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
269
- await rm(storedPath(paths, meta), { force: true });
270
- await rm(workspacePath(paths, meta), { force: true });
271
- await updateSession(workspaceRoot, current.id, {
272
- attachments: current.attachments.filter((item) => item.id !== attachmentId),
265
+ await updateSession(workspaceRoot, session.id, async (current) => {
266
+ const meta = findAttachment(current.attachments, attachmentId);
267
+ const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
268
+ await rm(storedPath(paths, meta), { force: true });
269
+ return {
270
+ attachments: current.attachments.filter(
271
+ (item) => item.id !== attachmentId,
272
+ ),
273
+ };
273
274
  });
274
275
  }
275
276
 
@@ -294,6 +295,42 @@ export async function readAttachment(
294
295
  };
295
296
  }
296
297
 
298
+ /* The host prepares a stable turn snapshot before path enforcement begins.
299
+ Uploads/removals only change the private store, never an active workspace. */
300
+ export async function materializeAttachments(
301
+ workspaceRoot: string,
302
+ session: SandboxSession,
303
+ ): Promise<readonly SessionAttachment[]> {
304
+ return withSessionLock(workspaceRoot, session.id, async () => {
305
+ const current = await readSession(workspaceRoot, session.id);
306
+ const paths = sessionPaths(workspaceRoot, current.id, current.moduleSuffix);
307
+ const reference = join(paths.workspace, 'reference');
308
+ const info = await lstat(reference).catch(
309
+ (error: NodeJS.ErrnoException) => {
310
+ if (error.code === 'ENOENT') return null;
311
+ throw error;
312
+ },
313
+ );
314
+ if (info && !info.isDirectory())
315
+ throw new SandboxSetupError(
316
+ 'ATTACHMENT_REFERENCE_INVALID',
317
+ 'The session reference directory is not a regular directory.',
318
+ );
319
+ await mkdir(reference, { recursive: true });
320
+ // rm unlinks a planted attachment symlink instead of following it.
321
+ await rm(paths.workspaceAttachments, { recursive: true, force: true });
322
+ await mkdir(paths.workspaceAttachments, { recursive: true });
323
+ for (const meta of current.attachments) {
324
+ await writeFile(
325
+ workspacePath(paths, meta),
326
+ await readFile(storedPath(paths, meta)),
327
+ { flag: 'wx' },
328
+ );
329
+ }
330
+ return current.attachments;
331
+ });
332
+ }
333
+
297
334
  /* The note prepended to a turn's instruction when the session has attachments.
298
335
  The files are already in reference/attachments/, so the agent opens them with
299
336
  its normal file tools; the path plus this note is the whole contract. */
@@ -1,3 +1,4 @@
1
+ import { materializeSdkReference } from './sdk-reference.ts';
1
2
  import { createRequire } from 'node:module';
2
3
  import {
3
4
  access,
@@ -104,6 +105,7 @@ Read-only copies of the platform contracts this session must implement against.
104
105
  Never edit anything in this directory: it is not part of the module and it is
105
106
  not ejected.
106
107
 
108
+ - sdk: complete installed SDK package and module sources, with package.json exports. Read these real files instead of following node_modules symlinks outside the workspace. Present when the host application has an installed SDK.
107
109
  - packages/server: defineEndpoint, HTTP helpers, and the endpoint identity contract.
108
110
  - packages/client: the client contribution contract (createClientContribution, ModuleClientContext), shell slots, and shell state.
109
111
  - packages/contracts: module manifest, spec, and blueprint schemas.
@@ -166,6 +168,7 @@ export async function materializeReference(
166
168
  .some((segment) => EXCLUDED.has(segment)),
167
169
  }).catch(() => undefined);
168
170
  }
171
+ await materializeSdkReference(workspaceRoot, sessionWorkspace);
169
172
  const skills = await listSkills(workspaceRoot);
170
173
  await writeFile(
171
174
  join(sessionWorkspace, 'reference/README.md'),
@@ -183,6 +186,7 @@ export async function writeAgentPointer(
183
186
  fileName: 'CLAUDE.md' | 'AGENTS.md',
184
187
  roleName: string,
185
188
  skill?: string | null,
189
+ hasSdk = false,
186
190
  ): Promise<void> {
187
191
  await writeFile(
188
192
  join(sessionWorkspace, fileName),
@@ -194,6 +198,11 @@ export async function writeAgentPointer(
194
198
  skill
195
199
  ? `- Read only reference/skills/${skill}/SKILL.md for this task. Do not load other skills or the whole reference catalog.`
196
200
  : '- No matching task skill is installed. Do not load unrelated skills.',
201
+ ...(hasSdk
202
+ ? [
203
+ '- Read installed SDK sources at reference/sdk; use its package.json exports to locate an API. Do not follow external node_modules symlinks or scan the whole SDK.',
204
+ ]
205
+ : []),
197
206
  '- Write only inside your module directory under modules/, in the paths the instruction allows. Everything under reference/ is read-only.',
198
207
  '- End your final message with the HANDOFF line the instruction describes.',
199
208
  '',
@@ -1266,9 +1266,8 @@ export function createSandboxRoutes(
1266
1266
  },
1267
1267
  });
1268
1268
 
1269
- /* Attachments are copied into the session workspace at upload time, so the
1270
- coding agent, which may only read inside the workspace, opens them by name
1271
- from reference/attachments/. The bytes never leave this origin. */
1269
+ /* Uploads stay outside the active workspace. Each turn copies its attachment
1270
+ snapshot into reference/attachments/ before the path guard starts. */
1272
1271
  const addSandboxAttachment = new ServerRoute({
1273
1272
  path: '/sandbox/api/sessions/:id/attachments',
1274
1273
  methods: ['POST'],
@@ -0,0 +1,99 @@
1
+ import { createRequire } from 'node:module';
2
+ import {
3
+ cp,
4
+ lstat,
5
+ mkdir,
6
+ mkdtemp,
7
+ readFile,
8
+ realpath,
9
+ rename,
10
+ rm,
11
+ writeFile,
12
+ } from 'node:fs/promises';
13
+ import { dirname, join, relative } from 'node:path';
14
+ import { SandboxSetupError } from './workspace-root.ts';
15
+
16
+ const EXCLUDED = new Set([
17
+ 'node_modules',
18
+ 'dist',
19
+ '.git',
20
+ '.flowdular',
21
+ '.turbo',
22
+ ]);
23
+
24
+ /* Published SDK contents are immutable. Cache a real copy per session and SDK
25
+ installation, outside the module graph. Never widen agent filesystem access. */
26
+ export async function materializeSdkReference(
27
+ workspaceRoot: string,
28
+ sessionWorkspace: string,
29
+ ): Promise<boolean> {
30
+ let source: string;
31
+ try {
32
+ const require = createRequire(join(workspaceRoot, 'platform/package.json'));
33
+ source = await realpath(
34
+ dirname(require.resolve('@flowdular/sdk/package.json')),
35
+ );
36
+ } catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code === 'MODULE_NOT_FOUND')
38
+ return false;
39
+ throw error;
40
+ }
41
+ const manifest = await readFile(join(source, 'package.json'), 'utf8');
42
+ const reference = join(sessionWorkspace, 'reference');
43
+ const info = await lstat(reference).catch((error: NodeJS.ErrnoException) => {
44
+ if (error.code === 'ENOENT') return null;
45
+ throw error;
46
+ });
47
+ if (info && !info.isDirectory())
48
+ throw new SandboxSetupError(
49
+ 'SDK_REFERENCE_INVALID',
50
+ 'The session reference directory is not a regular directory.',
51
+ );
52
+ await mkdir(reference, { recursive: true });
53
+ const target = join(reference, 'sdk');
54
+ // Metadata belongs to the host, outside the agent workspace and its guard.
55
+ const marker = join(dirname(sessionWorkspace), 'sdk-reference.json');
56
+ const identity = JSON.stringify({ source, manifest });
57
+ try {
58
+ if (
59
+ (await lstat(target)).isDirectory() &&
60
+ (await lstat(join(target, 'package.json'))).isFile() &&
61
+ (await readFile(marker, 'utf8')) === identity &&
62
+ (await readFile(join(target, 'package.json'), 'utf8')) === manifest
63
+ )
64
+ return true;
65
+ } catch (error) {
66
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
67
+ }
68
+ const staging = await mkdtemp(
69
+ join(dirname(sessionWorkspace), 'sdk-reference-'),
70
+ );
71
+ try {
72
+ await writeFile(join(staging, 'package.json'), manifest);
73
+ for (const member of ['packages', 'modules', 'modules.json']) {
74
+ const from = join(source, member);
75
+ try {
76
+ await lstat(from);
77
+ } catch (error) {
78
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
79
+ throw error;
80
+ }
81
+ await cp(from, join(staging, member), {
82
+ recursive: true,
83
+ filter: async (path) => {
84
+ const parts = relative(source, path).split('/');
85
+ if (parts.some((part) => EXCLUDED.has(part) || part.startsWith('.')))
86
+ return false;
87
+ // A dependency symlink must never survive inside the snapshot.
88
+ return !(await lstat(path)).isSymbolicLink();
89
+ },
90
+ });
91
+ }
92
+ await rm(target, { recursive: true, force: true });
93
+ await rename(staging, target);
94
+ await writeFile(marker, identity);
95
+ } finally {
96
+ await rm(staging, { recursive: true, force: true });
97
+ }
98
+ return true;
99
+ }
@@ -0,0 +1,26 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ /* Session metadata and operator-owned attachment snapshots share one queue.
4
+ Failed work releases the next caller and idle sessions retain no lock. */
5
+ const pending = new Map<string, Promise<void>>();
6
+
7
+ export async function withSessionLock<T>(
8
+ workspaceRoot: string,
9
+ sessionId: string,
10
+ work: () => Promise<T>,
11
+ ): Promise<T> {
12
+ const key = `${resolve(workspaceRoot)}\0${sessionId}`;
13
+ const previous = pending.get(key);
14
+ let release!: () => void;
15
+ const current = new Promise<void>((done) => {
16
+ release = done;
17
+ });
18
+ pending.set(key, current);
19
+ await previous;
20
+ try {
21
+ return await work();
22
+ } finally {
23
+ release();
24
+ if (pending.get(key) === current) pending.delete(key);
25
+ }
26
+ }
@@ -1,3 +1,4 @@
1
+ import { withSessionLock } from './session-lock.ts';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import {
3
4
  access,
@@ -588,16 +589,23 @@ export async function listSessions(
588
589
  return sessions.sort((left, right) => right.updatedAt - left.updatedAt);
589
590
  }
590
591
 
592
+ type SessionPatch = Partial<Omit<SandboxSession, 'id' | 'createdAt'>>;
593
+
591
594
  export async function updateSession(
592
595
  workspaceRoot: string,
593
596
  sessionId: string,
594
- patch: Partial<Omit<SandboxSession, 'id' | 'createdAt'>>,
597
+ patch:
598
+ | SessionPatch
599
+ | ((current: SandboxSession) => SessionPatch | Promise<SessionPatch>),
595
600
  ): Promise<SandboxSession> {
596
- const current = await readSession(workspaceRoot, sessionId);
597
- return writeSession(workspaceRoot, {
598
- ...current,
599
- ...patch,
600
- updatedAt: Date.now(),
601
+ return withSessionLock(workspaceRoot, sessionId, async () => {
602
+ const current = await readSession(workspaceRoot, sessionId);
603
+ const updates = typeof patch === 'function' ? await patch(current) : patch;
604
+ return writeSession(workspaceRoot, {
605
+ ...current,
606
+ ...updates,
607
+ updatedAt: Date.now(),
608
+ });
601
609
  });
602
610
  }
603
611
 
@@ -1,3 +1,4 @@
1
+ import { materializeSdkReference } from './sdk-reference.ts';
1
2
  import { spawn } from 'node:child_process';
2
3
  import { createHash } from 'node:crypto';
3
4
  import {
@@ -28,7 +29,10 @@ import {
28
29
  moduleReviewRevision,
29
30
  recordAutoReview,
30
31
  } from './auto-review.ts';
31
- import { attachmentInstruction } from './attachments.ts';
32
+ import {
33
+ attachmentInstruction,
34
+ materializeAttachments,
35
+ } from './attachments.ts';
32
36
  import { captureCheckpoint } from './checkpoints.ts';
33
37
  import { guardAgentPaths } from './path-guard.ts';
34
38
  import type { SandboxConfiguration } from './config.ts';
@@ -535,7 +539,7 @@ export async function* runTurn(
535
539
  context: TurnContext,
536
540
  input: TurnInput,
537
541
  ): AsyncGenerator<ChatEntry, TurnOutcome> {
538
- const session = await readSession(context.workspaceRoot, input.sessionId);
542
+ let session = await readSession(context.workspaceRoot, input.sessionId);
539
543
  const paths = sessionPaths(
540
544
  context.workspaceRoot,
541
545
  session.id,
@@ -576,6 +580,14 @@ export async function* runTurn(
576
580
  const driverId = input.driver ?? session.driver;
577
581
  const driver = await context.registry.resolve(driverId);
578
582
 
583
+ session = {
584
+ ...session,
585
+ attachments: await materializeAttachments(context.workspaceRoot, session),
586
+ };
587
+ const hasSdk = await materializeSdkReference(
588
+ context.workspaceRoot,
589
+ paths.workspace,
590
+ );
579
591
  const attachmentNote = attachmentInstruction(session.attachments);
580
592
 
581
593
  yield await appendChatEntry(context.workspaceRoot, session, {
@@ -671,6 +683,11 @@ export async function* runTurn(
671
683
  )}. This turn is yours in modules/${active.directory} only; another specialist takes the turn for the others. Each module is a project of this pnpm workspace, so a draft that imports another draft resolves the session copy.`,
672
684
  ]
673
685
  : []),
686
+ ...(hasSdk
687
+ ? [
688
+ 'Read the actual installed SDK under reference/sdk/packages and reference/sdk/modules. Its package.json maps public exports. These are readable copies inside the workspace; do not follow external SDK symlinks. Search only the API needed for the current task.',
689
+ ]
690
+ : []),
674
691
  'reference/ is read-only. Consult only the code and references needed for this task; do not preload its catalog.',
675
692
  'Other module.json files under modules/ describe the dependency graph. Only the draft module directories have sources you may change.',
676
693
  ...(active.kind === 'edit'
@@ -685,6 +702,7 @@ export async function* runTurn(
685
702
  driverId === 'claude-code' ? 'CLAUDE.md' : 'AGENTS.md',
686
703
  role.name,
687
704
  skill,
705
+ hasSdk,
688
706
  );
689
707
 
690
708
  const history = historyFrom(await readChat(context.workspaceRoot, session));
@@ -4,6 +4,7 @@ import { access, cp, readFile, readdir, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
6
  import type { SessionModule } from './sessions.ts';
7
+ import { parseDocument } from 'yaml';
7
8
 
8
9
  export interface InstallResult {
9
10
  readonly ran: boolean;
@@ -36,10 +37,6 @@ async function packageName(directory: string): Promise<string | null> {
36
37
  }
37
38
  }
38
39
 
39
- function yamlString(value: string): string {
40
- return `'${value.replace(/'/g, "''")}'`;
41
- }
42
-
43
40
  /* A session workspace is a pnpm workspace of its own: the draft modules are its
44
41
  projects, every other workspace package is linked to the live checkout, and
45
42
  the host lockfile seeds the resolution so the session installs exactly the
@@ -57,7 +54,7 @@ export async function materializeSessionWorkspace(options: {
57
54
  );
58
55
  if (name) draft.add(name);
59
56
  }
60
- const overrides: string[] = [];
57
+ const overrides: Record<string, string> = {};
61
58
  for (const group of ['packages', 'modules']) {
62
59
  let entries: readonly string[] = [];
63
60
  try {
@@ -69,9 +66,7 @@ export async function materializeSessionWorkspace(options: {
69
66
  const directory = join(options.workspaceRoot, group, entry);
70
67
  const name = await packageName(directory);
71
68
  if (!name || draft.has(name)) continue;
72
- overrides.push(
73
- ` ${yamlString(name)}: ${yamlString(`link:${directory}`)}`,
74
- );
69
+ overrides[name] = `link:${directory}`;
75
70
  }
76
71
  }
77
72
 
@@ -89,7 +84,7 @@ export async function materializeSessionWorkspace(options: {
89
84
  if (typeof sdk.version !== 'string')
90
85
  throw new Error('Invalid installed SDK version.');
91
86
  sdkVersion = sdk.version;
92
- overrides.push(` '@flowdular/sdk': ${yamlString(`link:${sdkRoot}`)}`);
87
+ overrides['@flowdular/sdk'] = `link:${sdkRoot}`;
93
88
  } catch (error) {
94
89
  if (
95
90
  !['MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(
@@ -132,29 +127,23 @@ export async function materializeSessionWorkspace(options: {
132
127
  join(options.workspaceRoot, 'pnpm-workspace.yaml'),
133
128
  'utf8',
134
129
  ).catch(() => '');
135
- const carried = hostWorkspace
136
- .split('\n')
137
- .filter(
138
- (line) =>
139
- !/^(packages|overrides):/.test(line) &&
140
- !/^\s+-\s+(platform|modules\/\*|packages\/\*)\s*$/.test(line),
141
- )
142
- .join('\n')
143
- .trim();
130
+ // Edit YAML structurally so lists, custom package globs and host overrides
131
+ // cannot spill into an unrelated setting when a top-level key is replaced.
132
+ const workspaceDocument = parseDocument(hostWorkspace);
133
+ if (workspaceDocument.errors.length > 0) throw workspaceDocument.errors[0];
134
+ workspaceDocument.set('packages', ['modules/*']);
135
+ workspaceDocument.set('allowUnusedPatches', true);
136
+ for (const name of draft) {
137
+ if (workspaceDocument.hasIn(['overrides', name])) {
138
+ workspaceDocument.deleteIn(['overrides', name]);
139
+ }
140
+ }
141
+ for (const [name, target] of Object.entries(overrides)) {
142
+ workspaceDocument.setIn(['overrides', name], target);
143
+ }
144
144
  await writeFile(
145
145
  join(options.sessionWorkspace, 'pnpm-workspace.yaml'),
146
- `${[
147
- 'packages:',
148
- ' - modules/*',
149
- /* Host patches travel along even when no draft depends on the patched
150
- package; pnpm must not treat that as an error. */
151
- 'allowUnusedPatches: true',
152
- carried,
153
- 'overrides:',
154
- ...overrides,
155
- ]
156
- .filter(Boolean)
157
- .join('\n')}\n`,
146
+ workspaceDocument.toString(),
158
147
  'utf8',
159
148
  );
160
149
  for (const shared of ['pnpm-lock.yaml', 'patches']) {