@wavehouse/chtypes 0.1.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 +24 -0
- package/LICENSE +202 -0
- package/README.md +442 -0
- package/dist/cli.d.ts +43 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +303 -0
- package/dist/cli.js.map +1 -0
- package/dist/discover.d.ts +134 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +291 -0
- package/dist/discover.js.map +1 -0
- package/dist/errors.d.ts +204 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +228 -0
- package/dist/errors.js.map +1 -0
- package/dist/fetch.d.ts +235 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +842 -0
- package/dist/fetch.js.map +1 -0
- package/dist/ffi.d.ts +346 -0
- package/dist/ffi.d.ts.map +1 -0
- package/dist/ffi.js +913 -0
- package/dist/ffi.js.map +1 -0
- package/dist/format.d.ts +104 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +108 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +127 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +577 -0
- package/dist/json.js.map +1 -0
- package/dist/library.d.ts +225 -0
- package/dist/library.d.ts.map +1 -0
- package/dist/library.js +234 -0
- package/dist/library.js.map +1 -0
- package/dist/numeric.d.ts +20 -0
- package/dist/numeric.d.ts.map +1 -0
- package/dist/numeric.js +107 -0
- package/dist/numeric.js.map +1 -0
- package/dist/paths.d.ts +47 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +78 -0
- package/dist/paths.js.map +1 -0
- package/dist/registry.d.ts +204 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +409 -0
- package/dist/registry.js.map +1 -0
- package/dist/results.d.ts +326 -0
- package/dist/results.d.ts.map +1 -0
- package/dist/results.js +235 -0
- package/dist/results.js.map +1 -0
- package/dist/schema.d.ts +444 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +448 -0
- package/dist/schema.js.map +1 -0
- package/dist/settings.d.ts +43 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +33 -0
- package/dist/settings.js.map +1 -0
- package/dist/tar.d.ts +32 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +310 -0
- package/dist/tar.js.map +1 -0
- package/dist/transform.d.ts +73 -0
- package/dist/transform.d.ts.map +1 -0
- package/dist/transform.js +527 -0
- package/dist/transform.js.map +1 -0
- package/package.json +62 -0
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetching, verifying and installing artifacts — docs/fetch.md, the contract
|
|
3
|
+
* every SDK implements identically: the same source (§2), the same
|
|
4
|
+
* verification chain (§3), the same signature policy (§4), the same lock
|
|
5
|
+
* file (§5), the same function (§6) and the same codes (§7).
|
|
6
|
+
*
|
|
7
|
+
* Nothing here is a verdict but the chain. No exit code, no
|
|
8
|
+
* `Content-Length`, no "download finished" ever is:
|
|
9
|
+
*
|
|
10
|
+
* 0. `SHA256SUMS.sig` — ed25519 over the exact bytes of `SHA256SUMS`, with a
|
|
11
|
+
* trusted key. Failure stops everything; nothing is downloaded around it.
|
|
12
|
+
* 1. `index.json` names the asset for the line/platform and its sha256.
|
|
13
|
+
* 2. `SHA256SUMS` — now known-authentic — must list the same file with the
|
|
14
|
+
* same sha256; a disagreement is a broken release, reported, not repaired.
|
|
15
|
+
* 3. The tarball is hashed BEFORE it is unpacked.
|
|
16
|
+
* 4. `manifest.json` inside names the library and its sha256; after the
|
|
17
|
+
* move into `<registry>/<minor>/`, the installed library is hashed again
|
|
18
|
+
* in place.
|
|
19
|
+
*
|
|
20
|
+
* The install is atomic — unpack into a temporary sibling, rename into place
|
|
21
|
+
* — and idempotent: an installed line that hashes what the release says is
|
|
22
|
+
* reported as installed and nothing is downloaded.
|
|
23
|
+
*
|
|
24
|
+
* Steady state is offline: once files are in a registry directory the loader
|
|
25
|
+
* trusts the directory, exactly as a runtime trusts `node_modules`.
|
|
26
|
+
* Verification is a fetch-time policy, not a load-time gate.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash, createPublicKey, randomBytes, verify as verifyRaw } from 'node:crypto';
|
|
29
|
+
import { createReadStream, createWriteStream, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { mkdir, readFile, rename, rm } from 'node:fs/promises';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { Readable, Transform } from 'node:stream';
|
|
33
|
+
import { pipeline } from 'node:stream/promises';
|
|
34
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { ArtifactCorruptError, ArtifactPinnedError, ArtifactUnpublishedError, ArtifactUntrustedError, ChtypesError, SourceUnreachableError, } from './errors.js';
|
|
37
|
+
import { fetchDestination, hostPlatform, isPlatformKey } from './paths.js';
|
|
38
|
+
import { extractTarGz } from './tar.js';
|
|
39
|
+
// ------------------------------------------------------------- constants
|
|
40
|
+
/** The public artifacts host (docs/fetch.md §2); `CHTYPES_ARTIFACTS_URL` overrides it. */
|
|
41
|
+
export const DEFAULT_ARTIFACTS_URL = 'https://artifacts.wavehouse.dev';
|
|
42
|
+
/** The rolling release every fetch reads by default; `tag` picks a frozen one. */
|
|
43
|
+
export const DEFAULT_RELEASE_TAG = 'artifacts';
|
|
44
|
+
/**
|
|
45
|
+
* The release signing key(s) — raw 32-byte ed25519 public keys, hex
|
|
46
|
+
* (docs/fetch.md §4). `CHTYPES_TRUSTED_KEYS` or the `trustedKeys` option
|
|
47
|
+
* REPLACES this list; it is never extended silently.
|
|
48
|
+
*/
|
|
49
|
+
export const RELEASE_PUBLIC_KEYS = [
|
|
50
|
+
'fdb5f06a8d4c9918d049a5f1748fa2e3b3238c3f2000986d5bb9e31beff778fc',
|
|
51
|
+
];
|
|
52
|
+
/** The lock-file schema this SDK writes and reads (docs/fetch.md §5). */
|
|
53
|
+
export const LOCK_SCHEMA = 1;
|
|
54
|
+
/** The lock file `frozen` reads when no `lock` names one (docs/fetch.md, Decisions); relative, so the working directory's. */
|
|
55
|
+
export const DEFAULT_LOCK_FILE = 'chtypes.lock';
|
|
56
|
+
const INDEX_SCHEMA = 1;
|
|
57
|
+
const CHANNEL = /-(lts|stable|prestable|testing)$/;
|
|
58
|
+
/**
|
|
59
|
+
* `25.8` is a line; `25.8.28.1-lts`, `v25.8.28.1-lts` and `25.8.28.1` are an
|
|
60
|
+
* exact patch and a hard requirement (a release that publishes another patch
|
|
61
|
+
* of the line refuses it — ask for the line to take what was published).
|
|
62
|
+
*
|
|
63
|
+
* @throws {ChtypesError} when the spelling is not a ClickHouse version at all.
|
|
64
|
+
*/
|
|
65
|
+
export function parseVersionSpelling(spelling) {
|
|
66
|
+
const s = spelling.trim().replace(/^v/, '');
|
|
67
|
+
const bare = s.replace(CHANNEL, '');
|
|
68
|
+
const parts = bare.split('.');
|
|
69
|
+
if (parts.length < 2 || !parts.every((p) => /^\d+$/.test(p))) {
|
|
70
|
+
throw new ChtypesError(`chtypes: cannot make a ClickHouse version out of ${JSON.stringify(spelling)}`);
|
|
71
|
+
}
|
|
72
|
+
const line = `${parts[0]}.${parts[1]}`;
|
|
73
|
+
return parts.length >= 4 ? { spelling, line, exact: s, bare } : { spelling, line, exact: null, bare: null };
|
|
74
|
+
}
|
|
75
|
+
/** Numeric order over dotted versions, channel suffix ignored: 25.10 > 25.8, 25.8.30.16 > 25.8.28.1. */
|
|
76
|
+
export function compareVersions(a, b) {
|
|
77
|
+
const pa = a.replace(CHANNEL, '').split('.').map((p) => Number.parseInt(p, 10));
|
|
78
|
+
const pb = b.replace(CHANNEL, '').split('.').map((p) => Number.parseInt(p, 10));
|
|
79
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
80
|
+
const x = pa[i] ?? -1;
|
|
81
|
+
const y = pb[i] ?? -1;
|
|
82
|
+
if (Number.isNaN(x) || Number.isNaN(y))
|
|
83
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
84
|
+
if (x !== y)
|
|
85
|
+
return x - y;
|
|
86
|
+
}
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
// ------------------------------------------------------------ signature
|
|
90
|
+
/** The 12-byte SubjectPublicKeyInfo header for an Ed25519 key (RFC 8410): SEQUENCE { AlgorithmIdentifier(1.3.101.112), BIT STRING }. */
|
|
91
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
92
|
+
const RAW_KEY = /^[0-9a-fA-F]{64}$/;
|
|
93
|
+
/** The key id docs/fetch.md §4 defines: the first 16 hex characters of sha256 over the raw 32-byte public key. */
|
|
94
|
+
export function keyId(rawKeyHex) {
|
|
95
|
+
return createHash('sha256').update(Buffer.from(rawKeyHex, 'hex')).digest('hex').slice(0, 16);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Verify an ed25519 signature over `message` with a raw public key, through
|
|
99
|
+
* `node:crypto` — the key wrapped as SPKI DER, the 64-byte signature as is.
|
|
100
|
+
* False, never a throw, for a malformed key or signature.
|
|
101
|
+
*/
|
|
102
|
+
export function verifyEd25519(message, signature, rawKeyHex) {
|
|
103
|
+
if (!RAW_KEY.test(rawKeyHex) || signature.byteLength !== 64)
|
|
104
|
+
return false;
|
|
105
|
+
try {
|
|
106
|
+
const key = createPublicKey({
|
|
107
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(rawKeyHex, 'hex')]),
|
|
108
|
+
format: 'der',
|
|
109
|
+
type: 'spki',
|
|
110
|
+
});
|
|
111
|
+
return verifyRaw(null, message, key, signature);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* `SHA256SUMS.sig`: an `untrusted comment:` line, then the base64 of the
|
|
119
|
+
* 64-byte signature. Null when the file is not in that shape.
|
|
120
|
+
*/
|
|
121
|
+
export function parseSignatureFile(bytes) {
|
|
122
|
+
const lines = Buffer.from(bytes).toString('utf8').split(/\r?\n/);
|
|
123
|
+
let comment = '';
|
|
124
|
+
for (const raw of lines) {
|
|
125
|
+
const line = raw.trim();
|
|
126
|
+
if (line === '')
|
|
127
|
+
continue;
|
|
128
|
+
if (line.startsWith('untrusted comment:')) {
|
|
129
|
+
comment = line.slice('untrusted comment:'.length).trim();
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(line))
|
|
133
|
+
return null;
|
|
134
|
+
const signature = Buffer.from(line, 'base64');
|
|
135
|
+
return signature.length === 64 ? { comment, signature } : null;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The keys a fetch trusts: the `trustedKeys` option, else `CHTYPES_TRUSTED_KEYS`
|
|
141
|
+
* (`<hex>[,<hex>…]`), else the embedded release key. Each REPLACES the one
|
|
142
|
+
* below it.
|
|
143
|
+
*
|
|
144
|
+
* @throws {ChtypesError} when an entry is not 64 hex characters.
|
|
145
|
+
*/
|
|
146
|
+
export function trustedKeys(explicit) {
|
|
147
|
+
let list;
|
|
148
|
+
if (explicit !== undefined)
|
|
149
|
+
list = explicit;
|
|
150
|
+
else {
|
|
151
|
+
const env = process.env['CHTYPES_TRUSTED_KEYS'];
|
|
152
|
+
const fromEnv = env === undefined ? [] : env.split(',').map((k) => k.trim()).filter((k) => k !== '');
|
|
153
|
+
list = fromEnv.length > 0 ? fromEnv : RELEASE_PUBLIC_KEYS;
|
|
154
|
+
}
|
|
155
|
+
return list.map((k) => {
|
|
156
|
+
if (!RAW_KEY.test(k)) {
|
|
157
|
+
throw new ChtypesError(`chtypes: ${JSON.stringify(k)} is not a raw ed25519 public key (64 hex characters) — check CHTYPES_TRUSTED_KEYS`);
|
|
158
|
+
}
|
|
159
|
+
return k.toLowerCase();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
class DirSource {
|
|
163
|
+
dir;
|
|
164
|
+
description;
|
|
165
|
+
constructor(dir) {
|
|
166
|
+
this.dir = dir;
|
|
167
|
+
this.description = dir;
|
|
168
|
+
}
|
|
169
|
+
async get(name) {
|
|
170
|
+
try {
|
|
171
|
+
return await readFile(path.join(this.dir, name));
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
if (isNoEntry(err))
|
|
175
|
+
return null;
|
|
176
|
+
throw new SourceUnreachableError(`chtypes: cannot read ${path.join(this.dir, name)}: ${errorText(err)}`, {
|
|
177
|
+
cause: err,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async download(name, to, onReceived) {
|
|
182
|
+
const from = path.join(this.dir, name);
|
|
183
|
+
if (!existsSync(from))
|
|
184
|
+
return false;
|
|
185
|
+
try {
|
|
186
|
+
await pipeline(createReadStream(from), progressTap(onReceived), createWriteStream(to));
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
throw new SourceUnreachableError(`chtypes: cannot read ${from}: ${errorText(err)}`, { cause: err });
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
class HttpSource {
|
|
195
|
+
base;
|
|
196
|
+
signal;
|
|
197
|
+
description;
|
|
198
|
+
constructor(base, signal) {
|
|
199
|
+
this.base = base;
|
|
200
|
+
this.signal = signal;
|
|
201
|
+
this.description = base;
|
|
202
|
+
}
|
|
203
|
+
async request(name) {
|
|
204
|
+
const url = `${this.base}/${name}`;
|
|
205
|
+
const headers = {};
|
|
206
|
+
const token = process.env['CHTYPES_DOWNLOAD_TOKEN'];
|
|
207
|
+
if (token !== undefined && token !== '')
|
|
208
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
209
|
+
let last;
|
|
210
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
211
|
+
try {
|
|
212
|
+
const res = await fetch(url, { headers, redirect: 'follow', ...(this.signal ? { signal: this.signal } : {}) });
|
|
213
|
+
if (res.status === 404) {
|
|
214
|
+
await res.body?.cancel();
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
if (res.ok)
|
|
218
|
+
return res;
|
|
219
|
+
await res.body?.cancel();
|
|
220
|
+
last = new Error(`HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`);
|
|
221
|
+
// Client errors do not get better on retry; server errors and throttling might.
|
|
222
|
+
if (res.status < 500 && res.status !== 408 && res.status !== 429)
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
if (this.signal?.aborted)
|
|
227
|
+
throw err;
|
|
228
|
+
last = err;
|
|
229
|
+
}
|
|
230
|
+
if (attempt < 3)
|
|
231
|
+
await sleep(500 * attempt);
|
|
232
|
+
}
|
|
233
|
+
throw new SourceUnreachableError(`chtypes: cannot fetch ${url}: ${errorText(last)}`, { cause: last });
|
|
234
|
+
}
|
|
235
|
+
async get(name) {
|
|
236
|
+
const res = await this.request(name);
|
|
237
|
+
if (res === null)
|
|
238
|
+
return null;
|
|
239
|
+
return Buffer.from(await res.arrayBuffer());
|
|
240
|
+
}
|
|
241
|
+
async download(name, to, onReceived) {
|
|
242
|
+
const res = await this.request(name);
|
|
243
|
+
if (res === null)
|
|
244
|
+
return false;
|
|
245
|
+
if (res.body === null) {
|
|
246
|
+
throw new SourceUnreachableError(`chtypes: ${this.base}/${name} returned no body`);
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await pipeline(Readable.fromWeb(res.body), progressTap(onReceived), createWriteStream(to));
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
throw new SourceUnreachableError(`chtypes: download of ${this.base}/${name} failed: ${errorText(err)}`, {
|
|
253
|
+
cause: err,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** §2: `url` is a full base (http(s), `file://`, or a directory); otherwise `<CHTYPES_ARTIFACTS_URL>/<tag>`. */
|
|
260
|
+
function openSource(options) {
|
|
261
|
+
if (options.url !== undefined && options.url !== '' && options.tag !== undefined && options.tag !== '') {
|
|
262
|
+
throw new ChtypesError('chtypes: url names a full base; tag selects a release on the artifacts host — pass one');
|
|
263
|
+
}
|
|
264
|
+
let base;
|
|
265
|
+
if (options.url !== undefined && options.url !== '')
|
|
266
|
+
base = options.url;
|
|
267
|
+
else {
|
|
268
|
+
const host = process.env['CHTYPES_ARTIFACTS_URL'];
|
|
269
|
+
const root = host !== undefined && host !== '' ? host : DEFAULT_ARTIFACTS_URL;
|
|
270
|
+
const tag = options.tag !== undefined && options.tag !== '' ? options.tag : DEFAULT_RELEASE_TAG;
|
|
271
|
+
base = `${root.replace(/\/+$/, '')}/${tag}`;
|
|
272
|
+
}
|
|
273
|
+
if (/^https?:\/\//i.test(base))
|
|
274
|
+
return new HttpSource(base.replace(/\/+$/, ''), options.signal);
|
|
275
|
+
const dir = base.startsWith('file://') ? fileURLToPath(base) : path.resolve(base);
|
|
276
|
+
return new DirSource(dir);
|
|
277
|
+
}
|
|
278
|
+
/** Steps 0–2's inputs, verified in that order: the signature over SHA256SUMS, then the index. */
|
|
279
|
+
async function loadRelease(source, options, emit) {
|
|
280
|
+
const sumsBytes = await source.get('SHA256SUMS');
|
|
281
|
+
if (sumsBytes === null)
|
|
282
|
+
throw new SourceUnreachableError(`chtypes: no SHA256SUMS at ${source.description}`);
|
|
283
|
+
let signed = false;
|
|
284
|
+
let signer = null;
|
|
285
|
+
if (options.allowUnsigned ?? envFlag('CHTYPES_ALLOW_UNSIGNED')) {
|
|
286
|
+
warn(`chtypes: WARNING — the signature of ${source.description}/SHA256SUMS is NOT being verified ` +
|
|
287
|
+
'(CHTYPES_ALLOW_UNSIGNED). Anything it lists will be installed on its hashes alone.');
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
const sigBytes = await source.get('SHA256SUMS.sig');
|
|
291
|
+
if (sigBytes === null) {
|
|
292
|
+
throw new ArtifactUntrustedError(`chtypes: the release at ${source.description} is unsigned (no SHA256SUMS.sig); nothing was downloaded. ` +
|
|
293
|
+
'CHTYPES_ALLOW_UNSIGNED=1 installs it anyway, loudly.');
|
|
294
|
+
}
|
|
295
|
+
const sig = parseSignatureFile(sigBytes);
|
|
296
|
+
if (sig === null) {
|
|
297
|
+
throw new ArtifactUntrustedError(`chtypes: ${source.description}/SHA256SUMS.sig is not an ed25519 signature file; nothing was downloaded.`);
|
|
298
|
+
}
|
|
299
|
+
const keys = trustedKeys(options.trustedKeys);
|
|
300
|
+
const hit = keys.find((k) => verifyEd25519(sumsBytes, sig.signature, k));
|
|
301
|
+
if (hit === undefined) {
|
|
302
|
+
throw new ArtifactUntrustedError(`chtypes: ${source.description}/SHA256SUMS.sig does not verify with any trusted key ` +
|
|
303
|
+
`(${keys.map(keyId).join(', ')}); the release is not trusted and nothing was downloaded.`);
|
|
304
|
+
}
|
|
305
|
+
signed = true;
|
|
306
|
+
signer = keyId(hit);
|
|
307
|
+
emit({ type: 'status', message: `SHA256SUMS signature verified (key ${signer})` });
|
|
308
|
+
}
|
|
309
|
+
const indexBytes = await source.get('index.json');
|
|
310
|
+
if (indexBytes === null)
|
|
311
|
+
throw new SourceUnreachableError(`chtypes: no index.json at ${source.description}`);
|
|
312
|
+
const index = parseIndex(indexBytes, source.description);
|
|
313
|
+
if (index.license !== undefined && index.license !== '') {
|
|
314
|
+
emit({
|
|
315
|
+
type: 'status',
|
|
316
|
+
message: `artifacts are licensed under ${index.license}${index.license_url ? ` (${index.license_url})` : ''} — LICENSE and NOTICE ship beside them`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return { source, index, sums: parseSums(sumsBytes), signed, keyId: signer };
|
|
320
|
+
}
|
|
321
|
+
function parseIndex(bytes, where) {
|
|
322
|
+
let doc;
|
|
323
|
+
try {
|
|
324
|
+
doc = JSON.parse(bytes.toString('utf8'));
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
throw new ArtifactCorruptError(`chtypes: index.json at ${where} is not JSON: ${errorText(err)}`, { cause: err });
|
|
328
|
+
}
|
|
329
|
+
if (typeof doc !== 'object' || doc === null)
|
|
330
|
+
throw new ArtifactCorruptError(`chtypes: index.json at ${where} is not an object`);
|
|
331
|
+
const d = doc;
|
|
332
|
+
if (d['schema'] !== INDEX_SCHEMA) {
|
|
333
|
+
throw new ArtifactCorruptError(`chtypes: index.json schema ${JSON.stringify(d['schema'])} is not ${INDEX_SCHEMA} — this SDK cannot read it`);
|
|
334
|
+
}
|
|
335
|
+
if (!Array.isArray(d['artifacts']))
|
|
336
|
+
throw new ArtifactCorruptError(`chtypes: index.json at ${where} has no artifacts list`);
|
|
337
|
+
const artifacts = d['artifacts'].map((row, i) => indexArtifact(row, i));
|
|
338
|
+
return {
|
|
339
|
+
schema: INDEX_SCHEMA,
|
|
340
|
+
...(typeof d['generated_at'] === 'string' ? { generated_at: d['generated_at'] } : {}),
|
|
341
|
+
...(typeof d['release_tag'] === 'string' ? { release_tag: d['release_tag'] } : {}),
|
|
342
|
+
...(typeof d['license'] === 'string' ? { license: d['license'] } : {}),
|
|
343
|
+
...(typeof d['license_url'] === 'string' ? { license_url: d['license_url'] } : {}),
|
|
344
|
+
artifacts,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function indexArtifact(row, i) {
|
|
348
|
+
if (typeof row !== 'object' || row === null)
|
|
349
|
+
throw new ArtifactCorruptError(`chtypes: index.json artifacts[${i}] is not an object`);
|
|
350
|
+
const r = row;
|
|
351
|
+
const str = (k) => {
|
|
352
|
+
const v = r[k];
|
|
353
|
+
if (typeof v !== 'string' || v === '')
|
|
354
|
+
throw new ArtifactCorruptError(`chtypes: index.json entry for ${String(r['file'] ?? i)} is missing ${k}`);
|
|
355
|
+
return v;
|
|
356
|
+
};
|
|
357
|
+
const bytes = r['bytes'];
|
|
358
|
+
if (typeof bytes !== 'number' || !Number.isSafeInteger(bytes) || bytes <= 0) {
|
|
359
|
+
throw new ArtifactCorruptError(`chtypes: index.json entry for ${String(r['file'] ?? i)} is missing bytes`);
|
|
360
|
+
}
|
|
361
|
+
const version = str('clickhouse_version');
|
|
362
|
+
const minor = typeof r['clickhouse_minor'] === 'string' && r['clickhouse_minor'] !== '' ? r['clickhouse_minor'] : minorOfVersion(version);
|
|
363
|
+
return {
|
|
364
|
+
os: str('os'),
|
|
365
|
+
arch: str('arch'),
|
|
366
|
+
file: str('file'),
|
|
367
|
+
sha256: str('sha256').toLowerCase(),
|
|
368
|
+
bytes,
|
|
369
|
+
clickhouse_version: version,
|
|
370
|
+
clickhouse_minor: minor,
|
|
371
|
+
library: str('library'),
|
|
372
|
+
library_sha256: str('library_sha256').toLowerCase(),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/** `sha256sum` format: `<hex> <file>` (or `<hex> *<file>`), one per line. */
|
|
376
|
+
function parseSums(bytes) {
|
|
377
|
+
const out = new Map();
|
|
378
|
+
for (const raw of bytes.toString('utf8').split(/\r?\n/)) {
|
|
379
|
+
const m = /^([0-9a-fA-F]{64})\s+\*?(\S.*)$/.exec(raw.trim());
|
|
380
|
+
if (m)
|
|
381
|
+
out.set(m[2], m[1].toLowerCase());
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
function minorOfVersion(version) {
|
|
386
|
+
const parts = version.split('.');
|
|
387
|
+
return parts.length < 2 ? version : `${parts[0]}.${parts[1]}`;
|
|
388
|
+
}
|
|
389
|
+
/** The one row for a request, or `CHTYPES_ARTIFACT_UNPUBLISHED` naming what the release does have. */
|
|
390
|
+
export function selectArtifact(index, platform, req) {
|
|
391
|
+
const rows = forPlatform(index, platform);
|
|
392
|
+
let hit;
|
|
393
|
+
if (req.exact !== null) {
|
|
394
|
+
const typedChannel = CHANNEL.test(req.exact);
|
|
395
|
+
hit = rows.filter((a) => a.clickhouse_version === req.exact || (!typedChannel && a.clickhouse_version.replace(CHANNEL, '') === req.bare));
|
|
396
|
+
if (hit.length === 0) {
|
|
397
|
+
throw new ArtifactUnpublishedError(`chtypes: you asked for exactly ClickHouse ${req.exact} on ${platform} and this release does not publish it ` +
|
|
398
|
+
`(it has: ${rows.map((a) => a.clickhouse_version).join(', ')}). Ask for the line (${req.line}) to take what was published.`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
hit = rows.filter((a) => a.clickhouse_minor === req.line);
|
|
403
|
+
if (hit.length === 0) {
|
|
404
|
+
throw new ArtifactUnpublishedError(`chtypes: no artifact for ClickHouse line ${req.line} on ${platform} (it has: ${rows.map((a) => a.clickhouse_version).join(', ')})`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// A release should not carry two patches of one line; if it does, the newer one.
|
|
408
|
+
return hit.sort((a, b) => compareVersions(a.clickhouse_version, b.clickhouse_version))[hit.length - 1];
|
|
409
|
+
}
|
|
410
|
+
/** Every line the release publishes for the platform, newest patch per line, oldest line first. */
|
|
411
|
+
export function selectAll(index, platform) {
|
|
412
|
+
const best = new Map();
|
|
413
|
+
for (const a of forPlatform(index, platform)) {
|
|
414
|
+
const cur = best.get(a.clickhouse_minor);
|
|
415
|
+
if (cur === undefined || compareVersions(a.clickhouse_version, cur.clickhouse_version) > 0)
|
|
416
|
+
best.set(a.clickhouse_minor, a);
|
|
417
|
+
}
|
|
418
|
+
return [...best.values()].sort((a, b) => compareVersions(a.clickhouse_minor, b.clickhouse_minor));
|
|
419
|
+
}
|
|
420
|
+
function forPlatform(index, platform) {
|
|
421
|
+
const [os, arch] = platform.split('-');
|
|
422
|
+
const rows = index.artifacts.filter((a) => a.os === os && a.arch === arch);
|
|
423
|
+
if (rows.length === 0) {
|
|
424
|
+
const have = [...new Set(index.artifacts.map((a) => `${a.os}-${a.arch}`))].sort();
|
|
425
|
+
throw new ArtifactUnpublishedError(`chtypes: the release has nothing for ${platform} (it has: ${have.length > 0 ? have.join(', ') : 'nothing'})`);
|
|
426
|
+
}
|
|
427
|
+
return rows;
|
|
428
|
+
}
|
|
429
|
+
// -------------------------------------------------------------- ensure
|
|
430
|
+
/** Concurrent `ensure`s of one line in one process share one fetch (docs/fetch.md §6). */
|
|
431
|
+
const inFlight = new Map();
|
|
432
|
+
/**
|
|
433
|
+
* Make one ClickHouse line available in a registry directory, through the
|
|
434
|
+
* verification chain, and say where it is. Idempotent: installed and
|
|
435
|
+
* verified is a no-op that downloads nothing (`force` re-downloads).
|
|
436
|
+
*
|
|
437
|
+
* @param spelling - a line (`"25.8"`) or an exact patch (`"25.8.28.1-lts"`, a hard requirement).
|
|
438
|
+
* @param options - see `EnsureOptions`; every §6 flag has a property here.
|
|
439
|
+
* @returns where the line is and what it is — `dir` is `<registry>/<minor>`.
|
|
440
|
+
* @throws {ArtifactUntrustedError} `CHTYPES_ARTIFACT_UNTRUSTED` — no signature, or none a trusted key verifies.
|
|
441
|
+
* @throws {ArtifactCorruptError} `CHTYPES_ARTIFACT_CORRUPT` — any hash mismatch, a release that disagrees with itself.
|
|
442
|
+
* @throws {ArtifactPinnedError} `CHTYPES_ARTIFACT_PINNED` — `frozen` and the lock says otherwise.
|
|
443
|
+
* @throws {ArtifactUnpublishedError} `CHTYPES_ARTIFACT_UNPUBLISHED` — nothing for this platform/line/patch.
|
|
444
|
+
* @throws {SourceUnreachableError} `CHTYPES_SOURCE_UNREACHABLE` — the source cannot be reached, or `offline` and not installed.
|
|
445
|
+
* @throws {ChtypesError} for a spelling that is not a version, a bad platform key, or conflicting options.
|
|
446
|
+
*/
|
|
447
|
+
export async function ensure(spelling, options = {}) {
|
|
448
|
+
const req = parseVersionSpelling(spelling);
|
|
449
|
+
const platform = resolvePlatform(options.platform);
|
|
450
|
+
const dest = fetchDestination(options.dest, platform);
|
|
451
|
+
const key = [dest, platform, req.exact ?? req.line, options.force ? 'force' : ''].join(' ');
|
|
452
|
+
const running = inFlight.get(key);
|
|
453
|
+
if (running !== undefined)
|
|
454
|
+
return running;
|
|
455
|
+
const p = ensureUncached(req, platform, dest, options).finally(() => {
|
|
456
|
+
inFlight.delete(key);
|
|
457
|
+
});
|
|
458
|
+
inFlight.set(key, p);
|
|
459
|
+
return p;
|
|
460
|
+
}
|
|
461
|
+
/** `fetch --all`: every line the release publishes for the platform, each through `ensure`'s chain. */
|
|
462
|
+
export async function ensureAll(options = {}) {
|
|
463
|
+
const platform = resolvePlatform(options.platform);
|
|
464
|
+
const dest = fetchDestination(options.dest, platform);
|
|
465
|
+
const emit = options.onProgress ?? noop;
|
|
466
|
+
if (options.offline)
|
|
467
|
+
throw new ChtypesError('chtypes: --all needs the release listing; it cannot run offline');
|
|
468
|
+
const ctx = installContext(platform, dest, options);
|
|
469
|
+
const source = openSource(options);
|
|
470
|
+
emit({ type: 'status', message: `every published ClickHouse line, ${platform} -> ${dest}` });
|
|
471
|
+
emit({ type: 'status', message: `source ${source.description}` });
|
|
472
|
+
const release = await loadRelease(source, options, emit);
|
|
473
|
+
const out = [];
|
|
474
|
+
for (const art of selectAll(release.index, platform))
|
|
475
|
+
out.push(await installOne(release, art, ctx));
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
function installContext(platform, dest, options) {
|
|
479
|
+
const frozen = options.frozen ?? false;
|
|
480
|
+
// frozen without a lock path reads ./chtypes.lock (docs/fetch.md, Decisions).
|
|
481
|
+
const lockPath = options.lock !== undefined && options.lock !== '' ? path.resolve(options.lock) : frozen ? path.resolve(DEFAULT_LOCK_FILE) : undefined;
|
|
482
|
+
const lock = lockPath !== undefined ? readLock(lockPath) : null;
|
|
483
|
+
if (frozen && lock === null) {
|
|
484
|
+
throw new ArtifactPinnedError(`chtypes: ${lockPath} does not exist, and frozen refuses anything it does not pin`);
|
|
485
|
+
}
|
|
486
|
+
if (platform !== hostPlatform()) {
|
|
487
|
+
warn(`chtypes: note — fetching ${platform} artifacts on a ${hostPlatform()} host. They are for a ${platform} process ` +
|
|
488
|
+
`(a container, usually), not this one.`);
|
|
489
|
+
}
|
|
490
|
+
return { platform, dest, lockPath, lock, frozen, force: options.force ?? false, emit: options.onProgress ?? noop };
|
|
491
|
+
}
|
|
492
|
+
async function ensureUncached(req, platform, dest, options) {
|
|
493
|
+
const ctx = installContext(platform, dest, options);
|
|
494
|
+
const install = path.join(dest, req.line);
|
|
495
|
+
if (options.offline) {
|
|
496
|
+
// Never touch the source: installed and hashing what its own manifest says is the whole test.
|
|
497
|
+
const have = await installedLine(install);
|
|
498
|
+
const pin = ctx.lock?.artifacts[`${platform}/${req.line}`];
|
|
499
|
+
if (have !== null && have.ok && (!ctx.frozen || pin !== undefined)) {
|
|
500
|
+
ctx.emit({ type: 'status', message: `offline — already installed and verified: ${install}` });
|
|
501
|
+
return {
|
|
502
|
+
dir: install,
|
|
503
|
+
registry: dest,
|
|
504
|
+
line: req.line,
|
|
505
|
+
version: have.version,
|
|
506
|
+
platform,
|
|
507
|
+
file: pin?.file ?? '',
|
|
508
|
+
sha256: pin?.sha256 ?? '',
|
|
509
|
+
library: have.library,
|
|
510
|
+
librarySha256: have.expected,
|
|
511
|
+
installed: false,
|
|
512
|
+
source: 'offline',
|
|
513
|
+
signed: false,
|
|
514
|
+
keyId: null,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
if (have !== null && have.ok && ctx.frozen) {
|
|
518
|
+
throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins nothing for ${platform}/${req.line}, and frozen refuses anything it does not pin`);
|
|
519
|
+
}
|
|
520
|
+
throw new SourceUnreachableError(`chtypes: offline — ClickHouse ${req.line} (${platform}) is not installed and verified in ${dest}` +
|
|
521
|
+
(have !== null && !have.ok ? ` (${have.problem})` : '') +
|
|
522
|
+
', and offline forbids fetching it');
|
|
523
|
+
}
|
|
524
|
+
const source = openSource(options);
|
|
525
|
+
ctx.emit({
|
|
526
|
+
type: 'status',
|
|
527
|
+
message: `ClickHouse ${req.spelling} -> line ${req.line}${req.exact !== null ? ` (exact ${req.exact})` : ''}, ${platform}`,
|
|
528
|
+
});
|
|
529
|
+
ctx.emit({ type: 'status', message: `source ${source.description}` });
|
|
530
|
+
const release = await loadRelease(source, options, ctx.emit);
|
|
531
|
+
const art = selectArtifact(release.index, platform, req);
|
|
532
|
+
return installOne(release, art, ctx);
|
|
533
|
+
}
|
|
534
|
+
/** One index row, from the release into `<dest>/<minor>/` — steps 2 through 4. */
|
|
535
|
+
async function installOne(release, art, ctx) {
|
|
536
|
+
const { dest, platform, emit } = ctx;
|
|
537
|
+
const minor = art.clickhouse_minor;
|
|
538
|
+
const install = path.join(dest, minor);
|
|
539
|
+
const lockKey = `${platform}/${minor}`;
|
|
540
|
+
emit({ type: 'status', message: `${art.file} (${art.bytes} bytes, ClickHouse ${art.clickhouse_version}, library ${art.library})` });
|
|
541
|
+
// Step 2: the authentic SHA256SUMS must agree with index.json, byte for byte.
|
|
542
|
+
const listed = release.sums.get(art.file);
|
|
543
|
+
if (listed === undefined)
|
|
544
|
+
throw new ArtifactCorruptError(`chtypes: SHA256SUMS has no line for ${art.file}`);
|
|
545
|
+
if (listed !== art.sha256) {
|
|
546
|
+
throw new ArtifactCorruptError(`chtypes: index.json says ${art.file} is ${art.sha256} but SHA256SUMS says ${listed} — the release disagrees with itself; not installing it`);
|
|
547
|
+
}
|
|
548
|
+
// §5: a frozen lock refuses anything but what it pins.
|
|
549
|
+
if (ctx.frozen && ctx.lock !== null) {
|
|
550
|
+
const pin = ctx.lock.artifacts[lockKey];
|
|
551
|
+
if (pin === undefined) {
|
|
552
|
+
throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins nothing for ${lockKey}, and frozen refuses anything it does not pin`);
|
|
553
|
+
}
|
|
554
|
+
if (pin.file !== art.file || pin.sha256.toLowerCase() !== art.sha256) {
|
|
555
|
+
throw new ArtifactPinnedError(`chtypes: ${ctx.lockPath} pins ${lockKey} to ${pin.file} (${pin.sha256}) but the release offers ${art.file} (${art.sha256}); frozen refuses it`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const result = (installed) => ({
|
|
559
|
+
dir: install,
|
|
560
|
+
registry: dest,
|
|
561
|
+
line: minor,
|
|
562
|
+
version: art.clickhouse_version,
|
|
563
|
+
platform,
|
|
564
|
+
file: art.file,
|
|
565
|
+
sha256: art.sha256,
|
|
566
|
+
library: art.library,
|
|
567
|
+
librarySha256: art.library_sha256,
|
|
568
|
+
installed,
|
|
569
|
+
source: release.source.description,
|
|
570
|
+
signed: release.signed,
|
|
571
|
+
keyId: release.keyId,
|
|
572
|
+
});
|
|
573
|
+
// Already installed and intact? The same hash the install path ends with,
|
|
574
|
+
// so "already there" is a verified claim, not an inference from a directory.
|
|
575
|
+
if (!ctx.force) {
|
|
576
|
+
const libPath = path.join(install, art.library);
|
|
577
|
+
if (existsSync(path.join(install, 'manifest.json')) && existsSync(libPath)) {
|
|
578
|
+
const have = await sha256File(libPath);
|
|
579
|
+
if (have === art.library_sha256) {
|
|
580
|
+
emit({ type: 'status', message: `already installed and verified: ${libPath}` });
|
|
581
|
+
if (ctx.lockPath !== undefined)
|
|
582
|
+
writeLockEntry(ctx.lockPath, lockKey, { file: art.file, sha256: art.sha256 });
|
|
583
|
+
return result(false);
|
|
584
|
+
}
|
|
585
|
+
emit({ type: 'status', message: `${libPath} is present but hashes ${have} (want ${art.library_sha256}) — replacing` });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
await mkdir(dest, { recursive: true });
|
|
589
|
+
const tag = `${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
590
|
+
// Hidden siblings: the loader skips dot-directories, so a half-written
|
|
591
|
+
// install is never scanned as a version.
|
|
592
|
+
const tarball = path.join(dest, `.${minor}.download.${tag}`);
|
|
593
|
+
const incoming = path.join(dest, `.${minor}.incoming.${tag}`);
|
|
594
|
+
try {
|
|
595
|
+
emit({ type: 'status', message: `downloading ${art.file}` });
|
|
596
|
+
const got = await release.source.download(art.file, tarball, (received) => emit({ type: 'download', file: art.file, received, total: art.bytes }));
|
|
597
|
+
if (!got)
|
|
598
|
+
throw new SourceUnreachableError(`chtypes: could not download ${art.file} from ${release.source.description}`);
|
|
599
|
+
// Step 3: size, then hash, before a byte is unpacked.
|
|
600
|
+
const size = statSync(tarball).size;
|
|
601
|
+
if (size !== art.bytes)
|
|
602
|
+
throw new ArtifactCorruptError(`chtypes: ${art.file} is ${size} bytes, index.json says ${art.bytes}`);
|
|
603
|
+
const sha = await sha256File(tarball);
|
|
604
|
+
if (sha !== art.sha256) {
|
|
605
|
+
throw new ArtifactCorruptError(`chtypes: ${art.file} FAILED its sha256: got ${sha}, want ${art.sha256} — not unpacking it`);
|
|
606
|
+
}
|
|
607
|
+
emit({ type: 'status', message: `sha256 verified before unpacking: ${sha}` });
|
|
608
|
+
await mkdir(incoming);
|
|
609
|
+
await extractTarGz(tarball, incoming);
|
|
610
|
+
const manifest = readInstalledManifest(incoming);
|
|
611
|
+
if (manifest === null)
|
|
612
|
+
throw new ArtifactCorruptError(`chtypes: ${art.file} contains no usable manifest.json at its root`);
|
|
613
|
+
// The index is a convenience; the manifest is the artifact's own claim
|
|
614
|
+
// about itself. They must agree, or the index was built from a different artifact.
|
|
615
|
+
if (manifest.library !== art.library ||
|
|
616
|
+
manifest.version !== art.clickhouse_version ||
|
|
617
|
+
manifest.minor !== minor ||
|
|
618
|
+
manifest.librarySha256 !== art.library_sha256) {
|
|
619
|
+
throw new ArtifactCorruptError(`chtypes: manifest.json inside ${art.file} disagrees with index.json ` +
|
|
620
|
+
`(${manifest.library}/${manifest.version}/${manifest.minor}/${manifest.librarySha256} vs ` +
|
|
621
|
+
`${art.library}/${art.clickhouse_version}/${minor}/${art.library_sha256})`);
|
|
622
|
+
}
|
|
623
|
+
if (!existsSync(path.join(incoming, manifest.library))) {
|
|
624
|
+
throw new ArtifactCorruptError(`chtypes: ${art.file} names library ${manifest.library} but does not contain it`);
|
|
625
|
+
}
|
|
626
|
+
// Into place through a sibling: an interrupted install never leaves a
|
|
627
|
+
// half-populated <minor>/ for the loader to dlopen.
|
|
628
|
+
const replaced = path.join(dest, `.${minor}.replaced.${tag}`);
|
|
629
|
+
if (existsSync(install))
|
|
630
|
+
await rename(install, replaced);
|
|
631
|
+
await rename(incoming, install);
|
|
632
|
+
await rm(replaced, { recursive: true, force: true });
|
|
633
|
+
// Step 4: re-hash the installed file, in place. Everything up to here
|
|
634
|
+
// proved the bytes were right somewhere else.
|
|
635
|
+
const installedLib = path.join(install, manifest.library);
|
|
636
|
+
const finalSha = await sha256File(installedLib);
|
|
637
|
+
if (finalSha !== manifest.librarySha256) {
|
|
638
|
+
const aside = path.join(dest, `.${minor}.corrupt.${tag}`);
|
|
639
|
+
await rename(install, aside);
|
|
640
|
+
throw new ArtifactCorruptError(`chtypes: installed ${installedLib} hashes ${finalSha}, manifest says ${manifest.librarySha256} — the install is bad; moved aside to ${aside}`);
|
|
641
|
+
}
|
|
642
|
+
emit({ type: 'status', message: `installed and verified: ${install} (${manifest.library} sha256 ${finalSha})` });
|
|
643
|
+
if (ctx.lockPath !== undefined)
|
|
644
|
+
writeLockEntry(ctx.lockPath, lockKey, { file: art.file, sha256: art.sha256 });
|
|
645
|
+
return result(true);
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
await rm(tarball, { force: true });
|
|
649
|
+
await rm(incoming, { recursive: true, force: true });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// ------------------------------------------------------ verify and list
|
|
653
|
+
/**
|
|
654
|
+
* `chtypes verify`: re-hash every installed line in a registry directory
|
|
655
|
+
* against its own manifest. A line whose manifest cannot be read is not a
|
|
656
|
+
* line (a scratch directory) and is skipped, as the loader skips it.
|
|
657
|
+
*/
|
|
658
|
+
export async function verifyInstalled(dest, platform) {
|
|
659
|
+
const dir = fetchDestination(dest, resolvePlatform(platform));
|
|
660
|
+
const out = [];
|
|
661
|
+
for (const line of installedLines(dir)) {
|
|
662
|
+
const state = await installedLine(path.join(dir, line));
|
|
663
|
+
if (state !== null)
|
|
664
|
+
out.push(state);
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
/** `chtypes list`: what is installed in the registry directory, and what the release offers for the platform. */
|
|
669
|
+
export async function listArtifacts(options = {}) {
|
|
670
|
+
const platform = resolvePlatform(options.platform);
|
|
671
|
+
const dir = fetchDestination(options.dest, platform);
|
|
672
|
+
const installed = [];
|
|
673
|
+
for (const line of installedLines(dir)) {
|
|
674
|
+
const m = readInstalledManifest(path.join(dir, line));
|
|
675
|
+
if (m === null)
|
|
676
|
+
continue;
|
|
677
|
+
const present = existsSync(path.join(dir, line, m.library));
|
|
678
|
+
installed.push({
|
|
679
|
+
line,
|
|
680
|
+
dir: path.join(dir, line),
|
|
681
|
+
version: m.version,
|
|
682
|
+
library: m.library,
|
|
683
|
+
expected: m.librarySha256,
|
|
684
|
+
actual: null,
|
|
685
|
+
ok: present,
|
|
686
|
+
problem: present ? null : `${m.library} is missing`,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
if (options.offline)
|
|
690
|
+
return { registry: dir, platform, installed, offered: null, source: null };
|
|
691
|
+
const source = openSource(options);
|
|
692
|
+
const release = await loadRelease(source, options, options.onProgress ?? noop);
|
|
693
|
+
let offered;
|
|
694
|
+
try {
|
|
695
|
+
offered = selectAll(release.index, platform);
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
if (!(err instanceof ArtifactUnpublishedError))
|
|
699
|
+
throw err;
|
|
700
|
+
offered = [];
|
|
701
|
+
}
|
|
702
|
+
return { registry: dir, platform, installed, offered, source: source.description };
|
|
703
|
+
}
|
|
704
|
+
// ------------------------------------------------------------ the lock
|
|
705
|
+
/** Read a lock file; null when it does not exist. */
|
|
706
|
+
export function readLock(file) {
|
|
707
|
+
let text;
|
|
708
|
+
try {
|
|
709
|
+
text = readFileSync(file, 'utf8');
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
if (isNoEntry(err))
|
|
713
|
+
return null;
|
|
714
|
+
throw new ChtypesError(`chtypes: cannot read lock file ${file}: ${errorText(err)}`, { cause: err });
|
|
715
|
+
}
|
|
716
|
+
let doc;
|
|
717
|
+
try {
|
|
718
|
+
doc = JSON.parse(text);
|
|
719
|
+
}
|
|
720
|
+
catch (err) {
|
|
721
|
+
throw new ChtypesError(`chtypes: lock file ${file} is not JSON: ${errorText(err)}`, { cause: err });
|
|
722
|
+
}
|
|
723
|
+
if (typeof doc !== 'object' || doc === null || doc['schema'] !== LOCK_SCHEMA) {
|
|
724
|
+
throw new ChtypesError(`chtypes: lock file ${file} is not schema ${LOCK_SCHEMA}`);
|
|
725
|
+
}
|
|
726
|
+
const raw = doc['artifacts'];
|
|
727
|
+
const artifacts = {};
|
|
728
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
729
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
730
|
+
if (typeof v !== 'object' || v === null)
|
|
731
|
+
continue;
|
|
732
|
+
const e = v;
|
|
733
|
+
if (typeof e['file'] === 'string' && typeof e['sha256'] === 'string')
|
|
734
|
+
artifacts[k] = { file: e['file'], sha256: e['sha256'] };
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return { schema: LOCK_SCHEMA, artifacts };
|
|
738
|
+
}
|
|
739
|
+
function writeLockEntry(file, key, entry) {
|
|
740
|
+
const current = readLock(file) ?? { schema: LOCK_SCHEMA, artifacts: {} };
|
|
741
|
+
const merged = { ...current.artifacts, [key]: entry };
|
|
742
|
+
const sorted = Object.fromEntries(Object.keys(merged).sort().map((k) => [k, merged[k]]));
|
|
743
|
+
writeFileSync(file, `${JSON.stringify({ schema: LOCK_SCHEMA, artifacts: sorted }, null, 2)}\n`);
|
|
744
|
+
}
|
|
745
|
+
// -------------------------------------------------------------- helpers
|
|
746
|
+
/** The platform a fetch is for: the option, else `CHTYPES_TARGET`, else this host. */
|
|
747
|
+
export function resolvePlatform(explicit) {
|
|
748
|
+
const env = process.env['CHTYPES_TARGET'];
|
|
749
|
+
const key = explicit !== undefined && explicit !== '' ? explicit : env !== undefined && env !== '' ? env : hostPlatform();
|
|
750
|
+
if (!isPlatformKey(key))
|
|
751
|
+
throw new ChtypesError(`chtypes: not a known platform key: ${key} ((linux|darwin)-(arm64|amd64))`);
|
|
752
|
+
return key;
|
|
753
|
+
}
|
|
754
|
+
function readInstalledManifest(dir) {
|
|
755
|
+
try {
|
|
756
|
+
const m = JSON.parse(readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
|
|
757
|
+
if (typeof m['library'] !== 'string' || m['library'] === '')
|
|
758
|
+
return null;
|
|
759
|
+
if (typeof m['library_sha256'] !== 'string' || m['library_sha256'] === '')
|
|
760
|
+
return null;
|
|
761
|
+
const version = typeof m['clickhouse_version'] === 'string' ? m['clickhouse_version'] : '';
|
|
762
|
+
// clickhouse_minor is absent from the first Linux artifacts; deriving it
|
|
763
|
+
// from clickhouse_version is what the loader does, so both agree.
|
|
764
|
+
const minor = typeof m['clickhouse_minor'] === 'string' && m['clickhouse_minor'] !== '' ? m['clickhouse_minor'] : minorOfVersion(version);
|
|
765
|
+
return { library: m['library'], version, minor, librarySha256: m['library_sha256'].toLowerCase() };
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/** The state of one installed line directory: null when it is not one (no usable manifest). */
|
|
772
|
+
async function installedLine(dir) {
|
|
773
|
+
const m = readInstalledManifest(dir);
|
|
774
|
+
if (m === null)
|
|
775
|
+
return null;
|
|
776
|
+
const line = path.basename(dir);
|
|
777
|
+
const libPath = path.join(dir, m.library);
|
|
778
|
+
if (!existsSync(libPath)) {
|
|
779
|
+
return { line, dir, version: m.version, library: m.library, expected: m.librarySha256, actual: null, ok: false, problem: `${m.library} is missing` };
|
|
780
|
+
}
|
|
781
|
+
const actual = await sha256File(libPath);
|
|
782
|
+
const ok = actual === m.librarySha256;
|
|
783
|
+
return {
|
|
784
|
+
line,
|
|
785
|
+
dir,
|
|
786
|
+
version: m.version,
|
|
787
|
+
library: m.library,
|
|
788
|
+
expected: m.librarySha256,
|
|
789
|
+
actual,
|
|
790
|
+
ok,
|
|
791
|
+
problem: ok ? null : `${m.library} hashes ${actual}, manifest says ${m.librarySha256}`,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function installedLines(dir) {
|
|
795
|
+
let entries;
|
|
796
|
+
try {
|
|
797
|
+
entries = readdirSync(dir);
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
return [];
|
|
801
|
+
}
|
|
802
|
+
return entries
|
|
803
|
+
.filter((e) => !e.startsWith('.') && existsSync(path.join(dir, e, 'manifest.json')))
|
|
804
|
+
.sort(compareVersions);
|
|
805
|
+
}
|
|
806
|
+
/** sha256 of a file, streamed — a 300 MB library never sits in memory whole. */
|
|
807
|
+
export async function sha256File(file) {
|
|
808
|
+
const hash = createHash('sha256');
|
|
809
|
+
await pipeline(createReadStream(file), async function* (chunks) {
|
|
810
|
+
for await (const chunk of chunks)
|
|
811
|
+
hash.update(chunk);
|
|
812
|
+
});
|
|
813
|
+
return hash.digest('hex');
|
|
814
|
+
}
|
|
815
|
+
function progressTap(onReceived) {
|
|
816
|
+
let received = 0;
|
|
817
|
+
return new Transform({
|
|
818
|
+
transform(chunk, _enc, cb) {
|
|
819
|
+
received += chunk.length;
|
|
820
|
+
onReceived(received);
|
|
821
|
+
cb(null, chunk);
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
function envFlag(name) {
|
|
826
|
+
const v = process.env[name];
|
|
827
|
+
return v !== undefined && ['1', 'true', 'yes', 'on'].includes(v.trim().toLowerCase());
|
|
828
|
+
}
|
|
829
|
+
function isNoEntry(err) {
|
|
830
|
+
return typeof err === 'object' && err !== null && err.code === 'ENOENT';
|
|
831
|
+
}
|
|
832
|
+
function errorText(err) {
|
|
833
|
+
if (!(err instanceof Error))
|
|
834
|
+
return String(err);
|
|
835
|
+
const cause = err.cause;
|
|
836
|
+
return cause instanceof Error && cause.message !== err.message ? `${err.message}: ${cause.message}` : err.message;
|
|
837
|
+
}
|
|
838
|
+
function warn(message) {
|
|
839
|
+
process.stderr.write(`${message}\n`);
|
|
840
|
+
}
|
|
841
|
+
const noop = () => { };
|
|
842
|
+
//# sourceMappingURL=fetch.js.map
|