@jacebenson/jsn 4.3.7 → 4.3.8-dev.47
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/README.md +266 -1
- package/package.json +1 -1
- package/skills/servicenow/SKILL.md +16 -0
- package/src/app.js +8 -1
- package/src/auth.js +14 -12
- package/src/cli-adapter.js +162 -11
- package/src/cli.js +121 -13
- package/src/commands/_generic.js +3 -2
- package/src/commands/_simple.js +1 -1
- package/src/commands/actions.js +156 -0
- package/src/commands/auth.js +21 -11
- package/src/commands/cmdb.js +18 -0
- package/src/commands/diff.js +205 -0
- package/src/commands/docs/docs.js +18 -16
- package/src/commands/flows.js +149 -47
- package/src/commands/graphql.js +95 -0
- package/src/commands/groupmembers.js +19 -4
- package/src/commands/grouproles.js +15 -4
- package/src/commands/midservers.js +708 -0
- package/src/commands/perf.js +36 -9
- package/src/commands/platform.js +202 -0
- package/src/commands/records.js +19 -15
- package/src/commands/skill.js +5 -2
- package/src/commands/transactions.js +78 -0
- package/src/commands/updatesets.js +77 -11
- package/src/errors.js +71 -6
- package/src/flow-create.js +884 -0
- package/src/flow-designer-interactive.js +417 -0
- package/src/flow-inspection.js +15 -5
- package/src/flow-publish.js +32 -0
- package/src/flow-test.js +122 -0
- package/src/flow-triggers.js +448 -0
- package/src/flow-versioning.js +53 -0
- package/src/help.js +93 -28
- package/src/helpers.js +124 -9
- package/src/midserver-direct.js +312 -0
- package/src/midserver-install.js +170 -0
- package/src/midserver-zip.js +107 -0
- package/src/output.js +7 -0
- package/src/processflow-actions.js +366 -0
- package/src/sdk.js +8 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { midInstallDirectory, normalizeMidName, defaultRun, validateMidCredentials } from './midserver-install.js';
|
|
7
|
+
import { extractMidZip, inspectMidZip } from './midserver-zip.js';
|
|
8
|
+
|
|
9
|
+
const METADATA = '.jsn-mid-direct.json';
|
|
10
|
+
const MAX_ARCHIVE = 1024 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
export function detectDirectPlatform(platform = process.platform, arch = process.arch) {
|
|
13
|
+
if (!['linux', 'win32'].includes(platform)) throw new Error('Direct MID installs require Linux and Windows');
|
|
14
|
+
if (arch !== 'x64') throw new Error('Direct MID ZIP installs require x86-64');
|
|
15
|
+
return platform === 'win32' ? 'windows' : 'linux';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildMidZipUrl(releaseName, platform = process.platform) {
|
|
19
|
+
const native = detectDirectPlatform(platform, 'x64');
|
|
20
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(releaseName)) throw new Error('Invalid MID release name');
|
|
21
|
+
const match = [...releaseName.matchAll(/(\d{4})-(\d{2})-(\d{2})|(\d{2})-(\d{2})-(\d{4})/g)].at(-1);
|
|
22
|
+
if (!match) throw new Error('MID release name has no build date');
|
|
23
|
+
const year = match[1] || match[6], month = match[2] || match[4], day = match[3] || match[5];
|
|
24
|
+
const date = new Date(`${year}-${month}-${day}T00:00:00Z`);
|
|
25
|
+
if (Number.isNaN(date.valueOf()) || date.toISOString().slice(0, 10) !== `${year}-${month}-${day}`) throw new Error('Invalid MID build date');
|
|
26
|
+
return `https://install.service-now.com/glide/distribution/builds/package/app-signed/mid/${year}/${month}/${day}/mid.${releaseName}.${native}.x86-64.zip`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function powershell(script, directory) {
|
|
30
|
+
return execFileSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand',
|
|
31
|
+
Buffer.from(`$ErrorActionPreference='Stop'; try {\n${script}\n} catch { exit 1 }`, 'utf16le').toString('base64')], {
|
|
32
|
+
env: { ...process.env, JSN_MID_DIRECTORY: directory }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// No chmod substitute on Windows: protect the DACL before any credential is written.
|
|
37
|
+
function protectDirectory(directory, platform) {
|
|
38
|
+
if (platform === 'linux') { fs.chmodSync(directory, 0o700); return; }
|
|
39
|
+
powershell(`$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
40
|
+
$acl=New-Object System.Security.AccessControl.DirectorySecurity
|
|
41
|
+
$acl.SetOwner($sid)
|
|
42
|
+
$acl.SetAccessRuleProtection($true,$false)
|
|
43
|
+
$rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow')
|
|
44
|
+
$acl.AddAccessRule($rule)
|
|
45
|
+
Set-Acl -LiteralPath $env:JSN_MID_DIRECTORY -AclObject $acl
|
|
46
|
+
$actual=Get-Acl -LiteralPath $env:JSN_MID_DIRECTORY
|
|
47
|
+
if (!$actual.AreAccessRulesProtected) { throw 'MID directory ACL is not protected' }
|
|
48
|
+
foreach($r in $actual.Access) { if($r.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value -or $r.AccessControlType -ne 'Allow') { throw 'Unexpected MID directory access rule' } }`, directory);
|
|
49
|
+
assertPrivateWindows(directory);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertPrivateWindows(directory) {
|
|
53
|
+
powershell(`$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
54
|
+
$acl=Get-Acl -LiteralPath $env:JSN_MID_DIRECTORY
|
|
55
|
+
if (!$acl.AreAccessRulesProtected -or @($acl.Access).Count -ne 1) { throw 'MID directory ACL must be private' }
|
|
56
|
+
$r=@($acl.Access)[0]
|
|
57
|
+
if ($r.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value -or $r.AccessControlType -ne 'Allow' -or $r.FileSystemRights -ne 'FullControl') { throw 'Unexpected MID directory access rule' }`, directory);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function checkParents(directory, platform) {
|
|
61
|
+
let current = path.dirname(directory);
|
|
62
|
+
while (true) {
|
|
63
|
+
if (fs.existsSync(current)) {
|
|
64
|
+
const st = fs.lstatSync(current);
|
|
65
|
+
if (st.isSymbolicLink() || !st.isDirectory()) throw new Error('Installation ancestors must be real directories');
|
|
66
|
+
if (platform === 'linux' && (st.mode & 0o022) && !(st.mode & 0o1000)) throw new Error('Installation ancestor is writable by other users');
|
|
67
|
+
}
|
|
68
|
+
const parent = path.dirname(current);
|
|
69
|
+
if (parent === current) break;
|
|
70
|
+
current = parent;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function downloadMidZip(url, archive, fetchImpl = fetch, onProgress) {
|
|
75
|
+
const response = await fetchImpl(url, { redirect: 'error', signal: AbortSignal.timeout(600000) });
|
|
76
|
+
if (!response.ok || !response.body) throw new Error(`MID ZIP download failed (HTTP ${response.status})`);
|
|
77
|
+
const length = Number(response.headers.get('content-length'));
|
|
78
|
+
if (length > MAX_ARCHIVE) throw new Error('MID ZIP exceeds 1 GiB download limit');
|
|
79
|
+
onProgress?.({ phase: 'download', received: 0, total: Number.isFinite(length) ? length : 0 });
|
|
80
|
+
const fd = fs.openSync(archive, 'wx', 0o600);
|
|
81
|
+
let size = 0;
|
|
82
|
+
let reported = 0;
|
|
83
|
+
const step = Math.max(4 * 1024 * 1024, Math.floor((Number.isFinite(length) ? length : MAX_ARCHIVE) / 100));
|
|
84
|
+
try {
|
|
85
|
+
for await (const chunk of response.body) {
|
|
86
|
+
size += chunk.length;
|
|
87
|
+
if (size > MAX_ARCHIVE) throw new Error('MID ZIP exceeds 1 GiB download limit');
|
|
88
|
+
fs.writeSync(fd, chunk);
|
|
89
|
+
if (onProgress && size - reported >= step) {
|
|
90
|
+
reported = size;
|
|
91
|
+
onProgress({ phase: 'download', received: size, total: Number.isFinite(length) ? length : 0 });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (length && size !== length) throw new Error('Incomplete MID ZIP download');
|
|
95
|
+
onProgress?.({ phase: 'download', received: size, total: size, done: true });
|
|
96
|
+
} finally { fs.closeSync(fd); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function xmlEscape(value) {
|
|
100
|
+
if ([...String(value)].some(char => char.codePointAt(0) < 32 && !['\t', '\n', '\r'].includes(char))) throw new Error('Invalid XML configuration character');
|
|
101
|
+
return String(value).replaceAll('&', '&').replaceAll('"', '"').replaceAll("'", ''').replaceAll('<', '<').replaceAll('>', '>').replaceAll('\r', ' ').replaceAll('\n', ' ').replaceAll('\t', '	');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function configureMidXml(template, values) {
|
|
105
|
+
// Work only on active parameter elements, never examples in template comments.
|
|
106
|
+
const comments = [];
|
|
107
|
+
let xml = template.replace(/<!--[\s\S]*?-->/g, comment => `<!--JSN_COMMENT_${comments.push(comment) - 1}-->`);
|
|
108
|
+
for (const [name, value] of Object.entries(values)) {
|
|
109
|
+
let found = 0;
|
|
110
|
+
xml = xml.replace(/<parameter\b[^>]*\/>/g, tag => {
|
|
111
|
+
if (!tag.match(/\bname\s*=\s*(["'])(.*?)\1/) || tag.match(/\bname\s*=\s*(["'])(.*?)\1/)[2] !== name) return tag;
|
|
112
|
+
found++;
|
|
113
|
+
const attribute = `value="${xmlEscape(value)}"`;
|
|
114
|
+
return /\bvalue\s*=/.test(tag) ? tag.replace(/\bvalue\s*=\s*(["'])[\s\S]*?\1/, attribute) : tag.replace(/\/>$/, ` ${attribute}/>`);
|
|
115
|
+
});
|
|
116
|
+
if (found > 1) throw new Error(`Duplicate MID configuration field ${name}`);
|
|
117
|
+
if (!found) {
|
|
118
|
+
if (!xml.includes('</parameters>')) throw new Error('Unrecognized vendor config.xml template');
|
|
119
|
+
xml = xml.replace('</parameters>', ` <parameter name="${name}" value="${xmlEscape(value)}"/>\n</parameters>`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return xml.replace(/<!--JSN_COMMENT_(\d+)-->/g, (_, index) => comments[index]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function nativeToken(origin, name, directory) {
|
|
126
|
+
return `jsn_mid_${createHash('sha256').update(`${origin}/${name}/${directory}`).digest('hex').slice(0, 20)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function refuseNativeService(token) {
|
|
130
|
+
for (const filename of [`/etc/systemd/system/${token}.service`, `/run/systemd/system/${token}.service`, `/usr/lib/systemd/system/${token}.service`, `/etc/init/${token}.conf`, `/etc/init.d/${token}`, `/etc/rc.d/init.d/${token}`]) {
|
|
131
|
+
try { fs.lstatSync(filename); } catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
132
|
+
throw new Error('A native service already uses this MID identity; refusing to operate on it');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function save(metadata) {
|
|
137
|
+
fs.writeFileSync(path.join(metadata.installDir, METADATA), JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function readDirectInstallation(instanceURL, name, directory) {
|
|
141
|
+
const installDir = path.resolve(directory || midInstallDirectory(instanceURL, name));
|
|
142
|
+
try { fs.lstatSync(path.join(installDir, METADATA)); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
143
|
+
checkParents(installDir, process.platform === 'linux' ? 'linux' : 'windows');
|
|
144
|
+
const root = fs.lstatSync(installDir);
|
|
145
|
+
if (!root.isDirectory() || root.isSymbolicLink() || (process.platform === 'linux' && ((root.mode & 0o077) || root.uid !== process.getuid()))) throw new Error('Refusing an untrusted installation directory');
|
|
146
|
+
if (process.platform === 'win32') assertPrivateWindows(installDir);
|
|
147
|
+
const metaPath = path.join(installDir, METADATA);
|
|
148
|
+
const st = fs.lstatSync(metaPath);
|
|
149
|
+
if (!st.isFile() || st.isSymbolicLink() || (process.platform === 'linux' && ((st.mode & 0o077) || st.uid !== process.getuid()))) throw new Error('Untrusted MID installation metadata');
|
|
150
|
+
const metadata = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
151
|
+
if (metadata.owner !== 'jsn-mid-direct-v1' || metadata.installDir !== installDir || metadata.instanceURL !== new URL(instanceURL).origin || metadata.name !== normalizeMidName(name) || metadata.platform !== detectDirectPlatform()) throw new Error('MID installation ownership does not match');
|
|
152
|
+
if (fs.lstatSync(path.join(installDir, 'agent')).isSymbolicLink()) throw new Error('Refusing symlink agent');
|
|
153
|
+
return metadata;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The vendor rotates agent0.log to agent0.log.0 (and upward), so the plain
|
|
157
|
+
// name may not exist on a healthy install. Newest real file wins; links and
|
|
158
|
+
// non-files are never followed.
|
|
159
|
+
export function resolveDirectLogFile(installDir) {
|
|
160
|
+
const directory = path.join(installDir, 'agent', 'logs');
|
|
161
|
+
let names;
|
|
162
|
+
try { names = fs.readdirSync(directory); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
163
|
+
const candidates = [];
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
const agent = /^agent0\.log(\.\d+)?$/.test(name);
|
|
166
|
+
if (!agent && name !== 'wrapper.log') continue;
|
|
167
|
+
const full = path.join(directory, name);
|
|
168
|
+
const stat = fs.lstatSync(full);
|
|
169
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue;
|
|
170
|
+
candidates.push({ full, agent, mtime: stat.mtimeMs });
|
|
171
|
+
}
|
|
172
|
+
candidates.sort((a, b) => (b.agent - a.agent) || (b.mtime - a.mtime));
|
|
173
|
+
return candidates.length ? candidates[0].full : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Tail a direct install's agent log, following rotation.
|
|
178
|
+
*
|
|
179
|
+
* The vendor rotates by creating a new file, so the followed path can change
|
|
180
|
+
* between polls; a shrinking file is treated as truncation and re-read from
|
|
181
|
+
* the start. Each appended chunk is handed to `writer` unchanged.
|
|
182
|
+
*/
|
|
183
|
+
export async function followDirectLog(installDir, { writer = chunk => process.stdout.write(chunk), intervalMs = 1000, iterations = Infinity, sleep } = {}) {
|
|
184
|
+
const pause = sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
185
|
+
let file = resolveDirectLogFile(installDir);
|
|
186
|
+
if (!file) throw new Error('No MID agent log yet; the installation may only be prepared');
|
|
187
|
+
let offset = fs.statSync(file).size;
|
|
188
|
+
for (let seen = 0; seen < iterations; seen++) {
|
|
189
|
+
await pause(intervalMs);
|
|
190
|
+
const next = resolveDirectLogFile(installDir) || file;
|
|
191
|
+
if (next !== file) { file = next; offset = 0; }
|
|
192
|
+
const stat = fs.lstatSync(file);
|
|
193
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return;
|
|
194
|
+
if (stat.size < offset) offset = 0;
|
|
195
|
+
if (stat.size === offset) continue;
|
|
196
|
+
const length = stat.size - offset;
|
|
197
|
+
const buffer = Buffer.alloc(length);
|
|
198
|
+
const fd = fs.openSync(file, 'r');
|
|
199
|
+
try { fs.readSync(fd, buffer, 0, length, offset); } finally { fs.closeSync(fd); }
|
|
200
|
+
offset = stat.size;
|
|
201
|
+
writer(buffer.toString('utf8'));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function uncertainState(metadata) {
|
|
206
|
+
return `MID '${metadata.name}' runtime state is uncertain after an interrupted lifecycle command. Confirm no vendor MID process from ${metadata.installDir} is running, then rerun; installed files are kept.`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function runDirectInstallation(metadata, action, run = defaultRun) {
|
|
210
|
+
const cwd = path.join(metadata.installDir, 'agent');
|
|
211
|
+
if (!['start', 'stop'].includes(action)) throw new Error('Unsupported MID runtime action');
|
|
212
|
+
if (metadata.state === 'uncertain') throw new Error(uncertainState(metadata));
|
|
213
|
+
if (metadata.runtime !== 'user-background') {
|
|
214
|
+
// Windows console runtime: never register a service, never guess at a state.
|
|
215
|
+
if (action === 'stop') {
|
|
216
|
+
throw new Error(metadata.state === 'console-running' ? 'Windows console MID: stop with Ctrl+C in its owning terminal before removal' : 'Windows console MID is not running');
|
|
217
|
+
}
|
|
218
|
+
if (metadata.state !== 'prepared') throw new Error('MID is already started; stop it before starting again');
|
|
219
|
+
metadata.state = 'console-running'; save(metadata);
|
|
220
|
+
try { run('cmd.exe', ['/d', '/s', '/c', 'bin\\mid.bat console'], { cwd }); }
|
|
221
|
+
catch { throw new Error('Windows MID console failed or was interrupted; runtime state is uncertain. Stop in its owning terminal; files retained.'); }
|
|
222
|
+
metadata.state = 'prepared'; save(metadata);
|
|
223
|
+
return metadata;
|
|
224
|
+
}
|
|
225
|
+
// Never guess at a state an interrupted Linux command left behind; make it explicit.
|
|
226
|
+
if (['starting', 'stopping', 'console-running'].includes(metadata.state)) {
|
|
227
|
+
metadata.state = 'uncertain'; save(metadata);
|
|
228
|
+
throw new Error(uncertainState(metadata));
|
|
229
|
+
}
|
|
230
|
+
// Call the native user-owned launcher, never install/register a system service.
|
|
231
|
+
if (process.getuid?.() === 0) throw new Error('Run native MID lifecycle commands as a non-root user');
|
|
232
|
+
refuseNativeService(nativeToken(metadata.instanceURL, metadata.name, metadata.installDir));
|
|
233
|
+
if (action === 'start' && metadata.state !== 'prepared') throw new Error('MID is already started; stop it before starting again');
|
|
234
|
+
if (action === 'stop' && metadata.state !== 'started') throw new Error('MID is not running');
|
|
235
|
+
metadata.state = action === 'start' ? 'starting' : 'stopping'; save(metadata);
|
|
236
|
+
try { run('sh', ['bin/mid.sh', action], { cwd, stdio: 'ignore' }); }
|
|
237
|
+
catch { throw new Error(`MID ${action} failed; installation retained at ${metadata.installDir}. Inspect local logs.`); }
|
|
238
|
+
metadata.state = action === 'start' ? 'started' : 'prepared'; save(metadata);
|
|
239
|
+
return metadata;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function removeDirectInstallation(metadata, run = defaultRun) {
|
|
243
|
+
if (metadata.state === 'uncertain') throw new Error(`Refusing to remove '${metadata.name}': runtime state is uncertain. Inspect ${metadata.installDir} and stop any vendor MID process first.`);
|
|
244
|
+
if (metadata.state !== 'prepared') runDirectInstallation(metadata, 'stop', run);
|
|
245
|
+
fs.rmSync(metadata.installDir, { recursive: true });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function installDirectMidServer({ instanceURL, username, password, name, directory, releaseName: suppliedRelease,
|
|
249
|
+
runNow = false, platform = process.platform, arch = process.arch, fetchImpl = fetch, download = downloadMidZip, onProgress,
|
|
250
|
+
protect = protectDirectory, run = defaultRun }) {
|
|
251
|
+
const native = detectDirectPlatform(platform, arch);
|
|
252
|
+
if (native === 'linux' && process.getuid?.() === 0) throw new Error('Run direct MID installation as a non-root user');
|
|
253
|
+
if (native === 'windows' && runNow) throw new Error('Prepare Windows first, then start its console in an owning terminal; no implicit service registration');
|
|
254
|
+
const shortName = normalizeMidName(name);
|
|
255
|
+
const origin = new URL(instanceURL).origin;
|
|
256
|
+
if (!origin.startsWith('https://')) throw new Error('MID credentials require an HTTPS instance');
|
|
257
|
+
const installDir = path.resolve(directory || midInstallDirectory(origin, shortName));
|
|
258
|
+
checkParents(installDir, native);
|
|
259
|
+
if (native === 'linux') refuseNativeService(nativeToken(origin, shortName, installDir));
|
|
260
|
+
if (fs.existsSync(installDir)) throw new Error(`Refusing to overwrite existing installation: ${installDir}`);
|
|
261
|
+
const releaseName = suppliedRelease || (await validateMidCredentials(origin, username, password, fetchImpl)).releaseName;
|
|
262
|
+
const archiveURL = buildMidZipUrl(releaseName, platform);
|
|
263
|
+
// Validate all credential fields before creating files or downloading.
|
|
264
|
+
for (const value of [origin, username, password, shortName]) xmlEscape(value);
|
|
265
|
+
if (!username || !password) throw new Error('MID credentials are required');
|
|
266
|
+
let ancestor = path.dirname(installDir);
|
|
267
|
+
while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor);
|
|
268
|
+
const disk = fs.statfsSync(ancestor);
|
|
269
|
+
if (disk.bavail * disk.bsize < 3 * MAX_ARCHIVE) throw new Error('Direct MID installation requires at least 3 GiB free disk space');
|
|
270
|
+
fs.mkdirSync(path.dirname(installDir), { recursive: true, mode: 0o700 });
|
|
271
|
+
fs.mkdirSync(installDir, { mode: 0o700 }); // atomic collision refusal
|
|
272
|
+
const archive = path.join(installDir, '.download.zip');
|
|
273
|
+
let prepared = false;
|
|
274
|
+
try {
|
|
275
|
+
protect(installDir, native);
|
|
276
|
+
onProgress?.({ phase: 'download-start', archiveURL, platform: native, installDir, name: shortName });
|
|
277
|
+
await download(archiveURL, archive, fetchImpl, onProgress);
|
|
278
|
+
const entries = inspectMidZip(archive);
|
|
279
|
+
onProgress?.({ phase: 'extract-start', entries: entries.length, installDir });
|
|
280
|
+
await extractMidZip(archive, installDir, entries);
|
|
281
|
+
onProgress?.({ phase: 'extract-done', entries: entries.length, installDir });
|
|
282
|
+
const agent = path.join(installDir, 'agent');
|
|
283
|
+
for (const filename of ['config.xml', 'conf/wrapper-override.conf', native === 'linux' ? 'bin/mid.sh' : 'bin/mid.bat']) {
|
|
284
|
+
if (!fs.statSync(path.join(agent, filename)).isFile()) throw new Error(`Vendor ZIP missing ${filename}`);
|
|
285
|
+
}
|
|
286
|
+
const config = path.join(agent, 'config.xml');
|
|
287
|
+
fs.writeFileSync(config, configureMidXml(fs.readFileSync(config, 'utf8'), {
|
|
288
|
+
url: `${origin}/`, 'mid.instance.username': username, 'mid.instance.password': password, name: shortName,
|
|
289
|
+
}), { mode: 0o600 });
|
|
290
|
+
const token = nativeToken(origin, shortName, installDir);
|
|
291
|
+
const override = path.join(agent, 'conf/wrapper-override.conf');
|
|
292
|
+
let text = fs.readFileSync(override, 'utf8').replace(/^wrapper\.(name|displayname)=.*\r?$/gm, '');
|
|
293
|
+
text += `\nwrapper.name=${token}\nwrapper.displayname=ServiceNow MID ${shortName}\n`;
|
|
294
|
+
fs.writeFileSync(override, text, { mode: 0o600 });
|
|
295
|
+
if (native === 'linux') {
|
|
296
|
+
fs.appendFileSync(path.join(agent, 'bin/mid.shconf_override'), `\nAPP_NAME="${token}"\nAPP_LONG_NAME="ServiceNow MID ${shortName}"\nSERVICE_MANAGEMENT_TOOL=initd\n`);
|
|
297
|
+
}
|
|
298
|
+
fs.rmSync(archive);
|
|
299
|
+
const metadata = { owner: 'jsn-mid-direct-v1', instanceURL: origin, name: shortName, releaseName, installDir, archiveURL, platform: native, runtime: native === 'linux' ? 'user-background' : 'console', state: 'prepared', serviceRegistered: false };
|
|
300
|
+
save(metadata);
|
|
301
|
+
prepared = true;
|
|
302
|
+
if (runNow) runDirectInstallation(metadata, 'start', run);
|
|
303
|
+
return metadata;
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (!prepared) fs.rmSync(installDir, { recursive: true, force: true });
|
|
306
|
+
// Do not expose nested process/fetch errors that could include credential-bearing content.
|
|
307
|
+
if (prepared) throw error;
|
|
308
|
+
// Do not retain raw causes, which can contain credentials.
|
|
309
|
+
// eslint-disable-next-line preserve-caught-error
|
|
310
|
+
throw new Error(`Direct MID preparation failed; owned files cleaned up (${error.code || 'preparation error'}). Check ZIP, disk, permissions and vendor layout.`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const INSTALL_HOST = 'install.service-now.com';
|
|
8
|
+
|
|
9
|
+
function defaultRun(command, args, options = {}) {
|
|
10
|
+
return execFileSync(command, args, {
|
|
11
|
+
cwd: options.cwd,
|
|
12
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
stdio: options.stdio || 'inherit',
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertSafePart(value, label) {
|
|
19
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
|
|
20
|
+
throw new Error(`${label} may contain only letters, numbers, dots, underscores, and dashes`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeMidName(name) {
|
|
25
|
+
const shortName = String(name || '').replace(/^mid\.server\./, '');
|
|
26
|
+
assertSafePart(shortName, 'MID Server name');
|
|
27
|
+
return shortName;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildMidRecipeUrl(releaseName) {
|
|
31
|
+
const matches = [...String(releaseName).matchAll(/(\d{4})-(\d{2})-(\d{2})|(\d{2})-(\d{2})-(\d{4})/g)];
|
|
32
|
+
const match = matches.at(-1);
|
|
33
|
+
if (!match) throw new Error(`MID release name has no build date: ${releaseName}`);
|
|
34
|
+
const year = match[1] || match[6];
|
|
35
|
+
const month = match[2] || match[4];
|
|
36
|
+
const day = match[3] || match[5];
|
|
37
|
+
const filename = `mid-linux-container-recipe.${releaseName}.linux.x86-64.zip`;
|
|
38
|
+
return `https://${INSTALL_HOST}/glide/distribution/builds/package/app-signed/mid-linux-container-recipe/${year}/${month}/${day}/${filename}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function validateMidCredentials(instanceURL, username, password, fetchImpl = fetch) {
|
|
42
|
+
if (!username || !password) throw new Error('MID Server username and password are required');
|
|
43
|
+
const endpoint = `${String(instanceURL).replace(/\/$/, '')}/api/now/table/sys_properties?sysparm_query=name=mid.version&sysparm_fields=value&sysparm_limit=1`;
|
|
44
|
+
let response;
|
|
45
|
+
try {
|
|
46
|
+
response = await fetchImpl(endpoint, {
|
|
47
|
+
headers: {
|
|
48
|
+
Accept: 'application/json',
|
|
49
|
+
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error('Could not reach the ServiceNow instance to validate the MID Server credentials');
|
|
54
|
+
}
|
|
55
|
+
if (response.status === 401 || response.status === 403) {
|
|
56
|
+
throw new Error('The MID Server username or password was rejected by the ServiceNow instance');
|
|
57
|
+
}
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new Error(`MID Server credential validation failed with HTTP ${response.status}`);
|
|
60
|
+
}
|
|
61
|
+
const body = await response.json();
|
|
62
|
+
const releaseName = body?.result?.[0]?.value;
|
|
63
|
+
if (!releaseName) throw new Error('Credential validation succeeded, but the instance returned no mid.version value');
|
|
64
|
+
return { releaseName };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function detectDocker(run = defaultRun) {
|
|
68
|
+
const status = { installed: false, compose: false, daemon: false, available: false };
|
|
69
|
+
try { run('docker', ['--version'], { stdio: 'ignore' }); status.installed = true; } catch { return status; }
|
|
70
|
+
try { run('docker', ['compose', 'version'], { stdio: 'ignore' }); status.compose = true; } catch { return status; }
|
|
71
|
+
try { run('docker', ['info'], { stdio: 'ignore' }); status.daemon = true; } catch { return status; }
|
|
72
|
+
status.available = true;
|
|
73
|
+
return status;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function dockerComposeAvailable(run = defaultRun) {
|
|
77
|
+
return detectDocker(run).available;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function composeFile({ serviceName }) {
|
|
81
|
+
return `services:\n ${serviceName}:\n container_name: ${serviceName}\n build: ./recipe\n image: ${serviceName}:\${MID_RELEASE}\n restart: unless-stopped\n volumes:\n - ./export:/opt/snc_mid_server/agent/export\n environment:\n MID_INSTANCE_URL: \${MID_INSTANCE_URL}\n MID_INSTANCE_USERNAME: \${MID_INSTANCE_USERNAME}\n MID_INSTANCE_PASSWORD: \${MID_INSTANCE_PASSWORD}\n MID_SERVER_NAME: \${MID_SERVER_NAME}\n`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function envFile({ instanceURL, username, password, displayName, releaseName }) {
|
|
85
|
+
return `MID_RELEASE=${releaseName}\nMID_INSTANCE_URL=${String(instanceURL).replace(/\/$/, '')}/\nMID_INSTANCE_USERNAME=${username}\nMID_INSTANCE_PASSWORD=${password}\nMID_SERVER_NAME=${displayName}\n`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function instanceName(instanceURL) {
|
|
89
|
+
const hostname = new URL(instanceURL).hostname;
|
|
90
|
+
return hostname.replace(/\.service-now\.com$|\.servicenowservices\.com$/i, '').replace(/[^A-Za-z0-9._-]/g, '_');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function midInstallDirectory(instanceURL, name, homeDirectory = os.homedir()) {
|
|
94
|
+
const shortName = normalizeMidName(name);
|
|
95
|
+
return path.join(homeDirectory, '.servicenow', 'mids', instanceName(instanceURL), shortName);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function installDockerMidServer({
|
|
99
|
+
instanceURL,
|
|
100
|
+
username,
|
|
101
|
+
password,
|
|
102
|
+
name,
|
|
103
|
+
directory,
|
|
104
|
+
releaseName: suppliedReleaseName,
|
|
105
|
+
runNow = true,
|
|
106
|
+
fetchImpl = fetch,
|
|
107
|
+
run = defaultRun,
|
|
108
|
+
}) {
|
|
109
|
+
const dockerAvailable = detectDocker(run).available;
|
|
110
|
+
const shortName = normalizeMidName(name);
|
|
111
|
+
const displayName = shortName;
|
|
112
|
+
const releaseName = suppliedReleaseName || (await validateMidCredentials(instanceURL, username, password, fetchImpl)).releaseName;
|
|
113
|
+
const recipeURL = buildMidRecipeUrl(releaseName);
|
|
114
|
+
const installDir = directory || midInstallDirectory(instanceURL, name);
|
|
115
|
+
fs.mkdirSync(installDir, { recursive: true, mode: 0o700 });
|
|
116
|
+
const exportDir = path.join(installDir, 'export');
|
|
117
|
+
fs.mkdirSync(exportDir, { recursive: true, mode: 0o777 });
|
|
118
|
+
fs.chmodSync(exportDir, 0o777);
|
|
119
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsn-mid-'));
|
|
120
|
+
const filename = path.basename(recipeURL);
|
|
121
|
+
const archive = path.join(workDir, filename);
|
|
122
|
+
const recipeDir = path.join(workDir, 'recipe');
|
|
123
|
+
try {
|
|
124
|
+
run('curl', ['-fSL', '--connect-timeout', '10', '--max-time', '300', recipeURL, '-o', archive]);
|
|
125
|
+
fs.mkdirSync(recipeDir, { recursive: true, mode: 0o700 });
|
|
126
|
+
run('unzip', ['-q', archive, '-d', recipeDir]);
|
|
127
|
+
const composePath = path.join(installDir, 'docker-compose.yaml');
|
|
128
|
+
const persistedRecipeDir = path.join(installDir, 'recipe');
|
|
129
|
+
const serviceName = `jsn_mid_${shortName.replace(/[^A-Za-z0-9_.-]/g, '_')}`;
|
|
130
|
+
const env = {
|
|
131
|
+
MID_RELEASE: releaseName,
|
|
132
|
+
MID_INSTANCE_URL: `${String(instanceURL).replace(/\/$/, '')}/`,
|
|
133
|
+
MID_INSTANCE_USERNAME: username,
|
|
134
|
+
MID_INSTANCE_PASSWORD: password,
|
|
135
|
+
MID_SERVER_NAME: displayName,
|
|
136
|
+
};
|
|
137
|
+
fs.rmSync(persistedRecipeDir, { recursive: true, force: true });
|
|
138
|
+
fs.cpSync(recipeDir, persistedRecipeDir, { recursive: true });
|
|
139
|
+
fs.writeFileSync(composePath, composeFile({ serviceName }), { mode: 0o600 });
|
|
140
|
+
fs.chmodSync(composePath, 0o600);
|
|
141
|
+
const envPath = path.join(installDir, '.env');
|
|
142
|
+
fs.writeFileSync(envPath, envFile({ instanceURL, username, password, displayName, releaseName }), { mode: 0o600 });
|
|
143
|
+
fs.chmodSync(envPath, 0o600);
|
|
144
|
+
if (!dockerAvailable || !runNow) {
|
|
145
|
+
return {
|
|
146
|
+
name: displayName,
|
|
147
|
+
releaseName,
|
|
148
|
+
installDir,
|
|
149
|
+
composePath,
|
|
150
|
+
serviceName,
|
|
151
|
+
prepared: true,
|
|
152
|
+
runCommand: `docker compose --env-file ${envPath} -f ${composePath} up -d`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
run('docker', ['build', '--tag', `${serviceName}:${releaseName}`, persistedRecipeDir]);
|
|
156
|
+
run('docker', ['compose', '--env-file', envPath, '-f', composePath, 'up', '-d'], { cwd: installDir, env });
|
|
157
|
+
return {
|
|
158
|
+
name: displayName,
|
|
159
|
+
releaseName,
|
|
160
|
+
installDir,
|
|
161
|
+
composePath,
|
|
162
|
+
serviceName,
|
|
163
|
+
rollback: () => run('docker', ['compose', '--env-file', envPath, '-f', composePath, 'down'], { cwd: installDir, env }),
|
|
164
|
+
};
|
|
165
|
+
} finally {
|
|
166
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export { defaultRun };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createInflateRaw } from 'node:zlib';
|
|
4
|
+
import { Transform } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
|
|
7
|
+
const MAX_EXPANDED = 2 * 1024 * 1024 * 1024;
|
|
8
|
+
const crcTable = Array.from({ length: 256 }, (_, n) => {
|
|
9
|
+
for (let i = 0; i < 8; i++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1;
|
|
10
|
+
return n >>> 0;
|
|
11
|
+
});
|
|
12
|
+
function read(fd, size, offset) {
|
|
13
|
+
const result = Buffer.alloc(size);
|
|
14
|
+
if (fs.readSync(fd, result, 0, size, offset) !== size) throw new Error('Truncated ZIP');
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Inspect every entry before extracting anything. ZIP64, split ZIPs, links and
|
|
19
|
+
// Windows aliases are deliberately unsupported, not delegated to a shell tool.
|
|
20
|
+
export function inspectMidZip(archive) {
|
|
21
|
+
const fd = fs.openSync(archive, 'r');
|
|
22
|
+
try {
|
|
23
|
+
const size = fs.fstatSync(fd).size;
|
|
24
|
+
if (size < 22) throw new Error('Invalid ZIP');
|
|
25
|
+
const tail = read(fd, Math.min(size, 65557), size - Math.min(size, 65557));
|
|
26
|
+
let end = -1;
|
|
27
|
+
for (let i = tail.length - 22; i >= 0; i--) {
|
|
28
|
+
if (tail.readUInt32LE(i) === 0x06054b50 && i + 22 + tail.readUInt16LE(i + 20) === tail.length) { end = i; break; }
|
|
29
|
+
}
|
|
30
|
+
if (end < 0) throw new Error('Missing ZIP directory');
|
|
31
|
+
const count = tail.readUInt16LE(end + 10), directorySize = tail.readUInt32LE(end + 12), offset = tail.readUInt32LE(end + 16);
|
|
32
|
+
if (tail.readUInt16LE(end + 4) || tail.readUInt16LE(end + 6) || tail.readUInt16LE(end + 8) !== count || count === 65535 || count > 20000 || directorySize > 16 * 1024 * 1024 || offset + directorySize > size - (tail.length - end)) throw new Error('Unsupported ZIP directory');
|
|
33
|
+
const directory = read(fd, directorySize, offset);
|
|
34
|
+
const entries = [], names = new Map();
|
|
35
|
+
let pos = 0, expanded = 0;
|
|
36
|
+
for (let i = 0; i < count; i++) {
|
|
37
|
+
if (pos + 46 > directory.length || directory.readUInt32LE(pos) !== 0x02014b50) throw new Error('Invalid ZIP entry');
|
|
38
|
+
const flags = directory.readUInt16LE(pos + 8), method = directory.readUInt16LE(pos + 10);
|
|
39
|
+
const compressedSize = directory.readUInt32LE(pos + 20), uncompressedSize = directory.readUInt32LE(pos + 24);
|
|
40
|
+
const nameLength = directory.readUInt16LE(pos + 28), extraLength = directory.readUInt16LE(pos + 30), commentLength = directory.readUInt16LE(pos + 32);
|
|
41
|
+
if (pos + 46 + nameLength + extraLength + commentLength > directory.length) throw new Error('Truncated ZIP entry');
|
|
42
|
+
const name = directory.subarray(pos + 46, pos + 46 + nameLength).toString('utf8');
|
|
43
|
+
const mode = directory.readUInt32LE(pos + 38) >>> 16;
|
|
44
|
+
const localOffset = directory.readUInt32LE(pos + 42);
|
|
45
|
+
const folder = name.endsWith('/');
|
|
46
|
+
const parts = name.replace(/\/$/, '').split('/');
|
|
47
|
+
if (!['agent', 'META-INF'].includes(parts[0]) || parts.some(part => !part || part === '.' || part === '..' || /[\\:<>"|?*]/.test(part) || [...part].some(char => char.codePointAt(0) < 32) || /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw new Error('Unsafe ZIP path');
|
|
48
|
+
const type = mode & 0o170000;
|
|
49
|
+
if (type && type !== 0o100000 && type !== 0o040000) throw new Error('ZIP links and special files are forbidden');
|
|
50
|
+
if ((flags & 1) || ![0, 8].includes(method) || compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localOffset === 0xffffffff) throw new Error('Unsupported ZIP encoding');
|
|
51
|
+
expanded += uncompressedSize;
|
|
52
|
+
if (expanded > MAX_EXPANDED) throw new Error('ZIP exceeds 2 GiB expanded limit');
|
|
53
|
+
const key = parts.join('/').toLowerCase();
|
|
54
|
+
if (names.has(key)) throw new Error('Duplicate ZIP path');
|
|
55
|
+
names.set(key, folder);
|
|
56
|
+
const header = read(fd, 30, localOffset);
|
|
57
|
+
if (header.readUInt32LE(0) !== 0x04034b50 || header.readUInt16LE(8) !== method || header.readUInt16LE(6) !== flags) throw new Error('ZIP header mismatch');
|
|
58
|
+
const localName = read(fd, header.readUInt16LE(26), localOffset + 30).toString('utf8');
|
|
59
|
+
if (localName !== name) throw new Error('ZIP path mismatch');
|
|
60
|
+
const dataOffset = localOffset + 30 + header.readUInt16LE(26) + header.readUInt16LE(28);
|
|
61
|
+
if (dataOffset + compressedSize > offset) throw new Error('Invalid ZIP data range');
|
|
62
|
+
entries.push({ name, folder, mode, method, compressedSize, uncompressedSize, dataOffset, crc: directory.readUInt32LE(pos + 16) });
|
|
63
|
+
pos += 46 + nameLength + extraLength + commentLength;
|
|
64
|
+
}
|
|
65
|
+
if (pos !== directory.length) throw new Error('Unexpected ZIP directory data');
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const parts = entry.name.replace(/\/$/, '').toLowerCase().split('/');
|
|
68
|
+
for (let i = 1; i < parts.length; i++) if (names.get(parts.slice(0, i).join('/')) === false) throw new Error('ZIP file/directory collision');
|
|
69
|
+
}
|
|
70
|
+
return entries;
|
|
71
|
+
} finally { fs.closeSync(fd); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function privateDirectory(directory) {
|
|
75
|
+
try { fs.mkdirSync(directory, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
76
|
+
const st = fs.lstatSync(directory);
|
|
77
|
+
if (!st.isDirectory() || st.isSymbolicLink()) throw new Error('Unsafe ZIP destination directory');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function extractMidZip(archive, destination, entries = inspectMidZip(archive)) {
|
|
81
|
+
privateDirectory(destination);
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const parts = entry.name.replace(/\/$/, '').split('/');
|
|
84
|
+
let parent = destination;
|
|
85
|
+
for (const part of parts.slice(0, entry.folder ? parts.length : -1)) {
|
|
86
|
+
parent = path.join(parent, part);
|
|
87
|
+
privateDirectory(parent);
|
|
88
|
+
}
|
|
89
|
+
const filename = path.join(destination, ...parts);
|
|
90
|
+
if (entry.folder) continue;
|
|
91
|
+
let size = 0, crc = 0xffffffff;
|
|
92
|
+
const verify = new Transform({ transform(chunk, _, callback) {
|
|
93
|
+
size += chunk.length;
|
|
94
|
+
if (size > entry.uncompressedSize) return callback(new Error('ZIP expanded size mismatch'));
|
|
95
|
+
for (const byte of chunk) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8);
|
|
96
|
+
callback(null, chunk);
|
|
97
|
+
} });
|
|
98
|
+
const output = fs.createWriteStream(filename, { flags: 'wx', mode: entry.mode & 0o111 ? 0o700 : 0o600 });
|
|
99
|
+
if (!entry.compressedSize) { output.end(); await new Promise((resolve, reject) => { output.on('close', resolve); output.on('error', reject); }); }
|
|
100
|
+
else {
|
|
101
|
+
const input = fs.createReadStream(archive, { start: entry.dataOffset, end: entry.dataOffset + entry.compressedSize - 1 });
|
|
102
|
+
if (entry.method === 8) await pipeline(input, createInflateRaw(), verify, output);
|
|
103
|
+
else await pipeline(input, verify, output);
|
|
104
|
+
}
|
|
105
|
+
if (size !== entry.uncompressedSize || ((crc ^ 0xffffffff) >>> 0) !== entry.crc) throw new Error('ZIP integrity check failed');
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/output.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Output formatting: JSON, Markdown, Styled, Quiet
|
|
2
2
|
|
|
3
3
|
import process from 'node:process';
|
|
4
|
+
import { errExtractionNotFound } from './errors.js';
|
|
4
5
|
|
|
5
6
|
export const FormatAuto = 'auto';
|
|
6
7
|
export const FormatJSON = 'json';
|
|
@@ -125,9 +126,15 @@ export class OutputWriter {
|
|
|
125
126
|
// --get extractor: resolve against the JSON envelope and print just that
|
|
126
127
|
// value as JSON. Takes precedence over styling/table formats — a path
|
|
127
128
|
// extract is inherently scriptable, not presentational.
|
|
129
|
+
//
|
|
130
|
+
// A path that does not resolve is an error, not a value. Stringifying
|
|
131
|
+
// `undefined` printed the literal token `undefined` and exited 0, so a
|
|
132
|
+
// typo looked like a successful empty result until the next tool in the
|
|
133
|
+
// pipeline failed to parse it. Present null/false/0/"" still print as-is.
|
|
128
134
|
if (this.jqFilter) {
|
|
129
135
|
const envelope = { ok: true, data, summary: opts.summary || '', breadcrumbs: opts.breadcrumbs || [] };
|
|
130
136
|
const value = resolvePath(envelope, this.jqFilter);
|
|
137
|
+
if (value === undefined) throw errExtractionNotFound(this.jqFilter);
|
|
131
138
|
this.writer.write(JSON.stringify(value, null, 2) + '\n');
|
|
132
139
|
return;
|
|
133
140
|
}
|