@nanobpm/urban 0.77.3 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -214,6 +214,71 @@ export type {
214
214
  LineageStoreOptions,
215
215
  } from "./core/modules/lineage-store.ts";
216
216
 
217
+ // ─────────────────────────────────────────── Read models ────────────────────────────────────────
218
+ // The declare-once, compile-to-both derived read-model primitive (ADR 0065): a closed expression DSL
219
+ // (`col`/`lit`/comparisons/`and`/`or`/`not`/`caseWhen`/`exists`) declared ONCE via `defineReadModel`,
220
+ // compiled to BOTH a SQLite VIEW select-list (`compileToSqlSelect`) and an in-process TS function
221
+ // (`compileToFn`) from the SAME AST, plus the framework-derived managed VIEW DDL and the parity guard
222
+ // that replaces per-projection hand-written parity tests.
223
+ //
224
+ // SIBLING REGISTRATION POINTS (ADR 0065 rollout — see core/read-model.ts for the full seam docs):
225
+ // • `readModelRegistry` — register a `defineReadModel(...)` result; the boot path (core/modules/
226
+ // workers.ts) applies its managed VIEW. The `writer-source-inversion` task registers here.
227
+ // • `projectionRegistry` — register a canonical engine-truth projection NAME usable in `exists(...)`.
228
+ // The `canonical-projections` task registers `urban_open_user_tasks` / `urban_instance_state`.
229
+ export {
230
+ and,
231
+ assertReadModelParity,
232
+ assertSqlIdentifier,
233
+ caseWhen,
234
+ col,
235
+ compileToFn,
236
+ compileToSqlSelect,
237
+ defineReadModel,
238
+ deriveReadModelViewDdl,
239
+ eq,
240
+ exists,
241
+ gt,
242
+ gte,
243
+ lit,
244
+ lt,
245
+ lte,
246
+ neq,
247
+ not,
248
+ or,
249
+ pcol,
250
+ ProjectionRegistry,
251
+ projectionRegistry,
252
+ ReadModelRegistry,
253
+ readModelRegistry,
254
+ when,
255
+ } from "./core/read-model.ts";
256
+ export type {
257
+ AndExpr,
258
+ BaseRow,
259
+ CaseExpr,
260
+ ColExpr,
261
+ CompareExpr,
262
+ CompareOp,
263
+ DerivationFn,
264
+ ExistsExpr,
265
+ Expr,
266
+ Literal,
267
+ LitExpr,
268
+ NotExpr,
269
+ OrExpr,
270
+ ParityDb,
271
+ ParityOptions,
272
+ ParitySample,
273
+ ProjColExpr,
274
+ ProjectionRows,
275
+ ProjectionSource,
276
+ ReadModel,
277
+ ReadModelDecl,
278
+ SqlCompileOptions,
279
+ WhenClause,
280
+ } from "./core/read-model.ts";
281
+
217
282
  // Phase — the framework-level *domain phase* primitive (issue #266): derive "which phase is this
218
283
  // instance in?" from the BPMN scope hierarchy + write-provenance (Tier 0, zero declaration), an
219
284
  // optional `nano:phase` override (Tier 1), and a lineage rollup for the epic frontier (Tier 2).
@@ -67,6 +67,24 @@ test("addConnector wires a pack-backed worker + a connection into nano.app.json"
67
67
  });
68
68
  });
69
69
 
