bambu-printer-mcp 1.1.6 → 1.1.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.
@@ -10,11 +10,13 @@
10
10
  *
11
11
  * This module:
12
12
  * 1. Indexes every BBL profile JSON by its `name` field.
13
- * 2. Recursively walks `inherits`, deep-merging parent into child.
13
+ * 2. Recursively walks `inherits`, deep-merging parent into child, and
14
+ * applies each level's `include` templates (G-code templates for
15
+ * machines, per-variant defaults for filaments) the way the GUI does.
14
16
  * 3. Derives `nozzle_volume_type` from `default_nozzle_volume_type[0]`
15
17
  * (the GUI does this implicitly; the CLI doesn't).
16
- * 4. Merges CLI-specific machine_limits from `BBL/cli_config.json` so the
17
- * printer doesn't run unsafe accelerations / jerks.
18
+ * 4. Validates the model in `BBL/cli_config.json` and merges its CLI-specific
19
+ * machine_limits where supplied for safe accelerations / jerks.
18
20
  * 5. Writes the flattened JSON to a temp file the caller passes to
19
21
  * BambuStudio CLI.
20
22
  *
@@ -23,6 +25,7 @@
23
25
  */
24
26
 
25
27
  import fs from "node:fs/promises";
28
+ import { existsSync, realpathSync } from "node:fs";
26
29
  import path from "node:path";
27
30
  import crypto from "node:crypto";
28
31
 
@@ -38,6 +41,9 @@ export interface FlattenedProfiles {
38
41
  machineLeafName: string;
39
42
  processLeafName: string;
40
43
  filamentLeafNames: string[];
44
+ /** False only for an explicit standalone custom machine. */
45
+ cliConfigValidated: boolean;
46
+ /** Some validated official model configs intentionally have no limits. */
41
47
  cliOverlayApplied: boolean;
42
48
  };
43
49
  }
@@ -66,6 +72,20 @@ export interface FlattenOptions {
66
72
  nozzleVolumeType?: "Standard" | "High Flow";
67
73
  /** BambuStudio display name, e.g. "Textured PEI Plate" or "Cool Plate". */
68
74
  bedType?: string;
75
+ /** Actual input configs, including user overrides on top of BBL parents. */
76
+ sourceProfiles?: {
77
+ machine?: Record<string, unknown>;
78
+ process?: Record<string, unknown>;
79
+ filaments?: (Record<string, unknown> | undefined)[];
80
+ };
81
+ /**
82
+ * Positional `#RRGGBB` colour per filament slot (e.g. from the input 3MF
83
+ * project or the caller). Missing entries keep the profile's own colour or
84
+ * fall back to DEFAULT_FILAMENT_COLOUR.
85
+ */
86
+ filamentColours?: (string | undefined)[];
87
+ /** Saved per-plate positions, used only when the process sets no position. */
88
+ projectTowerPosition?: { wipe_tower_x?: unknown; wipe_tower_y?: unknown };
69
89
  }
70
90
 
