-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathxform.ts
More file actions
331 lines (305 loc) · 15.9 KB
/
Copy pathxform.ts
File metadata and controls
331 lines (305 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
#!/usr/bin/env bun
/**
* IRIS DTL transform provider.
*
* A UNIX filter, which is exactly the contract PipeHat's External Transform
* Provider expects:
*
* stdin <- raw HL7
* stdout -> transformed HL7
* stderr -> diagnostics (compile errors, timings)
* exit 0 = success, non-zero = failure
*
* Nothing here is PipeHat-specific. Run it from a shell, from NppExec, from a
* test script, or from anything else that can spawn a process.
*
* The container transport is a bind mount rather than a REST endpoint: no web
* application, no auth decision, and `iris session` inside the container
* authenticates at the OS level.
*
* Exit codes: 0 ok, 1 transform failed, 2 docker down, 3 container down.
*/
import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, copyFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
// IRIS_MODE=docker talk to the iris-lab container (default; this machine)
// IRIS_MODE=local talk to a native IRIS install on this machine
//
// Same harness either way. Only the transport and the path flavour change:
// the container sees /lab and /src through bind mounts, a native install sees
// the real Windows paths. Lab.Runner takes them as arguments for exactly this.
const MODE = (process.env.IRIS_MODE ?? "docker").toLowerCase();
const CONTAINER = process.env.IRIS_CONTAINER ?? "iris-lab";
const INSTANCE = process.env.IRIS_INSTANCE ?? "IRIS";
const NAMESPACE = process.env.IRIS_NAMESPACE ?? "USER";
const IRIS_EXE = process.env.IRIS_EXE ?? "iris";
const LAB = join(import.meta.dir, "lab");
const SRC = join(import.meta.dir, "src");
const INPUT = join(LAB, "input.hl7");
const OUTPUT = join(LAB, "output.hl7");
// ObjectScript string literals: a Windows path's backslashes are fine, but a
// stray quote would break the line, so refuse rather than emit broken script.
function osStr(p: string): string {
if (p.includes('"')) {
process.stderr.write(`Path contains a double quote, which ObjectScript cannot take: ${p}\n`);
process.exit(1);
}
return `"${p}"`;
}
const paths =
MODE === "local"
? { runner: join(SRC, "Lab.Runner.cls"), input: INPUT, output: OUTPUT, xform: join(LAB, "Transform.cls") }
: { runner: "/src/Lab.Runner.cls", input: "/lab/input.hl7", output: "/lab/output.hl7", xform: "/lab/Transform.cls" };
// A native install normally has password authentication on the Terminal
// service, so a piped session is prompted for credentials before it will run
// anything -- and an unauthenticated session fails at the first line with
// "Access Denied" on the zn. The container has no such prompt (no web
// application, no auth decision), which is why this never came up in docker.
//
// irissession consumes the first two lines of stdin as username and password
// when it is prompting. Credentials go through stdin ON PURPOSE: a command-line
// argument is visible to every other user on the box via the process list, and
// on a work machine that is not a theoretical concern.
//
// Nothing here is logged or written to disk. Set IRIS_USER only when the
// instance actually prompts; sending credentials to a session that is not
// asking makes them the first two ObjectScript commands, which fails oddly.
const AUTH_LINES =
MODE === "local" && process.env.IRIS_USER
? [process.env.IRIS_USER, process.env.IRIS_PASSWORD ?? ""]
: [];
// Paths go into short variables first, and the call that uses them is kept
// under 60 characters. That is not style -- the terminal wraps a long input
// line, and a wrapped line is two ObjectScript commands, each a <SYNTAX>
// error, with the break landing mid-string-literal so neither error names the
// real problem. Docker never hit this because "/lab/input.hl7" is 14
// characters; a native Windows install turns the same call into ~145.
//
// LF, not CRLF. A here-string built in PowerShell ends its lines \r\n, and the
// trailing \r rides along into the password, which fails as "Access Denied"
// with a password that is visibly correct. join("\n") is the whole fix.
const BATCH = [
...AUTH_LINES,
`zn "${NAMESPACE}"`,
`set r=${osStr(paths.runner)}`,
`set i=${osStr(paths.input)}`,
`set o=${osStr(paths.output)}`,
`set x=${osStr(paths.xform)}`,
`do $system.OBJ.Load(r,"ck-d")`,
`do ##class(Lab.Runner).Run(i,o,x)`,
`halt`,
"",
].join("\n");
// `iris session <instance>` is the UNIX spelling. Windows iris.exe has no
// `session` subcommand at all -- it reports that `session` is not a valid
// parameter -- and the scriptable terminal is a separate executable,
// irissession.exe, living in the instance's own bin directory. Inside the
// container we are always on Linux, so the docker branch keeps `iris session`.
//
// Resolution order for the Windows local case:
// 1. IRIS_SESSION_EXE, if the caller knows better than we do
// 2. <directory from `iris list`>\bin\irissession.exe -- the reliable one,
// because an instance is not required to be on PATH
// 3. bare `irissession`, in case it is on PATH after all
function findWindowsSession(): string {
const override = process.env.IRIS_SESSION_EXE;
if (override) {
// Checked rather than trusted: a typo'd override reaches Bun.spawnSync as
// a throw, comes back from run() as exit 127 with empty stderr, and is
// indistinguishable from "the transform produced no output".
if (!existsSync(override)) {
die(2, `IRIS_SESSION_EXE points at a file that does not exist:\n ${override}`);
}
return override;
}
// `iris list` prints a block per instance; the install root is on a
// "directory:" line. Parsing that beats guessing C:\InterSystems\<name>.
const listed = run([IRIS_EXE, "list"]);
if (listed.exitCode === 0) {
const text = listed.stdout.toString();
// Narrow to this instance's block so a multi-instance box picks the right one.
const blocks = text.split(/^(?=Configuration|Instance)/mi);
const mine = blocks.find(b => b.toLowerCase().includes(INSTANCE.toLowerCase())) ?? text;
const dir = mine.match(/^\s*directory:\s*(.+?)\s*$/mi)?.[1];
if (dir) {
const candidate = join(dir, "bin", "irissession.exe");
if (existsSync(candidate)) return candidate;
}
}
// `iris list` is the CORRECT way to do this and the scan below is not, which
// is why the scan runs second. But a stock Windows install puts iris.exe in
// the instance's own bin directory and never touches PATH, so on a perfectly
// healthy machine step 2 cannot run at all. Guessing the install root is
// ugly; making someone hand-set an environment variable to tell us something
// sitting in the default location is worse.
//
// Instance name is matched against the directory name first, because a box
// with both IRIS and IRISHealth installed has two of these and picking the
// wrong one connects you to the wrong database with no error.
const roots = [
process.env.ProgramFiles ? join(process.env.ProgramFiles, "InterSystems") : null,
"C:\\InterSystems",
].filter((r): r is string => !!r && existsSync(r));
const found: string[] = [];
for (const root of roots) {
for (const entry of readdirSync(root)) {
const candidate = join(root, entry, "bin", "irissession.exe");
if (!existsSync(candidate)) continue;
// Directory name and instance name are related but not equal --
// "SIA-IRISHealth" lives in "IRISHealth" -- so match either direction.
const dirName = entry.toLowerCase();
const want = INSTANCE.toLowerCase();
if (dirName === want || want.includes(dirName) || dirName.includes(want)) return candidate;
found.push(candidate);
}
}
// Exactly one install and no name match: it is the only thing it can be.
// Two or more is genuinely ambiguous, so fall through and let the preflight
// ask for IRIS_SESSION_EXE rather than connect to a coin flip.
if (found.length === 1) return found[0]!;
// Resolve against PATH rather than returning the bare name, so the preflight
// below can tell "found it on PATH" from "gave up and guessed" by looking at
// the string it got back. Bun.which() and not a probe run: irissession has
// no --help to exit from, so spawning it to find out whether it exists opens
// a session that sits waiting for a username.
return Bun.which("irissession") ?? "irissession";
}
const SESSION =
MODE === "local"
? (process.platform === "win32"
? [findWindowsSession(), INSTANCE]
: [IRIS_EXE, "session", INSTANCE])
: ["docker", "exec", "-i", CONTAINER, "iris", "session", INSTANCE];
// True once we hold a real path to a real irissession.exe -- by override, by
// `iris list`, or by the install-root scan. That is the only question the
// `iris list` preflight was asking, so when this is true the preflight has
// nothing left to check and skipping it is what keeps a machine with IRIS off
// PATH from being told IRIS is not installed.
const SESSION_EXE_OK =
MODE === "local" && process.platform === "win32" && existsSync(SESSION[0]!);
// Bun.spawnSync THROWS when the executable does not exist rather than
// returning a non-zero exit, so a missing `iris` or `docker` would surface as
// a stack trace instead of the one-line message the caller needs.
function run(cmd: string[], stdin?: string) {
try {
const p = Bun.spawnSync(cmd, {
stdin: stdin === undefined ? "ignore" : new TextEncoder().encode(stdin),
stdout: "pipe",
stderr: "pipe",
});
return { exitCode: p.exitCode ?? 1, stdout: p.stdout, stderr: p.stderr };
} catch {
return { exitCode: 127, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
}
}
function die(code: number, msg: string): never {
process.stderr.write(msg + "\n");
process.exit(code);
}
// ── seed lab/ on first use ───────────────────────────────────────────────
// lab/ is gitignored in full, because it is the working directory and must be
// assumed to hold real messages. The consequence is that a fresh clone has no
// lab/input.hl7 and no lab/Transform.cls, so it cannot run at all -- which is
// exactly what happened to the first person to clone this. seed/ holds
// known-synthetic equivalents; copy them in when they are missing.
//
// Only ever copies when the target is absent, so your edits are never clobbered.
function seedLab() {
const seeds: [string, string][] = [
[join(import.meta.dir, "seed", "input.hl7"), INPUT],
[join(import.meta.dir, "seed", "Transform.cls"), join(LAB, "Transform.cls")],
];
for (const [from, to] of seeds) {
if (existsSync(to) || !existsSync(from)) continue;
mkdirSync(LAB, { recursive: true });
copyFileSync(from, to);
process.stderr.write(`seeded ${to} from seed/\n`);
}
}
seedLab();
// ── read the message off stdin ───────────────────────────────────────────
// No stdin (someone ran this by hand) means "use whatever is already in
// lab/input.hl7", which keeps the file-based workflow usable for debugging.
//
// isTTY is the whole reason this is not a one-liner. "Nothing piped in" and
// "waiting for you to type something" are the same call, and only the console
// case never ends: a Windows console handle does not reach end-of-file by
// itself, so reading it from an interactive shell hangs until Ctrl+Z. It hangs
// BEFORE spawning irissession, which makes it look like an IRIS connection
// problem -- no process to inspect, no output, no error, nothing to grep. The
// bug shipped because every test drove this through a pipe and pipes close.
const piped = process.stdin.isTTY ? "" : await Bun.stdin.text();
if (piped.trim().length > 0) {
writeFileSync(INPUT, piped, "utf8");
} else if (!existsSync(INPUT)) {
die(1, `No message on stdin and no ${INPUT} to fall back to.`);
}
// ── preflight ────────────────────────────────────────────────────────────
if (MODE === "docker") {
if (run(["docker", "info", "--format", "{{.ServerVersion}}"]).exitCode !== 0) {
die(2, "Docker daemon is not running. Start Docker Desktop, wait for the whale, retry.");
}
const ps = run(["docker", "ps", "--filter", `name=^${CONTAINER}$`, "--format", "{{.Names}}"]);
if (!ps.stdout.toString().includes(CONTAINER)) {
die(3, `Container "${CONTAINER}" is not running. From ${import.meta.dir}:\n docker compose up -d`);
}
} else if (!SESSION_EXE_OK && run([IRIS_EXE, "list"]).exitCode !== 0) {
// Gated on SESSION_EXE_OK because `iris list` is a way of FINDING the
// session executable, not a health check. A normal Windows install puts
// iris.exe in the instance's bin directory and never adds it to PATH, so on
// a machine where IRIS is installed, running, and perfectly reachable, this
// gate would fail with "Is IRIS installed and on PATH?" -- which is both
// wrong and pointed at the wrong variable. When the caller has already told
// us exactly which irissession.exe to use, there is nothing left to look up.
die(2, `Could not run "${IRIS_EXE} list". Is IRIS installed and on PATH?\n` +
`Set IRIS_EXE to its full path, or set IRIS_SESSION_EXE to the\n` +
`instance's bin\\irissession.exe and skip the lookup entirely.`);
} else if (process.platform === "win32" && !existsSync(SESSION[0]!) && !SESSION[0]!.includes("\\")) {
// SESSION[0] fell all the way through to bare "irissession", meaning it was
// not found under the instance directory and is only a hope about PATH. Say
// so now with the paths tried, rather than failing later as exit 127 with an
// empty stderr, which is indistinguishable from "the transform produced nothing".
die(2,
`Could not locate irissession.exe for instance "${INSTANCE}".\n` +
`\n` +
`On Windows there is no "${IRIS_EXE} session" subcommand -- that spelling is\n` +
`UNIX-only. The scriptable terminal is irissession.exe, in the instance's own\n` +
`bin directory. Find it with:\n` +
` ${IRIS_EXE} list (read the "directory:" line)\n` +
`then point at it directly:\n` +
` $env:IRIS_SESSION_EXE = "C:\\InterSystems\\<instance>\\bin\\irissession.exe"`);
}
// A stale output file must never be mistaken for this run's result.
if (existsSync(OUTPUT)) rmSync(OUTPUT);
// ── round trip ───────────────────────────────────────────────────────────
const t0 = performance.now();
const res = run(SESSION, BATCH);
const ms = Math.round(performance.now() - t0);
const raw = res.stdout.toString() + res.stderr.toString();
const status = raw
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.startsWith("OK ") || l.startsWith("!! "));
const failure = status.find((l) => l.startsWith("!! "));
if (failure) die(1, failure.replace(/^!!\s*/, ""));
// "Access Denied" from a piped session means the Terminal service is asking for
// credentials it never got. Say that, rather than letting it read as a broken
// transform -- it is the first thing a native install does that docker does not.
if (/access denied/i.test(raw) && AUTH_LINES.length === 0) {
die(1,
`IRIS refused the session: Access Denied.\n` +
`\n` +
`The instance has password authentication on the Terminal service, so a\n` +
`piped session must supply credentials. Set them for this shell only:\n` +
`\n` +
` $env:IRIS_USER = "<your iris username>"\n` +
` $env:IRIS_PASSWORD = Read-Host "IRIS password"\n` +
`\n` +
`Read-Host keeps the password out of your command history. They are sent on\n` +
`stdin, never as arguments, so they do not show up in the process list.\n` +
`Do not put them in a script or a persisted environment variable.`);
}
if (!existsSync(OUTPUT)) {
die(1, `The transform reported no error but produced no output.\n${raw.trim()}`);
}
process.stdout.write(readFileSync(OUTPUT, "utf8"));
process.stderr.write(`${status[0] ?? "OK"} | round trip ${ms} ms\n`);