@wavehouse/chtypes 0.4.0 → 0.5.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 +23 -0
- package/dist/cli.js.map +1 -1
- package/dist/fetch.d.ts +63 -14
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +418 -94
- package/dist/fetch.js.map +1 -1
- package/dist/format.d.ts +9 -4
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +9 -4
- package/dist/format.js.map +1 -1
- package/dist/index.d.ts +12 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +176 -66
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +475 -161
- package/dist/registry.js.map +1 -1
- package/dist/schema.d.ts +12 -6
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js.map +1 -1
- package/package.json +1 -1
package/dist/fetch.js
CHANGED
|
@@ -34,7 +34,7 @@ import { Readable, Transform } from 'node:stream';
|
|
|
34
34
|
import { pipeline } from 'node:stream/promises';
|
|
35
35
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
36
36
|
import { fileURLToPath } from 'node:url';
|
|
37
|
-
import { ABI_REVISION, ArtifactCorruptError, ArtifactPinnedError, ArtifactUnpublishedError, ArtifactUntrustedError, ChtypesError, SourceUnreachableError, } from './errors.js';
|
|
37
|
+
import { ABI_REVISION, ArtifactCorruptError, ArtifactPinnedError, ArtifactUnpublishedError, ArtifactUntrustedError, ChtypesError, FETCH_COMMAND, SourceUnreachableError, } from './errors.js';
|
|
38
38
|
import { fetchDestination, hostPlatform, isPlatformKey } from './paths.js';
|
|
39
39
|
import { extractTarGz } from './tar.js';
|
|
40
40
|
// ------------------------------------------------------------- constants
|
|
@@ -50,8 +50,17 @@ export const DEFAULT_RELEASE_TAG = 'artifacts';
|
|
|
50
50
|
export const RELEASE_PUBLIC_KEYS = [
|
|
51
51
|
'fdb5f06a8d4c9918d049a5f1748fa2e3b3238c3f2000986d5bb9e31beff778fc',
|
|
52
52
|
];
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The lock-file schema this SDK writes, and the newest it reads
|
|
55
|
+
* (docs/guides/fetch.md §5). Schema 1 (`<os>-<arch>/<minor>`) is still read
|
|
56
|
+
* and is converted to schema 2 (`<os>-<arch>/<clickhouse_version>`) the first
|
|
57
|
+
* time this SDK writes the file — schema 2 is what a two-patches-per-line
|
|
58
|
+
* cache needs to key on, since two patches of one line share a schema-1 key.
|
|
59
|
+
* SDKs through 0.4.x cannot read a schema-2 lock and refuse it (issue #284).
|
|
60
|
+
*/
|
|
61
|
+
export const LOCK_SCHEMA = 2;
|
|
62
|
+
/** The newest lock schema this SDK STILL READS besides its own (docs/guides/fetch.md §5). */
|
|
63
|
+
const LOCK_SCHEMA_LEGACY = 1;
|
|
55
64
|
/** The lock file `frozen` reads when no `lock` names one (docs/guides/fetch.md, Decisions); relative, so the working directory's. */
|
|
56
65
|
export const DEFAULT_LOCK_FILE = 'chtypes.lock';
|
|
57
66
|
const INDEX_SCHEMA = 1;
|
|
@@ -73,6 +82,21 @@ export function parseVersionSpelling(spelling) {
|
|
|
73
82
|
const line = `${parts[0]}.${parts[1]}`;
|
|
74
83
|
return parts.length >= 4 ? { spelling, line, exact: s, bare } : { spelling, line, exact: null, bare: null };
|
|
75
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Decision 7 / R2 (issue #284): does `candidate` — a served or installed
|
|
87
|
+
* `clickhouse_version` — match `req`? A spelled channel (`-lts` etc.) matches
|
|
88
|
+
* only itself; an unspelled one matches that patch on any channel. This is
|
|
89
|
+
* the ONE matching rule for every place a patch is matched: the registry, the
|
|
90
|
+
* release, the lock and `scripts/fetch.sh`. `false` for a line request — a
|
|
91
|
+
* line never "matches" a patch under this rule, it CONTAINS one.
|
|
92
|
+
*/
|
|
93
|
+
export function patchMatches(candidate, req) {
|
|
94
|
+
if (req.exact === null)
|
|
95
|
+
return false;
|
|
96
|
+
if (CHANNEL.test(req.exact))
|
|
97
|
+
return candidate === req.exact;
|
|
98
|
+
return candidate === req.exact || candidate.replace(CHANNEL, '') === req.bare;
|
|
99
|
+
}
|
|
76
100
|
/** Numeric order over dotted versions, channel suffix ignored: 25.10 > 25.8, 25.8.30.16 > 25.8.28.1. */
|
|
77
101
|
export function compareVersions(a, b) {
|
|
78
102
|
const pa = a.replace(CHANNEL, '').split('.').map((p) => Number.parseInt(p, 10));
|
|
@@ -587,14 +611,7 @@ export function selectArtifact(index, platform, req) {
|
|
|
587
611
|
const all = forPlatform(index, platform);
|
|
588
612
|
const rev = fetchAbiRevision();
|
|
589
613
|
const have = all.filter((a) => a.abi_revision === rev).map((a) => a.clickhouse_version).join(', ') || 'nothing';
|
|
590
|
-
|
|
591
|
-
if (req.exact !== null) {
|
|
592
|
-
const typedChannel = CHANNEL.test(req.exact);
|
|
593
|
-
anyRevision = all.filter((a) => a.clickhouse_version === req.exact || (!typedChannel && a.clickhouse_version.replace(CHANNEL, '') === req.bare));
|
|
594
|
-
}
|
|
595
|
-
else {
|
|
596
|
-
anyRevision = all.filter((a) => a.clickhouse_minor === req.line);
|
|
597
|
-
}
|
|
614
|
+
const anyRevision = req.exact !== null ? all.filter((a) => patchMatches(a.clickhouse_version, req)) : all.filter((a) => a.clickhouse_minor === req.line);
|
|
598
615
|
const hit = anyRevision.filter((a) => a.abi_revision === rev);
|
|
599
616
|
if (hit.length === 0) {
|
|
600
617
|
throw new ArtifactUnpublishedError(req.exact !== null
|
|
@@ -602,7 +619,7 @@ export function selectArtifact(index, platform, req) {
|
|
|
602
619
|
`release does not publish it at that revision: ${served(anyRevision, `that patch for ${platform}`)}` +
|
|
603
620
|
`; at ABI revision ${rev} the release has: ${have}. Ask for the line (${req.line}) to take what was published.`
|
|
604
621
|
: `chtypes: no artifact for ClickHouse line ${req.line} on ${platform} at ABI revision ${rev} (this SDK's): ` +
|
|
605
|
-
`${served(anyRevision, `that line for ${platform}`)}; at ABI revision ${rev} the release has: ${have}
|
|
622
|
+
`${served(anyRevision, `that line for ${platform}`)}; at ABI revision ${rev} the release has: ${have}.`);
|
|
606
623
|
}
|
|
607
624
|
// A line can carry more than one row: two patches, or the same patch built
|
|
608
625
|
// twice (a release keeps the two highest builds per version). Take the newest
|
|
@@ -647,7 +664,7 @@ function forPlatform(index, platform) {
|
|
|
647
664
|
const rows = index.artifacts.filter((a) => a.os === os && a.arch === arch);
|
|
648
665
|
if (rows.length === 0) {
|
|
649
666
|
const have = [...new Set(index.artifacts.map((a) => `${a.os}-${a.arch}`))].sort();
|
|
650
|
-
throw new ArtifactUnpublishedError(`chtypes: the release has nothing for ${platform} (it has: ${have.length > 0 ? have.join(', ') : 'nothing'})
|
|
667
|
+
throw new ArtifactUnpublishedError(`chtypes: the release has nothing for ${platform} (it has: ${have.length > 0 ? have.join(', ') : 'nothing'}).`);
|
|
651
668
|
}
|
|
652
669
|
return rows;
|
|
653
670
|
}
|
|
@@ -683,11 +700,20 @@ export async function ensure(spelling, options = {}) {
|
|
|
683
700
|
inFlight.set(key, p);
|
|
684
701
|
return p;
|
|
685
702
|
}
|
|
703
|
+
/** A synthetic line-shaped request, for the parts of `installOne` / the lock helpers that take one — `--all` never spells an exact patch. */
|
|
704
|
+
function lineRequest(line) {
|
|
705
|
+
return { spelling: line, line, exact: null, bare: null };
|
|
706
|
+
}
|
|
686
707
|
/**
|
|
687
708
|
* `fetch --all`: every line the release publishes for the platform at this
|
|
688
|
-
* binding's ABI revision, each through `ensure`'s chain
|
|
689
|
-
*
|
|
690
|
-
* rest go on.
|
|
709
|
+
* binding's ABI revision, each through `ensure`'s chain — installed flat, as
|
|
710
|
+
* a line request always is. A line it has only at another revision is not
|
|
711
|
+
* installed and is named in one loud stderr line; the rest go on.
|
|
712
|
+
*
|
|
713
|
+
* Under `--frozen` (F6): every line the lock pins for this platform, at its
|
|
714
|
+
* own newest pinned patch — never a line the release has that the lock does
|
|
715
|
+
* not pin (that is not drift in anything that was pinned). Every candidate's
|
|
716
|
+
* ABI revision is checked (F2) before the release is even read.
|
|
691
717
|
*/
|
|
692
718
|
export async function ensureAll(options = {}) {
|
|
693
719
|
const platform = resolvePlatform(options.platform);
|
|
@@ -696,10 +722,27 @@ export async function ensureAll(options = {}) {
|
|
|
696
722
|
if (options.offline)
|
|
697
723
|
throw new ChtypesError('chtypes: --all needs the release listing; it cannot run offline');
|
|
698
724
|
const ctx = installContext(platform, dest, options);
|
|
725
|
+
let pinnedLines = [];
|
|
726
|
+
if (ctx.frozen) {
|
|
727
|
+
pinnedLines = linesPinnedForPlatform(ctx.lock, platform);
|
|
728
|
+
for (const line of pinnedLines)
|
|
729
|
+
checkLockRevisionEarly(ctx, lineRequest(line));
|
|
730
|
+
}
|
|
699
731
|
const source = openSource(options);
|
|
700
732
|
emit({ type: 'status', message: `every published ClickHouse line, ${platform} -> ${dest}` });
|
|
701
733
|
emit({ type: 'status', message: `source ${source.description}` });
|
|
702
734
|
const release = await loadRelease(source, options, emit);
|
|
735
|
+
if (ctx.frozen) {
|
|
736
|
+
const out = [];
|
|
737
|
+
for (const line of pinnedLines) {
|
|
738
|
+
const req = lineRequest(line);
|
|
739
|
+
const candidate = selectFromLock(ctx.lock, platform, req);
|
|
740
|
+
const art = verifyFrozenCandidate(release, ctx, req, candidate);
|
|
741
|
+
out.push(await installOne(release, art, ctx, req));
|
|
742
|
+
}
|
|
743
|
+
await installGoldens(release, ctx, options);
|
|
744
|
+
return out;
|
|
745
|
+
}
|
|
703
746
|
const rows = selectAll(release.index, platform);
|
|
704
747
|
// A line the release has only at another ABI revision is not installed —
|
|
705
748
|
// and never silently: one loud line per such line, then the rest go on.
|
|
@@ -707,7 +750,7 @@ export async function ensureAll(options = {}) {
|
|
|
707
750
|
warn(`chtypes: WARNING: ${message}`);
|
|
708
751
|
const out = [];
|
|
709
752
|
for (const art of rows)
|
|
710
|
-
out.push(await installOne(release, art, ctx));
|
|
753
|
+
out.push(await installOne(release, art, ctx, lineRequest(art.clickhouse_minor)));
|
|
711
754
|
await installGoldens(release, ctx, options);
|
|
712
755
|
return out;
|
|
713
756
|
}
|
|
@@ -809,7 +852,10 @@ function installContext(platform, dest, options) {
|
|
|
809
852
|
// frozen without a lock path reads ./chtypes.lock (docs/guides/fetch.md, Decisions).
|
|
810
853
|
const lockPath = options.lock !== undefined && options.lock !== '' ? path.resolve(options.lock) : frozen ? path.resolve(DEFAULT_LOCK_FILE) : undefined;
|
|
811
854
|
const lock = lockPath !== undefined ? readLock(lockPath) : null;
|
|
812
|
-
|
|
855
|
+
// Decision 6: offline never reads the lock at all, so a missing lock file
|
|
856
|
+
// is not refused here — `ensureUncached`'s offline branch never consults
|
|
857
|
+
// `ctx.lock` and answers from the destination alone.
|
|
858
|
+
if (frozen && lock === null && options.offline !== true) {
|
|
813
859
|
throw new ArtifactPinnedError(`chtypes: ${lockPath} does not exist, and frozen refuses anything it does not pin`);
|
|
814
860
|
}
|
|
815
861
|
if (platform !== hostPlatform()) {
|
|
@@ -818,38 +864,141 @@ function installContext(platform, dest, options) {
|
|
|
818
864
|
}
|
|
819
865
|
return { platform, dest, lockPath, lock, frozen, force: options.force ?? false, emit: options.onProgress ?? noop };
|
|
820
866
|
}
|
|
867
|
+
/**
|
|
868
|
+
* Every lock entry (its full `<os>-<arch>/<clickhouse_version>` key) that
|
|
869
|
+
* `req` — a patch or a line spelling, for `platform` — matches under R2.
|
|
870
|
+
*/
|
|
871
|
+
function candidatesFromLock(lock, platform, req) {
|
|
872
|
+
const prefix = `${platform}/`;
|
|
873
|
+
const rows = [];
|
|
874
|
+
for (const [key, entry] of Object.entries(lock.artifacts)) {
|
|
875
|
+
if (!key.startsWith(prefix))
|
|
876
|
+
continue;
|
|
877
|
+
const version = key.slice(prefix.length);
|
|
878
|
+
const matches = req.exact !== null ? patchMatches(version, req) : minorOfVersion(version) === req.line;
|
|
879
|
+
if (matches)
|
|
880
|
+
rows.push({ key, entry, version });
|
|
881
|
+
}
|
|
882
|
+
return rows;
|
|
883
|
+
}
|
|
884
|
+
/** F1: the newest-versioned candidate the lock pins for `req` — a patch spelling's own entry, or a line's newest pinned patch. */
|
|
885
|
+
function selectFromLock(lock, platform, req) {
|
|
886
|
+
const rows = candidatesFromLock(lock, platform, req);
|
|
887
|
+
if (rows.length === 0)
|
|
888
|
+
return undefined;
|
|
889
|
+
return rows.reduce((best, cur) => (compareVersions(cur.version, best.version) > 0 ? cur : best));
|
|
890
|
+
}
|
|
891
|
+
/** Every line the lock pins at least one patch of, for `platform` — what `--all --frozen` (F6) installs. */
|
|
892
|
+
function linesPinnedForPlatform(lock, platform) {
|
|
893
|
+
const prefix = `${platform}/`;
|
|
894
|
+
const lines = new Set();
|
|
895
|
+
for (const key of Object.keys(lock.artifacts)) {
|
|
896
|
+
if (key.startsWith(prefix))
|
|
897
|
+
lines.add(minorOfVersion(key.slice(prefix.length)));
|
|
898
|
+
}
|
|
899
|
+
return [...lines].sort(compareVersions);
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Fails fast, before any network access, when `frozen`'s lock already names
|
|
903
|
+
* an ABI revision for one of `req`'s candidates that is not this binding's own
|
|
904
|
+
* (docs/guides/fetch.md §5, F2). A lock made for one ABI revision does not get
|
|
905
|
+
* a second chance disguised as a drifted pin or an unpublished line once the
|
|
906
|
+
* SDK moves to another: the fix is always the same re-lock, so the message
|
|
907
|
+
* says that directly instead of waiting to see which of the two symptoms
|
|
908
|
+
* selection would have produced.
|
|
909
|
+
*/
|
|
910
|
+
function checkLockRevisionEarly(ctx, req) {
|
|
911
|
+
if (!ctx.frozen || ctx.lock === null)
|
|
912
|
+
return;
|
|
913
|
+
for (const { key, entry } of candidatesFromLock(ctx.lock, ctx.platform, req)) {
|
|
914
|
+
if (entry.abi_revision === undefined || entry.abi_revision === fetchAbiRevision())
|
|
915
|
+
continue;
|
|
916
|
+
throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins ${key} at ABI revision ${entry.abi_revision}; this SDK speaks ABI revision ` +
|
|
917
|
+
`${fetchAbiRevision()} — re-lock with: ${FETCH_COMMAND} ${req.spelling} --lock ${ctx.lockPath}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* The one sentence appended to a PINNED or UNPUBLISHED message when, under
|
|
922
|
+
* `frozen`, one of the lock's own candidate entries for `req` exists but
|
|
923
|
+
* names no ABI revision at all — written by an SDK before this field
|
|
924
|
+
* existed. `''` when there is nothing to add.
|
|
925
|
+
*/
|
|
926
|
+
function revisionNote(ctx, req) {
|
|
927
|
+
if (!ctx.frozen || ctx.lock === null)
|
|
928
|
+
return '';
|
|
929
|
+
const entry = candidatesFromLock(ctx.lock, ctx.platform, req).find((r) => r.entry.abi_revision === undefined);
|
|
930
|
+
if (entry === undefined)
|
|
931
|
+
return '';
|
|
932
|
+
return (` ${ctx.lockPath} records no ABI revision (written by an older SDK); this SDK speaks ABI revision ` +
|
|
933
|
+
`${fetchAbiRevision()} — re-lock with: ${FETCH_COMMAND} ${req.spelling} --lock ${ctx.lockPath}`);
|
|
934
|
+
}
|
|
935
|
+
/** F5: the release's own row for a lock candidate, verified against SHA256SUMS, the pin's own hash, and the SDK's ABI revision — in that order. */
|
|
936
|
+
function verifyFrozenCandidate(release, ctx, req, candidate) {
|
|
937
|
+
const { key, entry, version } = candidate;
|
|
938
|
+
const [os, arch] = ctx.platform.split('-');
|
|
939
|
+
const row = release.index.artifacts.find((a) => a.file === entry.file && a.os === os && a.arch === arch);
|
|
940
|
+
const listedSha = release.sums.get(entry.file);
|
|
941
|
+
if (row === undefined || listedSha === undefined) {
|
|
942
|
+
throw new ArtifactUnpublishedError(`chtypes: the release at ${release.source.description} does not list ${entry.file}, which ${ctx.lockPath} pins for ${key}.${revisionNote(ctx, req)}`);
|
|
943
|
+
}
|
|
944
|
+
if (row.sha256 !== listedSha) {
|
|
945
|
+
throw new ArtifactCorruptError(`chtypes: index.json says ${entry.file} is ${row.sha256} but SHA256SUMS says ${listedSha} — the release disagrees with itself; not installing it`);
|
|
946
|
+
}
|
|
947
|
+
if (row.abi_revision !== fetchAbiRevision()) {
|
|
948
|
+
throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins ${key} to ${entry.file}, which the release at ${release.source.description} serves at ABI revision ` +
|
|
949
|
+
`${row.abi_revision ?? 'none recorded'}; this SDK speaks ABI revision ${fetchAbiRevision()} — re-lock with: ` +
|
|
950
|
+
`${FETCH_COMMAND} ${version} --lock ${ctx.lockPath}${revisionNote(ctx, req)}`);
|
|
951
|
+
}
|
|
952
|
+
if (entry.sha256.toLowerCase() !== listedSha) {
|
|
953
|
+
throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins ${key} to ${entry.file} (${entry.sha256}) but the release lists it as ${listedSha}; ` +
|
|
954
|
+
`frozen refuses it.${revisionNote(ctx, req)}`);
|
|
955
|
+
}
|
|
956
|
+
return row;
|
|
957
|
+
}
|
|
958
|
+
/** F1-F5: install exactly what the lock pins for `req`, never a newer patch or build the release also serves. */
|
|
959
|
+
async function installFrozen(release, req, ctx, options) {
|
|
960
|
+
const lock = ctx.lock; // installContext refuses `frozen` with no lock before this is ever called.
|
|
961
|
+
const candidate = selectFromLock(lock, ctx.platform, req);
|
|
962
|
+
if (candidate === undefined) {
|
|
963
|
+
throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins nothing for ${ctx.platform}/${req.spelling}, and frozen refuses anything it does not pin`);
|
|
964
|
+
}
|
|
965
|
+
const art = verifyFrozenCandidate(release, ctx, req, candidate);
|
|
966
|
+
const installed = await installOne(release, art, ctx, req);
|
|
967
|
+
await installGoldens(release, ctx, options);
|
|
968
|
+
return installed;
|
|
969
|
+
}
|
|
821
970
|
async function ensureUncached(req, platform, dest, options) {
|
|
822
971
|
const ctx = installContext(platform, dest, options);
|
|
823
|
-
const install = path.join(dest, req.line);
|
|
824
972
|
if (options.offline) {
|
|
825
|
-
// Never touch the source
|
|
826
|
-
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
|
|
973
|
+
// Never touch the source, and never the lock (Decision 6): installed and
|
|
974
|
+
// hashing what its own manifest says, at either slot, is the whole test.
|
|
975
|
+
const patches = await installedPatchesForLine(dest, req.line);
|
|
976
|
+
const match = req.exact !== null
|
|
977
|
+
? (patches.find((p) => patchMatches(p.version, req)) ?? null)
|
|
978
|
+
: patches.reduce((best, cur) => (best === null || compareVersions(cur.version, best.version) > 0 ? cur : best), null);
|
|
979
|
+
if (match !== null && match.ok) {
|
|
980
|
+
ctx.emit({ type: 'status', message: `offline — already installed and verified: ${match.dir}` });
|
|
830
981
|
return {
|
|
831
|
-
dir:
|
|
982
|
+
dir: match.dir,
|
|
832
983
|
registry: dest,
|
|
833
984
|
line: req.line,
|
|
834
|
-
version:
|
|
985
|
+
version: match.version,
|
|
835
986
|
platform,
|
|
836
|
-
file:
|
|
837
|
-
sha256:
|
|
838
|
-
library:
|
|
839
|
-
librarySha256:
|
|
987
|
+
file: '',
|
|
988
|
+
sha256: '',
|
|
989
|
+
library: match.library,
|
|
990
|
+
librarySha256: match.expected,
|
|
840
991
|
installed: false,
|
|
841
992
|
source: 'offline',
|
|
842
993
|
signed: false,
|
|
843
994
|
keyId: null,
|
|
844
995
|
};
|
|
845
996
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
}
|
|
849
|
-
throw new SourceUnreachableError(`chtypes: offline — ClickHouse ${req.line} (${platform}) is not installed and verified in ${dest}` +
|
|
850
|
-
(have !== null && !have.ok ? ` (${have.problem})` : '') +
|
|
997
|
+
throw new SourceUnreachableError(`chtypes: offline — ClickHouse ${req.spelling} (${platform}) is not installed and verified in ${dest}` +
|
|
998
|
+
(match !== null && !match.ok ? ` (${match.problem})` : '') +
|
|
851
999
|
', and offline forbids fetching it');
|
|
852
1000
|
}
|
|
1001
|
+
checkLockRevisionEarly(ctx, req);
|
|
853
1002
|
const source = openSource(options);
|
|
854
1003
|
ctx.emit({
|
|
855
1004
|
type: 'status',
|
|
@@ -857,17 +1006,58 @@ async function ensureUncached(req, platform, dest, options) {
|
|
|
857
1006
|
});
|
|
858
1007
|
ctx.emit({ type: 'status', message: `source ${source.description}` });
|
|
859
1008
|
const release = await loadRelease(source, options, ctx.emit);
|
|
1009
|
+
if (ctx.frozen)
|
|
1010
|
+
return installFrozen(release, req, ctx, options);
|
|
860
1011
|
const art = selectArtifact(release.index, platform, req);
|
|
861
|
-
const installed = await installOne(release, art, ctx);
|
|
1012
|
+
const installed = await installOne(release, art, ctx, req);
|
|
862
1013
|
await installGoldens(release, ctx, options);
|
|
863
1014
|
return installed;
|
|
864
1015
|
}
|
|
865
|
-
/**
|
|
866
|
-
|
|
1016
|
+
/**
|
|
1017
|
+
* Same-filesystem, atomic: move the current flat `<dest>/<minor>/` occupant
|
|
1018
|
+
* aside to `<dest>/patches/<minor>/<its version>/` — NEVER deleted — before a
|
|
1019
|
+
* different patch takes the flat slot (issue #284, the layout rule). A
|
|
1020
|
+
* no-op when nothing is flat yet, or when the flat occupant already IS
|
|
1021
|
+
* `incomingVersion` (the ordinary re-verify/rebuild path handles that case).
|
|
1022
|
+
*/
|
|
1023
|
+
async function demoteFlatIfDifferent(dest, minor, incomingVersion) {
|
|
1024
|
+
const flat = path.join(dest, minor);
|
|
1025
|
+
if (!existsSync(flat))
|
|
1026
|
+
return;
|
|
1027
|
+
const existing = readInstalledManifest(flat);
|
|
1028
|
+
if (existing === null || existing.version === incomingVersion)
|
|
1029
|
+
return;
|
|
1030
|
+
const patchesDir = path.join(dest, 'patches', minor);
|
|
1031
|
+
const target = path.join(patchesDir, existing.version);
|
|
1032
|
+
await mkdir(patchesDir, { recursive: true });
|
|
1033
|
+
if (existsSync(target))
|
|
1034
|
+
await rm(target, { recursive: true, force: true });
|
|
1035
|
+
await rename(flat, target);
|
|
1036
|
+
}
|
|
1037
|
+
/** Does `dir` already hold `art`, byte-verified — a manifest and a library hashing `art.library_sha256`? */
|
|
1038
|
+
async function verifiedInstall(dir, art) {
|
|
1039
|
+
const libPath = path.join(dir, art.library);
|
|
1040
|
+
if (!existsSync(path.join(dir, 'manifest.json')) || !existsSync(libPath))
|
|
1041
|
+
return false;
|
|
1042
|
+
return (await sha256File(libPath)) === art.library_sha256;
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* One index row, from the release into its slot — steps 2 through 4. A LINE
|
|
1046
|
+
* request's pick installs flat, `<dest>/<minor>/`, demoting a different flat
|
|
1047
|
+
* occupant first (never deleting it). Any other exact patch request installs
|
|
1048
|
+
* at `<dest>/patches/<minor>/<clickhouse_version>/` — unless that exact
|
|
1049
|
+
* version already sits flat, byte-verified, in which case it counts as
|
|
1050
|
+
* installed there and no second copy is made (docs/guides/fetch.md §4).
|
|
1051
|
+
*/
|
|
1052
|
+
async function installOne(release, art, ctx, req) {
|
|
867
1053
|
const { dest, platform, emit } = ctx;
|
|
868
1054
|
const minor = art.clickhouse_minor;
|
|
869
|
-
const
|
|
870
|
-
const
|
|
1055
|
+
const version = art.clickhouse_version;
|
|
1056
|
+
const wantsFlat = req.exact === null;
|
|
1057
|
+
const flat = path.join(dest, minor);
|
|
1058
|
+
const nested = path.join(dest, 'patches', minor, version);
|
|
1059
|
+
const install = wantsFlat ? flat : nested;
|
|
1060
|
+
const lockKey = `${platform}/${version}`;
|
|
871
1061
|
emit({ type: 'status', message: `${art.file} (${art.bytes} bytes, ClickHouse ${art.clickhouse_version}, library ${art.library})` });
|
|
872
1062
|
// Step 2: the authentic SHA256SUMS must agree with index.json, byte for byte.
|
|
873
1063
|
const listed = release.sums.get(art.file);
|
|
@@ -876,18 +1066,8 @@ async function installOne(release, art, ctx) {
|
|
|
876
1066
|
if (listed !== art.sha256) {
|
|
877
1067
|
throw new ArtifactCorruptError(`chtypes: index.json says ${art.file} is ${art.sha256} but SHA256SUMS says ${listed} — the release disagrees with itself; not installing it`);
|
|
878
1068
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
const pin = ctx.lock.artifacts[lockKey];
|
|
882
|
-
if (pin === undefined) {
|
|
883
|
-
throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins nothing for ${lockKey}, and frozen refuses anything it does not pin`);
|
|
884
|
-
}
|
|
885
|
-
if (pin.file !== art.file || pin.sha256.toLowerCase() !== art.sha256) {
|
|
886
|
-
throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins ${lockKey} to ${pin.file} (${pin.sha256}) but the release offers ${art.file} (${art.sha256}); frozen refuses it`);
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
const result = (installed) => ({
|
|
890
|
-
dir: install,
|
|
1069
|
+
const result = (installed, dir) => ({
|
|
1070
|
+
dir,
|
|
891
1071
|
registry: dest,
|
|
892
1072
|
line: minor,
|
|
893
1073
|
version: art.clickhouse_version,
|
|
@@ -901,22 +1081,33 @@ async function installOne(release, art, ctx) {
|
|
|
901
1081
|
signed: release.signed,
|
|
902
1082
|
keyId: release.keyId,
|
|
903
1083
|
});
|
|
1084
|
+
const recordLock = () => {
|
|
1085
|
+
if (ctx.lockPath !== undefined && !ctx.frozen) {
|
|
1086
|
+
writeLockEntry(ctx.lockPath, lockKey, { file: art.file, sha256: art.sha256, abi_revision: fetchAbiRevision() });
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
904
1089
|
// Already installed and intact? The same hash the install path ends with,
|
|
905
1090
|
// so "already there" is a verified claim, not an inference from a directory.
|
|
906
1091
|
if (!ctx.force) {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
}
|
|
916
|
-
|
|
1092
|
+
if (await verifiedInstall(install, art)) {
|
|
1093
|
+
emit({ type: 'status', message: `already installed and verified: ${path.join(install, art.library)}` });
|
|
1094
|
+
recordLock();
|
|
1095
|
+
return result(false, install);
|
|
1096
|
+
}
|
|
1097
|
+
// §4: a patch request whose exact version already sits flat (put there by
|
|
1098
|
+
// an earlier line fetch) is already installed — no second, nested copy.
|
|
1099
|
+
if (!wantsFlat && (await verifiedInstall(flat, art))) {
|
|
1100
|
+
emit({ type: 'status', message: `already installed and verified (flat): ${path.join(flat, art.library)}` });
|
|
1101
|
+
recordLock();
|
|
1102
|
+
return result(false, flat);
|
|
917
1103
|
}
|
|
918
1104
|
}
|
|
1105
|
+
// A line request whose pick differs from the current flat occupant demotes
|
|
1106
|
+
// that occupant to patches/ BEFORE the incoming patch takes the flat slot.
|
|
1107
|
+
if (wantsFlat)
|
|
1108
|
+
await demoteFlatIfDifferent(dest, minor, version);
|
|
919
1109
|
await mkdir(dest, { recursive: true });
|
|
1110
|
+
await mkdir(path.dirname(install), { recursive: true });
|
|
920
1111
|
const tag = `${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
921
1112
|
// Hidden siblings: the loader skips dot-directories, so a half-written
|
|
922
1113
|
// install is never scanned as a version.
|
|
@@ -955,7 +1146,7 @@ async function installOne(release, art, ctx) {
|
|
|
955
1146
|
throw new ArtifactCorruptError(`chtypes: ${art.file} names library ${manifest.library} but does not contain it`);
|
|
956
1147
|
}
|
|
957
1148
|
// Into place through a sibling: an interrupted install never leaves a
|
|
958
|
-
// half-populated
|
|
1149
|
+
// half-populated slot for the loader to dlopen.
|
|
959
1150
|
const replaced = path.join(dest, `.${minor}.replaced.${tag}`);
|
|
960
1151
|
if (existsSync(install))
|
|
961
1152
|
await rename(install, replaced);
|
|
@@ -971,9 +1162,8 @@ async function installOne(release, art, ctx) {
|
|
|
971
1162
|
throw new ArtifactCorruptError(`chtypes: installed ${installedLib} hashes ${finalSha}, manifest says ${manifest.librarySha256} — the install is bad; moved aside to ${aside}`);
|
|
972
1163
|
}
|
|
973
1164
|
emit({ type: 'status', message: `installed and verified: ${install} (${manifest.library} sha256 ${finalSha})` });
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
return result(true);
|
|
1165
|
+
recordLock();
|
|
1166
|
+
return result(true, install);
|
|
977
1167
|
}
|
|
978
1168
|
finally {
|
|
979
1169
|
await rm(tarball, { force: true });
|
|
@@ -982,33 +1172,34 @@ async function installOne(release, art, ctx) {
|
|
|
982
1172
|
}
|
|
983
1173
|
// ------------------------------------------------------ verify and list
|
|
984
1174
|
/**
|
|
985
|
-
* `chtypes verify`: re-hash every installed
|
|
986
|
-
*
|
|
987
|
-
*
|
|
1175
|
+
* `chtypes verify`: re-hash every installed PATCH in a registry directory —
|
|
1176
|
+
* the flat slot and every `patches/<minor>/<version>/` sibling (issue #284)
|
|
1177
|
+
* — against its own manifest. A directory whose manifest cannot be read is
|
|
1178
|
+
* not a patch (a scratch directory) and is skipped, as the loader skips it.
|
|
988
1179
|
*/
|
|
989
1180
|
export async function verifyInstalled(dest, platform) {
|
|
990
1181
|
const dir = fetchDestination(dest, resolvePlatform(platform));
|
|
991
1182
|
const out = [];
|
|
992
|
-
for (const
|
|
993
|
-
const state = await
|
|
1183
|
+
for (const sub of allInstalledDirs(dir)) {
|
|
1184
|
+
const state = await installedPatch(sub);
|
|
994
1185
|
if (state !== null)
|
|
995
1186
|
out.push(state);
|
|
996
1187
|
}
|
|
997
|
-
return out;
|
|
1188
|
+
return out.sort((a, b) => compareVersions(a.version, b.version));
|
|
998
1189
|
}
|
|
999
|
-
/** `chtypes list`: what is installed in the registry directory, and what the release offers for the platform. */
|
|
1190
|
+
/** `chtypes list`: what is installed in the registry directory (one record per patch), and what the release offers for the platform. */
|
|
1000
1191
|
export async function listArtifacts(options = {}) {
|
|
1001
1192
|
const platform = resolvePlatform(options.platform);
|
|
1002
1193
|
const dir = fetchDestination(options.dest, platform);
|
|
1003
1194
|
const installed = [];
|
|
1004
|
-
for (const
|
|
1005
|
-
const m = readInstalledManifest(
|
|
1195
|
+
for (const sub of allInstalledDirs(dir)) {
|
|
1196
|
+
const m = readInstalledManifest(sub);
|
|
1006
1197
|
if (m === null)
|
|
1007
1198
|
continue;
|
|
1008
|
-
const present = existsSync(path.join(
|
|
1199
|
+
const present = existsSync(path.join(sub, m.library));
|
|
1009
1200
|
installed.push({
|
|
1010
|
-
line,
|
|
1011
|
-
dir:
|
|
1201
|
+
line: m.minor,
|
|
1202
|
+
dir: sub,
|
|
1012
1203
|
version: m.version,
|
|
1013
1204
|
library: m.library,
|
|
1014
1205
|
expected: m.librarySha256,
|
|
@@ -1017,6 +1208,7 @@ export async function listArtifacts(options = {}) {
|
|
|
1017
1208
|
problem: present ? null : `${m.library} is missing`,
|
|
1018
1209
|
});
|
|
1019
1210
|
}
|
|
1211
|
+
installed.sort((a, b) => compareVersions(a.version, b.version));
|
|
1020
1212
|
if (options.offline)
|
|
1021
1213
|
return { registry: dir, platform, installed, offered: null, source: null };
|
|
1022
1214
|
const source = openSource(options);
|
|
@@ -1034,7 +1226,37 @@ export async function listArtifacts(options = {}) {
|
|
|
1034
1226
|
return { registry: dir, platform, installed, offered, source: source.description, ...(note !== undefined ? { notShown: note } : {}) };
|
|
1035
1227
|
}
|
|
1036
1228
|
// ------------------------------------------------------------ the lock
|
|
1037
|
-
/**
|
|
1229
|
+
/** `chtypes-<version>-<os>-<arch>[-b<N>].tar.gz` (docs/guides/artifacts.md), version captured whole (it may itself contain dots and a channel). */
|
|
1230
|
+
const ASSET_FILE_NAME = /^chtypes-(.+)-(linux|darwin)-(arm64|amd64)(?:-b\d+)?\.tar\.gz$/;
|
|
1231
|
+
/** The ClickHouse version a served asset file name claims, or `null` when it does not parse. */
|
|
1232
|
+
function versionFromAssetFile(file) {
|
|
1233
|
+
const m = ASSET_FILE_NAME.exec(file);
|
|
1234
|
+
return m === null ? null : m[1];
|
|
1235
|
+
}
|
|
1236
|
+
/** True for a PATCH spelling (four or more numeric components) — what schema 2's keys must name, never a bare line. */
|
|
1237
|
+
function isPatchVersion(version) {
|
|
1238
|
+
try {
|
|
1239
|
+
return parseVersionSpelling(version).exact !== null;
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
return false;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Read a lock file; `null` when it does not exist. Accepts schema 1 and
|
|
1247
|
+
* schema 2 (docs/guides/fetch.md §5); `.schema` reports the number that was
|
|
1248
|
+
* actually on disk (so a caller can tell which it read), while `.artifacts`
|
|
1249
|
+
* is ALWAYS schema-2-shaped: a schema-1 entry `<os>-<arch>/<minor>` is
|
|
1250
|
+
* converted to the schema-2 entry `<os>-<arch>/<v>`, `<v>` being the
|
|
1251
|
+
* ClickHouse version its own `file` names — the one schema-1 could not
|
|
1252
|
+
* record, because it never had two patches of a line to distinguish. Every
|
|
1253
|
+
* other reader in this module treats `.artifacts` as schema-2-keyed
|
|
1254
|
+
* unconditionally; only this doc comment and `.schema` itself ever mention
|
|
1255
|
+
* schema 1 again. Any other schema number is refused, naming both 1 and
|
|
1256
|
+
* `LOCK_SCHEMA`. An entry that does not parse, under either schema — including
|
|
1257
|
+
* a schema-2 key that names a bare line rather than an exact patch — makes the
|
|
1258
|
+
* whole lock unreadable and the error names it.
|
|
1259
|
+
*/
|
|
1038
1260
|
export function readLock(file) {
|
|
1039
1261
|
let text;
|
|
1040
1262
|
try {
|
|
@@ -1052,8 +1274,12 @@ export function readLock(file) {
|
|
|
1052
1274
|
catch (err) {
|
|
1053
1275
|
throw new ChtypesError(`chtypes: lock file ${file} is not JSON: ${errorText(err)}`, { cause: err });
|
|
1054
1276
|
}
|
|
1055
|
-
if (typeof doc !== 'object' || doc === null
|
|
1056
|
-
throw new ChtypesError(`chtypes: lock file ${file} is not schema ${LOCK_SCHEMA}`);
|
|
1277
|
+
if (typeof doc !== 'object' || doc === null) {
|
|
1278
|
+
throw new ChtypesError(`chtypes: lock file ${file} is not schema ${LOCK_SCHEMA_LEGACY} or ${LOCK_SCHEMA}`);
|
|
1279
|
+
}
|
|
1280
|
+
const schema = doc['schema'];
|
|
1281
|
+
if (schema !== LOCK_SCHEMA_LEGACY && schema !== LOCK_SCHEMA) {
|
|
1282
|
+
throw new ChtypesError(`chtypes: lock file ${file} is schema ${JSON.stringify(schema)}, not ${LOCK_SCHEMA_LEGACY} or ${LOCK_SCHEMA}`);
|
|
1057
1283
|
}
|
|
1058
1284
|
const raw = doc['artifacts'];
|
|
1059
1285
|
const artifacts = {};
|
|
@@ -1062,11 +1288,40 @@ export function readLock(file) {
|
|
|
1062
1288
|
if (typeof v !== 'object' || v === null)
|
|
1063
1289
|
continue;
|
|
1064
1290
|
const e = v;
|
|
1065
|
-
if (typeof e['file']
|
|
1066
|
-
|
|
1291
|
+
if (typeof e['file'] !== 'string' || typeof e['sha256'] !== 'string')
|
|
1292
|
+
continue;
|
|
1293
|
+
// abi_revision is optional and additive (docs/guides/fetch.md §5): an
|
|
1294
|
+
// entry written before this SDK recorded it simply has none, read the
|
|
1295
|
+
// same way an index row that carries none is — never a typed number
|
|
1296
|
+
// (TS cannot tell 5.0 from 5, so anything not a safe integer is absent).
|
|
1297
|
+
const entry = { file: e['file'], sha256: e['sha256'], ...revisionOf(e['abi_revision']) };
|
|
1298
|
+
const slash = k.indexOf('/');
|
|
1299
|
+
if (slash < 0) {
|
|
1300
|
+
throw new ChtypesError(`chtypes: lock file ${file}: entry ${JSON.stringify(k)} is not <os>-<arch>/<version>`);
|
|
1301
|
+
}
|
|
1302
|
+
const platformKey = k.slice(0, slash);
|
|
1303
|
+
const versionOrMinor = k.slice(slash + 1);
|
|
1304
|
+
if (schema === LOCK_SCHEMA_LEGACY) {
|
|
1305
|
+
const version = versionFromAssetFile(entry.file);
|
|
1306
|
+
if (version === null) {
|
|
1307
|
+
throw new ChtypesError(`chtypes: lock file ${file}: entry ${JSON.stringify(k)}'s file ${JSON.stringify(entry.file)} does not name a ` +
|
|
1308
|
+
'ClickHouse version — cannot convert this schema 1 lock to schema 2');
|
|
1309
|
+
}
|
|
1310
|
+
if (minorOfVersion(version) !== versionOrMinor) {
|
|
1311
|
+
throw new ChtypesError(`chtypes: lock file ${file}: entry ${JSON.stringify(k)} names line ${versionOrMinor} but its file ` +
|
|
1312
|
+
`${JSON.stringify(entry.file)} is ${version} — cannot convert this schema 1 lock to schema 2`);
|
|
1313
|
+
}
|
|
1314
|
+
artifacts[`${platformKey}/${version}`] = entry;
|
|
1315
|
+
}
|
|
1316
|
+
else {
|
|
1317
|
+
if (!isPatchVersion(versionOrMinor)) {
|
|
1318
|
+
throw new ChtypesError(`chtypes: lock file ${file}: entry ${JSON.stringify(k)} names a line, not an exact patch — schema ${LOCK_SCHEMA} keys are <os>-<arch>/<clickhouse_version>`);
|
|
1319
|
+
}
|
|
1320
|
+
artifacts[k] = entry;
|
|
1321
|
+
}
|
|
1067
1322
|
}
|
|
1068
1323
|
}
|
|
1069
|
-
return { schema
|
|
1324
|
+
return { schema, artifacts };
|
|
1070
1325
|
}
|
|
1071
1326
|
function writeLockEntry(file, key, entry) {
|
|
1072
1327
|
const current = readLock(file) ?? { schema: LOCK_SCHEMA, artifacts: {} };
|
|
@@ -1100,20 +1355,24 @@ function readInstalledManifest(dir) {
|
|
|
1100
1355
|
return null;
|
|
1101
1356
|
}
|
|
1102
1357
|
}
|
|
1103
|
-
/**
|
|
1104
|
-
|
|
1358
|
+
/**
|
|
1359
|
+
* The state of one installed patch directory (flat or `patches/<minor>/<version>/`):
|
|
1360
|
+
* null when it is not one (no usable manifest). `line` comes from the
|
|
1361
|
+
* manifest's own claim, never from the directory name — a nested directory is
|
|
1362
|
+
* named after the VERSION, not the line.
|
|
1363
|
+
*/
|
|
1364
|
+
async function installedPatch(dir) {
|
|
1105
1365
|
const m = readInstalledManifest(dir);
|
|
1106
1366
|
if (m === null)
|
|
1107
1367
|
return null;
|
|
1108
|
-
const line = path.basename(dir);
|
|
1109
1368
|
const libPath = path.join(dir, m.library);
|
|
1110
1369
|
if (!existsSync(libPath)) {
|
|
1111
|
-
return { line, dir, version: m.version, library: m.library, expected: m.librarySha256, actual: null, ok: false, problem: `${m.library} is missing` };
|
|
1370
|
+
return { line: m.minor, dir, version: m.version, library: m.library, expected: m.librarySha256, actual: null, ok: false, problem: `${m.library} is missing` };
|
|
1112
1371
|
}
|
|
1113
1372
|
const actual = await sha256File(libPath);
|
|
1114
1373
|
const ok = actual === m.librarySha256;
|
|
1115
1374
|
return {
|
|
1116
|
-
line,
|
|
1375
|
+
line: m.minor,
|
|
1117
1376
|
dir,
|
|
1118
1377
|
version: m.version,
|
|
1119
1378
|
library: m.library,
|
|
@@ -1123,17 +1382,82 @@ async function installedLine(dir) {
|
|
|
1123
1382
|
problem: ok ? null : `${m.library} hashes ${actual}, manifest says ${m.librarySha256}`,
|
|
1124
1383
|
};
|
|
1125
1384
|
}
|
|
1126
|
-
|
|
1385
|
+
/** Every installed-patch directory in a registry root: the flat slot per line, plus every `patches/<minor>/<version>/`. */
|
|
1386
|
+
function allInstalledDirs(dir) {
|
|
1387
|
+
const out = [];
|
|
1127
1388
|
let entries;
|
|
1128
1389
|
try {
|
|
1129
1390
|
entries = readdirSync(dir);
|
|
1130
1391
|
}
|
|
1131
1392
|
catch {
|
|
1132
|
-
return
|
|
1393
|
+
return out;
|
|
1394
|
+
}
|
|
1395
|
+
for (const entry of entries.sort()) {
|
|
1396
|
+
if (entry.startsWith('.') || entry === 'patches')
|
|
1397
|
+
continue;
|
|
1398
|
+
if (existsSync(path.join(dir, entry, 'manifest.json')))
|
|
1399
|
+
out.push(path.join(dir, entry));
|
|
1400
|
+
}
|
|
1401
|
+
const patchesRoot = path.join(dir, 'patches');
|
|
1402
|
+
let minorEntries;
|
|
1403
|
+
try {
|
|
1404
|
+
minorEntries = readdirSync(patchesRoot);
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
return out;
|
|
1408
|
+
}
|
|
1409
|
+
for (const minorEntry of minorEntries.sort()) {
|
|
1410
|
+
if (minorEntry.startsWith('.'))
|
|
1411
|
+
continue;
|
|
1412
|
+
const minorDir = path.join(patchesRoot, minorEntry);
|
|
1413
|
+
let versionEntries;
|
|
1414
|
+
try {
|
|
1415
|
+
versionEntries = readdirSync(minorDir);
|
|
1416
|
+
}
|
|
1417
|
+
catch {
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
for (const versionEntry of versionEntries.sort()) {
|
|
1421
|
+
if (versionEntry.startsWith('.'))
|
|
1422
|
+
continue;
|
|
1423
|
+
const sub = path.join(minorDir, versionEntry);
|
|
1424
|
+
if (existsSync(path.join(sub, 'manifest.json')))
|
|
1425
|
+
out.push(sub);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
return out;
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Every installed patch of `minor` in `dest` — the flat slot and every
|
|
1432
|
+
* `patches/<minor>/<version>/` sibling — byte-verified against its own
|
|
1433
|
+
* manifest. Used offline, where the source and the lock are both off limits
|
|
1434
|
+
* (Decision 6): "installed" there means hashed and matching, not merely present.
|
|
1435
|
+
*/
|
|
1436
|
+
async function installedPatchesForLine(dest, minor) {
|
|
1437
|
+
const flat = path.join(dest, minor);
|
|
1438
|
+
const dirs = existsSync(path.join(flat, 'manifest.json')) ? [flat] : [];
|
|
1439
|
+
const patchesDir = path.join(dest, 'patches', minor);
|
|
1440
|
+
let versionEntries = [];
|
|
1441
|
+
try {
|
|
1442
|
+
versionEntries = readdirSync(patchesDir);
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
versionEntries = [];
|
|
1133
1446
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1447
|
+
for (const versionEntry of versionEntries.sort()) {
|
|
1448
|
+
if (versionEntry.startsWith('.'))
|
|
1449
|
+
continue;
|
|
1450
|
+
const sub = path.join(patchesDir, versionEntry);
|
|
1451
|
+
if (existsSync(path.join(sub, 'manifest.json')))
|
|
1452
|
+
dirs.push(sub);
|
|
1453
|
+
}
|
|
1454
|
+
const out = [];
|
|
1455
|
+
for (const d of dirs) {
|
|
1456
|
+
const state = await installedPatch(d);
|
|
1457
|
+
if (state !== null)
|
|
1458
|
+
out.push(state);
|
|
1459
|
+
}
|
|
1460
|
+
return out;
|
|
1137
1461
|
}
|
|
1138
1462
|
/** sha256 of a file, streamed — a 300 MB library never sits in memory whole. */
|
|
1139
1463
|
export async function sha256File(file) {
|