pgserve 2.5.0 → 2.6.1

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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Database + role naming for `pgserve provision`.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
5
+ *
6
+ * Postgres identifiers max out at NAMEDATALEN-1 = 63 chars (default
7
+ * build). Our naming has to:
8
+ * 1. Be deterministic: same fingerprint → same name forever, so a
9
+ * re-run of `pgserve provision` is idempotent and `pgserve gc`
10
+ * can correlate `pg_database` rows back to `pgserve_meta` rows.
11
+ * 2. Be readable: include enough of the publisher slug that an
12
+ * operator looking at `\l` in psql can figure out which consumer
13
+ * a database belongs to.
14
+ * 3. Stay under 63 chars even when the publisher is long.
15
+ * 4. Avoid postgres reserved keywords and quoting requirements
16
+ * (lowercase + [a-z0-9_] only).
17
+ *
18
+ * Layout (≤63 chars):
19
+ *
20
+ * pgserve_<publisher-slug>_<fingerprint-hex-12>
21
+ *
22
+ * - prefix: "pgserve_" (8 chars) — fixed, used by gc to
23
+ * find candidate orphans without scanning every
24
+ * postgres DB.
25
+ * - publisher-slug: sanitized + truncated package.json name /
26
+ * pgserve.publisher. Effective max ≈ 37 chars
27
+ * (computed from min(dbSlugBudget, roleSlugBudget)
28
+ * so DB and role always share the same slug for
29
+ * operator UX in `\l` / `\du`).
30
+ * - fingerprint hex: first 12 hex chars of the fingerprint. Plenty
31
+ * of entropy to avoid collisions across consumers
32
+ * on the same host.
33
+ *
34
+ * Role name uses the same identifier with `_role` suffix. Because the
35
+ * publisher slug is already truncated to fit the database name, we
36
+ * recompute the role-side budget so the role also stays ≤63 chars.
37
+ *
38
+ * Pure function: no fs / network / pg.
39
+ */
40
+
41
+ const POSTGRES_MAX_IDENTIFIER = 63;
42
+ const PREFIX = 'pgserve_';
43
+ const FINGERPRINT_HEX_LEN = 12;
44
+ const ROLE_SUFFIX = '_role';
45
+
46
+ /**
47
+ * Lowercase + replace any char that's not [a-z0-9] with '_'. Collapses
48
+ * runs of '_' to a single '_' and trims leading / trailing '_'.
49
+ */
50
+ export function sanitizeSlug(input) {
51
+ if (typeof input !== 'string') return '';
52
+ return input
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9]+/g, '_')
55
+ .replace(/_+/g, '_')
56
+ .replace(/^_|_$/g, '');
57
+ }
58
+
59
+ /**
60
+ * @typedef {Object} ProvisionedNames
61
+ * @property {string} databaseName ≤63 chars, [a-z0-9_]+
62
+ * @property {string} roleName ≤63 chars, [a-z0-9_]+
63
+ * @property {string} slug the sanitized publisher slug used
64
+ * @property {string} fingerprintHex first 12 chars of the fingerprint
65
+ */
66
+
67
+ /**
68
+ * Derive the database + role name pair from a fingerprint + publisher.
69
+ *
70
+ * @param {object} args
71
+ * @param {string} args.fingerprint sha256-hex from resolveFingerprint
72
+ * @param {string} args.publisher e.g. '@automagik/genie' (may be '')
73
+ * @returns {ProvisionedNames}
74
+ */
75
+ export function deriveProvisionedNames({ fingerprint, publisher } = {}) {
76
+ if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
77
+ throw new TypeError('deriveProvisionedNames: fingerprint must be a non-empty string');
78
+ }
79
+ // Hex encoding is the typical case (sha256-hex). For pinned-string
80
+ // fingerprints (operator escape hatch) we still accept any chars, but
81
+ // we strip them through the same sanitizer so the database identifier
82
+ // stays valid.
83
+ const hexLike = /^[0-9a-f]+$/.test(fingerprint);
84
+ const fingerprintHex = hexLike
85
+ ? fingerprint.slice(0, FINGERPRINT_HEX_LEN)
86
+ : sanitizeSlug(fingerprint).slice(0, FINGERPRINT_HEX_LEN);
87
+ if (fingerprintHex.length === 0) {
88
+ throw new Error('deriveProvisionedNames: fingerprint produced an empty hex segment');
89
+ }
90
+
91
+ // Database identifier:
92
+ // PREFIX + slug + '_' + fingerprintHex ≤ 63
93
+ // → slug budget = 63 - len(PREFIX) - 1 - len(fingerprintHex)
94
+ const dbSlugBudget = POSTGRES_MAX_IDENTIFIER - PREFIX.length - 1 - fingerprintHex.length;
95
+
96
+ // Role identifier (separate budget — has to also fit ROLE_SUFFIX):
97
+ // PREFIX + slug + '_' + fingerprintHex + ROLE_SUFFIX ≤ 63
98
+ // → slug budget = 63 - len(PREFIX) - 1 - len(fingerprintHex) - len(ROLE_SUFFIX)
99
+ const roleSlugBudget = dbSlugBudget - ROLE_SUFFIX.length;
100
+
101
+ // We use the smaller of the two budgets so the same slug appears in
102
+ // both names — operators reading `\l` and `\du` in psql see matched
103
+ // pairs without surprise truncation.
104
+ const slugBudget = Math.max(0, Math.min(dbSlugBudget, roleSlugBudget));
105
+
106
+ const fullSlug = sanitizeSlug(publisher);
107
+ const slug = fullSlug.slice(0, slugBudget);
108
+
109
+ const databaseName = slug.length > 0
110
+ ? `${PREFIX}${slug}_${fingerprintHex}`
111
+ : `${PREFIX}${fingerprintHex}`;
112
+
113
+ const roleName = slug.length > 0
114
+ ? `${PREFIX}${slug}_${fingerprintHex}${ROLE_SUFFIX}`
115
+ : `${PREFIX}${fingerprintHex}${ROLE_SUFFIX}`;
116
+
117
+ return {
118
+ databaseName,
119
+ roleName,
120
+ slug,
121
+ fingerprintHex,
122
+ };
123
+ }
124
+
125
+ export const __testInternals = Object.freeze({
126
+ POSTGRES_MAX_IDENTIFIER,
127
+ PREFIX,
128
+ FINGERPRINT_HEX_LEN,
129
+ ROLE_SUFFIX,
130
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Fingerprint resolver for `pgserve provision`.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
5
+ *
6
+ * Goal: deterministically map a consumer location (cwd / explicit path /
7
+ * package.json contents) to a stable fingerprint string + the metadata
8
+ * `pgserve provision` needs to write a row into `pgserve_meta`.
9
+ *
10
+ * Fingerprint precedence (matches Decision in WISH.md §2.4):
11
+ * 1. If a package.json is found and declares `pgserve.fingerprint`,
12
+ * use that string verbatim. (Operator escape hatch — pinned across
13
+ * moves.)
14
+ * 2. Else if a package.json declares `name` + `version`, fingerprint =
15
+ * sha256(`<name>@<version>`).
16
+ * 3. Else if a package.json declares `name` only, fingerprint =
17
+ * sha256(`<name>`).
18
+ * 4. Else (no package.json or empty), fingerprint = sha256(absolute
19
+ * cwd path). This is the "fallback fingerprint" the wish describes.
20
+ *
21
+ * `publisher` resolution:
22
+ * - prefers `package.json#pgserve.publisher`
23
+ * - falls back to `package.json#name`
24
+ * - empty string when no package.json was found
25
+ *
26
+ * `sourcePath` is always the absolute filesystem path (cwd or supplied)
27
+ * — used by gc to detect "directory removed" orphans.
28
+ *
29
+ * Side effects: synchronous filesystem read of `<cwd>/package.json`
30
+ * (one open + one read; no recursion). No postgres I/O, no network, no
31
+ * globals. Hot-loop callers should cache the result themselves.
32
+ */
33
+
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import crypto from 'node:crypto';
37
+
38
+ /**
39
+ * @typedef {Object} ResolvedFingerprint
40
+ * @property {string} fingerprint sha256-hex (64 chars) OR a literal
41
+ * string if pgserve.fingerprint was set.
42
+ * @property {string} sourcePath absolute path that was inspected.
43
+ * @property {string} publisher resolved publisher; '' when unknown.
44
+ * @property {string} kind 'pinned' | 'name+version' | 'name' | 'cwd'
45
+ * @property {object} packageJson the parsed object; null when absent.
46
+ */
47
+
48
+ function sha256Hex(input) {
49
+ return crypto.createHash('sha256').update(input, 'utf8').digest('hex');
50
+ }
51
+
52
+ function readPackageJson(absDir) {
53
+ const candidate = path.join(absDir, 'package.json');
54
+ let raw;
55
+ try {
56
+ raw = fs.readFileSync(candidate, 'utf8');
57
+ } catch (err) {
58
+ if (err.code === 'ENOENT') return null;
59
+ throw err;
60
+ }
61
+ try {
62
+ const parsed = JSON.parse(raw);
63
+ if (!parsed || typeof parsed !== 'object') return null;
64
+ return parsed;
65
+ } catch (err) {
66
+ const e = new Error(`pgserve provision: package.json at ${candidate} is not valid JSON: ${err.message}`);
67
+ e.code = 'EFINGERPRINTPKG';
68
+ throw e;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Resolve a fingerprint for the given directory (defaults to cwd).
74
+ * @param {object} opts
75
+ * @param {string} [opts.cwd] absolute or relative path to inspect
76
+ * @param {string} [opts.explicit] caller-supplied fingerprint; bypasses package.json
77
+ * @returns {ResolvedFingerprint}
78
+ */
79
+ export function resolveFingerprint(opts = {}) {
80
+ const cwd = path.resolve(opts.cwd || process.cwd());
81
+ if (typeof opts.explicit === 'string' && opts.explicit.length > 0) {
82
+ // Caller passed an explicit fingerprint (CLI flag / config file).
83
+ // We still load package.json so callers get the full publisher
84
+ // metadata, but the fingerprint itself comes from the operator.
85
+ const pkg = readPackageJson(cwd);
86
+ return {
87
+ fingerprint: opts.explicit,
88
+ sourcePath: cwd,
89
+ publisher: derivePublisher(pkg),
90
+ kind: 'pinned',
91
+ packageJson: pkg,
92
+ };
93
+ }
94
+ const pkg = readPackageJson(cwd);
95
+ if (pkg && typeof pkg.pgserve === 'object' && pkg.pgserve !== null
96
+ && typeof pkg.pgserve.fingerprint === 'string'
97
+ && pkg.pgserve.fingerprint.length > 0) {
98
+ return {
99
+ fingerprint: pkg.pgserve.fingerprint,
100
+ sourcePath: cwd,
101
+ publisher: derivePublisher(pkg),
102
+ kind: 'pinned',
103
+ packageJson: pkg,
104
+ };
105
+ }
106
+ if (pkg && typeof pkg.name === 'string' && pkg.name.length > 0) {
107
+ if (typeof pkg.version === 'string' && pkg.version.length > 0) {
108
+ return {
109
+ fingerprint: sha256Hex(`${pkg.name}@${pkg.version}`),
110
+ sourcePath: cwd,
111
+ publisher: derivePublisher(pkg),
112
+ kind: 'name+version',
113
+ packageJson: pkg,
114
+ };
115
+ }
116
+ return {
117
+ fingerprint: sha256Hex(pkg.name),
118
+ sourcePath: cwd,
119
+ publisher: derivePublisher(pkg),
120
+ kind: 'name',
121
+ packageJson: pkg,
122
+ };
123
+ }
124
+ return {
125
+ fingerprint: sha256Hex(cwd),
126
+ sourcePath: cwd,
127
+ publisher: '',
128
+ kind: 'cwd',
129
+ packageJson: null,
130
+ };
131
+ }
132
+
133
+ function derivePublisher(pkg) {
134
+ if (!pkg || typeof pkg !== 'object') return '';
135
+ if (typeof pkg.pgserve === 'object' && pkg.pgserve !== null
136
+ && typeof pkg.pgserve.publisher === 'string'
137
+ && pkg.pgserve.publisher.length > 0) {
138
+ return pkg.pgserve.publisher;
139
+ }
140
+ if (typeof pkg.name === 'string' && pkg.name.length > 0) return pkg.name;
141
+ return '';
142
+ }
143
+
144
+ export const __testInternals = Object.freeze({ sha256Hex, derivePublisher, readPackageJson });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `pgserve_meta` table bootstrap.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3
5
+ * (foundation for `pgserve provision` and `pgserve gc`).
6
+ *
7
+ * Schema rationale (Decision P4 — additive across the cohort):
8
+ *
9
+ * `pgserve_meta` is the single source of truth for "which postgres
10
+ * databases on this host belong to a known pgserve consumer."
11
+ * `pgserve provision` writes a row when it idempotently CREATEs a DB +
12
+ * role for a fingerprint. `pgserve gc` scans the table and DROPs DBs
13
+ * whose `last_used_at` is older than the configured stale threshold or
14
+ * whose source_path no longer exists. The cosign verify columns
15
+ * (`verified_at`, `verified_identity`, `verified_tier`) are layered on
16
+ * top via `src/cosign/schema.js#applyVerifiedColumns`; they ALTER this
17
+ * table after CREATE.
18
+ *
19
+ * Why a separate module from cosign/schema.js: cosign owns the
20
+ * verification *delta*; this module owns the *base* table. Splitting
21
+ * them keeps the cosign migration genuinely additive (it's a no-op on
22
+ * fresh DBs that haven't bootstrapped, exactly as documented in
23
+ * cosign-meta-migration.js).
24
+ *
25
+ * Idempotency: every statement uses `IF NOT EXISTS` (table, columns,
26
+ * indexes). Re-running on an already-bootstrapped database is a no-op.
27
+ */
28
+
29
+ export const PGSERVE_META_TABLE = 'pgserve_meta';
30
+
31
+ /**
32
+ * Base columns owned by this module. Cosign columns are owned by
33
+ * src/cosign/schema.js and ALTERed in afterwards.
34
+ */
35
+ export const PGSERVE_META_COLUMNS = Object.freeze([
36
+ 'fingerprint',
37
+ 'database_name',
38
+ 'role_name',
39
+ 'publisher',
40
+ 'source_path',
41
+ 'created_at',
42
+ 'last_used_at',
43
+ ]);
44
+
45
+ /**
46
+ * Idempotent statements that CREATE the table + supporting indexes.
47
+ * Returned as an array so callers can run each one individually for
48
+ * clear error reporting (mirrors cosign/schema.js#getMigrationStatements).
49
+ *
50
+ * Constraints:
51
+ * - fingerprint: PRIMARY KEY — the package.json sha256 fingerprint
52
+ * - database_name: UNIQUE NOT NULL — guards against accidental dupes
53
+ * - role_name: NOT NULL — every provisioned DB has a paired role
54
+ * - publisher: nullable — older path-tier installs may have none
55
+ * - source_path: nullable — fallback fingerprint may not have one
56
+ * - created_at: NOT NULL DEFAULT now() — set on insert
57
+ * - last_used_at: NOT NULL DEFAULT now() — touched by provision; gc
58
+ * uses it as the staleness signal
59
+ */
60
+ // Schema-qualified name. The upgrade pipeline probes
61
+ // `to_regclass('public.pgserve_meta')` (cosign-meta-migration.js); if a
62
+ // non-default search_path is configured on the active role, an
63
+ // unqualified CREATE TABLE could land the bootstrap in a non-public
64
+ // schema while later migrations + gc continue to look in `public.`. The
65
+ // only safe option is to qualify both halves consistently.
66
+ const QUALIFIED = `public.${PGSERVE_META_TABLE}`;
67
+
68
+ export function getBootstrapStatements() {
69
+ return [
70
+ [
71
+ `CREATE TABLE IF NOT EXISTS ${QUALIFIED} (`,
72
+ ' fingerprint TEXT PRIMARY KEY,',
73
+ ' database_name TEXT NOT NULL UNIQUE,',
74
+ ' role_name TEXT NOT NULL,',
75
+ ' publisher TEXT,',
76
+ ' source_path TEXT,',
77
+ ' created_at TIMESTAMPTZ NOT NULL DEFAULT now(),',
78
+ ' last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()',
79
+ ')',
80
+ ].join('\n'),
81
+ `CREATE INDEX IF NOT EXISTS ${PGSERVE_META_TABLE}_last_used_at_idx ON ${QUALIFIED} (last_used_at)`,
82
+ `CREATE INDEX IF NOT EXISTS ${PGSERVE_META_TABLE}_publisher_idx ON ${QUALIFIED} (publisher)`,
83
+ ];
84
+ }
85
+
86
+ /**
87
+ * Single SQL string variant — convenient for embedding the bootstrap in
88
+ * a transaction or pg-init script.
89
+ */
90
+ export function getBootstrapSQL() {
91
+ return `${getBootstrapStatements().join(';\n\n')};\n`;
92
+ }
93
+
94
+ /**
95
+ * Apply the bootstrap via a node-postgres-compatible client. The client
96
+ * must expose an async `query(sql)` method (matches both `pg.Client` and
97
+ * `pg.PoolClient`). Returns the list of statements executed.
98
+ *
99
+ * Statements run sequentially so a failure on the index half doesn't
100
+ * masquerade as success after the table half ran.
101
+ */
102
+ export async function bootstrapPgserveMeta(client) {
103
+ if (!client || typeof client.query !== 'function') {
104
+ throw new TypeError('bootstrapPgserveMeta: client must expose an async query() method');
105
+ }
106
+ const statements = getBootstrapStatements();
107
+ for (const sql of statements) {
108
+ await client.query(sql);
109
+ }
110
+ return statements;
111
+ }
112
+
113
+ /**
114
+ * Predicate the upgrade pipeline calls before deciding whether to run
115
+ * the cosign-verify ALTER chain. We avoid loading pg here — callers pass
116
+ * the result of `SELECT to_regclass('public.pgserve_meta') IS NOT NULL`.
117
+ */
118
+ export function tableExistsFromRegclass(toRegclassResult) {
119
+ return toRegclassResult === true;
120
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Hardcoded blocklist — pgserve-singleton-no-proxy wish, Group 5.
3
+ *
4
+ * Compile-time list of pgserve versions that `pgserve install` and
5
+ * `pgserve update` MUST refuse, with a clear diagnostic. The trust root
6
+ * is opaque to operators: they cannot edit this file at runtime, only
7
+ * receive an updated list via `pgserve update`.
8
+ *
9
+ * Per SHARED-DESIGN.md §2.5: this is the ONLY revocation surface for v2.4+.
10
+ * No Rekor consultation, no revoked.json sync, no DNS-served blocklist.
11
+ * The list grows when a known-bad version ships and gets pinned here, then
12
+ * an updated pgserve release rolls out via the normal channel.
13
+ *
14
+ * Format: array of { version, reason, advisoryUrl? } records.
15
+ * - version: exact semver string. Range matchers are intentionally
16
+ * unsupported — every blocked version is named explicitly so an
17
+ * auditor reading this file knows exactly what is rejected.
18
+ * - reason: one-line operator-facing explanation (printed on refusal).
19
+ * - advisoryUrl: optional pointer to a CVE/security advisory for the
20
+ * blocked release.
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} BlockedVersion
25
+ * @property {string} version Exact semver string (no ranges).
26
+ * @property {string} reason Operator-facing diagnostic line.
27
+ * @property {string} [advisoryUrl] Pointer to CVE/security advisory.
28
+ */
29
+
30
+ /** @type {readonly BlockedVersion[]} */
31
+ export const BLOCKED_VERSIONS = Object.freeze([
32
+ // Empty by default. Populate as known-bad versions are identified.
33
+ // Example shape (uncomment + edit when a real block is needed):
34
+ // { version: '2.6.0', reason: 'Postmaster crash on Linux ARM64 — see #999', advisoryUrl: 'https://github.com/namastexlabs/pgserve/security/advisories/GHSA-xxxx' },
35
+ ]);
36
+
37
+ /**
38
+ * Test-only override. The compile-time list is frozen so production code
39
+ * cannot mutate it; tests need a way to inject blocked entries to exercise
40
+ * the throw path. Populated via __addBlockedForTest() and consulted by
41
+ * findBlocked() when non-empty. Cleared via __clearBlockedTestOverridesForTest().
42
+ *
43
+ * @type {BlockedVersion[]}
44
+ */
45
+ const _testOverrides = [];
46
+
47
+ /**
48
+ * Test-only — register an additional blocked entry for the lifetime of a
49
+ * test. Call __clearBlockedTestOverridesForTest() in afterEach to keep
50
+ * tests isolated.
51
+ *
52
+ * @param {BlockedVersion} entry
53
+ */
54
+ export function __addBlockedForTest(entry) {
55
+ if (!entry || typeof entry.version !== 'string' || typeof entry.reason !== 'string') {
56
+ throw new Error('__addBlockedForTest: entry needs { version, reason }');
57
+ }
58
+ _testOverrides.push(entry);
59
+ }
60
+
61
+ /** Test-only — drop all entries registered via __addBlockedForTest. */
62
+ export function __clearBlockedTestOverridesForTest() {
63
+ _testOverrides.length = 0;
64
+ }
65
+
66
+ /**
67
+ * Find the blocklist entry for an exact version string, if any.
68
+ * Considers both the compile-time BLOCKED_VERSIONS list and any test
69
+ * overrides registered via __addBlockedForTest().
70
+ *
71
+ * @param {string} version
72
+ * @returns {BlockedVersion | undefined}
73
+ */
74
+ export function findBlocked(version) {
75
+ if (typeof version !== 'string' || version.length === 0) return undefined;
76
+ const fromOverrides = _testOverrides.find((b) => b.version === version);
77
+ if (fromOverrides) return fromOverrides;
78
+ return BLOCKED_VERSIONS.find((b) => b.version === version);
79
+ }
80
+
81
+ /**
82
+ * Assert that a version is not blocked. Throws an Error with a stable,
83
+ * grep-able prefix (`EBLOCKEDVERSION`) when the version is blocked, so
84
+ * callers (cli-install / upgrade) can detect it and exit with a known code.
85
+ *
86
+ * @param {string} version
87
+ * @throws {Error} when version is blocked
88
+ */
89
+ export function assertNotBlocked(version) {
90
+ const hit = findBlocked(version);
91
+ if (!hit) return;
92
+ const lines = [
93
+ `EBLOCKEDVERSION: pgserve@${version} is blocked.`,
94
+ ` reason: ${hit.reason}`,
95
+ ];
96
+ if (hit.advisoryUrl) lines.push(` advisory: ${hit.advisoryUrl}`);
97
+ lines.push(' remediation: install a different version (run `pgserve update` for the latest).');
98
+ const err = new Error(lines.join('\n'));
99
+ err.code = 'EBLOCKEDVERSION';
100
+ err.version = version;
101
+ err.reason = hit.reason;
102
+ throw err;
103
+ }
@@ -57,8 +57,8 @@ export async function execute({ log, warn }) {
57
57
 
58
58
  if (!downloadFn) {
59
59
  warn(`binary needs refresh (pinned=${pinned}, cached=${marker || 'missing'}) but autopg postgres module not exposing download API`);
60
- warn(`operator action: rerun \`bun install -g @automagik/autopg@latest\``);
61
- return { status: 'FAIL', detail: 'binary refresh needs autopg npm reinstall' };
60
+ warn(`operator action: rerun \`curl -fsSL https://raw.githubusercontent.com/namastexlabs/pgserve/main/install.sh | bash\` to refresh from GitHub Releases`);
61
+ return { status: 'FAIL', detail: 'binary refresh needs install.sh rerun (no npm dependency)' };
62
62
  }
63
63
  log(`re-downloading PG ${pinned} into ${cacheDir}`);
64
64
  await downloadFn({ version: pinned, targetDir: cacheDir });