fhirsmith 0.9.7 → 0.10.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 +31 -0
- package/package.json +1 -1
- package/packages/package-crawler.js +123 -6
- package/packages/packages.js +104 -0
- package/publisher/publisher.js +191 -5
- package/registry/crawler.js +85 -6
- package/registry/registry.js +18 -1
- package/server.js +5 -0
- package/stats.js +26 -18
- package/translations/Messages.properties +1 -0
- package/tx/html/home-metrics.liquid +10 -10
- package/tx/library/codesystem.js +31 -0
- package/tx/library/conceptmap.js +24 -0
- package/tx/library/valueset.js +46 -0
- package/tx/operation-context.js +109 -4
- package/tx/tx-html.js +27 -0
- package/tx/tx.js +80 -17
- package/tx/workers/cache-control.js +186 -0
- package/tx/workers/{related.js → compare.js} +27 -27
- package/tx/workers/metadata.js +11 -6
- package/tx/workers/worker.js +133 -46
- package/tx/data/OperationDefinition-ValueSet-related.json +0 -133
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
## [v0.10.0] - 2026-06-27
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Publisher: track the IG Publisher version used for each task, and show it on the tasks list and task detail pages
|
|
14
|
+
- Packages: new `POST /packages/update-package` endpoint to force-refresh specific packages in the registry, bypassing the feed crawler's de-duplication (used to push out a corrected package after a bad publish)
|
|
15
|
+
- Publisher: automatically update SUSHI to the latest release before each draft build and publication run, installed into a FHIRsmith-owned prefix so it needs no root privileges
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Rebuilt how caching works (see the tools IG for documentation)
|
|
20
|
+
- Renamed the compare worker/operation
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Publisher: the publication run now verifies that the package about to be published is a real publication build (not a draft) before committing it to the web tree, so a failed publication build can no longer silently ship a `notForPublication` draft package
|
|
25
|
+
- Packages: the crawler now rejects any package flagged `notForPublication` at ingest, instead of only checking the feed entry
|
|
26
|
+
- Registry: better handling of versions on manual queries against the external registry
|
|
27
|
+
- Fixed handling of the cache-control header
|
|
28
|
+
- Corrections to generated test cases
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
|
|
32
|
+
- SSRF protection: outbound fetches in the packages and registry crawlers now reject any host that resolves to a non-public address (private, loopback, link-local including cloud-metadata, CGNAT, unique-local, etc.). Enforced at connection time, so it also covers redirect targets and DNS rebinding, and prevents leaking registry API keys to a redirected host
|
|
33
|
+
- Path-injection hardening: local-file feed reads in the packages crawler are confined to explicitly-allowed directories; the `update-package` endpoint only accepts http(s) URLs
|
|
34
|
+
- Dependency updates (npm audit)
|
|
35
|
+
|
|
36
|
+
### Tx Conformance Statement
|
|
37
|
+
|
|
38
|
+
FHIRsmith passed all 2503 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.1, runner v6.9.11
|
|
39
|
+
|
|
9
40
|
## [v0.9.7] - 2026-06-12
|
|
10
41
|
|
|
11
42
|
### Added
|
package/package.json
CHANGED
|
@@ -9,8 +9,27 @@ const {XMLParser} = require('fast-xml-parser');
|
|
|
9
9
|
const crypto = require('crypto');
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
|
+
const dns = require('dns');
|
|
13
|
+
const http = require('http');
|
|
14
|
+
const https = require('https');
|
|
15
|
+
const ipaddr = require('ipaddr.js');
|
|
12
16
|
const {debugLog} = require("../tx/operation-context");
|
|
13
17
|
|
|
18
|
+
// True if an IP literal is anything other than a normal public (unicast) address:
|
|
19
|
+
// loopback, private, link-local (incl. 169.254.169.254 cloud metadata), unique-local,
|
|
20
|
+
// CGNAT, multicast, reserved, etc. IPv4-mapped IPv6 addresses are unwrapped first.
|
|
21
|
+
function isNonPublicAddress(ip) {
|
|
22
|
+
try {
|
|
23
|
+
let addr = ipaddr.parse(ip);
|
|
24
|
+
if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
|
|
25
|
+
addr = addr.toIPv4Address();
|
|
26
|
+
}
|
|
27
|
+
return addr.range() !== 'unicast';
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return true; // unparseable - treat as unsafe
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
14
33
|
class PackageCrawler {
|
|
15
34
|
log;
|
|
16
35
|
packages = new Set();
|
|
@@ -139,10 +158,96 @@ class PackageCrawler {
|
|
|
139
158
|
return url.replace(/^http:/, 'https:');
|
|
140
159
|
}
|
|
141
160
|
|
|
161
|
+
// Roots under which local-file feed reads are permitted. Reading local files supports
|
|
162
|
+
// locally-configured feeds (e.g. for testing); allowed roots come from
|
|
163
|
+
// config.localFeedDirs, plus the directory of a local master feed url. Anything else
|
|
164
|
+
// is rejected so a third-party feed can't point a read at an arbitrary server file.
|
|
165
|
+
allowedLocalRoots() {
|
|
166
|
+
const roots = [];
|
|
167
|
+
const cfg = this.config && this.config.localFeedDirs;
|
|
168
|
+
if (Array.isArray(cfg)) {
|
|
169
|
+
roots.push(...cfg);
|
|
170
|
+
} else if (typeof cfg === 'string' && cfg.length > 0) {
|
|
171
|
+
roots.push(cfg);
|
|
172
|
+
}
|
|
173
|
+
if (this.config && typeof this.config.masterUrl === 'string' && this.config.masterUrl.startsWith('/')) {
|
|
174
|
+
roots.push(path.dirname(this.config.masterUrl));
|
|
175
|
+
}
|
|
176
|
+
return roots.map((r) => path.resolve(r));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A DNS lookup wrapper that rejects any host resolving to a non-public address.
|
|
180
|
+
// Enforced at connection time (so it also covers redirect targets and defeats
|
|
181
|
+
// DNS-rebinding), this is the SSRF guard for all outbound http(s) fetches.
|
|
182
|
+
// Set config.allowPrivateAddresses = true to disable (e.g. for local test registries).
|
|
183
|
+
ssrfLookup() {
|
|
184
|
+
const allowPrivate = !!(this.config && this.config.allowPrivateAddresses);
|
|
185
|
+
return (hostname, options, callback) => {
|
|
186
|
+
if (typeof options === 'function') {
|
|
187
|
+
callback = options;
|
|
188
|
+
options = {};
|
|
189
|
+
}
|
|
190
|
+
const wantAll = !!(options && options.all);
|
|
191
|
+
dns.lookup(hostname, Object.assign({}, options, { all: true }), (err, addresses) => {
|
|
192
|
+
if (err) {
|
|
193
|
+
callback(err);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (!allowPrivate) {
|
|
197
|
+
for (const a of addresses) {
|
|
198
|
+
if (isNonPublicAddress(a.address)) {
|
|
199
|
+
callback(new Error('Blocked request to non-public address ' + a.address + ' (host ' + hostname + ')'));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (wantAll) {
|
|
205
|
+
callback(null, addresses);
|
|
206
|
+
} else {
|
|
207
|
+
callback(null, addresses[0].address, addresses[0].family);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// http/https agents that route every connection through the SSRF lookup. Cached so
|
|
214
|
+
// connections can be pooled across requests.
|
|
215
|
+
guardedAgents() {
|
|
216
|
+
if (!this._guardedAgents) {
|
|
217
|
+
const lookup = this.ssrfLookup();
|
|
218
|
+
class GuardedHttpAgent extends http.Agent {
|
|
219
|
+
createConnection(options, cb) {
|
|
220
|
+
return super.createConnection(Object.assign({}, options, { lookup }), cb);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
class GuardedHttpsAgent extends https.Agent {
|
|
224
|
+
createConnection(options, cb) {
|
|
225
|
+
return super.createConnection(Object.assign({}, options, { lookup }), cb);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this._guardedAgents = {
|
|
229
|
+
httpAgent: new GuardedHttpAgent({ keepAlive: true }),
|
|
230
|
+
httpsAgent: new GuardedHttpsAgent({ keepAlive: true })
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return this._guardedAgents;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Resolve a local feed path and confine it to an allowed root (path-injection guard).
|
|
237
|
+
resolveLocalReadPath(url) {
|
|
238
|
+
const resolved = path.resolve(url);
|
|
239
|
+
const roots = this.allowedLocalRoots();
|
|
240
|
+
const allowed = roots.some((root) => resolved === root || resolved.startsWith(root + path.sep));
|
|
241
|
+
if (!allowed) {
|
|
242
|
+
throw new Error('Refusing to read local file outside the allowed feed directories: ' + url);
|
|
243
|
+
}
|
|
244
|
+
return resolved;
|
|
245
|
+
}
|
|
246
|
+
|
|
142
247
|
async fetchJson(url) {
|
|
143
248
|
try {
|
|
144
249
|
if (url.startsWith("/")) {
|
|
145
|
-
const content = await fs.promises.readFile(url, "utf8");
|
|
250
|
+
const content = await fs.promises.readFile(this.resolveLocalReadPath(url), "utf8");
|
|
146
251
|
return JSON.parse(content);
|
|
147
252
|
} else {
|
|
148
253
|
const response = await axios.get(url, {
|
|
@@ -150,7 +255,9 @@ class PackageCrawler {
|
|
|
150
255
|
signal: this.abortController?.signal,
|
|
151
256
|
headers: {
|
|
152
257
|
'User-Agent': 'FHIR Package Crawler/1.0'
|
|
153
|
-
}
|
|
258
|
+
},
|
|
259
|
+
httpAgent: this.guardedAgents().httpAgent,
|
|
260
|
+
httpsAgent: this.guardedAgents().httpsAgent
|
|
154
261
|
});
|
|
155
262
|
return response.data;
|
|
156
263
|
}
|
|
@@ -166,7 +273,7 @@ class PackageCrawler {
|
|
|
166
273
|
async fetchXml(url) {
|
|
167
274
|
try {
|
|
168
275
|
if (url.startsWith("/")) {
|
|
169
|
-
const content = await fs.promises.readFile(url, 'utf8');
|
|
276
|
+
const content = await fs.promises.readFile(this.resolveLocalReadPath(url), 'utf8');
|
|
170
277
|
const parser = new XMLParser({
|
|
171
278
|
ignoreAttributes: false,
|
|
172
279
|
attributeNamePrefix: '@_',
|
|
@@ -180,7 +287,9 @@ class PackageCrawler {
|
|
|
180
287
|
signal: this.abortController?.signal,
|
|
181
288
|
headers: {
|
|
182
289
|
'User-Agent': 'FHIR Package Crawler/1.0'
|
|
183
|
-
}
|
|
290
|
+
},
|
|
291
|
+
httpAgent: this.guardedAgents().httpAgent,
|
|
292
|
+
httpsAgent: this.guardedAgents().httpsAgent
|
|
184
293
|
});
|
|
185
294
|
|
|
186
295
|
const parser = new XMLParser({
|
|
@@ -203,7 +312,7 @@ class PackageCrawler {
|
|
|
203
312
|
async fetchUrl(url) {
|
|
204
313
|
try {
|
|
205
314
|
if (url.startsWith("/")) {
|
|
206
|
-
const buffer = await fs.promises.readFile(url);
|
|
315
|
+
const buffer = await fs.promises.readFile(this.resolveLocalReadPath(url));
|
|
207
316
|
this.totalBytes += buffer.byteLength;
|
|
208
317
|
return buffer;
|
|
209
318
|
} else {
|
|
@@ -213,7 +322,9 @@ class PackageCrawler {
|
|
|
213
322
|
signal: this.abortController?.signal,
|
|
214
323
|
headers: {
|
|
215
324
|
'User-Agent': 'FHIR Package Crawler/1.0'
|
|
216
|
-
}
|
|
325
|
+
},
|
|
326
|
+
httpAgent: this.guardedAgents().httpAgent,
|
|
327
|
+
httpsAgent: this.guardedAgents().httpsAgent
|
|
217
328
|
});
|
|
218
329
|
|
|
219
330
|
this.totalBytes += response.data.byteLength;
|
|
@@ -558,6 +669,12 @@ class PackageCrawler {
|
|
|
558
669
|
if (npmPackage.hasJavaScript && !isTemplate && id !== 'hl7.fhir.pubpack') {
|
|
559
670
|
throw new Error(`Package ${idver} rejected: contains JavaScript files but is not a template package`);
|
|
560
671
|
}
|
|
672
|
+
// The feed gate (item.notForPublication) only sees the RSS entry. A package whose
|
|
673
|
+
// feed entry is clean can still carry notForPublication inside the tarball - that is
|
|
674
|
+
// a draft build that must never enter the registry. Reject it here too.
|
|
675
|
+
if (npmPackage.notForPublication) {
|
|
676
|
+
throw new Error(`Package ${idver} rejected: tarball is flagged notForPublication (draft build, not suitable for publication)`);
|
|
677
|
+
}
|
|
561
678
|
|
|
562
679
|
// Extract URLs from package
|
|
563
680
|
const urls = this.processPackageUrls(npmPackage);
|
package/packages/packages.js
CHANGED
|
@@ -592,6 +592,64 @@ class PackagesModule {
|
|
|
592
592
|
}
|
|
593
593
|
}
|
|
594
594
|
|
|
595
|
+
// Re-fetch a single package tarball and replace whatever is stored for it, bypassing
|
|
596
|
+
// the crawler's GUID dedup and notForPublication feed gate. Used to push out a
|
|
597
|
+
// corrected package that was already (mis)published.
|
|
598
|
+
async forceUpdatePackage(link) {
|
|
599
|
+
// Only allow fetching over http(s). This endpoint must never be usable to read
|
|
600
|
+
// local server files (path injection) - tarballs are always published web URLs.
|
|
601
|
+
if (!link || !/^https?:\/\//i.test(link)) {
|
|
602
|
+
throw new Error('Invalid package link (must be an http(s) URL): ' + link);
|
|
603
|
+
}
|
|
604
|
+
if (!this.crawler) {
|
|
605
|
+
this.crawler = new PackageCrawler(this.config, this.db, this.stats);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// The feed uses the versioned package.tgz url as the (permalink) GUID, so reusing
|
|
609
|
+
// the link as the GUID keeps a later crawl from inserting a duplicate.
|
|
610
|
+
const guid = link;
|
|
611
|
+
|
|
612
|
+
const buffer = await this.crawler.fetchUrl(link);
|
|
613
|
+
const npm = await this.crawler.extractNpmPackage(buffer, link);
|
|
614
|
+
|
|
615
|
+
// Refuse to re-store a still-broken package - that would just re-publish the bug.
|
|
616
|
+
if (npm.notForPublication) {
|
|
617
|
+
throw new Error('Refusing to store ' + npm.id + '#' + npm.version + ': fetched tarball is still flagged notForPublication');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const idver = npm.id + '#' + npm.version;
|
|
621
|
+
const replaced = await this.deleteVersionsByGuid(guid);
|
|
622
|
+
|
|
623
|
+
const itemLog = { status: '??' };
|
|
624
|
+
await this.crawler.store(link, link, guid, new Date(), buffer, idver, itemLog);
|
|
625
|
+
|
|
626
|
+
pckLog.info('Force-updated ' + idver + ' from ' + link + ' (replaced ' + replaced + ' existing row(s))');
|
|
627
|
+
return { status: 'updated', id: npm.id, version: npm.version, replaced };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Delete a stored package version and all of its child rows, by GUID.
|
|
631
|
+
deleteVersionsByGuid(guid) {
|
|
632
|
+
return new Promise((resolve, reject) => {
|
|
633
|
+
this.db.all('SELECT PackageVersionKey FROM PackageVersions WHERE GUID = ?', [guid], (err, rows) => {
|
|
634
|
+
if (err) return reject(err);
|
|
635
|
+
const keys = (rows || []).map(r => r.PackageVersionKey);
|
|
636
|
+
if (keys.length === 0) return resolve(0);
|
|
637
|
+
const ph = keys.map(() => '?').join(',');
|
|
638
|
+
const stmts = [
|
|
639
|
+
'DELETE FROM PackageFHIRVersions WHERE PackageVersionKey IN (' + ph + ')',
|
|
640
|
+
'DELETE FROM PackageDependencies WHERE PackageVersionKey IN (' + ph + ')',
|
|
641
|
+
'DELETE FROM PackageURLs WHERE PackageVersionKey IN (' + ph + ')',
|
|
642
|
+
'DELETE FROM PackageVersions WHERE PackageVersionKey IN (' + ph + ')'
|
|
643
|
+
];
|
|
644
|
+
const runNext = (i) => {
|
|
645
|
+
if (i >= stmts.length) return resolve(keys.length);
|
|
646
|
+
this.db.run(stmts[i], keys, (e) => e ? reject(e) : runNext(i + 1));
|
|
647
|
+
};
|
|
648
|
+
runNext(0);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
595
653
|
async initializeDatabase() {
|
|
596
654
|
return new Promise((resolve, reject) => {
|
|
597
655
|
// Use config path if absolute, otherwise resolve relative to data dir
|
|
@@ -1223,6 +1281,52 @@ class PackagesModule {
|
|
|
1223
1281
|
}
|
|
1224
1282
|
});
|
|
1225
1283
|
|
|
1284
|
+
// Force-refresh specific packages, bypassing the feed. The crawler only fetches a
|
|
1285
|
+
// package once (it dedupes on the feed GUID) and skips anything flagged
|
|
1286
|
+
// notForPublication, so there is normally no way to make it re-pick-up a package
|
|
1287
|
+
// that was published incorrectly and later corrected. This endpoint re-fetches the
|
|
1288
|
+
// tarball(s) directly and replaces whatever is stored.
|
|
1289
|
+
//
|
|
1290
|
+
// POST /update-package { "links": ["http://hl7.org/fhir/uv/ips/2.0.1/package.tgz", ...] }
|
|
1291
|
+
//
|
|
1292
|
+
// The link is the versioned package.tgz url, which is exactly the GUID the feed uses,
|
|
1293
|
+
// so the replacement keeps the same GUID and a later crawl won't create a duplicate.
|
|
1294
|
+
// If config.updateToken is set, the request must carry it in the x-update-token header.
|
|
1295
|
+
this.router.post('/update-package', async (req, res) => {
|
|
1296
|
+
const start = Date.now();
|
|
1297
|
+
try {
|
|
1298
|
+
if (this.config.updateToken && req.headers['x-update-token'] !== this.config.updateToken) {
|
|
1299
|
+
res.status(403).json({ error: 'forbidden: missing or invalid x-update-token' });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
let links = req.body && (req.body.links || req.body.packages || (req.body.url ? [req.body.url] : (req.body.link ? [req.body.link] : null)));
|
|
1303
|
+
if (typeof links === 'string') links = [links];
|
|
1304
|
+
if (!Array.isArray(links) || links.length === 0) {
|
|
1305
|
+
res.status(400).json({ error: 'Provide a JSON body like {"links": ["<package.tgz url>", ...]}' });
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
const results = [];
|
|
1309
|
+
for (const link of links) {
|
|
1310
|
+
try {
|
|
1311
|
+
results.push(Object.assign({ link }, await this.forceUpdatePackage(link)));
|
|
1312
|
+
} catch (e) {
|
|
1313
|
+
pckLog.error('Force update failed for ' + link + ': ' + e.message);
|
|
1314
|
+
results.push({ link, status: 'error', error: e.message });
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const failed = results.filter(r => r.status === 'error').length;
|
|
1318
|
+
res.status(failed === results.length ? 500 : 200).json({
|
|
1319
|
+
message: 'Processed ' + results.length + ' package(s), ' + failed + ' failed',
|
|
1320
|
+
results
|
|
1321
|
+
});
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
pckLog.error('update-package endpoint failed:', error);
|
|
1324
|
+
res.status(500).json({ error: 'update-package failed', message: error.message });
|
|
1325
|
+
} finally {
|
|
1326
|
+
this.stats.countRequest('update-package', Date.now() - start);
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1226
1330
|
// Crawler statistics endpoint (existing)
|
|
1227
1331
|
this.router.get('/stats', async (req, res) => {
|
|
1228
1332
|
const start = Date.now();
|
package/publisher/publisher.js
CHANGED
|
@@ -200,6 +200,17 @@ class PublisherModule {
|
|
|
200
200
|
});
|
|
201
201
|
});
|
|
202
202
|
}
|
|
203
|
+
if (!columnNames.includes('publisher_version')) {
|
|
204
|
+
await new Promise((resolve, reject) => {
|
|
205
|
+
this.db.run('ALTER TABLE tasks ADD COLUMN publisher_version TEXT', (err) => {
|
|
206
|
+
if (err) reject(err);
|
|
207
|
+
else {
|
|
208
|
+
this.logger.info('Migration: added publisher_version column to tasks table');
|
|
209
|
+
resolve();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
203
214
|
const websiteColumns = await new Promise((resolve, reject) => {
|
|
204
215
|
this.db.all("PRAGMA table_info(websites)", (err, rows) => {
|
|
205
216
|
if (err) reject(err);
|
|
@@ -542,6 +553,11 @@ class PublisherModule {
|
|
|
542
553
|
// Step 3: Clone GitHub repository
|
|
543
554
|
await this.cloneRepository(task, draftDir);
|
|
544
555
|
|
|
556
|
+
// Step 3b: Update SUSHI to the latest release. The IG Publisher runs SUSHI with
|
|
557
|
+
// --require-latest, so a SUSHI that has fallen behind npm's latest makes the build
|
|
558
|
+
// fail. Refreshing it here keeps draft builds working.
|
|
559
|
+
await this.ensureLatestSushi(task.id);
|
|
560
|
+
|
|
545
561
|
// Step 4: Run IG publisher
|
|
546
562
|
await this.runIGPublisher(publisherJar, draftDir, logFile, task.id);
|
|
547
563
|
|
|
@@ -578,6 +594,16 @@ class PublisherModule {
|
|
|
578
594
|
throw new Error('Could not find publisher.jar in latest release');
|
|
579
595
|
}
|
|
580
596
|
|
|
597
|
+
// Record which IG Publisher version this task is using. The same jar is reused
|
|
598
|
+
// for the publication run, so this is the version that produced the output.
|
|
599
|
+
const publisherVersion = releaseResponse.data.tag_name || releaseResponse.data.name || 'unknown';
|
|
600
|
+
try {
|
|
601
|
+
await this.updateTaskFields(taskId, { publisher_version: publisherVersion });
|
|
602
|
+
} catch (e) {
|
|
603
|
+
this.logger.warn('Failed to record publisher_version for task ' + taskId + ': ' + e.message);
|
|
604
|
+
}
|
|
605
|
+
await this.logTaskMessage(taskId, 'info', 'Using IG Publisher version ' + publisherVersion);
|
|
606
|
+
|
|
581
607
|
await this.logTaskMessage(taskId, 'info', 'Downloading from: ' + downloadUrl);
|
|
582
608
|
|
|
583
609
|
// Download the file
|
|
@@ -603,6 +629,62 @@ class PublisherModule {
|
|
|
603
629
|
}
|
|
604
630
|
}
|
|
605
631
|
|
|
632
|
+
// Directory holding FHIRsmith's own copy of SUSHI. Installing here (rather than -g)
|
|
633
|
+
// needs no root: on the server the global npm prefix (/usr) is root-owned.
|
|
634
|
+
sushiDir() {
|
|
635
|
+
return folders.filePath('publisher', 'sushi');
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Environment for spawning the IG Publisher so it finds our managed SUSHI on PATH.
|
|
639
|
+
// The publisher resolves the `sushi` command from PATH, so prepending our bin dir
|
|
640
|
+
// makes it use the version we just installed.
|
|
641
|
+
publisherEnv() {
|
|
642
|
+
const binDir = path.join(this.sushiDir(), 'bin');
|
|
643
|
+
return Object.assign({}, process.env, {
|
|
644
|
+
PATH: binDir + path.delimiter + (process.env.PATH || '')
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Update FHIRsmith's managed SUSHI to the latest npm release before a run. The IG
|
|
649
|
+
// Publisher invokes SUSHI with --require-latest, so a SUSHI that has fallen behind
|
|
650
|
+
// npm's latest makes the build abort. We install into a FHIRsmith-owned prefix
|
|
651
|
+
// (sushiDir) to avoid needing root for a global install, and the publisher picks it
|
|
652
|
+
// up via publisherEnv(). Best-effort: a failure is logged but not fatal - the
|
|
653
|
+
// corrected runBuild check in the IG Publisher now turns a stale SUSHI into a loud
|
|
654
|
+
// publication failure rather than a silently-published draft.
|
|
655
|
+
async ensureLatestSushi(taskId) {
|
|
656
|
+
const { spawn } = require('child_process');
|
|
657
|
+
const dir = this.sushiDir();
|
|
658
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
659
|
+
await this.logTaskMessage(taskId, 'info', 'Ensuring SUSHI is up to date in ' + dir + ' ...');
|
|
660
|
+
await new Promise((resolve) => {
|
|
661
|
+
// -g with --prefix makes npm treat `dir` as the global prefix, so the binary
|
|
662
|
+
// lands at dir/bin/sushi (a FHIRsmith-owned, writable location).
|
|
663
|
+
const npm = spawn('npm', ['install', '-g', 'fsh-sushi@latest', '--prefix', dir], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
664
|
+
let err = '';
|
|
665
|
+
npm.stdout.on('data', () => { /* ignore */ });
|
|
666
|
+
npm.stderr.on('data', (d) => { err += d.toString(); });
|
|
667
|
+
npm.on('error', async (e) => {
|
|
668
|
+
await this.logTaskMessage(taskId, 'warn', 'Could not run npm to update SUSHI: ' + e.message);
|
|
669
|
+
resolve();
|
|
670
|
+
});
|
|
671
|
+
npm.on('close', async (code) => {
|
|
672
|
+
if (code === 0) {
|
|
673
|
+
let version = '';
|
|
674
|
+
try {
|
|
675
|
+
version = require('child_process').execSync('sushi --version', { env: this.publisherEnv(), encoding: 'utf8' }).trim();
|
|
676
|
+
} catch (e) {
|
|
677
|
+
version = '(version check failed)';
|
|
678
|
+
}
|
|
679
|
+
await this.logTaskMessage(taskId, 'info', 'SUSHI is up to date: ' + version);
|
|
680
|
+
} else {
|
|
681
|
+
await this.logTaskMessage(taskId, 'warn', 'SUSHI update exited with code ' + code + (err ? ': ' + err.trim().slice(-400) : ''));
|
|
682
|
+
}
|
|
683
|
+
resolve();
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
606
688
|
async cloneRepository(task, draftDir) {
|
|
607
689
|
const { spawn } = require('child_process');
|
|
608
690
|
const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
|
|
@@ -658,7 +740,8 @@ class PublisherModule {
|
|
|
658
740
|
'.'
|
|
659
741
|
], {
|
|
660
742
|
cwd: draftDir,
|
|
661
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
743
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
744
|
+
env: this.publisherEnv()
|
|
662
745
|
});
|
|
663
746
|
|
|
664
747
|
// Create log file stream
|
|
@@ -684,7 +767,7 @@ class PublisherModule {
|
|
|
684
767
|
const elapsedMs = Date.now() - buildStart;
|
|
685
768
|
const sinceDataMs = Date.now() - lastDataAt;
|
|
686
769
|
let logKb = 0;
|
|
687
|
-
try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) {}
|
|
770
|
+
try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) { /* log file not created yet */ }
|
|
688
771
|
const elapsedMin = Math.floor(elapsedMs / 60000);
|
|
689
772
|
const elapsedSec = Math.floor(elapsedMs / 1000) % 60;
|
|
690
773
|
const idleSec = Math.floor(sinceDataMs / 1000);
|
|
@@ -760,6 +843,91 @@ class PublisherModule {
|
|
|
760
843
|
await this.logTaskMessage(task.id, 'info', 'Build output verified: package-id=' + qaData['package-id'] + ', version=' + qaData['ig-ver']);
|
|
761
844
|
}
|
|
762
845
|
|
|
846
|
+
// Read package/package.json out of a .tgz without unpacking the whole archive.
|
|
847
|
+
inspectPackageTgz(tgzPath) {
|
|
848
|
+
const { spawn } = require('child_process');
|
|
849
|
+
return new Promise((resolve, reject) => {
|
|
850
|
+
const tar = spawn('tar', ['-xzOf', tgzPath, 'package/package.json']);
|
|
851
|
+
let out = '';
|
|
852
|
+
let err = '';
|
|
853
|
+
tar.stdout.on('data', (d) => { out += d.toString(); });
|
|
854
|
+
tar.stderr.on('data', (d) => { err += d.toString(); });
|
|
855
|
+
tar.on('error', reject);
|
|
856
|
+
tar.on('close', (code) => {
|
|
857
|
+
if (code !== 0) {
|
|
858
|
+
reject(new Error('Could not read ' + tgzPath + ': ' + (err.trim() || ('tar exit ' + code))));
|
|
859
|
+
} else {
|
|
860
|
+
try {
|
|
861
|
+
resolve(JSON.parse(out));
|
|
862
|
+
} catch (e) {
|
|
863
|
+
reject(new Error('Invalid package.json in ' + tgzPath + ': ' + e.message));
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Confirm the package.tgz files that landed in the web tree are publication builds,
|
|
871
|
+
// not the draft. Throws (failing the task) if a draft package was published.
|
|
872
|
+
async verifyPublishedPackage(task, website, draftDir) {
|
|
873
|
+
await this.logTaskMessage(task.id, 'info', 'Verifying published package(s) are publication builds...');
|
|
874
|
+
|
|
875
|
+
// The intended publication path comes from the IG's publication-request.json.
|
|
876
|
+
const prPath = path.join(draftDir, 'publication-request.json');
|
|
877
|
+
if (!fs.existsSync(prPath)) {
|
|
878
|
+
await this.logTaskMessage(task.id, 'warn', 'No publication-request.json found at ' + prPath + ' - skipping published-package check');
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
const pr = JSON.parse(fs.readFileSync(prPath, 'utf8'));
|
|
882
|
+
const pubPath = pr.path;
|
|
883
|
+
|
|
884
|
+
// The website base url lets us map a canonical url to a folder in the web tree.
|
|
885
|
+
const setupPath = path.join(website.local_folder, 'publish-setup.json');
|
|
886
|
+
if (!pubPath || !fs.existsSync(setupPath)) {
|
|
887
|
+
await this.logTaskMessage(task.id, 'warn', 'Cannot resolve web path (path or publish-setup.json missing) - skipping published-package check');
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const setup = JSON.parse(fs.readFileSync(setupPath, 'utf8'));
|
|
891
|
+
const baseUrl = setup.website && setup.website.url;
|
|
892
|
+
if (!baseUrl || !pubPath.startsWith(baseUrl)) {
|
|
893
|
+
await this.logTaskMessage(task.id, 'warn', 'Publication path ' + pubPath + ' is not under website url ' + baseUrl + ' - skipping published-package check');
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const relVer = pubPath.substring(baseUrl.length).replace(/^\/+/, '');
|
|
898
|
+
const relCur = relVer.replace(/\/[^/]+\/?$/, ''); // strip the version segment for the "current" copy
|
|
899
|
+
const candidates = [
|
|
900
|
+
path.join(website.local_folder, relVer, 'package.tgz'),
|
|
901
|
+
path.join(website.local_folder, relCur, 'package.tgz')
|
|
902
|
+
];
|
|
903
|
+
|
|
904
|
+
let checked = 0;
|
|
905
|
+
for (const pkgPath of candidates) {
|
|
906
|
+
if (!fs.existsSync(pkgPath)) {
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
checked++;
|
|
910
|
+
const json = await this.inspectPackageTgz(pkgPath);
|
|
911
|
+
const problems = [];
|
|
912
|
+
if (json.notForPublication) {
|
|
913
|
+
problems.push('notForPublication is set');
|
|
914
|
+
}
|
|
915
|
+
if (typeof json.url === 'string' && json.url.startsWith('file:')) {
|
|
916
|
+
problems.push('url is a local file path (' + json.url + ')');
|
|
917
|
+
}
|
|
918
|
+
if (problems.length > 0) {
|
|
919
|
+
throw new Error('Published package ' + pkgPath + ' is a draft build, not a publication build (' +
|
|
920
|
+
problems.join('; ') + '). The IG Publisher publication run likely skipped package ' +
|
|
921
|
+
'regeneration (e.g. a Jekyll/template failure). Not committing.');
|
|
922
|
+
}
|
|
923
|
+
await this.logTaskMessage(task.id, 'info', 'Verified publication package: ' + pkgPath);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (checked === 0) {
|
|
927
|
+
await this.logTaskMessage(task.id, 'warn', 'No published package.tgz found to verify under ' + relVer + ' or ' + relCur);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
763
931
|
async runPublication(task) {
|
|
764
932
|
const website = await this.getWebsite(task.website_id);
|
|
765
933
|
if (!website) {
|
|
@@ -824,6 +992,12 @@ class PublisherModule {
|
|
|
824
992
|
// Step 3: Pull latest web folder before publishing into it
|
|
825
993
|
await this.runCommand('git', ['pull'], { cwd: website.git_root }, task.id, 'Pulling latest web folder');
|
|
826
994
|
|
|
995
|
+
// Step 3b: Update SUSHI to the latest release before the publication run. This is the
|
|
996
|
+
// step that previously failed silently: the publication build runs SUSHI with
|
|
997
|
+
// --require-latest, and a draft approved days earlier may now face a newer SUSHI on
|
|
998
|
+
// npm. Refresh it so the publication build doesn't abort on a version mismatch.
|
|
999
|
+
await this.ensureLatestSushi(task.id);
|
|
1000
|
+
|
|
827
1001
|
// Step 4: Run the IG publisher in go-publish mode
|
|
828
1002
|
await this.runPublisherGoPublish(task.id, publisherJar, draftDir, website.local_folder,
|
|
829
1003
|
registryDir, historyDir, templatesDir, zipsDir, publishLogFile);
|
|
@@ -836,6 +1010,12 @@ class PublisherModule {
|
|
|
836
1010
|
}
|
|
837
1011
|
await this.logTaskMessage(task.id, 'info', 'Publication run verified: ' + pubLogName + ' found');
|
|
838
1012
|
|
|
1013
|
+
// Step 5b: Verify the published package is actually a publication build.
|
|
1014
|
+
// If the IG Publisher's publication run skipped package regeneration (e.g. a Jekyll
|
|
1015
|
+
// or template failure), the draft package - flagged notForPublication with a file://
|
|
1016
|
+
// url - can survive into the web tree. Catch that here, before anything is committed.
|
|
1017
|
+
await this.verifyPublishedPackage(task, website, draftDir);
|
|
1018
|
+
|
|
839
1019
|
// Step 6: Commit and push the web folder
|
|
840
1020
|
await this.logTaskMessage(task.id, 'info', 'Committing changes to web folder...');
|
|
841
1021
|
const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
|
|
@@ -892,7 +1072,8 @@ class PublisherModule {
|
|
|
892
1072
|
|
|
893
1073
|
return new Promise((resolve, reject) => {
|
|
894
1074
|
const java = spawn('java', args, {
|
|
895
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
1075
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1076
|
+
env: this.publisherEnv()
|
|
896
1077
|
});
|
|
897
1078
|
|
|
898
1079
|
const logStream = fs.createWriteStream(logFile);
|
|
@@ -916,7 +1097,7 @@ class PublisherModule {
|
|
|
916
1097
|
const elapsedMs = Date.now() - buildStart;
|
|
917
1098
|
const sinceDataMs = Date.now() - lastDataAt;
|
|
918
1099
|
let logKb = 0;
|
|
919
|
-
try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) {}
|
|
1100
|
+
try { logKb = Math.round(fs.statSync(logFile).size / 1024); } catch (_) { /* log file not created yet */ }
|
|
920
1101
|
const elapsedMin = Math.floor(elapsedMs / 60000);
|
|
921
1102
|
const elapsedSec = Math.floor(elapsedMs / 1000) % 60;
|
|
922
1103
|
const idleSec = Math.floor(sinceDataMs / 1000);
|
|
@@ -1217,7 +1398,7 @@ class PublisherModule {
|
|
|
1217
1398
|
} else {
|
|
1218
1399
|
content += '<div class="table-responsive">';
|
|
1219
1400
|
content += '<table class="table table-striped">';
|
|
1220
|
-
content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Website</th><th>Status</th><th>Queued</th><th>User</th><th>Actions</th></tr></thead>';
|
|
1401
|
+
content += '<thead><tr><th>ID</th><th>Package</th><th>Version</th><th>Website</th><th>Status</th><th>IG Publisher</th><th>Queued</th><th>User</th><th>Actions</th></tr></thead>';
|
|
1221
1402
|
content += '<tbody>';
|
|
1222
1403
|
|
|
1223
1404
|
for (const task of tasks) {
|
|
@@ -1238,6 +1419,7 @@ class PublisherModule {
|
|
|
1238
1419
|
content += '<td>' + task.version + '</td>';
|
|
1239
1420
|
content += '<td>' + task.website_name + '</td>';
|
|
1240
1421
|
content += '<td><span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></td>';
|
|
1422
|
+
content += '<td>' + (task.publisher_version ? '<code>' + escape(task.publisher_version) + '</code>' : '<span class="text-muted">—</span>') + '</td>';
|
|
1241
1423
|
content += '<td>' + new Date(task.queued_at).toLocaleString() + '</td>';
|
|
1242
1424
|
content += '<td>' + task.user_name + '</td>';
|
|
1243
1425
|
content += '<td class="task-actions">';
|
|
@@ -1542,6 +1724,10 @@ class PublisherModule {
|
|
|
1542
1724
|
content += '<p><strong>Status:</strong> <span class="badge bg-' + this.getStatusColor(task.status) + '">' + task.status + '</span></p>';
|
|
1543
1725
|
content += '<p><strong>GitHub:</strong> ' + task.github_org + '/' + task.github_repo + ' (' + task.git_branch + ')</p>';
|
|
1544
1726
|
|
|
1727
|
+
if (task.publisher_version) {
|
|
1728
|
+
content += '<p><strong>IG Publisher:</strong> <code>' + escape(task.publisher_version) + '</code></p>';
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1545
1731
|
if (task.local_folder) {
|
|
1546
1732
|
content += '<p><strong>Local Folder:</strong> <code>' + task.local_folder + '</code></p>';
|
|
1547
1733
|
}
|