@tikoci/quickchr 0.5.0 → 0.5.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.
- package/DESIGN.md +57 -7
- package/MANUAL.md +124 -26
- package/README.md +20 -2
- package/package.json +2 -2
- package/src/cli/flags.ts +7 -2
- package/src/cli/index.ts +59 -31
- package/src/lib/cache.ts +220 -38
- package/src/lib/disk.ts +90 -33
- package/src/lib/download.ts +3 -2
- package/src/lib/images.ts +34 -15
- package/src/lib/lock.ts +2 -1
- package/src/lib/packages.ts +66 -40
- package/src/lib/quickchr.ts +83 -53
- package/src/lib/rename.ts +41 -0
- package/src/lib/settings.ts +20 -9
- package/src/lib/state.ts +26 -1
- package/src/lib/storage-lease.ts +169 -0
- package/src/lib/storage.ts +223 -66
- package/src/lib/types.ts +13 -3
package/src/lib/disk.ts
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
* - {@link formatSnapshotTable} — render snapshot list as a human-readable ANSI table
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
import { existsSync, unlinkSync } from "node:fs";
|
|
15
|
+
import { basename, join } from "node:path";
|
|
16
|
+
import { copyFileSync, existsSync, rmSync, unlinkSync } from "node:fs";
|
|
17
17
|
import type { BootDiskFormat, SnapshotInfo } from "./types.ts";
|
|
18
18
|
import { QuickCHRError } from "./types.ts";
|
|
19
19
|
import { requireQemuImg } from "./platform.ts";
|
|
20
|
+
import { renameWithRetry } from "./rename.ts";
|
|
20
21
|
|
|
21
22
|
const DISK_SIZE_RE = /^\d+[KMGT]?$/i;
|
|
22
23
|
|
|
@@ -25,6 +26,8 @@ export interface DiskInfo {
|
|
|
25
26
|
virtualSize: number;
|
|
26
27
|
actualSize: number;
|
|
27
28
|
filename: string;
|
|
29
|
+
/** Set when the image depends on another file (a qcow2 backing chain). */
|
|
30
|
+
backingFilename?: string;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/** True when a disk size string is valid for qemu-img (e.g. 512M, 1G, 2048). */
|
|
@@ -139,34 +142,58 @@ export async function getDiskInfo(path: string): Promise<DiskInfo> {
|
|
|
139
142
|
virtualSize: info["virtual-size"],
|
|
140
143
|
actualSize: info["actual-size"],
|
|
141
144
|
filename: info.filename,
|
|
145
|
+
backingFilename: info["backing-filename"],
|
|
142
146
|
};
|
|
143
147
|
}
|
|
144
148
|
|
|
149
|
+
/** The boot format a machine record implies. Records from before `bootDiskFormat`
|
|
150
|
+
* existed only had `bootSize`, which always meant qcow2. */
|
|
151
|
+
export function bootDiskFormatOf(state: { bootDiskFormat?: BootDiskFormat; bootSize?: string }): BootDiskFormat {
|
|
152
|
+
return state.bootDiskFormat ?? (state.bootSize ? "qcow2" : "raw");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Where a machine's boot disk lives: `disk.img` for raw, `boot.qcow2` for qcow2. */
|
|
156
|
+
export function bootDiskPath(machineDir: string, format: BootDiskFormat): string {
|
|
157
|
+
return join(machineDir, format === "qcow2" ? "boot.qcow2" : "disk.img");
|
|
158
|
+
}
|
|
159
|
+
|
|
145
160
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
161
|
+
* Write a machine's boot disk from a factory raw image, replacing any existing one.
|
|
162
|
+
*
|
|
163
|
+
* The disk is built under a `.tmp` name and renamed into place only once complete, so
|
|
164
|
+
* an interrupted copy or conversion never leaves a file the next start would accept.
|
|
165
|
+
* qcow2 is converted straight from `sourceImg` (normally the shared cache image) and
|
|
166
|
+
* checked to be standalone: no machine-local raw copy is kept, and no guest disk
|
|
167
|
+
* depends on the cache through a backing chain.
|
|
149
168
|
*/
|
|
150
|
-
export async function
|
|
169
|
+
export async function writeBootDisk(
|
|
151
170
|
machineDir: string,
|
|
171
|
+
sourceImg: string,
|
|
172
|
+
format: BootDiskFormat,
|
|
152
173
|
bootSize?: string,
|
|
153
|
-
bootDiskFormat?: BootDiskFormat,
|
|
154
174
|
): Promise<{ path: string; format: BootDiskFormat }> {
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
175
|
+
const path = bootDiskPath(machineDir, format);
|
|
176
|
+
const tmpPath = `${path}.tmp`;
|
|
177
|
+
rmSync(tmpPath, { force: true });
|
|
178
|
+
try {
|
|
179
|
+
if (format === "raw") {
|
|
180
|
+
copyFileSync(sourceImg, tmpPath);
|
|
181
|
+
} else {
|
|
182
|
+
await convertRawToQcow2(sourceImg, tmpPath);
|
|
183
|
+
if (bootSize) await resizeQcow2(tmpPath, bootSize);
|
|
184
|
+
const info = await getDiskInfo(tmpPath);
|
|
185
|
+
if (info.format !== "qcow2" || info.backingFilename) {
|
|
186
|
+
throw new QuickCHRError(
|
|
187
|
+
"PROCESS_FAILED",
|
|
188
|
+
`Converted boot disk is not a standalone qcow2 (format=${info.format}${info.backingFilename ? `, backing=${info.backingFilename}` : ""})`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
renameWithRetry(tmpPath, path);
|
|
193
|
+
} finally {
|
|
194
|
+
rmSync(tmpPath, { force: true });
|
|
168
195
|
}
|
|
169
|
-
return { path
|
|
196
|
+
return { path, format };
|
|
170
197
|
}
|
|
171
198
|
|
|
172
199
|
/**
|
|
@@ -186,24 +213,53 @@ export async function prepareExtraDisks(
|
|
|
186
213
|
return results;
|
|
187
214
|
}
|
|
188
215
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
216
|
+
export interface DiskSources {
|
|
217
|
+
/** True once the guest has booted. Its disks then hold guest state, so a missing one
|
|
218
|
+
* is an error: rebuilding it from the factory image would quietly reset the router. */
|
|
219
|
+
booted: boolean;
|
|
220
|
+
/** The factory raw image a missing boot disk of a never-booted machine is built from.
|
|
221
|
+
* Called only when it is needed. */
|
|
222
|
+
factoryImage?: () => Promise<string>;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function missingDiskError(machineDir: string, path: string): QuickCHRError {
|
|
226
|
+
const name = basename(machineDir);
|
|
227
|
+
return new QuickCHRError(
|
|
228
|
+
"MACHINE_NOT_FOUND",
|
|
229
|
+
`Disk image not found for "${name}": ${path}. The machine has booted before, so quickchr will not rebuild it from the factory image — that would silently reset the router. Restore the file, or run 'quickchr clean ${name}' to reset it to factory.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Resolve the disks a machine record calls for, creating any that a never-booted
|
|
234
|
+
* machine is still missing. New machines, and older add()-created states that predate
|
|
235
|
+
* eager disk materialization, are built here; a booted machine's disks never are.
|
|
236
|
+
*
|
|
237
|
+
* A qcow2 machine needs only `boot.qcow2`. Machines created before 0.5.1 also hold a
|
|
238
|
+
* `disk.img`; it is unused once `boot.qcow2` exists, and is the conversion source for a
|
|
239
|
+
* legacy never-booted state that has no `boot.qcow2` yet. */
|
|
192
240
|
export async function ensureConfiguredDisks(
|
|
193
241
|
machineDir: string,
|
|
194
|
-
bootSize
|
|
195
|
-
extraDiskSizes
|
|
196
|
-
bootDiskFormat
|
|
242
|
+
bootSize: string | undefined,
|
|
243
|
+
extraDiskSizes: string[] | undefined,
|
|
244
|
+
bootDiskFormat: BootDiskFormat,
|
|
245
|
+
sources: DiskSources,
|
|
197
246
|
): Promise<{
|
|
198
247
|
bootDisk: { path: string; format: BootDiskFormat };
|
|
199
248
|
extraDisks?: { path: string; format: "qcow2" }[];
|
|
200
249
|
}> {
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
250
|
+
const bootPath = bootDiskPath(machineDir, bootDiskFormat);
|
|
251
|
+
let bootDisk = { path: bootPath, format: bootDiskFormat };
|
|
252
|
+
if (!existsSync(bootPath)) {
|
|
253
|
+
if (sources.booted) throw missingDiskError(machineDir, bootPath);
|
|
254
|
+
const legacyRaw = join(machineDir, "disk.img");
|
|
255
|
+
const source = bootDiskFormat === "qcow2" && existsSync(legacyRaw)
|
|
256
|
+
? legacyRaw
|
|
257
|
+
: await sources.factoryImage?.();
|
|
258
|
+
if (!source) {
|
|
259
|
+
throw new QuickCHRError("MACHINE_NOT_FOUND", `Disk image not found for "${basename(machineDir)}": ${bootPath}`);
|
|
260
|
+
}
|
|
261
|
+
bootDisk = await writeBootDisk(machineDir, source, bootDiskFormat, bootSize);
|
|
262
|
+
}
|
|
207
263
|
|
|
208
264
|
let extraDisks: { path: string; format: "qcow2" }[] | undefined;
|
|
209
265
|
if (extraDiskSizes && extraDiskSizes.length > 0) {
|
|
@@ -211,6 +267,7 @@ export async function ensureConfiguredDisks(
|
|
|
211
267
|
for (let i = 0; i < extraDiskSizes.length; i++) {
|
|
212
268
|
const diskPath = join(machineDir, `disk${i + 1}.qcow2`);
|
|
213
269
|
if (!existsSync(diskPath)) {
|
|
270
|
+
if (sources.booted) throw missingDiskError(machineDir, diskPath);
|
|
214
271
|
await createQcow2Disk(diskPath, extraDiskSizes[i] as string);
|
|
215
272
|
}
|
|
216
273
|
extraDisks.push({ path: diskPath, format: "qcow2" });
|
package/src/lib/download.ts
CHANGED
|
@@ -46,11 +46,12 @@
|
|
|
46
46
|
* a transfer; it does not change the transport).
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
|
-
import { unlinkSync, existsSync
|
|
49
|
+
import { unlinkSync, existsSync } from "node:fs";
|
|
50
50
|
import { randomUUID } from "node:crypto";
|
|
51
51
|
import { QuickCHRError } from "./types.ts";
|
|
52
52
|
import { fetchResilient } from "./net.ts";
|
|
53
53
|
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
54
|
+
import { renameWithRetry } from "./rename.ts";
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* Throughput to assume when budgeting a COLD download from MikroTik, in bytes
|
|
@@ -257,7 +258,7 @@ export async function downloadToFile(
|
|
|
257
258
|
// so this is a same-filesystem move and the artifact appears at its final
|
|
258
259
|
// path atomically. Copying would re-write every byte (52 MB for the
|
|
259
260
|
// largest artifact) for no benefit.
|
|
260
|
-
|
|
261
|
+
renameWithRetry(partPath, destPath);
|
|
261
262
|
log.status(` Saved (${describeTransfer(outcome)})`);
|
|
262
263
|
return outcome;
|
|
263
264
|
} catch (e) {
|
package/src/lib/images.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Image download, ZIP extraction, and cache management.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync,
|
|
5
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import type { Arch } from "./types.ts";
|
|
8
8
|
import { QuickCHRError } from "./types.ts";
|
|
@@ -11,7 +11,18 @@ import { downloadToFile } from "./download.ts";
|
|
|
11
11
|
import { getCacheDir, ensureDir } from "./state.ts";
|
|
12
12
|
import { createLogger, type ProgressLogger } from "./log.ts";
|
|
13
13
|
import { extractZip } from "./zip.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { admitStorage, STORAGE_ESTIMATES } from "./storage.ts";
|
|
15
|
+
import { renameWithRetry } from "./rename.ts";
|
|
16
|
+
|
|
17
|
+
/** A cache hit is a use: bump the mtime so automatic eviction counts the entry as
|
|
18
|
+
* recently used while this operation reads it (#199). Best-effort: a read-only cache
|
|
19
|
+
* still works. */
|
|
20
|
+
export function touchCacheEntry(path: string): void {
|
|
21
|
+
try {
|
|
22
|
+
const now = new Date();
|
|
23
|
+
utimesSync(path, now, now);
|
|
24
|
+
} catch { /* read-only cache */ }
|
|
25
|
+
}
|
|
15
26
|
|
|
16
27
|
function finalizeExtractedImage(zipPath: string, imgPath: string): string {
|
|
17
28
|
if (existsSync(imgPath) && existsSync(zipPath)) {
|
|
@@ -118,7 +129,7 @@ export async function extractImage(
|
|
|
118
129
|
if (!(await isUsableCachedImage(extractedPath))) {
|
|
119
130
|
throw new QuickCHRError("PROCESS_FAILED", `Extracted CHR image is incomplete or invalid: ${expected}`);
|
|
120
131
|
}
|
|
121
|
-
|
|
132
|
+
renameWithRetry(extractedPath, imgPath);
|
|
122
133
|
} finally {
|
|
123
134
|
rmSync(tempDir, { recursive: true, force: true });
|
|
124
135
|
}
|
|
@@ -138,21 +149,29 @@ export async function ensureCachedImage(
|
|
|
138
149
|
if (await isUsableCachedImage(imgPath)) {
|
|
139
150
|
const log = logger ?? createLogger();
|
|
140
151
|
log.status(` Using cached image: ${chrImageBasename(version, arch)}`);
|
|
152
|
+
touchCacheEntry(imgPath);
|
|
141
153
|
return imgPath;
|
|
142
154
|
}
|
|
143
155
|
if (existsSync(imgPath)) unlinkSync(imgPath);
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
const log = logger ?? createLogger();
|
|
157
|
+
const base = chrImageBasename(version, arch);
|
|
158
|
+
const lease = admitStorage(
|
|
159
|
+
`cache CHR ${version} (${arch})`,
|
|
160
|
+
{
|
|
161
|
+
parts: [{ label: "image download", bytes: STORAGE_ESTIMATES.imageDownload, path: cache }],
|
|
162
|
+
protectVersions: [version],
|
|
163
|
+
pins: [`${base}.img`, `${base}.img.zip`],
|
|
164
|
+
cacheDir: cache,
|
|
165
|
+
},
|
|
166
|
+
{ logger: (message: string) => log.status(message) },
|
|
167
|
+
);
|
|
168
|
+
try {
|
|
169
|
+
const zipPath = await downloadImage(version, arch, cacheDir, logger);
|
|
170
|
+
const extractedImgPath = await extractImage(zipPath, cacheDir, logger);
|
|
171
|
+
return finalizeExtractedImage(zipPath, extractedImgPath);
|
|
172
|
+
} finally {
|
|
173
|
+
lease.release();
|
|
174
|
+
}
|
|
156
175
|
}
|
|
157
176
|
|
|
158
177
|
/** List cached images. */
|
package/src/lib/lock.ts
CHANGED
|
@@ -65,7 +65,8 @@ export type LockOperation =
|
|
|
65
65
|
| "remove"
|
|
66
66
|
| "device-mode"
|
|
67
67
|
| "remove-orphan"
|
|
68
|
-
| "allocate"
|
|
68
|
+
| "allocate"
|
|
69
|
+
| "storage";
|
|
69
70
|
|
|
70
71
|
/** Resources a machine creation has claimed but not yet persisted in `machine.json`.
|
|
71
72
|
* Carried in the creator's machine lock, so it lives exactly as long as the create
|
package/src/lib/packages.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Extra package download and installation for CHR instances.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync, mkdtempSync, readdirSync,
|
|
5
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
6
6
|
import { join, basename } from "node:path";
|
|
7
7
|
import { QuickCHRError, type Arch } from "./types.ts";
|
|
8
8
|
import { packagesDownloadUrl } from "./versions.ts";
|
|
@@ -12,6 +12,10 @@ import { createLogger, type ProgressLogger } from "./log.ts";
|
|
|
12
12
|
import { restPost } from "./rest.ts";
|
|
13
13
|
import { scpPush } from "./scp.ts";
|
|
14
14
|
import { extractZip } from "./zip.ts";
|
|
15
|
+
import { renameWithRetry } from "./rename.ts";
|
|
16
|
+
import { admitStorage, STORAGE_ESTIMATES } from "./storage.ts";
|
|
17
|
+
import { touchCacheEntry } from "./images.ts";
|
|
18
|
+
import { pinCacheEntries } from "./storage-lease.ts";
|
|
15
19
|
|
|
16
20
|
const PACKAGE_CACHE_MANIFEST = ".quickchr-complete.json";
|
|
17
21
|
|
|
@@ -76,50 +80,65 @@ export async function downloadPackages(
|
|
|
76
80
|
if (await isCompletePackageCache(version, arch, cache)) {
|
|
77
81
|
const log = logger ?? createLogger();
|
|
78
82
|
log.status(` Using cached packages: ${version} (${arch})`);
|
|
83
|
+
touchCacheEntry(zipPath);
|
|
79
84
|
return extractDir;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Extract into a sibling directory, record the exact complete set, then
|
|
93
|
-
// rename it into place. A killed extraction can never look like a cache hit.
|
|
94
|
-
const tempDir = mkdtempSync(join(cache, ".quickchr-packages-"));
|
|
87
|
+
const lease = admitStorage(
|
|
88
|
+
`cache packages ${version} (${arch})`,
|
|
89
|
+
{
|
|
90
|
+
parts: [{ label: "packages download", bytes: STORAGE_ESTIMATES.packagesDownload, path: cache }],
|
|
91
|
+
protectVersions: [version],
|
|
92
|
+
pins: [`packages-${arch}-${version}`],
|
|
93
|
+
cacheDir: cache,
|
|
94
|
+
},
|
|
95
|
+
{ logger: (message: string) => (logger ?? createLogger()).status(message) },
|
|
96
|
+
);
|
|
95
97
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// Download if needed. Same bounded path as the image download (#116) — this
|
|
99
|
+
// call site previously had neither a deadline nor retries, so its only bound
|
|
100
|
+
// was whatever the calling test happened to impose.
|
|
101
|
+
if (!existsSync(zipPath)) {
|
|
102
|
+
const log = logger ?? createLogger();
|
|
103
|
+
log.status(`Downloading packages for ${version} (${arch})...`);
|
|
104
|
+
log.status(` ${url}`);
|
|
105
|
+
await downloadToFile(url, zipPath, { logger: log });
|
|
100
106
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
|
|
108
|
+
// Extract into a sibling directory, record the exact complete set, then
|
|
109
|
+
// rename it into place. A killed extraction can never look like a cache hit.
|
|
110
|
+
const tempDir = mkdtempSync(join(cache, ".quickchr-packages-"));
|
|
111
|
+
try {
|
|
112
|
+
extractZip(zipPath, tempDir);
|
|
113
|
+
const packages = packageFiles(tempDir);
|
|
114
|
+
if (packages.length === 0) {
|
|
115
|
+
throw new Error(`No non-empty .npk files found in ${zipName}`);
|
|
116
|
+
}
|
|
117
|
+
const manifest: PackageCacheManifest = {
|
|
118
|
+
version,
|
|
119
|
+
arch,
|
|
120
|
+
archiveSize: statSync(zipPath).size,
|
|
121
|
+
packages,
|
|
122
|
+
};
|
|
123
|
+
await Bun.write(join(tempDir, PACKAGE_CACHE_MANIFEST), `${JSON.stringify(manifest, null, "\t")}\n`);
|
|
124
|
+
if (existsSync(extractDir)) rmSync(extractDir, { recursive: true, force: true });
|
|
125
|
+
renameWithRetry(tempDir, extractDir);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
128
|
+
if (error instanceof Error && error.message.startsWith("No non-empty")) {
|
|
129
|
+
throw new QuickCHRError("PROCESS_FAILED", error.message);
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
114
132
|
}
|
|
115
|
-
throw error;
|
|
116
|
-
}
|
|
117
133
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
if (!(await isCompletePackageCache(version, arch, cache))) {
|
|
135
|
+
throw new QuickCHRError(
|
|
136
|
+
"PROCESS_FAILED",
|
|
137
|
+
`Package cache verification failed for ${version} (${arch})`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
lease.release();
|
|
123
142
|
}
|
|
124
143
|
|
|
125
144
|
return extractDir;
|
|
@@ -225,8 +244,15 @@ export async function downloadAndListPackages(
|
|
|
225
244
|
version: string,
|
|
226
245
|
arch: Arch,
|
|
227
246
|
): Promise<string[]> {
|
|
228
|
-
|
|
229
|
-
|
|
247
|
+
// Pinned through the listing: once downloadPackages() returns, nothing else stops a
|
|
248
|
+
// concurrent eviction from removing the extraction mid-read.
|
|
249
|
+
const pin = pinCacheEntries(`list packages ${version} (${arch})`, [`packages-${arch}-${version}`]);
|
|
250
|
+
try {
|
|
251
|
+
const extractDir = await downloadPackages(version, arch);
|
|
252
|
+
return listAvailablePackages(extractDir);
|
|
253
|
+
} finally {
|
|
254
|
+
pin.release();
|
|
255
|
+
}
|
|
230
256
|
}
|
|
231
257
|
|
|
232
258
|
/** Download all packages and install every one of them. Useful for API schema generation.
|