mcp-3d-printer-server 1.2.20 → 1.2.22
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/CHANGELOG.md +27 -0
- package/README.md +112 -9
- package/dist/blender-mcp-bridge.d.ts +21 -0
- package/dist/blender-mcp-bridge.js +3 -3
- package/dist/counterpart/contract.d.ts +395 -0
- package/dist/counterpart/contract.js +220 -0
- package/dist/counterpart/progress.d.ts +32 -0
- package/dist/counterpart/progress.js +443 -0
- package/dist/counterpart/service.d.ts +56 -0
- package/dist/counterpart/service.js +232 -0
- package/dist/fusion-mcp-bridge.d.ts +166 -0
- package/dist/fusion-mcp-bridge.js +529 -0
- package/dist/index.js +272 -8
- package/dist/printers/creality.d.ts +26 -1
- package/dist/printers/creality.js +75 -2
- package/dist/printers/duet.d.ts +16 -0
- package/dist/printers/duet.js +64 -0
- package/dist/printers/generic-base.d.ts +52 -6
- package/dist/printers/generic-base.js +134 -7
- package/dist/printers/klipper.d.ts +33 -2
- package/dist/printers/klipper.js +106 -0
- package/dist/printers/octoprint.d.ts +23 -1
- package/dist/printers/octoprint.js +81 -0
- package/dist/printers/prusa.d.ts +13 -0
- package/dist/printers/prusa.js +59 -0
- package/dist/printers/repetier.d.ts +17 -1
- package/dist/printers/repetier.js +55 -0
- package/dist/slicer/bambu-cli.d.ts +2 -0
- package/dist/slicer/bambu-cli.js +11 -0
- package/dist/slicer/filament-profiles.d.ts +25 -0
- package/dist/slicer/filament-profiles.js +249 -0
- package/dist/stl/stl-manipulator.d.ts +21 -1
- package/dist/stl/stl-manipulator.js +86 -6
- package/package.json +7 -2
- package/server.json +2 -2
- package/src/blender-mcp-bridge.ts +3 -3
- package/src/counterpart/contract.ts +326 -0
- package/src/counterpart/progress.ts +470 -0
- package/src/counterpart/service.ts +253 -0
- package/src/fusion-mcp-bridge.ts +512 -0
- package/src/index.ts +314 -10
- package/src/printers/creality.ts +84 -1
- package/src/printers/duet.ts +67 -0
- package/src/printers/generic-base.ts +172 -1
- package/src/printers/klipper.ts +118 -2
- package/src/printers/octoprint.ts +86 -1
- package/src/printers/prusa.ts +60 -0
- package/src/printers/repetier.ts +63 -1
- package/src/slicer/bambu-cli.ts +13 -0
- package/src/slicer/filament-profiles.ts +249 -0
- package/src/stl/stl-manipulator.ts +100 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import type { AxiosInstance } from "axios";
|
|
6
6
|
import { PrinterImplementation } from "../types.js";
|
|
7
7
|
import { cancelPendingPrinterOperations, withPrinterOperation } from "../safety/artifact.js";
|
|
@@ -17,6 +17,15 @@ import {
|
|
|
17
17
|
type GenericPrintInspection,
|
|
18
18
|
type Heater,
|
|
19
19
|
} from "../safety/gcode-thermal.js";
|
|
20
|
+
import type { CounterpartProgress, FilamentSlot, PrinterIdentity } from "../counterpart/contract.js";
|
|
21
|
+
|
|
22
|
+
/** A camera snapshot URL as the firmware configures it, before resolution against the printer host. */
|
|
23
|
+
export interface SnapshotCandidate {
|
|
24
|
+
url: string;
|
|
25
|
+
source: string;
|
|
26
|
+
/** True when the firmware's own settings name this URL; false for a conventional fallback. */
|
|
27
|
+
configured: boolean;
|
|
28
|
+
}
|
|
20
29
|
|
|
21
30
|
/** Normalized adapter state used by the print and heating preflight. */
|
|
22
31
|
export interface GenericPrinterState {
|
|
@@ -45,15 +54,172 @@ export const REMOTE_DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
|
45
54
|
* uploaded, check fresh adapter state, ask a human (MCP elicitation), recheck
|
|
46
55
|
* state and only then dispatch. Heater-off and cancel are never gated.
|
|
47
56
|
*/
|
|
57
|
+
/** seenRunning: a report showed this job printing or paused, so a later ended report is about it, not about the previous job. */
|
|
58
|
+
type CheckedJob = { name: string; sha256: string; peaks: GenericPrintInspection["peaks"]; materials: string[]; seenRunning: boolean };
|
|
59
|
+
const checkedJob = (name: string, inspection: GenericPrintInspection): CheckedJob =>
|
|
60
|
+
({ name, sha256: inspection.sha256, peaks: inspection.peaks, materials: inspection.materials, seenRunning: false });
|
|
61
|
+
const RUNNING_STATES = new Set<string>(["preparing", "printing", "paused"]);
|
|
62
|
+
/** Reports after which the checked job can no longer be resumed. */
|
|
63
|
+
const ENDED_STATES = new Set<string>(["idle", "finished", "cancelled", "failed"]);
|
|
64
|
+
|
|
48
65
|
export abstract class GenericPrinterImplementation extends PrinterImplementation {
|
|
49
66
|
protected abstract readonly printerLabel: string;
|
|
50
67
|
/** True when rawUploadFile(print=true) starts the uploaded bytes atomically. */
|
|
51
68
|
protected readonly uploadStartsPrint: boolean = false;
|
|
52
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The last job this server instance inspected and started, per printer.
|
|
72
|
+
* Cleared by cancel and by a report that the job ended, so a later job
|
|
73
|
+
* reusing the name is never resumed on this record.
|
|
74
|
+
*/
|
|
75
|
+
private readonly checkedJobs = new Map<string, CheckedJob>();
|
|
76
|
+
|
|
53
77
|
constructor(apiClient: AxiosInstance, private readonly confirm?: ConfirmHardware) {
|
|
54
78
|
super(apiClient);
|
|
55
79
|
}
|
|
56
80
|
|
|
81
|
+
// ---- Counterpart contract (counterpart/1) ----
|
|
82
|
+
|
|
83
|
+
/** Normalized progress for counterpart_print_progress; throws when the printer cannot be read. */
|
|
84
|
+
abstract counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress>;
|
|
85
|
+
|
|
86
|
+
/** Progress that also retires the resume record once the printer reports the job ended. */
|
|
87
|
+
async observedProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
|
|
88
|
+
const progress = await this.counterpartProgress(host, port, apiKey);
|
|
89
|
+
const key = this.operationKey(host, port);
|
|
90
|
+
const checked = this.checkedJobs.get(key);
|
|
91
|
+
if (checked && RUNNING_STATES.has(progress.state) && progress.job?.name === checked.name) checked.seenRunning = true;
|
|
92
|
+
else if (checked?.seenRunning && (ENDED_STATES.has(progress.state) || (RUNNING_STATES.has(progress.state) && progress.job?.name !== undefined))) {
|
|
93
|
+
this.checkedJobs.delete(key);
|
|
94
|
+
}
|
|
95
|
+
return progress;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Optional identity fields; failures are ignored by the caller. */
|
|
99
|
+
async counterpartIdentity(_host: string, _port: string, _apiKey: string): Promise<PrinterIdentity> {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Snapshot URLs from the firmware's own settings, in preference order. */
|
|
104
|
+
async snapshotCandidates(_host: string, _port: string, _apiKey: string): Promise<SnapshotCandidate[]> {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The single extruder slot; adapters with a spool manager override this. */
|
|
109
|
+
async counterpartFilaments(_host: string, _port: string, _apiKey: string): Promise<FilamentSlot[]> {
|
|
110
|
+
return [{ id: "ext", source: "unknown" }];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether progress reports carry layer numbers on this firmware. */
|
|
114
|
+
async reportsLayers(_host: string, _port: string, _apiKey: string): Promise<boolean> {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async supportsPauseResume(_host: string, _port: string, _apiKey: string): Promise<boolean> {
|
|
119
|
+
return this.rawPauseJob !== undefined && this.rawResumeJob !== undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
protected rawPauseJob?(host: string, port: string, apiKey: string): Promise<unknown>;
|
|
123
|
+
protected rawResumeJob?(host: string, port: string, apiKey: string): Promise<unknown>;
|
|
124
|
+
|
|
125
|
+
/** Pause is never gated, like cancel and heater-off. */
|
|
126
|
+
async pauseJob(host: string, port: string, apiKey: string): Promise<{ status: string; action: "pause"; message: string }> {
|
|
127
|
+
if (!(await this.supportsPauseResume(host, port, apiKey)) || !this.rawPauseJob) {
|
|
128
|
+
throw new Error(`Unsupported: ${this.printerLabel} has no verified pause route in this server.`);
|
|
129
|
+
}
|
|
130
|
+
await this.rawPauseJob(host, port, apiKey);
|
|
131
|
+
return { status: "success", action: "pause", message: `Pause command sent to ${this.printerLabel} (${host}:${port}).` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resume reheats and moves the printer, so it is gated like a print start:
|
|
136
|
+
* only the job this server instance inspected and started, only while the
|
|
137
|
+
* printer freshly reports that job paused, with its bytes re-read and
|
|
138
|
+
* compared where the firmware can download them, and after human
|
|
139
|
+
* confirmation (unless the server opted out). A stop or heater-off request
|
|
140
|
+
* in the meantime cancels it.
|
|
141
|
+
*/
|
|
142
|
+
async resumeJob(host: string, port: string, apiKey: string): Promise<{ status: string; action: "resume"; message: string; jobName: string; bytesVerified: boolean }> {
|
|
143
|
+
if (!(await this.supportsPauseResume(host, port, apiKey)) || !this.rawResumeJob) {
|
|
144
|
+
throw new Error(`Unsupported: ${this.printerLabel} has no verified resume route in this server.`);
|
|
145
|
+
}
|
|
146
|
+
const key = this.operationKey(host, port);
|
|
147
|
+
return withPrinterOperation(host, key, async (assertActive) => {
|
|
148
|
+
const checked = this.checkedJobs.get(key);
|
|
149
|
+
if (!checked) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Print safety: resume_print only resumes a job this server instance inspected and started on ${this.printerLabel}; nothing was sent. ` +
|
|
152
|
+
"Check the paused job at the printer and resume it there."
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
await this.assertPausedJob(host, port, apiKey, checked.name);
|
|
156
|
+
let bytesVerified = false;
|
|
157
|
+
if (this.downloadRemoteFile) {
|
|
158
|
+
const sha256 = await this.remoteSha256(host, port, apiKey, checked.name).catch(() => {
|
|
159
|
+
throw new Error(`Print safety: could not re-read ${checked.name} from ${this.printerLabel} to confirm it is the inspected file; nothing was sent. Resume it at the printer.`);
|
|
160
|
+
});
|
|
161
|
+
if (sha256 !== checked.sha256) {
|
|
162
|
+
this.checkedJobs.delete(key);
|
|
163
|
+
throw new Error(`Print safety: ${checked.name} on ${this.printerLabel} is no longer the file this server inspected (SHA-256 changed); nothing was sent.`);
|
|
164
|
+
}
|
|
165
|
+
bytesVerified = true;
|
|
166
|
+
}
|
|
167
|
+
assertActive();
|
|
168
|
+
await requireHumanConfirmation(
|
|
169
|
+
this.confirm,
|
|
170
|
+
`Resume ${checked.name} on ${this.printerLabel} (${host}:${port})? It reheats to its targets (peak nozzle ${checked.peaks.nozzle}°C, ` +
|
|
171
|
+
`bed ${checked.peaks.bed}°C, chamber ${checked.peaks.chamber}°C) and moves. Materials: ${checked.materials.length ? checked.materials.join(", ") : "none declared"}. ` +
|
|
172
|
+
`File SHA-256: ${checked.sha256}${bytesVerified ? " (re-read from the printer)" : " (this firmware cannot re-read the file; matched by name)"}. ` +
|
|
173
|
+
"Confirm the nozzle path is clear and the filament is still loaded.",
|
|
174
|
+
"generic"
|
|
175
|
+
);
|
|
176
|
+
assertActive();
|
|
177
|
+
// Someone may have resumed or stopped it at the printer while the prompt was open.
|
|
178
|
+
await this.assertPausedJob(host, port, apiKey, checked.name);
|
|
179
|
+
// A progress read during the prompt may have retired this record (the job ended, and a new one
|
|
180
|
+
// could now be paused under the same name); only the record that was confirmed may be resumed.
|
|
181
|
+
if (this.checkedJobs.get(key) !== checked) {
|
|
182
|
+
throw new Error(`Print safety: ${checked.name} ended on ${this.printerLabel} while the confirmation was open; nothing was sent.`);
|
|
183
|
+
}
|
|
184
|
+
assertActive();
|
|
185
|
+
await this.rawResumeJob!(host, port, apiKey);
|
|
186
|
+
return {
|
|
187
|
+
status: "success",
|
|
188
|
+
action: "resume",
|
|
189
|
+
message: `Resume command sent to ${this.printerLabel} (${host}:${port}) for ${checked.name}.`,
|
|
190
|
+
jobName: checked.name,
|
|
191
|
+
bytesVerified,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async assertPausedJob(host: string, port: string, apiKey: string, name: string): Promise<void> {
|
|
197
|
+
const progress = await this.observedProgress(host, port, apiKey);
|
|
198
|
+
if (progress.state === "paused" && progress.job?.stage === "Pausing") {
|
|
199
|
+
// OctoPrint accepts a resume while still pausing and then drops it.
|
|
200
|
+
throw new Error(`Print safety: ${this.printerLabel} is still pausing; nothing was sent. Resume once it reports paused.`);
|
|
201
|
+
}
|
|
202
|
+
if (progress.state !== "paused") {
|
|
203
|
+
throw new Error(`Print safety: ${this.printerLabel} reports '${progress.state}', not a paused job; nothing was sent.`);
|
|
204
|
+
}
|
|
205
|
+
if (progress.job?.name !== name) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Print safety: the paused job (${progress.job?.name ?? "unknown"}) does not match the job this server started (${name}); nothing was sent.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async remoteSha256(host: string, port: string, apiKey: string, name: string): Promise<string> {
|
|
213
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "printer-resume-"));
|
|
214
|
+
try {
|
|
215
|
+
const destination = path.join(directory, "job.gcode");
|
|
216
|
+
await this.downloadRemoteFile!(host, port, apiKey, name, destination);
|
|
217
|
+
return createHash("sha256").update(await fs.readFile(destination)).digest("hex");
|
|
218
|
+
} finally {
|
|
219
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
57
223
|
protected abstract rawUploadFile(host: string, port: string, apiKey: string, filePath: string, filename: string, print: boolean): Promise<any>;
|
|
58
224
|
protected abstract rawStartJob(host: string, port: string, apiKey: string, filename: string): Promise<any>;
|
|
59
225
|
protected abstract rawCancelJob(host: string, port: string, apiKey: string): Promise<any>;
|
|
@@ -91,6 +257,8 @@ export abstract class GenericPrinterImplementation extends PrinterImplementation
|
|
|
91
257
|
|
|
92
258
|
async cancelJob(host: string, port: string, apiKey: string): Promise<any> {
|
|
93
259
|
cancelPendingPrinterOperations(host, this.operationKey(host, port));
|
|
260
|
+
// A cancelled job is never resumed, even if a later job reuses its name.
|
|
261
|
+
this.checkedJobs.delete(this.operationKey(host, port));
|
|
94
262
|
return this.rawCancelJob(host, port, apiKey);
|
|
95
263
|
}
|
|
96
264
|
|
|
@@ -241,6 +409,7 @@ export abstract class GenericPrinterImplementation extends PrinterImplementation
|
|
|
241
409
|
status: "success",
|
|
242
410
|
printRequested: true,
|
|
243
411
|
remotePath: remoteName,
|
|
412
|
+
jobName: remoteName,
|
|
244
413
|
sha256: inspection.sha256,
|
|
245
414
|
materials: inspection.materials,
|
|
246
415
|
peakTemperatures: inspection.peaks,
|
|
@@ -258,6 +427,7 @@ export abstract class GenericPrinterImplementation extends PrinterImplementation
|
|
|
258
427
|
if (!this.uploadStartedPrint(response)) {
|
|
259
428
|
throw new Error(`${this.printerLabel} stored ${remoteName} but reports that it did not start printing it.`);
|
|
260
429
|
}
|
|
430
|
+
this.checkedJobs.set(this.operationKey(host, port), checkedJob(remoteName, inspection));
|
|
261
431
|
return { ...summary, response };
|
|
262
432
|
}
|
|
263
433
|
const upload = await this.rawUploadFile(host, port, apiKey, snapshot, remoteName, false);
|
|
@@ -270,6 +440,7 @@ export abstract class GenericPrinterImplementation extends PrinterImplementation
|
|
|
270
440
|
this.assertBedClearance(dispatch, initial.finishedJob);
|
|
271
441
|
assertActive();
|
|
272
442
|
const response = await this.rawStartJob(host, port, apiKey, remoteName);
|
|
443
|
+
this.checkedJobs.set(this.operationKey(host, port), checkedJob(remoteName, inspection));
|
|
273
444
|
return { ...summary, upload, response };
|
|
274
445
|
}
|
|
275
446
|
|
package/src/printers/klipper.ts
CHANGED
|
@@ -1,13 +1,129 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import FormData from "form-data";
|
|
3
|
-
import {
|
|
3
|
+
import { isAxiosError } from "axios";
|
|
4
|
+
import { GenericPrinterImplementation, type GenericPrinterState, type SnapshotCandidate } from "./generic-base.js";
|
|
5
|
+
import type { CounterpartProgress, FilamentSlot, PrinterIdentity } from "../counterpart/contract.js";
|
|
6
|
+
import { finiteNumber, nonEmptyText } from "../counterpart/contract.js";
|
|
7
|
+
import { mapMoonraker } from "../counterpart/progress.js";
|
|
8
|
+
|
|
9
|
+
const PROGRESS_QUERY = "print_stats&virtual_sdcard&display_status&extruder&heater_bed&webhooks";
|
|
10
|
+
const ESTIMATE_TTL_MS = 10 * 60_000;
|
|
11
|
+
export const MACRO_TIMEOUT_MS = 60_000;
|
|
4
12
|
|
|
5
13
|
function encodePath(filename: string): string {
|
|
6
14
|
return filename.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/");
|
|
7
15
|
}
|
|
8
16
|
|
|
9
17
|
export class KlipperImplementation extends GenericPrinterImplementation {
|
|
10
|
-
protected readonly printerLabel = "Klipper/Moonraker";
|
|
18
|
+
protected readonly printerLabel: string = "Klipper/Moonraker";
|
|
19
|
+
/** Slicer estimates by host:port:filename, so a poll does not re-read file metadata. */
|
|
20
|
+
private readonly estimates = new Map<string, { value?: number; at: number }>();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One object query; remaining time is the file's slicer estimate minus
|
|
24
|
+
* print_duration. https://moonraker.readthedocs.io/en/latest/external_api/printer/
|
|
25
|
+
* and .../external_api/file_manager/ (estimated_time, seconds).
|
|
26
|
+
*/
|
|
27
|
+
async counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
|
|
28
|
+
const data = (await this.apiClient.get(`http://${host}:${port}/printer/objects/query?${PROGRESS_QUERY}`, this.authHeaders(apiKey))).data;
|
|
29
|
+
const observedAt = new Date().toISOString();
|
|
30
|
+
const status = data?.result?.status;
|
|
31
|
+
if (!status || typeof status !== "object") throw new Error("Moonraker returned no printer object status.");
|
|
32
|
+
const stats = status.print_stats ?? {};
|
|
33
|
+
const filename = nonEmptyText(stats.filename);
|
|
34
|
+
const estimate = filename && (stats.state === "printing" || stats.state === "paused")
|
|
35
|
+
? await this.estimatedTime(host, port, apiKey, filename)
|
|
36
|
+
: undefined;
|
|
37
|
+
return mapMoonraker(status, estimate, observedAt);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async estimatedTime(host: string, port: string, apiKey: string, filename: string): Promise<number | undefined> {
|
|
41
|
+
const key = `${host}:${port}:${filename}`;
|
|
42
|
+
const cached = this.estimates.get(key);
|
|
43
|
+
if (cached && Date.now() - cached.at < ESTIMATE_TTL_MS) return cached.value;
|
|
44
|
+
let value: number | undefined;
|
|
45
|
+
try {
|
|
46
|
+
const metadata = (await this.apiClient.get(
|
|
47
|
+
`http://${host}:${port}/server/files/metadata?filename=${encodeURIComponent(filename)}`,
|
|
48
|
+
this.authHeaders(apiKey)
|
|
49
|
+
)).data;
|
|
50
|
+
const estimate = finiteNumber(metadata?.result?.estimated_time ?? metadata?.estimated_time);
|
|
51
|
+
value = estimate !== undefined && estimate > 0 ? estimate : undefined;
|
|
52
|
+
} catch {
|
|
53
|
+
value = undefined;
|
|
54
|
+
}
|
|
55
|
+
if (this.estimates.size > 64) this.estimates.clear();
|
|
56
|
+
this.estimates.set(key, { value, at: Date.now() });
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async reportsLayers(): Promise<boolean> {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* GET /server/webcams/list: enabled webcams' snapshot_url, which may be a
|
|
66
|
+
* path relative to the host. https://moonraker.readthedocs.io/en/latest/external_api/webcams/
|
|
67
|
+
*/
|
|
68
|
+
async snapshotCandidates(host: string, port: string, apiKey: string): Promise<SnapshotCandidate[]> {
|
|
69
|
+
const data = (await this.apiClient.get(`http://${host}:${port}/server/webcams/list`, this.authHeaders(apiKey))).data;
|
|
70
|
+
const webcams = Array.isArray(data?.result?.webcams) ? data.result.webcams : [];
|
|
71
|
+
return webcams
|
|
72
|
+
.filter((webcam: any) => webcam?.enabled !== false && nonEmptyText(webcam?.snapshot_url))
|
|
73
|
+
.map((webcam: any) => ({ url: String(webcam.snapshot_url).trim(), source: "moonraker-webcam", configured: true }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Spoolman through Moonraker: GET /server/spoolman/spool_id, then the proxy
|
|
78
|
+
* for /v1/spool/<id>. https://moonraker.readthedocs.io/en/latest/external_api/integrations/
|
|
79
|
+
* Spoolman color_hex has no '#'. Without Spoolman (404, null id) the slot is unknown.
|
|
80
|
+
*/
|
|
81
|
+
async counterpartFilaments(host: string, port: string, apiKey: string): Promise<FilamentSlot[]> {
|
|
82
|
+
const unknown: FilamentSlot[] = [{ id: "ext", source: "unknown" }];
|
|
83
|
+
let spoolId: number | undefined;
|
|
84
|
+
try {
|
|
85
|
+
const active = (await this.apiClient.get(`http://${host}:${port}/server/spoolman/spool_id`, this.authHeaders(apiKey))).data;
|
|
86
|
+
spoolId = finiteNumber(active?.result?.spool_id);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (isAxiosError(error) && error.response) return unknown;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
if (spoolId === undefined) return unknown;
|
|
92
|
+
const proxied = (await this.apiClient.post(
|
|
93
|
+
`http://${host}:${port}/server/spoolman/proxy`,
|
|
94
|
+
{ request_method: "GET", path: `/v1/spool/${spoolId}` } as any,
|
|
95
|
+
this.authHeaders(apiKey)
|
|
96
|
+
)).data;
|
|
97
|
+
// v1 responses return the spool directly; use_v2_response wraps it in {response, error}.
|
|
98
|
+
const spool = proxied?.result?.response ?? proxied?.result;
|
|
99
|
+
const filament = spool?.filament ?? {};
|
|
100
|
+
const hex = nonEmptyText(filament.color_hex)?.replace(/^#/, "");
|
|
101
|
+
return [{
|
|
102
|
+
id: "ext",
|
|
103
|
+
material: nonEmptyText(filament.material),
|
|
104
|
+
color: hex && /^[0-9a-f]{6}$/i.test(hex) ? `#${hex.toUpperCase()}` : undefined,
|
|
105
|
+
name: nonEmptyText(filament.name),
|
|
106
|
+
source: "spoolman",
|
|
107
|
+
}];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity> {
|
|
111
|
+
const info = (await this.apiClient.get(`http://${host}:${port}/printer/info`, this.authHeaders(apiKey))).data;
|
|
112
|
+
return { name: nonEmptyText(info?.result?.hostname) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* POST /printer/print/pause and /printer/print/resume (Moonraker printer API).
|
|
117
|
+
* Moonraker answers after the PAUSE/RESUME macro finishes (parking, retract),
|
|
118
|
+
* which can exceed the shared 10 s client timeout.
|
|
119
|
+
*/
|
|
120
|
+
protected async rawPauseJob(host: string, port: string, apiKey: string) {
|
|
121
|
+
return (await this.apiClient.post(`http://${host}:${port}/printer/print/pause`, null as any, { ...this.authHeaders(apiKey), timeout: MACRO_TIMEOUT_MS })).data;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
protected async rawResumeJob(host: string, port: string, apiKey: string) {
|
|
125
|
+
return (await this.apiClient.post(`http://${host}:${port}/printer/print/resume`, null as any, { ...this.authHeaders(apiKey), timeout: MACRO_TIMEOUT_MS })).data;
|
|
126
|
+
}
|
|
11
127
|
|
|
12
128
|
protected authHeaders(apiKey: string): Record<string, unknown> {
|
|
13
129
|
return apiKey ? { headers: { "X-Api-Key": apiKey } } : {};
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import FormData from "form-data";
|
|
3
3
|
import { isAxiosError } from "axios";
|
|
4
|
-
import { GenericPrinterImplementation, type GenericPrinterState, type HeaterComponent } from "./generic-base.js";
|
|
4
|
+
import { GenericPrinterImplementation, type GenericPrinterState, type HeaterComponent, type SnapshotCandidate } from "./generic-base.js";
|
|
5
|
+
import type { CounterpartProgress, PrinterIdentity } from "../counterpart/contract.js";
|
|
6
|
+
import { nonEmptyText } from "../counterpart/contract.js";
|
|
7
|
+
import { mapOctoPrint } from "../counterpart/progress.js";
|
|
5
8
|
|
|
6
9
|
const BLOCKING_FLAGS = ["printing", "paused", "pausing", "cancelling", "resuming", "finishing", "error", "closedOrError"];
|
|
7
10
|
|
|
@@ -14,6 +17,88 @@ export class OctoPrintImplementation extends GenericPrinterImplementation {
|
|
|
14
17
|
// POST /api/files/local with print=true uploads and starts the same bytes.
|
|
15
18
|
protected readonly uploadStartsPrint = true;
|
|
16
19
|
|
|
20
|
+
/**
|
|
21
|
+
* GET /api/job plus GET /api/printer (temperatures; 409 when not
|
|
22
|
+
* operational). An Operational job with no progress reads the file's print
|
|
23
|
+
* history so a cancelled job is reported as cancelled.
|
|
24
|
+
* https://docs.octoprint.org/en/master/api/job.html
|
|
25
|
+
*/
|
|
26
|
+
async counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
|
|
27
|
+
const base = `http://${host}:${port}`;
|
|
28
|
+
const headers = { "X-Api-Key": apiKey };
|
|
29
|
+
const job = (await this.apiClient.get(`${base}/api/job`, { headers })).data;
|
|
30
|
+
let printer: any;
|
|
31
|
+
try {
|
|
32
|
+
printer = (await this.apiClient.get(`${base}/api/printer`, { headers })).data;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (!(isAxiosError(error) && error.response?.status === 409)) throw error;
|
|
35
|
+
}
|
|
36
|
+
const observedAt = new Date().toISOString();
|
|
37
|
+
let lastPrint: { success?: unknown } | undefined;
|
|
38
|
+
const file = job?.job?.file;
|
|
39
|
+
if (String(job?.state ?? "").toLowerCase() === "operational" && typeof file?.path === "string" && file.path &&
|
|
40
|
+
(job?.progress?.completion === null || job?.progress?.completion === undefined)) {
|
|
41
|
+
const origin = file.origin === "sdcard" ? "sdcard" : "local";
|
|
42
|
+
lastPrint = await this.apiClient.get(`${base}/api/files/${origin}/${encodePath(file.path)}`, { headers })
|
|
43
|
+
.then((response) => response.data?.prints?.last)
|
|
44
|
+
.catch(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
return mapOctoPrint({ job, printer, lastPrint }, observedAt);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Snapshot URL from GET /api/settings: the bundled Classic Webcam plugin's
|
|
51
|
+
* plugins.classicwebcam.snapshot on 1.9+, webcam.snapshotUrl on 1.8 and
|
|
52
|
+
* earlier (1.9+ still fills it through its compatibility layer), then the
|
|
53
|
+
* OctoPi convention http://host/webcam/?action=snapshot.
|
|
54
|
+
* Source: OctoPrint src/octoprint/server/api/settings.py and
|
|
55
|
+
* src/octoprint/plugins/classicwebcam/__init__.py; checked on 1.8.7 and 1.11.2.
|
|
56
|
+
*/
|
|
57
|
+
async snapshotCandidates(host: string, port: string, apiKey: string): Promise<SnapshotCandidate[]> {
|
|
58
|
+
const settings = (await this.apiClient.get(`http://${host}:${port}/api/settings`, { headers: { "X-Api-Key": apiKey } })).data;
|
|
59
|
+
const candidates: SnapshotCandidate[] = [];
|
|
60
|
+
if (settings?.webcam?.webcamEnabled !== false) {
|
|
61
|
+
const configured = [
|
|
62
|
+
settings?.plugins?.classicwebcam?.snapshot,
|
|
63
|
+
settings?.webcam?.snapshotUrl,
|
|
64
|
+
...(Array.isArray(settings?.webcam?.webcams) ? settings.webcam.webcams.map((webcam: any) => webcam?.compat?.snapshot) : []),
|
|
65
|
+
];
|
|
66
|
+
for (const url of configured) {
|
|
67
|
+
const text = nonEmptyText(url);
|
|
68
|
+
if (text) candidates.push({ url: text, source: "octoprint-settings", configured: true });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
candidates.push({ url: `http://${host}/webcam/?action=snapshot`, source: "octoprint-fallback", configured: false });
|
|
72
|
+
return candidates;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity> {
|
|
76
|
+
const headers = { "X-Api-Key": apiKey };
|
|
77
|
+
const [profiles, settings] = await Promise.all([
|
|
78
|
+
this.apiClient.get(`http://${host}:${port}/api/printerprofiles`, { headers }).then((r) => r.data).catch(() => undefined),
|
|
79
|
+
this.apiClient.get(`http://${host}:${port}/api/settings`, { headers }).then((r) => r.data).catch(() => undefined),
|
|
80
|
+
]);
|
|
81
|
+
const all = Object.values(profiles?.profiles ?? {}) as any[];
|
|
82
|
+
const profile = all.find((entry) => entry?.current) ?? all.find((entry) => entry?.default);
|
|
83
|
+
return { model: nonEmptyText(profile?.model), name: nonEmptyText(settings?.appearance?.name) ?? nonEmptyText(profile?.name) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** POST /api/job {command:"pause", action:"pause"|"resume"}; 204 on success, 409 without an active job. */
|
|
87
|
+
protected async rawPauseJob(host: string, port: string, apiKey: string) {
|
|
88
|
+
return this.jobCommand(host, port, apiKey, { command: "pause", action: "pause" });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
protected async rawResumeJob(host: string, port: string, apiKey: string) {
|
|
92
|
+
return this.jobCommand(host, port, apiKey, { command: "pause", action: "resume" });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async jobCommand(host: string, port: string, apiKey: string, body: Record<string, string>) {
|
|
96
|
+
const response = await this.apiClient.post(`http://${host}:${port}/api/job`, body as any, {
|
|
97
|
+
headers: { "X-Api-Key": apiKey, "Content-Type": "application/json" },
|
|
98
|
+
});
|
|
99
|
+
return response.data;
|
|
100
|
+
}
|
|
101
|
+
|
|
17
102
|
async getStatus(host: string, port: string, apiKey: string) {
|
|
18
103
|
const url = `http://${host}:${port}/api/printer`;
|
|
19
104
|
const response = await this.apiClient.get(url, {
|
package/src/printers/prusa.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { isAxiosError, type AxiosRequestConfig, type AxiosResponse } from "axios";
|
|
2
2
|
import { GenericPrinterImplementation, type GenericPrinterState, type HeaterComponent } from "./generic-base.js";
|
|
3
|
+
import type { CounterpartProgress, PrinterIdentity } from "../counterpart/contract.js";
|
|
4
|
+
import { finiteNumber, nonEmptyText } from "../counterpart/contract.js";
|
|
5
|
+
import { mapOctoPrint, mapPrusaLink } from "../counterpart/progress.js";
|
|
3
6
|
import fs from "fs";
|
|
4
7
|
import FormData from "form-data";
|
|
5
8
|
|
|
@@ -124,6 +127,63 @@ export class PrusaImplementation extends GenericPrinterImplementation {
|
|
|
124
127
|
throw lastError ?? new Error("No compatible Prusa POST endpoint found.");
|
|
125
128
|
}
|
|
126
129
|
|
|
130
|
+
/**
|
|
131
|
+
* PrusaLink GET /api/v1/status, plus GET /api/v1/job for the file name
|
|
132
|
+
* (204 when there is no job). Older firmware without /api/v1 uses the
|
|
133
|
+
* OctoPrint-compatible /api/job and /api/printer.
|
|
134
|
+
* https://github.com/prusa3d/Prusa-Link-Web/blob/master/spec/openapi.yaml
|
|
135
|
+
*/
|
|
136
|
+
async counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
|
|
137
|
+
const baseUrl = this.buildBaseUrl(host, port);
|
|
138
|
+
const headers = this.buildAuthHeaders(apiKey);
|
|
139
|
+
let status: any;
|
|
140
|
+
try {
|
|
141
|
+
status = (await this.apiClient.get(`${baseUrl}/api/v1/status`, { headers })).data;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (!this.isFallbackStatus(error)) throw error;
|
|
144
|
+
const job = (await this.apiClient.get(`${baseUrl}/api/job`, { headers })).data;
|
|
145
|
+
const printer = await this.apiClient.get(`${baseUrl}/api/printer`, { headers }).then((r) => r.data).catch(() => undefined);
|
|
146
|
+
return mapOctoPrint({ job, printer }, new Date().toISOString());
|
|
147
|
+
}
|
|
148
|
+
const observedAt = new Date().toISOString();
|
|
149
|
+
let job: any;
|
|
150
|
+
if (status?.job && finiteNumber(status.job.id) !== undefined) {
|
|
151
|
+
const response = await this.apiClient.get(`${baseUrl}/api/v1/job`, { headers }).catch(() => undefined);
|
|
152
|
+
job = response?.status === 200 ? response.data : undefined;
|
|
153
|
+
}
|
|
154
|
+
return mapPrusaLink(status, job, observedAt);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity> {
|
|
158
|
+
const info = (await this.apiClient.get(`${this.buildBaseUrl(host, port)}/api/v1/info`, { headers: this.buildAuthHeaders(apiKey) })).data;
|
|
159
|
+
return { name: nonEmptyText(info?.name) ?? nonEmptyText(info?.hostname), serial: nonEmptyText(info?.serial) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** PUT /api/v1/job/{id}/pause; the OctoPrint-compatible POST /api/job on older firmware. */
|
|
163
|
+
protected async rawPauseJob(host: string, port: string, apiKey: string) {
|
|
164
|
+
return this.jobControl(host, port, apiKey, "pause");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
protected async rawResumeJob(host: string, port: string, apiKey: string) {
|
|
168
|
+
return this.jobControl(host, port, apiKey, "resume");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async jobControl(host: string, port: string, apiKey: string, action: "pause" | "resume") {
|
|
172
|
+
const baseUrl = this.buildBaseUrl(host, port);
|
|
173
|
+
const headers = this.buildAuthHeaders(apiKey);
|
|
174
|
+
let id: number | undefined;
|
|
175
|
+
try {
|
|
176
|
+
id = finiteNumber((await this.apiClient.get(`${baseUrl}/api/v1/status`, { headers })).data?.job?.id);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (!this.isFallbackStatus(error)) throw error;
|
|
179
|
+
const response = await this.apiClient.post(`${baseUrl}/api/job`, { command: "pause", action } as any, { headers });
|
|
180
|
+
return response.data;
|
|
181
|
+
}
|
|
182
|
+
if (id === undefined) throw new Error(`PrusaLink reports no current job to ${action}; nothing was sent.`);
|
|
183
|
+
const response = await this.apiClient.put(`${baseUrl}/api/v1/job/${id}/${action}`, undefined, { headers });
|
|
184
|
+
return response.data;
|
|
185
|
+
}
|
|
186
|
+
|
|
127
187
|
async getStatus(host: string, port: string, apiKey: string) {
|
|
128
188
|
const response = await this.getWithFallback(host, port, apiKey, [
|
|
129
189
|
"/api/v1/status",
|
package/src/printers/repetier.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import FormData from "form-data";
|
|
3
|
-
import { GenericPrinterImplementation, type GenericPrinterState } from "./generic-base.js";
|
|
3
|
+
import { GenericPrinterImplementation, type GenericPrinterState, type SnapshotCandidate } from "./generic-base.js";
|
|
4
|
+
import type { CounterpartProgress, PrinterIdentity } from "../counterpart/contract.js";
|
|
5
|
+
import { nonEmptyText } from "../counterpart/contract.js";
|
|
6
|
+
import { mapRepetier } from "../counterpart/progress.js";
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* Note: Repetier-Server documents printer commands at /printer/api/<slug>;
|
|
@@ -12,6 +15,65 @@ export class RepetierImplementation extends GenericPrinterImplementation {
|
|
|
12
15
|
// The upload form's print=1 flag uploads and starts the same bytes.
|
|
13
16
|
protected readonly uploadStartsPrint = true;
|
|
14
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The printer the counterpart tools address: REPETIER_SLUG when set,
|
|
20
|
+
* otherwise the only printer listPrinter reports.
|
|
21
|
+
* REST form: /printer/api/<slug>?a=<command>&data=<json>&apikey=<key>
|
|
22
|
+
* (https://www.repetier-server.com/manuals/programming/API/index.html).
|
|
23
|
+
*/
|
|
24
|
+
private async selectPrinter(host: string, port: string, apiKey: string): Promise<{ slug: string; entry: any }> {
|
|
25
|
+
const wanted = process.env.REPETIER_SLUG?.trim();
|
|
26
|
+
const route = wanted ? encodeURIComponent(wanted) : "";
|
|
27
|
+
const data = (await this.apiClient.get(`http://${host}:${port}/printer/api/${route}?a=listPrinter&apikey=${encodeURIComponent(apiKey)}`)).data;
|
|
28
|
+
const printers: any[] = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
|
|
29
|
+
if (wanted) {
|
|
30
|
+
const entry = printers.find((printer) => printer?.slug === wanted);
|
|
31
|
+
if (!entry) throw new Error(`Repetier lists no printer with slug "${wanted}" (REPETIER_SLUG).`);
|
|
32
|
+
return { slug: wanted, entry };
|
|
33
|
+
}
|
|
34
|
+
if (printers.length !== 1 || typeof printers[0]?.slug !== "string") {
|
|
35
|
+
throw new Error(`Repetier reports ${printers.length} printers; set REPETIER_SLUG to the printer's slug.`);
|
|
36
|
+
}
|
|
37
|
+
return { slug: printers[0].slug, entry: printers[0] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private printerUrl(host: string, port: string, apiKey: string, slug: string, action: string, data?: unknown): string {
|
|
41
|
+
const query = `a=${action}${data === undefined ? "" : `&data=${encodeURIComponent(JSON.stringify(data))}`}&apikey=${encodeURIComponent(apiKey)}`;
|
|
42
|
+
return `http://${host}:${port}/printer/api/${encodeURIComponent(slug)}?${query}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async counterpartProgress(host: string, port: string, apiKey: string): Promise<CounterpartProgress> {
|
|
46
|
+
const { entry } = await this.selectPrinter(host, port, apiKey);
|
|
47
|
+
return mapRepetier(entry, new Date().toISOString());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async counterpartIdentity(host: string, port: string, apiKey: string): Promise<PrinterIdentity> {
|
|
51
|
+
const { entry, slug } = await this.selectPrinter(host, port, apiKey);
|
|
52
|
+
return { name: nonEmptyText(entry?.name) ?? slug };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** getPrinterConfig webcams[].staticUrl (older servers: a single webcam object). */
|
|
56
|
+
async snapshotCandidates(host: string, port: string, apiKey: string): Promise<SnapshotCandidate[]> {
|
|
57
|
+
const { slug } = await this.selectPrinter(host, port, apiKey);
|
|
58
|
+
const config = (await this.apiClient.get(this.printerUrl(host, port, apiKey, slug, "getPrinterConfig"))).data;
|
|
59
|
+
const webcams = Array.isArray(config?.webcams) ? config.webcams : config?.webcam ? [config.webcam] : [];
|
|
60
|
+
return webcams
|
|
61
|
+
.map((webcam: any) => nonEmptyText(webcam?.staticUrl))
|
|
62
|
+
.filter((url: string | undefined): url is string => url !== undefined)
|
|
63
|
+
.map((url: string) => ({ url, source: "repetier-webcam", configured: true }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Pause sends the @pause host command; resume is the documented continueJob. */
|
|
67
|
+
protected async rawPauseJob(host: string, port: string, apiKey: string) {
|
|
68
|
+
const { slug } = await this.selectPrinter(host, port, apiKey);
|
|
69
|
+
return (await this.apiClient.get(this.printerUrl(host, port, apiKey, slug, "send", { cmd: "@pause" }))).data;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
protected async rawResumeJob(host: string, port: string, apiKey: string) {
|
|
73
|
+
const { slug } = await this.selectPrinter(host, port, apiKey);
|
|
74
|
+
return (await this.apiClient.get(this.printerUrl(host, port, apiKey, slug, "continueJob"))).data;
|
|
75
|
+
}
|
|
76
|
+
|
|
15
77
|
async getStatus(host: string, port: string, apiKey: string) {
|
|
16
78
|
const url = `http://${host}:${port}/printer/api/?a=getPrinterInfo&apikey=${apiKey}`;
|
|
17
79
|
const response = await this.apiClient.get(url);
|
package/src/slicer/bambu-cli.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface BambuSliceOptions {
|
|
|
49
49
|
minSave?: boolean; // --min-save
|
|
50
50
|
skipModifiedGcodes?: boolean;// --skip-modified-gcodes
|
|
51
51
|
slicePlate?: number; // --slice N (0 = all plates)
|
|
52
|
+
/** Process keys written over the resolved process preset (layer_height, wall_loops, sparse_infill_density). */
|
|
53
|
+
processOverrides?: Record<string, string>;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
export interface BambuSettingsBundle {
|
|
@@ -527,6 +529,17 @@ export class BambuCliProfilePreparer {
|
|
|
527
529
|
this.writeTempJson(outputBase, "process_orca_resolved", this.sanitizeProcessForOrca(this.readJsonFile(processPath), printerPreset)),
|
|
528
530
|
].join(";");
|
|
529
531
|
}
|
|
532
|
+
// Print-sheet quality/strength choices win over the preset and any template.
|
|
533
|
+
if (options.processOverrides && Object.keys(options.processOverrides).length && bundle.settingsArg) {
|
|
534
|
+
const [machinePath, processPath, ...rest] = bundle.settingsArg.split(";");
|
|
535
|
+
if (processPath) {
|
|
536
|
+
bundle.settingsArg = [
|
|
537
|
+
machinePath,
|
|
538
|
+
this.writeTempJson(outputBase, "process_overrides", { ...this.readJsonFile(processPath), ...options.processOverrides }),
|
|
539
|
+
...rest,
|
|
540
|
+
].join(";");
|
|
541
|
+
}
|
|
542
|
+
}
|
|
530
543
|
return this.isolateMachineSettings(bundle);
|
|
531
544
|
}
|
|
532
545
|
|