@aryacode/dsh-engineering 0.7.0 → 0.8.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.
- package/CHANGELOG.md +25 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/vendor/ftview/SKILL.md +34 -5
- package/vendor/ftview/lib/display-tree.d.ts +99 -0
- package/vendor/ftview/lib/display-tree.js +306 -0
- package/vendor/ftview/lib/edit/operations.d.ts +85 -1
- package/vendor/ftview/lib/edit/operations.js +201 -0
- package/vendor/ftview/lib/edit/prepare.d.ts +3 -0
- package/vendor/ftview/lib/edit/prepare.js +35 -1
- package/vendor/ftview/lib/edit/tools.js +249 -5
- package/vendor/ftview/lib/handoff.d.ts +58 -0
- package/vendor/ftview/lib/handoff.js +95 -0
- package/vendor/ftview/lib/index.d.ts +1 -0
- package/vendor/ftview/lib/index.js +3 -1
- package/vendor/ftview/lib/schema.d.ts +68 -0
- package/vendor/ftview/lib/schema.js +156 -0
- package/vendor/ftview/lib/vendor-tools.d.ts +17 -0
- package/vendor/ftview/lib/vendor-tools.js +74 -0
- package/vendor/ftview/lib/vendor.d.ts +56 -0
- package/vendor/ftview/lib/vendor.js +143 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove that what Studio imported is what we authored.
|
|
3
|
+
*
|
|
4
|
+
* An export is the vendor's own view of an application. Comparing a fresh
|
|
5
|
+
* export against the prepared draft, object by object, is the only evidence
|
|
6
|
+
* that the import took: file hashes would say nothing about what the engine
|
|
7
|
+
* kept, normalised or rejected.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-engineering-ftview/handoff
|
|
10
|
+
*/
|
|
11
|
+
import type { FsService } from './core.ts';
|
|
12
|
+
/** One object as it exists in a display. */
|
|
13
|
+
export interface ObjectSignature {
|
|
14
|
+
/** Element type, e.g. `momentaryButton`. */
|
|
15
|
+
readonly type: string;
|
|
16
|
+
/** Object name when it has one. */
|
|
17
|
+
readonly name?: string;
|
|
18
|
+
/** Attributes, decoded, sorted by name. */
|
|
19
|
+
readonly attributes: Readonly<Record<string, string>>;
|
|
20
|
+
/** Nested objects, in document order. */
|
|
21
|
+
readonly children: readonly ObjectSignature[];
|
|
22
|
+
}
|
|
23
|
+
/** One difference between intended and imported. */
|
|
24
|
+
export interface SignatureDifference {
|
|
25
|
+
/** Where the difference sits, e.g. `Main Screen > StartButton > caption`. */
|
|
26
|
+
readonly path: string;
|
|
27
|
+
/** What the draft had. */
|
|
28
|
+
readonly expected?: string;
|
|
29
|
+
/** What the import produced. */
|
|
30
|
+
readonly actual?: string;
|
|
31
|
+
}
|
|
32
|
+
/** Compare one display's intended and imported trees. */
|
|
33
|
+
export interface DisplayVerification {
|
|
34
|
+
/** Export-relative display file. */
|
|
35
|
+
readonly file: string;
|
|
36
|
+
/** True when the trees are identical. */
|
|
37
|
+
readonly matched: boolean;
|
|
38
|
+
/** Differences, capped by the caller. */
|
|
39
|
+
readonly differences: readonly SignatureDifference[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Signature of one display document: its objects, nested, without layout noise.
|
|
43
|
+
*
|
|
44
|
+
* @param xml - Display XML.
|
|
45
|
+
* @returns Root signature.
|
|
46
|
+
*/
|
|
47
|
+
export declare function signatureOf(xml: string): ObjectSignature;
|
|
48
|
+
/**
|
|
49
|
+
* Compare the prepared draft's displays with a fresh export.
|
|
50
|
+
*
|
|
51
|
+
* @param fs - Filesystem provider.
|
|
52
|
+
* @param root - Export folder produced by the vendor after the import.
|
|
53
|
+
* @param expected - Export-relative file to intended XML.
|
|
54
|
+
* @returns One verification per display.
|
|
55
|
+
*/
|
|
56
|
+
export declare function verifyDisplays(fs: FsService, root: string, expected: ReadonlyMap<string, string>): Promise<readonly DisplayVerification[]>;
|
|
57
|
+
/** Whether every verification matched. */
|
|
58
|
+
export declare function allMatched(results: readonly DisplayVerification[]): boolean;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove that what Studio imported is what we authored.
|
|
3
|
+
*
|
|
4
|
+
* An export is the vendor's own view of an application. Comparing a fresh
|
|
5
|
+
* export against the prepared draft, object by object, is the only evidence
|
|
6
|
+
* that the import took: file hashes would say nothing about what the engine
|
|
7
|
+
* kept, normalised or rejected.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-engineering-ftview/handoff
|
|
10
|
+
*/
|
|
11
|
+
import { attributeOf, parseDisplayTree } from "./display-tree.js";
|
|
12
|
+
/** One signature of a node, recursively. */
|
|
13
|
+
function signatureOfNode(node) {
|
|
14
|
+
const attributes = {};
|
|
15
|
+
for (const name of Object.keys(node.attributes).sort())
|
|
16
|
+
attributes[name] = attributeOf(node, name) ?? '';
|
|
17
|
+
return {
|
|
18
|
+
type: node.type,
|
|
19
|
+
...(node.attributes.name === undefined ? {} : { name: attributeOf(node, 'name') }),
|
|
20
|
+
attributes,
|
|
21
|
+
children: node.children.filter(child => child.attributes.name !== undefined || ['group', 'connections', 'states'].includes(child.type)).map(signatureOfNode),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Signature of one display document: its objects, nested, without layout noise.
|
|
26
|
+
*
|
|
27
|
+
* @param xml - Display XML.
|
|
28
|
+
* @returns Root signature.
|
|
29
|
+
*/
|
|
30
|
+
export function signatureOf(xml) {
|
|
31
|
+
const tree = parseDisplayTree(xml);
|
|
32
|
+
return { type: tree.root.type, attributes: {}, children: tree.root.children.map(signatureOfNode) };
|
|
33
|
+
}
|
|
34
|
+
/** Collect differences between two signatures at one path. */
|
|
35
|
+
function diff(expected, actual, path, out) {
|
|
36
|
+
if (out.length >= 50)
|
|
37
|
+
return;
|
|
38
|
+
if (expected.type !== actual.type) {
|
|
39
|
+
out.push({ path, expected: expected.type, actual: actual.type });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const names = new Set([...Object.keys(expected.attributes), ...Object.keys(actual.attributes)]);
|
|
43
|
+
for (const name of names) {
|
|
44
|
+
if (expected.attributes[name] !== actual.attributes[name]) {
|
|
45
|
+
out.push({ path: `${path} @${name}`, expected: expected.attributes[name], actual: actual.attributes[name] });
|
|
46
|
+
if (out.length >= 50)
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const expectedChildren = expected.children;
|
|
51
|
+
const actualChildren = actual.children;
|
|
52
|
+
for (let index = 0; index < Math.max(expectedChildren.length, actualChildren.length); index += 1) {
|
|
53
|
+
const left = expectedChildren[index];
|
|
54
|
+
const right = actualChildren[index];
|
|
55
|
+
const label = left?.name ?? right?.name ?? `child[${index}]`;
|
|
56
|
+
if (left === undefined) {
|
|
57
|
+
out.push({ path: `${path} > ${label}`, actual: 'present in import, missing in draft' });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (right === undefined) {
|
|
61
|
+
out.push({ path: `${path} > ${label}`, expected: 'present in draft, missing in import' });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
diff(left, right, `${path} > ${label}`, out);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Compare the prepared draft's displays with a fresh export.
|
|
69
|
+
*
|
|
70
|
+
* @param fs - Filesystem provider.
|
|
71
|
+
* @param root - Export folder produced by the vendor after the import.
|
|
72
|
+
* @param expected - Export-relative file to intended XML.
|
|
73
|
+
* @returns One verification per display.
|
|
74
|
+
*/
|
|
75
|
+
export async function verifyDisplays(fs, root, expected) {
|
|
76
|
+
const results = [];
|
|
77
|
+
for (const [file, intended] of expected) {
|
|
78
|
+
const bytes = await fs.readBytes(`${root}/${file}`, undefined, 64 * 1024 * 1024);
|
|
79
|
+
const actual = new TextDecoder('utf-8').decode(bytes);
|
|
80
|
+
const differences = [];
|
|
81
|
+
try {
|
|
82
|
+
diff(signatureOf(intended), signatureOf(actual), file, differences);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
results.push({ file, matched: false, differences: [{ path: file, expected: 'a parsable display', actual: error instanceof Error ? error.message : String(error) }] });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
results.push({ file, matched: differences.length === 0, differences });
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
/** Whether every verification matched. */
|
|
93
|
+
export function allMatched(results) {
|
|
94
|
+
return results.every(result => result.matched);
|
|
95
|
+
}
|
|
@@ -27,5 +27,6 @@ export interface Config {
|
|
|
27
27
|
export declare function apply(ctx: PluginContext, config?: Config): void;
|
|
28
28
|
export { ViewSession } from './session.ts';
|
|
29
29
|
export { ftviewTools } from './tools.ts';
|
|
30
|
+
export { vendorTools } from './vendor-tools.ts';
|
|
30
31
|
export { EngineeringError } from './core.ts';
|
|
31
32
|
export type { Coverage, NextStep, Success, ToolDefinition } from './core.ts';
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { stringFs } from "./core.js";
|
|
10
10
|
import { ViewSession } from "./session.js";
|
|
11
11
|
import { ftviewTools } from "./tools.js";
|
|
12
|
+
import { vendorTools } from "./vendor-tools.js";
|
|
12
13
|
/** Plugin name in a composition. */
|
|
13
14
|
export const name = 'ftview-read';
|
|
14
15
|
/** Services this plugin cannot register without. */
|
|
@@ -28,9 +29,10 @@ export function apply(ctx, config = {}) {
|
|
|
28
29
|
}
|
|
29
30
|
const session = new ViewSession(stringFs(ctx.fs), maxFiles);
|
|
30
31
|
ctx.provide?.(VIEWS_SERVICE, session);
|
|
31
|
-
for (const definition of ftviewTools(session))
|
|
32
|
+
for (const definition of [...ftviewTools(session), ...vendorTools(stringFs(ctx.fs))])
|
|
32
33
|
ctx.tools.register(definition);
|
|
33
34
|
}
|
|
34
35
|
export { ViewSession } from "./session.js";
|
|
35
36
|
export { ftviewTools } from "./tools.js";
|
|
37
|
+
export { vendorTools } from "./vendor-tools.js";
|
|
36
38
|
export { EngineeringError } from "./core.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate display XML against the installed FactoryTalk View schema.
|
|
3
|
+
*
|
|
4
|
+
* The vendor ships `Gfx-ME*.xsd` with Studio. Rather than re-implement an XSD
|
|
5
|
+
* engine, this module asks .NET (through PowerShell) to validate in process,
|
|
6
|
+
* which is exact and needs no third-party package. When the schema or the host
|
|
7
|
+
* is unavailable the caller gets a clear `schema_unavailable` result instead of
|
|
8
|
+
* a silent pass.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-engineering-ftview/schema
|
|
11
|
+
*/
|
|
12
|
+
import type { Coverage } from './core.ts';
|
|
13
|
+
/** One file's validation outcome. */
|
|
14
|
+
export interface SchemaFinding {
|
|
15
|
+
/** File or display name the finding belongs to. */
|
|
16
|
+
readonly file: string;
|
|
17
|
+
/** Schema message, already rendered by .NET. */
|
|
18
|
+
readonly message: string;
|
|
19
|
+
/** True when the schema rejected the document. */
|
|
20
|
+
readonly error: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** One validation run. */
|
|
23
|
+
export interface SchemaValidation {
|
|
24
|
+
/** Schema file used, or the reason validation could not run. */
|
|
25
|
+
readonly schema?: string;
|
|
26
|
+
/** Findings across every document. */
|
|
27
|
+
readonly findings: readonly SchemaFinding[];
|
|
28
|
+
/** True when validation actually ran. */
|
|
29
|
+
readonly ran: boolean;
|
|
30
|
+
/** Why it could not run, when it did not. */
|
|
31
|
+
readonly unavailable?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Coverage note shared by every schema-dependent operation. */
|
|
34
|
+
export declare const SCHEMA_COVERAGE: Coverage;
|
|
35
|
+
/** The schema directory, or undefined when Studio is not installed here. */
|
|
36
|
+
export declare function schemaDirectory(): string | undefined;
|
|
37
|
+
/** Every installed schema version, newest last. */
|
|
38
|
+
export declare function schemaVersions(): readonly string[];
|
|
39
|
+
/** The declared schema of one display, from `xsi:noNamespaceSchemaLocation`. */
|
|
40
|
+
export declare function declaredSchema(xml: string): string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Pick the schema file for one document.
|
|
43
|
+
*
|
|
44
|
+
* A display names its schema; if that file is not installed, the newest
|
|
45
|
+
* installed schema is used and the caller is told which one ran.
|
|
46
|
+
*
|
|
47
|
+
* @param xml - Display XML, used only for its declared schema.
|
|
48
|
+
* @returns Absolute schema path and its file name, or undefined.
|
|
49
|
+
*/
|
|
50
|
+
export declare function schemaFor(xml: string): {
|
|
51
|
+
path: string;
|
|
52
|
+
name: string;
|
|
53
|
+
} | undefined;
|
|
54
|
+
/** One item handed to the validator. */
|
|
55
|
+
export interface SchemaDocument {
|
|
56
|
+
/** Name reported in findings. */
|
|
57
|
+
readonly file: string;
|
|
58
|
+
/** Display XML. */
|
|
59
|
+
readonly xml: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Validate one batch of display documents against the installed schema.
|
|
63
|
+
*
|
|
64
|
+
* @param documents - Documents to check; an empty list runs nothing.
|
|
65
|
+
* @param executable - PowerShell to use; defaults to `powershell.exe`.
|
|
66
|
+
* @returns Findings, whether validation ran, and the schema used.
|
|
67
|
+
*/
|
|
68
|
+
export declare function validateSchema(documents: readonly SchemaDocument[], executable?: string): Promise<SchemaValidation>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate display XML against the installed FactoryTalk View schema.
|
|
3
|
+
*
|
|
4
|
+
* The vendor ships `Gfx-ME*.xsd` with Studio. Rather than re-implement an XSD
|
|
5
|
+
* engine, this module asks .NET (through PowerShell) to validate in process,
|
|
6
|
+
* which is exact and needs no third-party package. When the schema or the host
|
|
7
|
+
* is unavailable the caller gets a clear `schema_unavailable` result instead of
|
|
8
|
+
* a silent pass.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-engineering-ftview/schema
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
/** Where Studio installs its display schemas. */
|
|
16
|
+
const SCHEMA_ROOTS = [
|
|
17
|
+
'C:/Program Files (x86)/Rockwell Software/RSView Enterprise/Schemas',
|
|
18
|
+
'C:/Program Files/Rockwell Software/RSView Enterprise/Schemas',
|
|
19
|
+
];
|
|
20
|
+
/** Coverage note shared by every schema-dependent operation. */
|
|
21
|
+
export const SCHEMA_COVERAGE = {
|
|
22
|
+
complete: false,
|
|
23
|
+
unverified: [
|
|
24
|
+
'sequence order and occurrence counts are enforced by Studio, not by this check',
|
|
25
|
+
'Studio import and runtime behaviour',
|
|
26
|
+
'semantic intent of the logic behind a display',
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
/** The schema directory, or undefined when Studio is not installed here. */
|
|
30
|
+
export function schemaDirectory() {
|
|
31
|
+
const override = process.env.FTVIEW_SCHEMA_DIR;
|
|
32
|
+
if (override !== undefined && override !== '')
|
|
33
|
+
return existsSync(override) ? override : undefined;
|
|
34
|
+
return SCHEMA_ROOTS.find(root => existsSync(root));
|
|
35
|
+
}
|
|
36
|
+
/** Every installed schema version, newest last. */
|
|
37
|
+
export function schemaVersions() {
|
|
38
|
+
const directory = schemaDirectory();
|
|
39
|
+
if (directory === undefined)
|
|
40
|
+
return [];
|
|
41
|
+
const version = (name) => Number.parseInt(name.replace(/\D+/gu, ''), 10);
|
|
42
|
+
return readdirSync(directory)
|
|
43
|
+
.filter(name => /^Gfx-ME\d+\.xsd$/iu.test(name))
|
|
44
|
+
.sort((left, right) => version(left) - version(right));
|
|
45
|
+
}
|
|
46
|
+
/** The declared schema of one display, from `xsi:noNamespaceSchemaLocation`. */
|
|
47
|
+
export function declaredSchema(xml) {
|
|
48
|
+
const match = /xsi:noNamespaceSchemaLocation\s*=\s*"([^"]+)"/u.exec(xml);
|
|
49
|
+
return match?.[1];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Pick the schema file for one document.
|
|
53
|
+
*
|
|
54
|
+
* A display names its schema; if that file is not installed, the newest
|
|
55
|
+
* installed schema is used and the caller is told which one ran.
|
|
56
|
+
*
|
|
57
|
+
* @param xml - Display XML, used only for its declared schema.
|
|
58
|
+
* @returns Absolute schema path and its file name, or undefined.
|
|
59
|
+
*/
|
|
60
|
+
export function schemaFor(xml) {
|
|
61
|
+
const directory = schemaDirectory();
|
|
62
|
+
if (directory === undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
const declared = declaredSchema(xml);
|
|
65
|
+
if (declared !== undefined && existsSync(join(directory, declared)))
|
|
66
|
+
return { path: join(directory, declared), name: declared };
|
|
67
|
+
const versions = schemaVersions();
|
|
68
|
+
if (versions.length === 0)
|
|
69
|
+
return undefined;
|
|
70
|
+
const newest = versions[versions.length - 1];
|
|
71
|
+
return { path: join(directory, newest), name: newest };
|
|
72
|
+
}
|
|
73
|
+
/** PowerShell that validates a batch of XML strings against one schema. */
|
|
74
|
+
const VALIDATOR = `
|
|
75
|
+
$ErrorActionPreference = 'Stop'
|
|
76
|
+
$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
|
77
|
+
$schemas = New-Object System.Xml.Schema.XmlSchemaSet
|
|
78
|
+
$null = $schemas.Add('', [string]$payload.schema)
|
|
79
|
+
$results = New-Object System.Collections.ArrayList
|
|
80
|
+
foreach ($item in $payload.items) {
|
|
81
|
+
$messages = New-Object 'System.Collections.Generic.List[string]'
|
|
82
|
+
$settings = New-Object System.Xml.XmlReaderSettings
|
|
83
|
+
$settings.ValidationType = [System.Xml.ValidationType]::Schema
|
|
84
|
+
$settings.Schemas = $schemas
|
|
85
|
+
$settings.ValidationFlags = $settings.ValidationFlags -bor [System.Xml.Schema.XmlSchemaValidationFlags]::ReportValidationWarnings
|
|
86
|
+
$handler = [System.Xml.Schema.ValidationEventHandler]{ param($sender, $eventArgs) $messages.Add("$($eventArgs.Severity): $($eventArgs.Message)") }
|
|
87
|
+
$settings.add_ValidationEventHandler($handler)
|
|
88
|
+
$reader = [System.Xml.XmlReader]::Create((New-Object System.IO.StringReader([string]$item.xml)), $settings)
|
|
89
|
+
try { while ($reader.Read()) { } } catch { $messages.Add("error: $($_.Exception.Message)") } finally { $reader.Dispose() }
|
|
90
|
+
[void]$results.Add([pscustomobject]@{ file = [string]$item.file; messages = @($messages) })
|
|
91
|
+
}
|
|
92
|
+
$results | ConvertTo-Json -Depth 4 -Compress
|
|
93
|
+
`;
|
|
94
|
+
/**
|
|
95
|
+
* Validate one batch of display documents against the installed schema.
|
|
96
|
+
*
|
|
97
|
+
* @param documents - Documents to check; an empty list runs nothing.
|
|
98
|
+
* @param executable - PowerShell to use; defaults to `powershell.exe`.
|
|
99
|
+
* @returns Findings, whether validation ran, and the schema used.
|
|
100
|
+
*/
|
|
101
|
+
export async function validateSchema(documents, executable = 'powershell.exe') {
|
|
102
|
+
if (documents.length === 0)
|
|
103
|
+
return { findings: [], ran: true };
|
|
104
|
+
// Only Studio documents declare a schema. A hand-made <display> file is not
|
|
105
|
+
// one, and validating it against Gfx-ME would report a root element the
|
|
106
|
+
// fixture never claimed to be. Such files are skipped, with a reason.
|
|
107
|
+
const declared = documents.filter(document => declaredSchema(document.xml) !== undefined);
|
|
108
|
+
if (declared.length === 0) {
|
|
109
|
+
return {
|
|
110
|
+
findings: [],
|
|
111
|
+
ran: false,
|
|
112
|
+
unavailable: 'None of the changed documents declares xsi:noNamespaceSchemaLocation, so there is no vendor schema to validate against. Studio exports and authored displays always declare one.',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const first = declared[0];
|
|
116
|
+
const schema = schemaFor(first.xml);
|
|
117
|
+
if (schema === undefined) {
|
|
118
|
+
return {
|
|
119
|
+
findings: [],
|
|
120
|
+
ran: false,
|
|
121
|
+
unavailable: 'No Gfx-ME*.xsd schema is installed on this host. Install FactoryTalk View Studio, or set FTVIEW_SCHEMA_DIR to the folder that holds Gfx-ME*.xsd.',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const payload = JSON.stringify({ schema: schema.path, items: declared });
|
|
125
|
+
const encoded = Buffer.from(VALIDATOR, 'utf16le').toString('base64');
|
|
126
|
+
const output = await runPowerShell(executable, ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], payload);
|
|
127
|
+
if (output.code !== 0) {
|
|
128
|
+
return {
|
|
129
|
+
findings: [],
|
|
130
|
+
ran: false,
|
|
131
|
+
schema: schema.name,
|
|
132
|
+
unavailable: `The schema validator did not run: ${output.stderr.trim() || `exit ${output.code}`}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const parsed = JSON.parse(output.stdout);
|
|
136
|
+
const findings = [];
|
|
137
|
+
for (const result of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
138
|
+
for (const message of result.messages ?? []) {
|
|
139
|
+
findings.push({ file: result.file, message, error: message.startsWith('Error') || message.startsWith('error:') });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { findings, ran: true, schema: schema.name };
|
|
143
|
+
}
|
|
144
|
+
/** Run one process with stdin and collect its output. */
|
|
145
|
+
function runPowerShell(executable, args, stdin) {
|
|
146
|
+
return new Promise(resolvePromise => {
|
|
147
|
+
const child = spawn(executable, args, { windowsHide: true });
|
|
148
|
+
let stdout = '';
|
|
149
|
+
let stderr = '';
|
|
150
|
+
child.stdout?.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
|
|
151
|
+
child.stderr?.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
|
|
152
|
+
child.on('error', error => resolvePromise({ code: -1, stdout, stderr: stderr || error.message }));
|
|
153
|
+
child.on('close', code => resolvePromise({ code: code ?? -1, stdout, stderr }));
|
|
154
|
+
child.stdin?.end(stdin, 'utf8');
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools that reach the installed ME engine.
|
|
3
|
+
*
|
|
4
|
+
* These are the only entry points that talk to Studio's world: listing the
|
|
5
|
+
* applications it knows and exporting one to a folder. Both are read-only with
|
|
6
|
+
* respect to the project.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-engineering-ftview/vendor-tools
|
|
9
|
+
*/
|
|
10
|
+
import { type FsService, type ToolDefinition } from './core.ts';
|
|
11
|
+
/**
|
|
12
|
+
* The vendor tools.
|
|
13
|
+
*
|
|
14
|
+
* @param fs - Filesystem provider used to resolve the destination folder.
|
|
15
|
+
* @returns Tool definitions.
|
|
16
|
+
*/
|
|
17
|
+
export declare function vendorTools(fs: FsService): readonly ToolDefinition[];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools that reach the installed ME engine.
|
|
3
|
+
*
|
|
4
|
+
* These are the only entry points that talk to Studio's world: listing the
|
|
5
|
+
* applications it knows and exporting one to a folder. Both are read-only with
|
|
6
|
+
* respect to the project.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-engineering-ftview/vendor-tools
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { EngineeringError, object, optionalString, requiredString, string, success, tool } from "./core.js";
|
|
12
|
+
import { exportApplication, listApplications } from "./vendor.js";
|
|
13
|
+
/** What a vendor call cannot prove. */
|
|
14
|
+
const VENDOR_COVERAGE = {
|
|
15
|
+
complete: false,
|
|
16
|
+
unverified: [
|
|
17
|
+
'the exported XML is a snapshot of the saved application, not of a running terminal',
|
|
18
|
+
'display compile and download to a PanelView terminal',
|
|
19
|
+
'Studio import behaviour',
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The vendor tools.
|
|
24
|
+
*
|
|
25
|
+
* @param fs - Filesystem provider used to resolve the destination folder.
|
|
26
|
+
* @returns Tool definitions.
|
|
27
|
+
*/
|
|
28
|
+
export function vendorTools(fs) {
|
|
29
|
+
return [
|
|
30
|
+
tool({
|
|
31
|
+
name: 'ftview_apps',
|
|
32
|
+
description: 'List the FactoryTalk View ME applications installed on this host, through the vendor export server. Requires FactoryTalk View ME on the machine.',
|
|
33
|
+
parameters: object({
|
|
34
|
+
filter: string('Case-insensitive substring matched against the application name.'),
|
|
35
|
+
}, []),
|
|
36
|
+
async run(args) {
|
|
37
|
+
const filter = (optionalString(args, 'filter') ?? '').toLowerCase();
|
|
38
|
+
const listed = await listApplications();
|
|
39
|
+
if (listed.error !== undefined) {
|
|
40
|
+
throw new EngineeringError('VENDOR_UNAVAILABLE', listed.error, 'Install FactoryTalk View ME Studio on this host, or work from an existing export folder.');
|
|
41
|
+
}
|
|
42
|
+
const apps = (listed.value ?? []).filter(app => filter === '' || app.name.toLowerCase().includes(filter));
|
|
43
|
+
return success(`${apps.length} ME application(s)${filter === '' ? '' : ` matching ${JSON.stringify(filter)}`} on this host.`, { total: apps.length, apps }, [{ tool: 'ftview_export', args: { application: apps[0]?.name ?? 'NAME', output_root: 'C:/Exports/NAME' }, why: 'Export one application to a folder this surface can read.' }], VENDOR_COVERAGE);
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
tool({
|
|
47
|
+
name: 'ftview_export',
|
|
48
|
+
description: 'Export one installed ME application (displays, global objects, tags) to a NEW folder through the vendor export server. The application is not modified.',
|
|
49
|
+
parameters: object({
|
|
50
|
+
application: string('Application name as ftview_apps lists it.'),
|
|
51
|
+
output_root: string('New folder for the export. It must not exist.'),
|
|
52
|
+
}, ['application', 'output_root']),
|
|
53
|
+
async run(args, exec) {
|
|
54
|
+
const application = requiredString(args, 'application');
|
|
55
|
+
const requested = requiredString(args, 'output_root');
|
|
56
|
+
const destination = await fs.resolve(requested, {
|
|
57
|
+
...(exec.cwd === undefined ? {} : { cwd: exec.cwd }),
|
|
58
|
+
...(exec.signal === undefined ? {} : { signal: exec.signal }),
|
|
59
|
+
});
|
|
60
|
+
if (existsSync(destination)) {
|
|
61
|
+
throw new EngineeringError('OUTPUT_EXISTS', `A file or folder already exists at ${requested}.`, 'Pass a new output_root; export never merges into an existing folder.');
|
|
62
|
+
}
|
|
63
|
+
const exported = await exportApplication(application, destination);
|
|
64
|
+
if (exported.error !== undefined) {
|
|
65
|
+
throw new EngineeringError('VENDOR_EXPORT_FAILED', exported.error, 'Check that the application is not open in Studio, then retry with a new output_root.');
|
|
66
|
+
}
|
|
67
|
+
const files = exported.value ?? [];
|
|
68
|
+
const displays = files.filter(file => /\/Displays\/.+\.xml$/iu.test(file) && !/batchimport/iu.test(file));
|
|
69
|
+
const manifests = files.filter(file => /batchimport/iu.test(file));
|
|
70
|
+
return success(`Exported ${application}: ${files.length} file(s), ${displays.length} display(s)${manifests.length === 0 ? '' : `, ${manifests.length} import manifest(s)`}.`, { application, output_root: destination, files, display_count: displays.length, manifests }, [{ tool: 'ftview_open', args: { root: destination }, why: 'Open the fresh export and author against it.' }], VENDOR_COVERAGE);
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive the installed FactoryTalk View ME engine.
|
|
3
|
+
*
|
|
4
|
+
* Studio ships two scriptable COM servers that matter here: `MEExportSvr`
|
|
5
|
+
* (list applications, export displays) and `AppMgrServer` (backup and restore).
|
|
6
|
+
* The export server runs out of process and works from a 64-bit host; the
|
|
7
|
+
* application manager is a 32-bit in-process server, so it is reached through
|
|
8
|
+
* the 32-bit PowerShell. Nothing here edits a project: export reads, backup
|
|
9
|
+
* copies.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-engineering-ftview/vendor
|
|
12
|
+
*/
|
|
13
|
+
/** CLSID of the ME export server (`MEExport.exe`). */
|
|
14
|
+
export declare const EXPORT_CLSID = "9d882577-aa92-466b-a749-6d6b5093b623";
|
|
15
|
+
/** CLSID of the application manager (`ApplicationManagerServer.dll`). */
|
|
16
|
+
export declare const APP_MANAGER_CLSID = "4fead81a-cd72-42da-b659-f1a41499f0f9";
|
|
17
|
+
/** One installed ME application. */
|
|
18
|
+
export interface MeApplication {
|
|
19
|
+
/** Application name as Studio stores it. */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** Target platform when the engine reports one. */
|
|
22
|
+
readonly platform?: string;
|
|
23
|
+
/** Version string when the engine reports one. */
|
|
24
|
+
readonly version?: string;
|
|
25
|
+
}
|
|
26
|
+
/** Result of one vendor call. */
|
|
27
|
+
export interface VendorResult<T> {
|
|
28
|
+
/** Parsed value on success. */
|
|
29
|
+
readonly value?: T;
|
|
30
|
+
/** Human-readable failure. */
|
|
31
|
+
readonly error?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* List every ME application the engine knows.
|
|
35
|
+
*
|
|
36
|
+
* @returns Applications, or the reason the engine could not be reached.
|
|
37
|
+
*/
|
|
38
|
+
export declare function listApplications(): Promise<VendorResult<readonly MeApplication[]>>;
|
|
39
|
+
/**
|
|
40
|
+
* Export one application's displays and globals into a new folder.
|
|
41
|
+
*
|
|
42
|
+
* @param application - Application name as listed.
|
|
43
|
+
* @param destination - Folder to create and fill.
|
|
44
|
+
* @returns Files written, relative to the destination.
|
|
45
|
+
*/
|
|
46
|
+
export declare function exportApplication(application: string, destination: string): Promise<VendorResult<readonly string[]>>;
|
|
47
|
+
/**
|
|
48
|
+
* Back one application up to a zip.
|
|
49
|
+
*
|
|
50
|
+
* @param application - Application name.
|
|
51
|
+
* @param zipPath - Destination zip; must not exist.
|
|
52
|
+
* @returns The zip path on success.
|
|
53
|
+
*/
|
|
54
|
+
export declare function backupApplication(application: string, zipPath: string): Promise<VendorResult<string>>;
|
|
55
|
+
/** Size of one file, for reports. */
|
|
56
|
+
export declare function fileSize(path: string): number;
|