70
+ test("addConnector preserves the manifest's existing indentation (no lint reformat)", async () => {
71
+ // Regression (nano-ide#454): the scaffold's Biome config formats nano.app.json with tabs, so a
72
+ // hard-coded 2-space rewrite here would turn `npm run lint` red the moment a connector is
73
+ // enabled in a fresh app. addConnector must round-trip the manifest's own indentation, like
74
+ // `urban gen` does via detectJsonIndent.
75
+ const tabManifest = `${JSON.stringify({ schemaVersion: 1, id: "t", name: "T" }, null, "\t")}\n`;
76
+ const io = memIO({ ...baseFiles(), "app/nano.app.json": tabManifest });
77
+ await addConnector({ root: "app", pkg: PKG, io });
78
+ const written = io.files["app/nano.app.json"];
79
+ assert.match(written, /\n\t"workers"/, "tab indentation is preserved");
80
+ assert.doesNotMatch(written, /\n {2}"workers"/, "no 2-space indentation was introduced");
81
+
82
+ const twoSpace = `${JSON.stringify({ schemaVersion: 1, id: "t", name: "T" }, null, 2)}\n`;
83
+ const io2 = memIO({ ...baseFiles(), "app/nano.app.json": twoSpace });
84
+ await addConnector({ root: "app", pkg: PKG, io: io2 });
85
+ assert.match(io2.files["app/nano.app.json"], /\n {2}"workers"/, "2-space indentation is preserved");
86
+ });
87
+
70
88
  test("addConnector is idempotent — re-running does not duplicate entries", async () => {
71
89
  const io = memIO(baseFiles());
72
90
  await addConnector({ root: "app", pkg: PKG, io });
@@ -14,6 +14,7 @@
14
14
 
15
15
  import type { GenIO } from "./gen.ts";
16
16
  import { errorMessage, isRecord } from "../runtime/core/guards.ts";
17
+ import { detectJsonIndent } from "./scaffold.ts";
17
18
 
18
19
  /** A config field a pack declares (subset of ext-types `ConfigField`). */
19
20
  interface PackConfigField {
@@ -116,13 +117,15 @@ export async function addConnector(opts: AddConnectorOptions): Promise<AddConnec
116
117
  if (!(await io.exists(appManifestPath))) {
117
118
  throw new Error(`no ${manifestFile} at ${appManifestPath} — run this inside an Urban app.`);
118
119
  }
120
+ let appRaw: string;
119
121
  let app: {
120
122
  workers?: { taskType?: string; connector?: string; connection?: string; handler?: string; llm?: string }[];
121
123
  connections?: Record<string, Record<string, unknown>>;
122
124
  [k: string]: unknown;
123
125
  };
124
126
  try {
125
- app = JSON.parse(await io.readText(appManifestPath));
127
+ appRaw = await io.readText(appManifestPath);
128
+ app = JSON.parse(appRaw);
126
129
  } catch (err) {
127
130
  throw new Error(`failed to parse ${appManifestPath}: ${errorMessage(err)}`);
128
131
  }
@@ -223,7 +226,10 @@ export async function addConnector(opts: AddConnectorOptions): Promise<AddConnec
223
226
  wired.push({ taskType: spec.type, alreadyPresent: false });
224
227
  }
225
228
 
226
- await io.writeText(appManifestPath, JSON.stringify(app, null, 2) + "\n");
229
+ // Preserve the manifest's own indentation (default tab, matching the scaffold's Biome config)
230
+ // so enabling a connector never reformats nano.app.json out from under `npm run lint` — the same
231
+ // fidelity `urban gen` gives via detectJsonIndent, kept as the single source of truth.
232
+ await io.writeText(appManifestPath, `${JSON.stringify(app, null, detectJsonIndent(appRaw))}\n`);
227
233
 
228
234
  return {
229
235
  packId: pack.id,
@@ -149,7 +149,7 @@ function lintUnderScaffold(
149
149
  biomeBin: string,
150
150
  dirs: string[],
151
151
  ): { status: number | null; stdout: string; stderr: string } {
152
- const templateBiome = JSON.parse(readFileSync(join(templateDir, "biome.json"), "utf8"));
152
+ const templateBiome = JSON.parse(readFileSync(join(templateDir, "_biome.json"), "utf8"));
153
153
  // Copy the scaffold's GritQL plugin (bans `as` assertions) next to the config so its relative
154
154
  // path resolves, then reuse the template's exact ruleset — force-scoped to the generated dirs so
155
155
  // the guard tracks the scaffold standard without depending on the template's own `includes`.
@@ -320,7 +320,7 @@ function lintWithTemplateConfigVerbatim(
320
320
  templateDir: string,
321
321
  biomeBin: string,
322
322
  ): { status: number | null; stdout: string; stderr: string } {
323
- const templateBiome = JSON.parse(readFileSync(join(templateDir, "biome.json"), "utf8"));
323
+ const templateBiome = JSON.parse(readFileSync(join(templateDir, "_biome.json"), "utf8"));
324
324
  cpSync(join(templateDir, "plugins"), join(work, "plugins"), { recursive: true });
325
325
  const config = { ...templateBiome, $schema: undefined, root: true };
326
326
  writeFileSync(join(work, "biome.json"), `${JSON.stringify(config, null, "\t")}\n`);
@@ -34,6 +34,49 @@ test("renderOperationStub adjusts the generated-import depth for a nested api.di
34
34
  assert.match(stub, /import \{ defineOperation \} from "\.\.\/\.\.\/nano-generated\/operations\.ts";/);
35
35
  });
36
36
 
37
+ test("operation stub is lint-clean under the scaffold config: tab-indented, both params referenced", () => {
38
+ // Guards the same two failure modes as the worker stub (nano-ide#454): the scaffold's Biome
39
+ // config formats with tabs and enables correctness/noUnusedFunctionParameters, so a
40
+ // space-indented body or an unused `input`/`app` makes a freshly generated operation fail the
41
+ // app's own `biome check`. Body lines must be tab-indented and both params read before the
42
+ // author fills in the body.
43
+ const stub = renderOperationStub("getInvoice");
44
+ const bodyLines = stub
45
+ .split("\n")
46
+ .filter((l) => l.startsWith(" ") || l.startsWith("\t"));
47
+ assert.ok(bodyLines.length > 0, "the stub has an indented body");
48
+ for (const line of bodyLines) {
49
+ assert.ok(line.startsWith("\t"), `body line is tab-indented, not space-indented: ${line}`);
50
+ }
51
+ assert.match(
52
+ stub,
53
+ /app\.log\.warn\("operation not implemented", \{\n\t\toperationId: "getInvoice",\n\t\tmethod: input\.req\.method,\n\t\}\);/,
54
+ );
55
+ // Guard the same Biome-width defect class the reviewer flagged (nano-ide#454): the generated
56
+ // stub is linted by the scaffold's `operations/**/*.ts` include at Biome's default 80-column
57
+ // width (tab = 2 columns), so a single-line log call that overruns 80 makes `urban gen` emit a
58
+ // file `biome check` immediately reports as needing formatting. Assert every non-comment code
59
+ // line fits, so any future template change that reintroduces an over-wide line fails here.
60
+ for (const line of stub.split("\n")) {
61
+ if (line.trim().startsWith("//")) continue;
62
+ const leadingTabs = line.length - line.replace(/^\t+/, "").length;
63
+ const width = leadingTabs * 2 + (line.length - leadingTabs);
64
+ assert.ok(width <= 80, `generated code line exceeds Biome's 80-column width (${width}): ${line}`);
65
+ }
66
+ // Guard the PII/credential-leak defect class (nano-ide#454): the stub must reference a
67
+ // non-sensitive identifier, never serialize the whole validated `input` (which carries
68
+ // params/query/body/req) into the NDJSON log line.
69
+ assert.doesNotMatch(stub, /app\.log\.warn\([^)]*,\s*input\s*\}/);
70
+ });
71
+
72
+ test("operation stub logs a non-sensitive identifier, not the raw input payload", () => {
73
+ // Red/Green guard for the operation-stub log-leak: an unedited 501 handler must not emit the
74
+ // validated request body/params/query (potential credentials or PII) to structured logs.
75
+ const stub = renderOperationStub("createInvoice");
76
+ assert.doesNotMatch(stub, /,\s*input\s*\}\)/);
77
+ assert.match(stub, /method: input\.req\.method/);
78
+ });
79
+
37
80
  test("planOperationScaffold plans one stub per declared operationId", () => {
38
81
  const plans = planOperationScaffold(doc);
39
82
  const byId = Object.fromEntries(plans.map((p) => [p.operationId, p.handlerPath]));
@@ -44,16 +44,21 @@ export function renderOperationStub(operationId: string, dir: string = DEFAULT_O
44
44
  `// (ADR 0059). This file is yours to edit — \`urban gen\` will never overwrite it. The\n` +
45
45
  `// generated controller (nano-generated/controller.ts) imports this default export and\n` +
46
46
  `// type-checks it against the spec, so the signature cannot drift. Implement the body below\n` +
47
- `// (the validated \`{ req, params, query, body }\` input + the injected \`app\`) and delete the throw.\n` +
47
+ `// (the validated \`{ req, params, query, body }\` input + the injected \`app\`), replacing the\n` +
48
+ `// guard warn + throw.\n` +
48
49
  `import { NotImplemented } from "@nanobpm/urban";\n` +
49
50
  `import { defineOperation } from ${JSON.stringify(`${up}nano-generated/operations.ts`)};\n` +
50
51
  `\n` +
51
52
  `export default defineOperation(${key}, async (input, app) => {\n` +
52
- ` // \`input\` is the validated request (params/query/body/req); reach app state + services\n` +
53
- ` // through the injected \`app\` API. Structured logs via \`app.log.info("…", { … })\` are\n` +
54
- ` // auto-tagged with this request's { method, path, operationId } (ADR 0061).\n` +
55
- ` // Return \`{ status?, headers?, body? }\` (or nothing → 204).\n` +
56
- ` throw new NotImplemented(${key});\n` +
53
+ `\t// \`input\` is the validated request (params/query/body/req); reach app state + services\n` +
54
+ `\t// through the injected \`app\` API. Structured logs via \`app.log.info("…", { … })\` are\n` +
55
+ `\t// auto-tagged with this request's { method, path, operationId } (ADR 0061).\n` +
56
+ `\t// Return \`{ status?, headers?, body? }\` (or nothing → 204). Replace the two lines below.\n` +
57
+ `\tapp.log.warn("operation not implemented", {\n` +
58
+ `\t\toperationId: ${key},\n` +
59
+ `\t\tmethod: input.req.method,\n` +
60
+ `\t});\n` +
61
+ `\tthrow new NotImplemented(${key});\n` +
57
62
  `});\n`
58
63
  );
59
64
  }
@@ -7,7 +7,7 @@ import {
7
7
  type ScaffoldWorker,
8
8
  } from "./workers.ts";
9
9
  import type { ModelSource } from "../derivers/worker-io.ts";
10
- import { scaffoldWorkers } from "../scaffold.ts";
10
+ import { detectJsonIndent, scaffoldWorkers } from "../scaffold.ts";
11
11
  import type { GenIO } from "../gen.ts";
12
12
 
13
13
  // A service task with a data-envelope in/out contract; `type` is the taskType, and the
@@ -53,6 +53,43 @@ test("untyped stub imports no generated types and uses bare AppJobHandler", () =
53
53
  assert.match(stub, /const handler: AppJobHandler = async \(job, app\) =>/);
54
54
  });
55
55
 
56
+ test("stub is lint-clean under the scaffold config: tab-indented, both params referenced", () => {
57
+ // Guards the two ways `urban gen`'s stub used to fail a fresh scaffold's own `biome check`
58
+ // (nano-ide#451): space indentation (Biome defaults to tabs) and unused `job`/`app` params
59
+ // (correctness/noUnusedFunctionParameters). Body lines must be tab-indented and both params
60
+ // must be read before the author fills in the body.
61
+ const stub = renderWorkerStub("greet.hello", false, false);
62
+ const bodyLines = stub.split("\n").filter((l) => l.startsWith(" ") || l.startsWith("\t"));
63
+ assert.ok(bodyLines.length > 0, "the stub has an indented body");
64
+ for (const line of bodyLines) {
65
+ assert.ok(line.startsWith("\t"), `body line is tab-indented, not space-indented: ${line}`);
66
+ }
67
+ assert.match(stub, /app\.log\.warn\("worker not implemented", \{ jobKey: job\.jobKey \}\)/);
68
+ });
69
+
70
+ test("detectJsonIndent reuses tab/space width but clamps to JSON.stringify's 10-char cap", () => {
71
+ assert.equal(detectJsonIndent('{\n\t"a": 1\n}'), "\t");
72
+ assert.equal(detectJsonIndent('{\n "a": 1\n}'), " ");
73
+ assert.equal(detectJsonIndent("{}"), "\t", "no indentation falls back to a tab");
74
+ // JSON.stringify silently truncates a string `space` to 10 chars, so a wider space indent must
75
+ // be clamped rather than promising a fidelity the serializer cannot honor.
76
+ const wide = `{\n${" ".repeat(12)}"a": 1\n}`;
77
+ const indent = detectJsonIndent(wide);
78
+ assert.equal(indent, " ".repeat(10));
79
+ assert.equal(JSON.stringify({ a: 1 }, null, indent), JSON.stringify({ a: 1 }, null, wide.match(/\n( +)/)?.[1]));
80
+ // The 10-char cap is not space-only: an overlong *tab* indent must clamp too, or the rewrite
81
+ // would silently differ from an 11+-tab input (JSON.stringify truncates any string `space`).
82
+ const wideTabs = `{\n${"\t".repeat(12)}"a": 1\n}`;
83
+ const tabIndent = detectJsonIndent(wideTabs);
84
+ assert.equal(tabIndent, "\t".repeat(10));
85
+ assert.equal(JSON.stringify({ a: 1 }, null, tabIndent), JSON.stringify({ a: 1 }, null, wideTabs.match(/\n(\t+)/)?.[1]));
86
+ // Line-ending agnostic: the width must be detected regardless of terminator, or a space-indented
87
+ // Windows/old-Mac manifest would be rewritten at the tab fallback width instead of preserved.
88
+ assert.equal(detectJsonIndent('{\r\n "a": 1\r\n}'), " ", "CRLF space indent preserved");
89
+ assert.equal(detectJsonIndent('{\r "a": 1\r}'), " ", "bare-CR space indent preserved");
90
+ assert.equal(detectJsonIndent('{\n\r "a": 1\n\r}'), " ", "LFCR space indent preserved");
91
+ });
92
+
56
93
  test("typed-out-only stub fills In with the open default", () => {
57
94
  const stub = renderWorkerStub("demo.out", false, true);
58
95
  assert.match(stub, /import type \{ WorkerOutputs \} from/);
@@ -183,6 +220,21 @@ test("scaffoldWorkers --write creates the stub and patches the manifest once", a
183
220
  assert.ok(io.files["/app/nano.app.json"].endsWith("\n"), "manifest ends with newline");
184
221
  });
185
222
 
223
+ test("scaffoldWorkers preserves the manifest's own indentation when wiring (nano-ide#451)", async () => {
224
+ // `urban gen` used to reserialize nano.app.json with a hard-coded 2-space indent, clobbering
225
+ // the tab-indented scaffold and breaking its own `biome check`. The patch must round-trip the
226
+ // file's existing indentation instead.
227
+ for (const indent of ["\t", " "]) {
228
+ const files = appFixture();
229
+ files["/app/nano.app.json"] = `${JSON.stringify(JSON.parse(files["/app/nano.app.json"]), null, indent)}\n`;
230
+ const io = memIO(files);
231
+ await scaffoldWorkers({ root: "/app", io, write: true });
232
+ const patched = io.files["/app/nano.app.json"];
233
+ const firstIndent = patched.match(/\n([ \t]+)\S/)?.[1];
234
+ assert.equal(firstIndent, indent, `indentation preserved (${JSON.stringify(indent)})`);
235
+ }
236
+ });
237
+
186
238
  test("scaffoldWorkers never clobbers an existing stub (keeps it), still wires it", async () => {
187
239
  const files = appFixture();
188
240
  files["/app/workers/t-new/worker.ts"] = "// hand-edited, do not touch\n";
@@ -87,23 +87,28 @@ export function renderWorkerStub(
87
87
  else if (typedOut) generic = `<Record<string, unknown>, WorkerOutputs[${key}]>`;
88
88
 
89
89
  const varsNote = typedIn
90
- ? ` // \`job.variables\` is typed as WorkerInputs[${key}].`
91
- : ` // \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
90
+ ? `\t// \`job.variables\` is typed as WorkerInputs[${key}].`
91
+ : `\t// \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
92
92
 
93
+ // The stub arrives lint-clean under the scaffold's own Biome config: tab-indented, and
94
+ // referencing both `job` and `app` (via the warn line) so `noUnusedFunctionParameters`
95
+ // does not fire before the author has filled in the body.
93
96
  return (
94
97
  `// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
95
98
  `// This file is yours to edit — \`urban gen\` will never overwrite it. Implement the body\n` +
96
- `// below (use \`app.data.table(...)\` for state) and delete the throw.\n` +
99
+ `// below (use \`app.data.table(...)\` for state), replacing the guard warn + throw so the\n` +
100
+ `// "worker not implemented" warning does not survive into a working handler.\n` +
97
101
  `import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
98
102
  generatedImport +
99
103
  `\n` +
100
104
  `const handler: AppJobHandler${generic} = async (job, app) => {\n` +
101
105
  varsNote +
102
106
  `\n` +
103
- ` // Reach app state and services through the injected \`app\` API.\n` +
104
- ` // Structured logs: \`app.log.info("done", { … })\` — auto-tagged with this job's\n` +
105
- ` // { jobKey, jobType, processInstanceKey, elementId } (ADR 0061).\n` +
106
- ` throw new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
107
+ `\t// Reach app state and services through the injected \`app\` API.\n` +
108
+ `\t// Structured logs: \`app.log.info("done", { … })\` — auto-tagged with this job's\n` +
109
+ `\t// { jobKey, jobType, processInstanceKey, elementId } (ADR 0061).\n` +
110
+ `\tapp.log.warn("worker not implemented", { jobKey: job.jobKey });\n` +
111
+ `\tthrow new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
107
112
  `};\n` +
108
113
  `\n` +
109
114
  `export default handler;\n`
@@ -14,6 +14,27 @@ import {
14
14
  import { planOperationScaffold } from "./scaffold/operations.ts";
15
15
  import { parseSpec } from "../openapi/spec.ts";
16
16
 
17
+ /**
18
+ * Detect the indentation of a JSON document from its first indented line, so a rewrite can
19
+ * reuse it (`"\t"` or an N-space string) instead of hard-coding one style. Falls back to a tab
20
+ * — the scaffold's Biome default — when the file is empty/single-line or uses no indentation.
21
+ *
22
+ * Any indent — spaces or tabs — is clamped to 10 characters because `JSON.stringify` silently
23
+ * truncates a string `space` argument to 10; returning a wider prefix would promise a fidelity the
24
+ * serializer cannot honor (e.g. an 11+-tab manifest would be rewritten at a different width than
25
+ * its input). Tab indentation (the scaffold default) and any realistic width (2/4 spaces) are
26
+ * unaffected.
27
+ */
28
+ export function detectJsonIndent(json: string): string {
29
+ // Anchor on any line terminator, not just `\n`, so the width is detected regardless of the
30
+ // manifest's line endings: LF (`\n`), CRLF (`\r\n`, where `\n` already precedes the indent),
31
+ // bare-CR old-Mac (`\r`), and LFCR (`\n\r`) all resolve to the indentation that follows.
32
+ const m = json.match(/[\r\n]([ \t]+)\S/);
33
+ if (!m) return "\t";
34
+ const indent = m[1];
35
+ return indent.length > 10 ? indent.slice(0, 10) : indent;
36
+ }
37
+
17
38
  /** The manifest fields the scaffolder reads. */
18
39
  interface ScaffoldManifest {
19
40
  types?: Record<string, unknown>;
@@ -61,7 +82,11 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
61
82
  const manifestFile = opts.manifestFile ?? "nano.app.json";
62
83
  const manifestPath = joinPath(root, manifestFile);
63
84
 
64
- const manifest: ScaffoldManifest = JSON.parse(await io.readText(manifestPath));
85
+ const manifestRaw = await io.readText(manifestPath);
86
+ const manifest: ScaffoldManifest = JSON.parse(manifestRaw);
87
+ // Preserve the manifest's own indentation when we patch it back (default tab, matching the
88
+ // scaffold's Biome config), so wiring a worker never reformats the file out from under lint.
89
+ const manifestIndent = detectJsonIndent(manifestRaw);
65
90
  const models = await readModels(root, io, manifest);
66
91
  const declaredTypeIds = Object.keys(manifest.types ?? {});
67
92
 
@@ -105,7 +130,7 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
105
130
  ...manifest,
106
131
  workers: [...(manifest.workers ?? []), ...wired],
107
132
  };
108
- await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, 2)}\n`);
133
+ await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, manifestIndent)}\n`);
109
134
  manifestPatched = true;
110
135
  }
111
136