71
91
  interface IndexedProfile {
@@ -146,39 +166,81 @@ async function buildNameIndex(
146
166
  * - Unknown name (broken `inherits` reference).
147
167
  * - Cycles (A -> B -> A).
148
168
  */
149
- function flattenByName(leafName: string, index: NameIndex): Record<string, unknown> {
150
- const chain: Record<string, unknown>[] = [];
151
- const visited = new Set<string>();
152
- let cursor: string | undefined = leafName;
169
+ function flattenByName(
170
+ leafName: string,
171
+ index: NameIndex,
172
+ visiting = new Set<string>()
173
+ ): Record<string, unknown> {
174
+ if (visiting.has(leafName)) {
175
+ throw new Error(`Profile inheritance cycle detected (inherits/include): ${[...visiting, leafName].join(" -> ")}`);
176
+ }
177
+ const entry = index.get(leafName);
178
+ if (!entry) {
179
+ throw new Error(`Profile "${leafName}" not found in index. The profile tree is incomplete or the name is misspelled.`);
180
+ }
181
+ visiting.add(leafName);
182
+ try {
183
+ return flattenData(entry.data, index, visiting);
184
+ } finally {
185
+ visiting.delete(leafName);
186
+ }
187
+ }
153
188
 
154
- while (cursor) {
155
- if (visited.has(cursor)) {
156
- throw new Error(
157
- `Profile inheritance cycle detected at "${cursor}" (chain: ${[...visited].join(" -> ")})`
158
- );
159
- }
160
- visited.add(cursor);
189
+ function flattenData(
190
+ data: Record<string, unknown>,
191
+ index: NameIndex,
192
+ visiting = new Set<string>()
193
+ ): Record<string, unknown> {
194
+ const parent = data["inherits"];
195
+ const merged = typeof parent === "string" && parent.length > 0
196
+ ? flattenByName(parent, index, visiting)
197
+ : {};
198
+ // Includes may themselves inherit or include templates. Each level wins
199
+ // over its parent, then the including profile's own settings win last.
200
+ applyIncludes(merged, data, index, visiting);
201
+ Object.assign(merged, data);
202
+ delete merged["include"];
203
+ return merged;
204
+ }
161
205
 
162
- const entry = index.get(cursor);
206
+ /** Keys of an include template that describe the template, not the config. */
207
+ const INCLUDE_METADATA_KEYS = new Set(["name", "type", "from", "instantiation", "inherits", "include", "setting_id"]);
208
+
209
+ /**
210
+ * Recent BBL profiles (e.g. "Bambu Lab P2S 0.4 nozzle") no longer carry
211
+ * their G-code inline: `machine_start_gcode`, `machine_end_gcode`,
212
+ * `change_filament_gcode`, ... live in separate "... template <key>"
213
+ * profiles listed under `include`. Skipping them silently falls back to
214
+ * the generic G-code inherited from `fdm_machine_common` & co, which is
215
+ * wrong for the printer (no AMS filament load, other printer's moves).
216
+ */
217
+ function applyIncludes(
218
+ target: Record<string, unknown>,
219
+ profile: Record<string, unknown>,
220
+ index: NameIndex,
221
+ visiting: Set<string>
222
+ ): void {
223
+ const raw = profile["include"];
224
+ if (raw === undefined || raw === null) return;
225
+ if (typeof raw !== "string" && !Array.isArray(raw)) {
226
+ throw new Error(`Profile "${String(profile["name"])}" has an invalid include; expected a name or array of names.`);
227
+ }
228
+ const names = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : [];
229
+ for (const name of names) {
230
+ if (typeof name !== "string" || name.trim().length === 0) {
231
+ throw new Error(`Profile "${String(profile["name"])}" has an invalid include reference; every entry must be a nonempty name.`);
232
+ }
233
+ const entry = index.get(name);
163
234
  if (!entry) {
164
235
  throw new Error(
165
- `Profile "${cursor}" not found in index. ` +
166
- `Inherits chain so far: ${[...visited].join(" -> ")}. ` +
167
- `This usually means the leaf name is misspelled or the profile tree is incomplete.`
236
+ `Profile "${String(profile["name"])}" includes "${name}", which is not in the index. ` +
237
+ `Refusing to fall back to inherited defaults (wrong G-code for this printer).`
168
238
  );
169
239
  }
170
-
171
- chain.push(entry.data);
172
- const parent = entry.data["inherits"];
173
- cursor = typeof parent === "string" && parent.length > 0 ? parent : undefined;
174
- }
175
-
176
- // Merge root-most parent first, leaf last (so leaf wins).
177
- const merged: Record<string, unknown> = {};
178
- for (let i = chain.length - 1; i >= 0; i--) {
179
- Object.assign(merged, chain[i]);
240
+ for (const [key, value] of Object.entries(flattenByName(name, index, visiting))) {
241
+ if (!INCLUDE_METADATA_KEYS.has(key)) target[key] = value;
242
+ }
180
243
  }
181
- return merged;
182
244
  }
183
245
 
184
246
  /* -------------------------------------------------------------------------- */
@@ -254,6 +316,91 @@ function enforceMatchingNozzles(arr: unknown[]): void {
254
316
  }
255
317
 
256
318
  /** Best-effort extruder count for fallback nozzle_volume_type sizing. */
319
+ /** BambuStudio's built-in filament_colour default. */
320
+ export const DEFAULT_FILAMENT_COLOUR = "#00AE42";
321
+ const FILAMENT_COLOUR_RE = /^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/;
322
+
323
+ /**
324
+ * No BBL filament profile defines `filament_colour`; the GUI fills it from
325
+ * the AMS. Without it the CLI keeps a single-entry colour vector however many
326
+ * filaments are loaded and crashes (access violation) as soon as a slice uses
327
+ * any filament after the first. Give every slot exactly one colour.
328
+ */
329
+ function ensureFilamentColour(flat: Record<string, unknown>, colour?: string): void {
330
+ if (colour !== undefined) {
331
+ if (!FILAMENT_COLOUR_RE.test(colour)) {
332
+ throw new Error(`Invalid filament colour "${colour}"; expected #RRGGBB.`);
333
+ }
334
+ flat["filament_colour"] = [colour];
335
+ return;
336
+ }
337
+ const own = flat["filament_colour"];
338
+ if (Array.isArray(own) && typeof own[0] === "string" && FILAMENT_COLOUR_RE.test(own[0])) {
339
+ flat["filament_colour"] = [own[0]];
340
+ return;
341
+ }
342
+ flat["filament_colour"] = [DEFAULT_FILAMENT_COLOUR];
343
+ }
344
+
345
+ // BambuStudio's defaults when a process sets no prime tower position/width.
346
+ const DEFAULT_WIPE_TOWER_X = 15;
347
+ const DEFAULT_WIPE_TOWER_Y = 220;
348
+ const DEFAULT_PRIME_TOWER_WIDTH = 35;
349
+ // Clearance inside the shared nozzle area for the auto-sized tower brim and
350
+ // the CLI's own safety margin (X2D fails at 5 mm from the edge, passes at 6).
351
+ const WIPE_TOWER_MARGIN = 15;
352
+
353
+ interface Rect { minX: number; maxX: number; minY: number; maxY: number }
354
+
355
+ function parseArea(area: string): Rect {
356
+ const points = area.split(",").map((p) => p.trim().split("x").map(Number));
357
+ if (points.length < 3 || points.some((p) => p.length !== 2 || p.some((n) => !Number.isFinite(n)))) {
358
+ throw new Error(`Malformed extruder_printable_area entry "${area}".`);
359
+ }
360
+ const xs = points.map((p) => p[0]);
361
+ const ys = points.map((p) => p[1]);
362
+ return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
363
+ }
364
+
365
+ /**
366
+ * On multi-nozzle machines (H2D, X2D) each nozzle reaches only part of the
367
+ * bed (`extruder_printable_area`), and the prime tower must be reachable by
368
+ * every nozzle. The CLI's default tower position (x=15) lies outside the
369
+ * X2D/H2D right-nozzle area, so slicing fails with "G-code outside of the
370
+ * printable area". When the process sets no position and the default does
371
+ * not fit, move the tower inside the area all nozzles share.
372
+ */
373
+ function placePrimeTowerForAllNozzles(
374
+ machineFlat: Record<string, unknown>,
375
+ processFlat: Record<string, unknown>
376
+ ): void {
377
+ if (processFlat["wipe_tower_x"] !== undefined || processFlat["wipe_tower_y"] !== undefined) return;
378
+ if (["0", "false"].includes(String(processFlat["enable_prime_tower"]).toLowerCase())) return;
379
+ const areas = machineFlat["extruder_printable_area"];
380
+ if (!Array.isArray(areas) || areas.length < 2) return;
381
+ const rects = areas.map((a) => {
382
+ if (typeof a !== "string") throw new Error("Malformed extruder_printable_area in machine profile.");
383
+ return parseArea(a);
384
+ });
385
+ const shared: Rect = {
386
+ minX: Math.max(...rects.map((r) => r.minX)) + WIPE_TOWER_MARGIN,
387
+ maxX: Math.min(...rects.map((r) => r.maxX)) - WIPE_TOWER_MARGIN,
388
+ minY: Math.max(...rects.map((r) => r.minY)) + WIPE_TOWER_MARGIN,
389
+ maxY: Math.min(...rects.map((r) => r.maxY)) - WIPE_TOWER_MARGIN,
390
+ };
391
+ const width = Number(processFlat["prime_tower_width"] ?? DEFAULT_PRIME_TOWER_WIDTH);
392
+ const size = Number.isFinite(width) && width > 0 ? width : DEFAULT_PRIME_TOWER_WIDTH;
393
+ if (shared.maxX - shared.minX < size || shared.maxY - shared.minY < size) {
394
+ throw new Error("No bed area is reachable by every nozzle for the prime tower.");
395
+ }
396
+ const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
397
+ const x = clamp(DEFAULT_WIPE_TOWER_X, shared.minX, shared.maxX - size);
398
+ const y = clamp(DEFAULT_WIPE_TOWER_Y, shared.minY, shared.maxY - size);
399
+ if (x === DEFAULT_WIPE_TOWER_X && y === DEFAULT_WIPE_TOWER_Y) return;
400
+ processFlat["wipe_tower_x"] = [String(x)];
401
+ processFlat["wipe_tower_y"] = [String(y)];
402
+ }
403
+
257
404
  function inferExtruderCount(flat: Record<string, unknown>): number {
258
405
  for (const key of ["nozzle_diameter", "extruder_type", "extruder_variant_list"]) {
259
406
  const v = flat[key];
@@ -263,16 +410,11 @@ function inferExtruderCount(flat: Record<string, unknown>): number {
263
410
  }
264
411
 
265
412
  /**
266
- * Bambu ships `BBL/cli_config.json` with per-printer overlays containing
267
- * machine_limits keys (cli_safe_acceleration_*, cli_safe_jerk_*,
268
- * cli_safe_speed_*). Without these the slicer can emit movements faster
269
- * than the printer's safe envelope -- dangerous on real hardware.
270
- *
271
- * The overlay keys are scoped under printer.<printer_name>.machine_limits.
272
- * We look up by the leaf machine's `printer_model` or `name` and merge
273
- * those keys into the flattened machine profile.
274
- *
275
- * Returns true if an overlay was found and applied, false otherwise.
413
+ * Validate the selected model's CLI config before making a bundled profile
414
+ * into a User profile. A1-family models require machine_limits overrides;
415
+ * other official models (including P1S and H2D) intentionally declare only
416
+ * downward_check. Missing config cannot tell us whether limits are required.
417
+ * Returns whether limits were applied, not whether validation succeeded.
276
418
  */
277
419
  async function applyCliOverlay(
278
420
  flat: Record<string, unknown>,
@@ -280,47 +422,80 @@ async function applyCliOverlay(
280
422
  vendor: string
281
423
  ): Promise<boolean> {
282
424
  const cliConfigPath = path.join(profilesRoot, vendor, "cli_config.json");
425
+ const fail = (reason: string): never => {
426
+ throw new Error(
427
+ `Cannot prepare machine profile "${String(flat["name"])}" ` +
428
+ `(model "${String(flat["printer_model"] ?? stripNozzleSuffix(String(flat["name"])))}"): ` +
429
+ `${cliConfigPath}: ${reason}. Check your BambuStudio installation or BAMBU_PROFILES_ROOT.`
430
+ );
431
+ };
283
432
  let raw: string;
284
433
  try {
285
434
  raw = await fs.readFile(cliConfigPath, "utf8");
286
435
  } catch {
287
- return false;
436
+ return fail("required CLI configuration is missing or unreadable");
288
437
  }
289
- let cliConfig: Record<string, unknown>;
438
+ let cliConfig: unknown;
290
439
  try {
291
- cliConfig = JSON.parse(raw) as Record<string, unknown>;
440
+ cliConfig = JSON.parse(raw);
292
441
  } catch {
293
- return false;
442
+ return fail("invalid JSON in required CLI configuration");
294
443
  }
295
-
444
+ if (!isRecord(cliConfig)) return fail("expected a CLI configuration object");
296
445
  const printerSection = cliConfig["printer"];
297
- if (!printerSection || typeof printerSection !== "object") return false;
446
+ if (!isRecord(printerSection)) return fail("expected a printer configuration object");
298
447
 
299
448
  // Match key: cli_config.json keys are bare printer names like
300
- // "Bambu Lab H2D" / "Bambu Lab A1". Try, in order: explicit printer_model,
301
- // printer_settings_id with the " 0.4 nozzle" suffix stripped, raw name
302
- // with that suffix stripped, raw name as-is.
303
- const candidates = [
304
- flat["printer_model"],
305
- typeof flat["printer_settings_id"] === "string"
306
- ? stripNozzleSuffix(flat["printer_settings_id"])
307
- : undefined,
308
- typeof flat["name"] === "string" ? stripNozzleSuffix(flat["name"]) : undefined,
309
- flat["name"],
310
- ].filter((v): v is string => typeof v === "string" && v.length > 0);
311
-
312
- for (const key of candidates) {
313
- const block = (printerSection as Record<string, unknown>)[key];
314
- if (!block || typeof block !== "object") continue;
315
- const mlimits = (block as Record<string, unknown>)["machine_limits"];
316
- if (!mlimits || typeof mlimits !== "object") continue;
317
- // Merge machine_limits into the flat profile. These are CLI-only safety
318
- // values; they should never be overridden by the leaf.
319
- Object.assign(flat, mlimits as Record<string, unknown>);
320
- return true;
321
- }
322
-
323
- return false;
449
+ // "Bambu Lab H2D" / "Bambu Lab A1". If no model is provided, try preset
450
+ // identity with the nozzle suffix stripped, then the name as-is.
451
+ // An explicit model is authoritative; do not silently use another model's
452
+ // limits because a stale preset name happens to match.
453
+ const candidates = typeof flat["printer_model"] === "string" && flat["printer_model"].trim()
454
+ ? [flat["printer_model"]]
455
+ : [
456
+ typeof flat["printer_settings_id"] === "string"
457
+ ? stripNozzleSuffix(flat["printer_settings_id"])
458
+ : undefined,
459
+ typeof flat["name"] === "string" ? stripNozzleSuffix(flat["name"]) : undefined,
460
+ flat["name"],
461
+ ].filter((v): v is string => typeof v === "string" && v.length > 0);
462
+
463
+ const model = candidates.find(key => Object.prototype.hasOwnProperty.call(printerSection, key));
464
+ if (!model) return fail(`selected model is absent (looked for ${candidates.join(", ")})`);
465
+ const block = printerSection[model];
466
+ if (!isRecord(block)) return fail(`printer.${model} must be an object`);
467
+
468
+ const downward = block["downward_check"];
469
+ if (Object.prototype.hasOwnProperty.call(block, "downward_check") && (
470
+ !isRecord(downward) || Object.keys(downward).length === 0 ||
471
+ Object.entries(downward).some(([name, values]) => !name.trim() || !Array.isArray(values) ||
472
+ values.some(value => typeof value !== "string" || !value.trim()))
473
+ )) {
474
+ return fail(`printer.${model}.downward_check must map profile names to arrays of names`);
475
+ }
476
+
477
+ if (!Object.prototype.hasOwnProperty.call(block, "machine_limits")) {
478
+ if (!isRecord(downward)) return fail(`printer.${model} has neither machine_limits nor downward_check`);
479
+ return false;
480
+ }
481
+ const limits = block["machine_limits"];
482
+ if (!isRecord(limits) || Object.keys(limits).length === 0) {
483
+ return fail(`printer.${model}.machine_limits must be a nonempty object`);
484
+ }
485
+ const limitKey = /^cli_safe_(?:acceleration_(?:e|extruding|retracting|travel|x|y|z)|(?:jerk|speed)_(?:e|x|y|z))$/;
486
+ for (const [key, value] of Object.entries(limits)) {
487
+ if (!limitKey.test(key) || typeof value !== "string" ||
488
+ value.split(",").some(part => !part.trim() || !Number.isFinite(Number(part)) || Number(part) < 0)) {
489
+ return fail(`printer.${model}.machine_limits contains an invalid limit "${key}"`);
490
+ }
491
+ }
492
+ // CLI safety values override even explicit values in the selected leaf.
493
+ Object.assign(flat, limits);
494
+ return true;
495
+ }
496
+
497
+ function isRecord(value: unknown): value is Record<string, unknown> {
498
+ return value !== null && typeof value === "object" && !Array.isArray(value);
324
499
  }
325
500
 
326
501
  /**
@@ -472,20 +647,49 @@ export async function flattenForCli(opts: FlattenOptions): Promise<FlattenedProf
472
647
  const index = await buildNameIndex(opts.profilesRoot, vendor);
473
648
 
474
649
  // Flatten each leaf.
475
- const machineFlat = flattenByName(opts.machineLeaf, index);
476
- const processFlat = flattenByName(opts.processLeaf, index);
477
- const filamentFlats = opts.filamentLeaves.map((n) => flattenByName(n, index));
650
+ if (opts.sourceProfiles?.filaments && opts.sourceProfiles.filaments.length !== opts.filamentLeaves.length) {
651
+ throw new Error("Every filament slot must have a source profile.");
652
+ }
653
+ const machineFlat = opts.sourceProfiles?.machine
654
+ ? flattenData(opts.sourceProfiles.machine, index)
655
+ : flattenByName(opts.machineLeaf, index);
656
+ const processFlat = opts.sourceProfiles?.process
657
+ ? flattenData(opts.sourceProfiles.process, index)
658
+ : flattenByName(opts.processLeaf, index);
659
+ const filamentFlats = opts.filamentLeaves.map((n, i) => {
660
+ const source = opts.sourceProfiles?.filaments?.[i];
661
+ return source ? flattenData(source, index) : flattenByName(n, index);
662
+ });
478
663
 
479
664
  // CLI-specific post-processing on machine profile only.
480
665
  deriveNozzleVolumeType(machineFlat, opts.nozzleVolumeType);
481
666
  applyMachineModelBedMetadata(machineFlat, index);
482
- const cliOverlayApplied = await applyCliOverlay(machineFlat, opts.profilesRoot, vendor);
667
+ // A standalone custom machine can accompany process/filament profiles that
668
+ // need BBL resolution; do not impose bundled model config on that machine.
669
+ const sourceMachine = opts.sourceProfiles?.machine;
670
+ const standaloneMachine = sourceMachine && String(sourceMachine.from).toLowerCase() !== "system" &&
671
+ !(typeof sourceMachine.inherits === "string" && sourceMachine.inherits.length > 0) &&
672
+ (sourceMachine.include === undefined || sourceMachine.include === null);
673
+ const cliConfigValidated = !standaloneMachine;
674
+ const cliOverlayApplied = cliConfigValidated
675
+ ? await applyCliOverlay(machineFlat, opts.profilesRoot, vendor)
676
+ : false;
483
677
 
484
678
  // Normalize each flattened profile for CLI consumption.
485
679
  normalizeForCli(machineFlat, "machine", opts.machineLeaf);
486
680
  normalizeForCli(processFlat, "process", opts.processLeaf);
487
681
  filamentFlats.forEach((f, i) => normalizeForCli(f, "filament", opts.filamentLeaves[i]));
682
+ if (opts.filamentColours && opts.filamentColours.length !== filamentFlats.length) {
683
+ throw new Error(
684
+ `${opts.filamentColours.length} filament colours were supplied for ${filamentFlats.length} filament slots.`
685
+ );
686
+ }
687
+ filamentFlats.forEach((f, i) => ensureFilamentColour(f, opts.filamentColours?.[i]));
488
688
  applyBedType(processFlat, opts.bedType);
689
+ if (processFlat["wipe_tower_x"] === undefined && processFlat["wipe_tower_y"] === undefined) {
690
+ Object.assign(processFlat, opts.projectTowerPosition);
691
+ }
692
+ placePrimeTowerForAllNozzles(machineFlat, processFlat);
489
693
 
490
694
  // Mirror the GUI's auto-extend behavior: when the caller explicitly
491
695
  // chose a process or filament that wasn't pre-declared compatible with
@@ -495,8 +699,8 @@ export async function flattenForCli(opts: FlattenOptions): Promise<FlattenedProf
495
699
  ensureMachineInCompatList(processFlat, opts.machineLeaf);
496
700
  filamentFlats.forEach((f) => ensureMachineInCompatList(f, opts.machineLeaf));
497
701
 
498
- // Write temp files. Hash the leaf name into the filename so concurrent
499
- // slices for different printers don't collide.
702
+ // Hash the resolved content so concurrent jobs using the same preset with
703
+ // different overrides never overwrite each other's input files.
500
704
  await fs.mkdir(opts.tempDir, { recursive: true });
501
705
  const machinePath = await writeTemp(opts.tempDir, "machine", opts.machineLeaf, machineFlat);
502
706
  const processPath = await writeTemp(opts.tempDir, "process", opts.processLeaf, processFlat);
@@ -516,6 +720,7 @@ export async function flattenForCli(opts: FlattenOptions): Promise<FlattenedProf
516
720
  machineLeafName: opts.machineLeaf,
517
721
  processLeafName: opts.processLeaf,
518
722
  filamentLeafNames: opts.filamentLeaves,
723
+ cliConfigValidated,
519
724
  cliOverlayApplied,
520
725
  },
521
726
  };
@@ -527,11 +732,12 @@ async function writeTemp(
527
732
  leafName: string,
528
733
  data: Record<string, unknown>
529
734
  ): Promise<string> {
530
- const hash = crypto.createHash("sha1").update(leafName).digest("hex").slice(0, 8);
735
+ const serialized = JSON.stringify(data, null, 2);
736
+ const hash = crypto.createHash("sha256").update(leafName).update("\0").update(serialized).digest("hex").slice(0, 16);
531
737
  const safe = leafName.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 64);
532
738
  const filename = `flat-${kind}-${safe}-${hash}.json`;
533
739
  const out = path.join(tempDir, filename);
534
- await fs.writeFile(out, JSON.stringify(data, null, 2), "utf8");
740
+ await fs.writeFile(out, serialized, "utf8");
535
741
  return out;
536
742
  }
537
743
 
@@ -541,7 +747,7 @@ async function writeTemp(
541
747
 
542
748
  /**
543
749
  * Given the SLICER_PATH (path to BambuStudio executable), walk up to the
544
- * Resources/profiles directory. Falls back to common platform paths.
750
+ * profile directory for that installation (macOS, Windows, or Linux prefix).
545
751
  *
546
752
  * Override via BAMBU_PROFILES_ROOT env.
547
753
  */
@@ -551,10 +757,29 @@ export function detectProfilesRoot(slicerPath?: string): string {
551
757
  }
552
758
 
553
759
  if (slicerPath) {
554
- // macOS: /Applications/BambuStudio.app/Contents/MacOS/BambuStudio
555
- // -> /Applications/BambuStudio.app/Contents/Resources/profiles
556
- const macGuess = path.resolve(path.dirname(slicerPath), "..", "Resources", "profiles");
557
- return macGuess;
760
+ let executable = slicerPath;
761
+ if (!slicerPath.includes('/') && !slicerPath.includes('\\')) {
762
+ const located = (process.env.PATH ?? '').split(path.delimiter)
763
+ .map(dir => path.join(dir, slicerPath))
764
+ .find(candidate => existsSync(candidate));
765
+ if (located) executable = located;
766
+ }
767
+ // Package-manager launchers may be symlinks into the installation prefix.
768
+ try { executable = realpathSync(executable); } catch { /* Probe the supplied path below. */ }
769
+ const bin = path.dirname(executable);
770
+ const candidates = path.basename(bin) === 'MacOS'
771
+ ? [path.resolve(bin, '..', 'Resources', 'profiles')]
772
+ : [
773
+ path.join(bin, 'resources', 'profiles'),
774
+ path.join(bin, 'Resources', 'profiles'),
775
+ path.resolve(bin, '..', 'share', 'BambuStudio', 'profiles'),
776
+ path.resolve(bin, '..', 'share', 'bambu-studio', 'profiles'),
777
+ path.resolve(bin, '..', 'share', 'BambuStudio', 'resources', 'profiles'),
778
+ ];
779
+ const found = candidates.find(root => existsSync(path.join(root, 'BBL', 'machine')));
780
+ // Do not select another installation if this executable has no profile tree.
781
+ // flattenForCli reports the missing tree and asks for BAMBU_PROFILES_ROOT.
782
+ return found ?? candidates[0];
558
783
  }
559
784
 
560
785
  // Default macOS install.