@tsplc/compiler 0.1.0 → 0.2.1
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/dist/cli.js +718 -62
- package/dist/cli.js.map +9 -6
- package/dist/project.js +22 -16
- package/dist/project.js.map +3 -3
- package/package.json +6 -5
- package/project/cli.ts +35 -12
- package/project/plant.ts +437 -0
- package/project/project.ts +34 -20
package/project/plant.ts
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { basename, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
AgentOperationError,
|
|
6
|
+
type AgentClient,
|
|
7
|
+
createAgentClient,
|
|
8
|
+
type ReleaseFile,
|
|
9
|
+
} from "@tsplc/agent/client";
|
|
10
|
+
import type { Credentials, ReleaseManifest, Target, Variable } from "@tsplc/agent";
|
|
11
|
+
import type { LoginCommand, LoginResult } from "@tsplc/build";
|
|
12
|
+
import { controllerFor, type loadTsplcConfig } from "@tsplc/config";
|
|
13
|
+
import { hasErrors } from "@tsplc/compiler/ir";
|
|
14
|
+
import { generateProject } from "@tsplc/typegen";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { buildProject, projectTargets } from "./project.ts";
|
|
17
|
+
import { buildServer } from "./server.ts";
|
|
18
|
+
|
|
19
|
+
export const plantCommands = new Set([
|
|
20
|
+
"deploy",
|
|
21
|
+
"target",
|
|
22
|
+
"status",
|
|
23
|
+
"login",
|
|
24
|
+
"logout",
|
|
25
|
+
"vars",
|
|
26
|
+
"read",
|
|
27
|
+
"write",
|
|
28
|
+
"force",
|
|
29
|
+
"unforce",
|
|
30
|
+
"start",
|
|
31
|
+
"stop",
|
|
32
|
+
"reset",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export interface PlantFlags {
|
|
36
|
+
download?: boolean;
|
|
37
|
+
"initialize-user"?: boolean;
|
|
38
|
+
restore?: boolean;
|
|
39
|
+
select?: boolean;
|
|
40
|
+
yes?: boolean;
|
|
41
|
+
"frozen-lockfile"?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type LoadedConfig = Awaited<ReturnType<typeof loadTsplcConfig>>;
|
|
45
|
+
|
|
46
|
+
const ask = async (question: string) => {
|
|
47
|
+
const lines = createInterface({ input: process.stdin, output: process.stdout });
|
|
48
|
+
try {
|
|
49
|
+
return (await lines.question(question)).trim();
|
|
50
|
+
} finally {
|
|
51
|
+
lines.close();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Reads a line without echoing it. */
|
|
56
|
+
const askSecret = (question: string) =>
|
|
57
|
+
new Promise<string>((accept, reject) => {
|
|
58
|
+
if (!process.stdin.isTTY) {
|
|
59
|
+
reject(new Error("The controller password needs an interactive terminal."));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Echo goes off before the prompt appears, so nothing typed at it can show.
|
|
63
|
+
process.stdin.setRawMode(true);
|
|
64
|
+
process.stdin.setEncoding("utf8");
|
|
65
|
+
process.stdin.resume();
|
|
66
|
+
process.stdout.write(question);
|
|
67
|
+
let secret = "";
|
|
68
|
+
const finish = () => {
|
|
69
|
+
process.stdin.off("data", read);
|
|
70
|
+
process.stdin.setRawMode(false);
|
|
71
|
+
process.stdin.pause();
|
|
72
|
+
process.stdout.write("\n");
|
|
73
|
+
};
|
|
74
|
+
const read = (text: string) => {
|
|
75
|
+
for (const character of text) {
|
|
76
|
+
if (character === "\r" || character === "\n") {
|
|
77
|
+
finish();
|
|
78
|
+
accept(secret);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (character === "\u0003") {
|
|
82
|
+
finish();
|
|
83
|
+
reject(new Error("Cancelled."));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
secret = character === "\u007f" ? secret.slice(0, -1) : secret + character;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
process.stdin.on("data", read);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const choose = async <T>(question: string, items: readonly T[], label: (item: T) => string) => {
|
|
93
|
+
items.forEach((item, index) => console.log(` ${index + 1}. ${label(item)}`));
|
|
94
|
+
while (true) {
|
|
95
|
+
const item = items[Number(await ask(`${question} [1-${items.length}]: `)) - 1];
|
|
96
|
+
if (item !== undefined) return item;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const confirm = async (question: string, flags: PlantFlags) =>
|
|
101
|
+
flags.yes || /^y(es)?$/i.test(await ask(`${question} [y/N]: `));
|
|
102
|
+
|
|
103
|
+
const askCredentials = async (): Promise<Credentials> => ({
|
|
104
|
+
username: await ask("Controller username: "),
|
|
105
|
+
password: await askSecret("Controller password: "),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const progress = (message: string) => console.log(` ${message}`);
|
|
109
|
+
|
|
110
|
+
const connect = () => {
|
|
111
|
+
const environment = z
|
|
112
|
+
.object({
|
|
113
|
+
TSPLC_AGENT_URL: z.url({
|
|
114
|
+
error: "Set TSPLC_AGENT_URL to the plant agent, such as http://line-pc:4850",
|
|
115
|
+
}),
|
|
116
|
+
TSPLC_AGENT_TOKEN: z.string({ error: "Set TSPLC_AGENT_TOKEN to the plant agent's token" }),
|
|
117
|
+
})
|
|
118
|
+
.parse(process.env);
|
|
119
|
+
return createAgentClient({
|
|
120
|
+
url: environment.TSPLC_AGENT_URL,
|
|
121
|
+
token: environment.TSPLC_AGENT_TOKEN,
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const selectTarget = async (agent: AgentClient): Promise<Target> => {
|
|
126
|
+
const interfaces = await agent.interfaces();
|
|
127
|
+
if (!interfaces.length) throw new Error("The plant PC has no IPv4 network.");
|
|
128
|
+
const network =
|
|
129
|
+
interfaces.length === 1 && interfaces[0]
|
|
130
|
+
? interfaces[0]
|
|
131
|
+
: await choose(
|
|
132
|
+
"Which network is the controller on?",
|
|
133
|
+
interfaces,
|
|
134
|
+
(item) => `${item.name} ${item.address}/${item.prefixLength}`,
|
|
135
|
+
);
|
|
136
|
+
console.log(`Scanning ${network.address}/${network.prefixLength} for CODESYS controllers…`);
|
|
137
|
+
const found = await agent.discover(network.name);
|
|
138
|
+
const manual = { address: "", port: 11740, hostname: null };
|
|
139
|
+
const choice = await choose("Which controller?", [...found, manual], (item) =>
|
|
140
|
+
item === manual
|
|
141
|
+
? "Enter an address"
|
|
142
|
+
: `${item.address}${item.hostname ? ` (${item.hostname})` : ""}`,
|
|
143
|
+
);
|
|
144
|
+
const host = choice === manual ? await ask("Controller address: ") : choice.address;
|
|
145
|
+
return agent.setTarget({ host, port: choice.port });
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Every published leaf the generated contract describes, as CODESYS expressions name it. */
|
|
149
|
+
const contractVariables = async (config: LoadedConfig["config"], root: string) => {
|
|
150
|
+
if (!config.typegen) return [];
|
|
151
|
+
const contract = z
|
|
152
|
+
.object({
|
|
153
|
+
symbols: z.array(z.object({ name: z.string(), gvl: z.string(), type: z.string() })),
|
|
154
|
+
typeFields: z.record(z.string(), z.array(z.object({ field: z.string(), type: z.string() }))),
|
|
155
|
+
})
|
|
156
|
+
.parse(await import(pathToFileURL(resolve(root, config.typegen.dir, "plc.ts")).href));
|
|
157
|
+
return contract.symbols.flatMap(({ name, gvl, type }): Variable[] => {
|
|
158
|
+
const path = `${gvl}.${name}`;
|
|
159
|
+
const fields = contract.typeFields[type];
|
|
160
|
+
if (!fields) return [{ path, type }];
|
|
161
|
+
return fields.map((field) => ({
|
|
162
|
+
path:
|
|
163
|
+
field.field === ""
|
|
164
|
+
? path
|
|
165
|
+
: `${path}${field.field.startsWith("[") ? "" : "."}${field.field}`,
|
|
166
|
+
type: field.type,
|
|
167
|
+
}));
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** Compiles the production program and its server into the files `tsplc deploy` uploads. */
|
|
172
|
+
const prepareRelease = async (file: string, loaded: LoadedConfig, flags: PlantFlags) => {
|
|
173
|
+
const { config, root } = loaded;
|
|
174
|
+
if (!config.compiler) throw new Error("tsplc deploy deploys tsPLC programs; configure compiler.");
|
|
175
|
+
const emitted = await buildProject(file, {
|
|
176
|
+
emitOnly: true,
|
|
177
|
+
forController: true,
|
|
178
|
+
frozenLockfile: flags["frozen-lockfile"],
|
|
179
|
+
loaded,
|
|
180
|
+
buildTarget: "production",
|
|
181
|
+
controller: controllerFor(config, "deploy"),
|
|
182
|
+
});
|
|
183
|
+
if (hasErrors(emitted.diagnostics)) return { diagnostics: emitted.diagnostics };
|
|
184
|
+
const settings = emitted.controllerConfig;
|
|
185
|
+
if (!settings) throw new Error("Configure build settings for deployment.");
|
|
186
|
+
await generateProject(file, { loaded, targets: await projectTargets(file, { loaded }) });
|
|
187
|
+
|
|
188
|
+
const portable = (path: string) => path.split(sep).join("/");
|
|
189
|
+
const libraries = (settings.libraries ?? []).map((library) => ({
|
|
190
|
+
...library,
|
|
191
|
+
release: `libraries/${library.sha256}-${basename(library.path)}`,
|
|
192
|
+
}));
|
|
193
|
+
const files: ReleaseFile[] = [
|
|
194
|
+
{ path: "template.project", content: Bun.file(settings.template) },
|
|
195
|
+
...libraries.map((library) => ({ path: library.release, content: Bun.file(library.path) })),
|
|
196
|
+
];
|
|
197
|
+
// Plain TypeScript programs emit their generated modules under `.tsplc/`.
|
|
198
|
+
for await (const source of new Bun.Glob("**/*").scan({ cwd: settings.sourceDir, dot: true }))
|
|
199
|
+
files.push({
|
|
200
|
+
path: `st/${portable(source)}`,
|
|
201
|
+
content: Bun.file(resolve(settings.sourceDir, source)),
|
|
202
|
+
});
|
|
203
|
+
let server: string | undefined;
|
|
204
|
+
if (config.server?.runtime) {
|
|
205
|
+
const { executable } = (await buildServer(config, root)).server;
|
|
206
|
+
server = `server/${basename(executable)}`;
|
|
207
|
+
files.push({ path: server, content: Bun.file(executable) });
|
|
208
|
+
}
|
|
209
|
+
const manifest: ReleaseManifest = {
|
|
210
|
+
// Paths are relative to the release; the agent supplies the output and controller settings.
|
|
211
|
+
config: {
|
|
212
|
+
io: {},
|
|
213
|
+
deviceTree: settings.deviceTree,
|
|
214
|
+
controller: settings.controller,
|
|
215
|
+
libraryReferences: settings.libraryReferences,
|
|
216
|
+
libraries: libraries.map(({ release, ...library }) => ({ ...library, path: release })),
|
|
217
|
+
task: settings.task,
|
|
218
|
+
tasks: settings.tasks,
|
|
219
|
+
removeObjects: settings.removeObjects,
|
|
220
|
+
symbols: settings.symbols,
|
|
221
|
+
timeouts: settings.timeouts,
|
|
222
|
+
template: "template.project",
|
|
223
|
+
sourceDir: "st",
|
|
224
|
+
entry: `st/${portable(relative(settings.sourceDir, settings.entry))}`,
|
|
225
|
+
},
|
|
226
|
+
variables: await contractVariables(config, root),
|
|
227
|
+
server,
|
|
228
|
+
};
|
|
229
|
+
return { release: { manifest, files } };
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const deploy = async (
|
|
233
|
+
agent: AgentClient,
|
|
234
|
+
file: string,
|
|
235
|
+
loaded: LoadedConfig,
|
|
236
|
+
flags: PlantFlags,
|
|
237
|
+
) => {
|
|
238
|
+
const status = await agent.status();
|
|
239
|
+
if (status.login) throw new Error("A login is open; log out before deploying: tsplc logout");
|
|
240
|
+
const target = !status.target || flags.select ? await selectTarget(agent) : status.target;
|
|
241
|
+
console.log(`Building the release for ${target.host}…`);
|
|
242
|
+
const { release, diagnostics } = await prepareRelease(file, loaded, flags);
|
|
243
|
+
if (!release) return { diagnostics };
|
|
244
|
+
if (
|
|
245
|
+
flags.download &&
|
|
246
|
+
!(await confirm(
|
|
247
|
+
"A full download stops the running application while it loads. Continue?",
|
|
248
|
+
flags,
|
|
249
|
+
))
|
|
250
|
+
)
|
|
251
|
+
throw new Error("Cancelled.");
|
|
252
|
+
const credentials = await askCredentials();
|
|
253
|
+
const settings = {
|
|
254
|
+
credentials,
|
|
255
|
+
download: flags.download ?? false,
|
|
256
|
+
initializeUser: flags["initialize-user"] ?? false,
|
|
257
|
+
};
|
|
258
|
+
console.log(
|
|
259
|
+
release.manifest.server ? "Deploying the server and the program…" : "Deploying the program…",
|
|
260
|
+
);
|
|
261
|
+
try {
|
|
262
|
+
return await agent.deploy(release, settings, progress);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (
|
|
265
|
+
!(error instanceof AgentOperationError && error.downloadRequired) ||
|
|
266
|
+
!(await confirm(
|
|
267
|
+
`${error.message}. A full download stops the running application while it loads. Download now?`,
|
|
268
|
+
flags,
|
|
269
|
+
))
|
|
270
|
+
)
|
|
271
|
+
throw error;
|
|
272
|
+
return agent.deploy(release, { ...settings, download: true }, progress);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** Opens a login when none is open; it stays open across commands until `tsplc logout`. */
|
|
277
|
+
const ensureLogin = async (agent: AgentClient) => {
|
|
278
|
+
if ((await agent.status()).login) return;
|
|
279
|
+
const credentials = await askCredentials();
|
|
280
|
+
console.log(
|
|
281
|
+
"Logging in. CODESYS takes a few minutes to start; the login stays open until tsplc logout.",
|
|
282
|
+
);
|
|
283
|
+
await agent.login(credentials, progress);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/** The children of `prefix` in the variable tree, with array indexes folded into one range. */
|
|
287
|
+
export const variableChildren = (variables: readonly Variable[], prefix: string) => {
|
|
288
|
+
const children = new Map<string, { type?: string; leaves: number }>();
|
|
289
|
+
const start = prefix.toLowerCase();
|
|
290
|
+
for (const { path, type } of variables) {
|
|
291
|
+
const lower = path.toLowerCase();
|
|
292
|
+
if (
|
|
293
|
+
prefix &&
|
|
294
|
+
!(lower === start || lower.startsWith(`${start}.`) || lower.startsWith(`${start}[`))
|
|
295
|
+
)
|
|
296
|
+
continue;
|
|
297
|
+
const rest = path.slice(prefix.length).replace(/^\./, "");
|
|
298
|
+
if (rest === "") return [{ name: path, type, leaves: 1 }];
|
|
299
|
+
const name = /^(\[[^\]]*\]|[^.[]+)/.exec(rest)?.[0] ?? rest;
|
|
300
|
+
const child = children.get(name) ?? { leaves: 0 };
|
|
301
|
+
children.set(name, { type: rest === name ? type : undefined, leaves: child.leaves + 1 });
|
|
302
|
+
}
|
|
303
|
+
const indexes = [...children.keys()]
|
|
304
|
+
.filter((name) => /^\[\d+\]$/.test(name))
|
|
305
|
+
.map((name) => Number(name.slice(1, -1)));
|
|
306
|
+
const listed = [...children].filter(([name]) => !/^\[\d+\]$/.test(name));
|
|
307
|
+
if (indexes.length) {
|
|
308
|
+
const first = children.get(`[${Math.min(...indexes)}]`);
|
|
309
|
+
listed.unshift([`[${Math.min(...indexes)}..${Math.max(...indexes)}]`, first ?? { leaves: 0 }]);
|
|
310
|
+
}
|
|
311
|
+
return listed.map(([name, child]) => ({ name, ...child }));
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const printLogin = (result: LoginResult) => {
|
|
315
|
+
if ("values" in result) {
|
|
316
|
+
for (const [expression, value] of Object.entries(result.values))
|
|
317
|
+
console.log(`${expression} = ${value}`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
console.log(`Application ${result.state}`);
|
|
321
|
+
if (result.forced.length) console.log(`Forced: ${result.forced.join(", ")}`);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Runs a command against the plant agent named by TSPLC_AGENT_URL and TSPLC_AGENT_TOKEN.
|
|
326
|
+
* Returns a deployment's compiler diagnostics when the program doesn't compile.
|
|
327
|
+
*/
|
|
328
|
+
export const runPlantCommand = async (
|
|
329
|
+
command: string,
|
|
330
|
+
args: string[],
|
|
331
|
+
flags: PlantFlags,
|
|
332
|
+
project: { file: string; load: () => Promise<LoadedConfig> },
|
|
333
|
+
) => {
|
|
334
|
+
const agent = connect();
|
|
335
|
+
const expect = (count: number, usage: string) => {
|
|
336
|
+
if (args.length !== count) throw new Error(`Usage: tsplc ${command} ${usage}`);
|
|
337
|
+
};
|
|
338
|
+
const send = async (loginCommand: LoginCommand) => {
|
|
339
|
+
await ensureLogin(agent);
|
|
340
|
+
printLogin(await agent.command(loginCommand));
|
|
341
|
+
};
|
|
342
|
+
switch (command) {
|
|
343
|
+
case "deploy": {
|
|
344
|
+
expect(0, "[--download] [--select] [--initialize-user]");
|
|
345
|
+
const deployed = await deploy(agent, project.file, await project.load(), flags);
|
|
346
|
+
if ("diagnostics" in deployed) return deployed;
|
|
347
|
+
console.log(`Deployed release ${deployed.id} (${deployed.operation}).`);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
case "target": {
|
|
351
|
+
expect(0, "");
|
|
352
|
+
const target = await selectTarget(agent);
|
|
353
|
+
console.log(`Deployments now go to ${target.host}:${target.port}.`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
case "status": {
|
|
357
|
+
expect(0, "");
|
|
358
|
+
const status = await agent.status();
|
|
359
|
+
console.log(
|
|
360
|
+
`Controller ${status.target ? `${status.target.host}:${status.target.port}` : "not selected (tsplc target)"}`,
|
|
361
|
+
);
|
|
362
|
+
console.log(
|
|
363
|
+
`Release ${status.release ? `${status.release.id}, ${status.release.operation} at ${status.release.deployedAt}` : "none"}`,
|
|
364
|
+
);
|
|
365
|
+
console.log(
|
|
366
|
+
`Server ${status.server ? `${status.server.running ? "running" : "restarting"} (${status.server.executable})` : "none"}`,
|
|
367
|
+
);
|
|
368
|
+
console.log(
|
|
369
|
+
`Login ${status.login ? `open; application ${status.login.state}${status.login.forced.length ? `; forced: ${status.login.forced.join(", ")}` : ""}` : "closed"}`,
|
|
370
|
+
);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
case "login":
|
|
374
|
+
expect(0, "");
|
|
375
|
+
await ensureLogin(agent);
|
|
376
|
+
printLogin(await agent.command({ command: "state" }));
|
|
377
|
+
return;
|
|
378
|
+
case "logout": {
|
|
379
|
+
expect(0, "");
|
|
380
|
+
const forced = (await agent.status()).login?.forced ?? [];
|
|
381
|
+
if (
|
|
382
|
+
forced.length &&
|
|
383
|
+
!(await confirm(
|
|
384
|
+
`Forced: ${forced.join(", ")}. Forces stay on the controller after logout. Log out anyway?`,
|
|
385
|
+
flags,
|
|
386
|
+
))
|
|
387
|
+
)
|
|
388
|
+
throw new Error("Cancelled.");
|
|
389
|
+
await agent.logout();
|
|
390
|
+
console.log("Logged out.");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
case "vars": {
|
|
394
|
+
if (args.length > 1) throw new Error("Usage: tsplc vars [path]");
|
|
395
|
+
const variables = await agent.variables();
|
|
396
|
+
const children = variableChildren(variables, args[0] ?? "");
|
|
397
|
+
if (!children.length) throw new Error(`No published variable ${args[0]}.`);
|
|
398
|
+
for (const child of children)
|
|
399
|
+
console.log(`${child.name.padEnd(32)} ${child.type ?? `${child.leaves} variables`}`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
case "read": {
|
|
403
|
+
const [first, ...rest] = args;
|
|
404
|
+
if (!first) throw new Error("Usage: tsplc read <expression>...");
|
|
405
|
+
return send({ command: "read", expressions: [first, ...rest] });
|
|
406
|
+
}
|
|
407
|
+
case "write":
|
|
408
|
+
case "force": {
|
|
409
|
+
const [expression, value, ...extra] = args;
|
|
410
|
+
if (!expression || !value || extra.length)
|
|
411
|
+
throw new Error(`Usage: tsplc ${command} <expression> <value>`);
|
|
412
|
+
return send({ command, values: { [expression]: value } });
|
|
413
|
+
}
|
|
414
|
+
case "unforce":
|
|
415
|
+
return send({ command: "unforce", expressions: args, restore: flags.restore ?? false });
|
|
416
|
+
case "start":
|
|
417
|
+
expect(0, "");
|
|
418
|
+
return send({ command: "start" });
|
|
419
|
+
case "stop":
|
|
420
|
+
expect(0, "");
|
|
421
|
+
if (!(await confirm("Stop the application on the controller?", flags)))
|
|
422
|
+
throw new Error("Cancelled.");
|
|
423
|
+
return send({ command: "stop" });
|
|
424
|
+
case "reset": {
|
|
425
|
+
expect(1, "warm|cold");
|
|
426
|
+
const kind = z.enum(["warm", "cold"]).parse(args[0]);
|
|
427
|
+
if (
|
|
428
|
+
!(await confirm(
|
|
429
|
+
`Reset the application (${kind})? It stops and reinitializes its variables.`,
|
|
430
|
+
flags,
|
|
431
|
+
))
|
|
432
|
+
)
|
|
433
|
+
throw new Error("Cancelled.");
|
|
434
|
+
return send({ command: "reset", kind: kind === "warm" ? "Warm" : "Cold" });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
};
|
package/project/project.ts
CHANGED
|
@@ -158,6 +158,25 @@ export async function checkProject(
|
|
|
158
158
|
};
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/** The installed libraries and fieldbus device tree a CODESYS project of this program needs. */
|
|
162
|
+
async function engineeringInputs(
|
|
163
|
+
shared: ResolvedTsplcConfig,
|
|
164
|
+
configFile: string,
|
|
165
|
+
bundle: Pick<ReturnType<typeof emitCodesys>, "ioMappings">,
|
|
166
|
+
frozen?: boolean,
|
|
167
|
+
) {
|
|
168
|
+
const root = dirname(configFile);
|
|
169
|
+
const tree = await deviceTree(shared, await loadHardware(shared, root));
|
|
170
|
+
return {
|
|
171
|
+
libraries: await installLibraries(shared.dependencies, root, { frozen }),
|
|
172
|
+
// The emitted names locate mapped IO; the tree maps their channels.
|
|
173
|
+
deviceTree: tree && {
|
|
174
|
+
...tree,
|
|
175
|
+
mappings: bundle.ioMappings.map(({ address, variable }) => ({ address, variable })),
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
161
180
|
export async function buildProject(
|
|
162
181
|
file = "tsplc.config.ts",
|
|
163
182
|
options: {
|
|
@@ -166,6 +185,8 @@ export async function buildProject(
|
|
|
166
185
|
loaded?: LoadedConfig;
|
|
167
186
|
controller?: Controller;
|
|
168
187
|
buildTarget?: BuildTarget;
|
|
188
|
+
/** Resolve libraries and the device tree into `controllerConfig` even when only emitting. */
|
|
189
|
+
forController?: boolean;
|
|
169
190
|
} = {},
|
|
170
191
|
): Promise<{
|
|
171
192
|
directory?: string;
|
|
@@ -194,12 +215,18 @@ export async function buildProject(
|
|
|
194
215
|
if (hasErrors(bundle.diagnostics)) {
|
|
195
216
|
return { diagnostics: bundle.diagnostics };
|
|
196
217
|
}
|
|
197
|
-
const
|
|
218
|
+
const shared = checked.shared;
|
|
219
|
+
const engineering =
|
|
220
|
+
shared && (!options.emitOnly || options.forController)
|
|
221
|
+
? await engineeringInputs(shared, configFile, bundle, options.frozenLockfile)
|
|
222
|
+
: undefined;
|
|
223
|
+
const controllerConfig = shared?.build
|
|
198
224
|
? codesysBuildConfig(checked.plan, bundle, {
|
|
199
225
|
configFile,
|
|
200
226
|
sourceDir: join(previous.output, "st"),
|
|
201
|
-
outDir: resolve(dirname(configFile),
|
|
227
|
+
outDir: resolve(dirname(configFile), shared.build.outDir),
|
|
202
228
|
controller: options.controller,
|
|
229
|
+
...engineering,
|
|
203
230
|
})
|
|
204
231
|
: undefined;
|
|
205
232
|
|
|
@@ -221,30 +248,17 @@ export async function buildProject(
|
|
|
221
248
|
let vendor: Awaited<ReturnType<typeof buildCodesys>> | undefined;
|
|
222
249
|
if (!options.emitOnly) {
|
|
223
250
|
// The engineering cache has its own ownership marker and survives clean.
|
|
224
|
-
const
|
|
225
|
-
? resolve(dirname(configFile),
|
|
251
|
+
const outDir = shared?.build
|
|
252
|
+
? resolve(dirname(configFile), shared.build.outDir ?? "build")
|
|
226
253
|
: join(previous.root, "engineering");
|
|
227
|
-
|
|
228
|
-
? await installLibraries(checked.shared.dependencies, dirname(configFile), {
|
|
229
|
-
frozen: options.frozenLockfile,
|
|
230
|
-
})
|
|
231
|
-
: undefined;
|
|
232
|
-
await engineeringDirectory(engineering, configFile);
|
|
233
|
-
const tree =
|
|
234
|
-
checked.shared &&
|
|
235
|
-
(await deviceTree(checked.shared, await loadHardware(checked.shared, dirname(configFile))));
|
|
254
|
+
await engineeringDirectory(outDir, configFile);
|
|
236
255
|
try {
|
|
237
256
|
vendor = await buildCodesys(checked.plan, bundle, {
|
|
238
257
|
configFile,
|
|
239
258
|
sourceDir: join(staging, "st"),
|
|
240
|
-
outDir
|
|
241
|
-
libraries,
|
|
259
|
+
outDir,
|
|
242
260
|
controller: options.controller,
|
|
243
|
-
|
|
244
|
-
deviceTree: tree && {
|
|
245
|
-
...tree,
|
|
246
|
-
mappings: bundle.ioMappings.map(({ address, variable }) => ({ address, variable })),
|
|
247
|
-
},
|
|
261
|
+
...engineering,
|
|
248
262
|
});
|
|
249
263
|
} catch (cause) {
|
|
250
264
|
return { diagnostics: diagnoseCodesys(cause, bundle) };
|