@tikoci/quickchr 0.5.0 → 0.5.2
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/.bun-version +1 -0
- package/DESIGN.md +60 -8
- package/MANUAL.md +150 -43
- package/README.md +26 -11
- package/package.json +3 -2
- package/src/cli/flags.ts +7 -2
- package/src/cli/index.ts +63 -33
- package/src/cli/wizard.ts +54 -58
- 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/platform.ts +13 -1
- package/src/lib/quickchr.ts +87 -62
- 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/cli/flags.ts
CHANGED
|
@@ -25,7 +25,7 @@ const CREATE_FLAGS = [
|
|
|
25
25
|
/** `start` also boots, so it owns the run-time flags on top of the create set. */
|
|
26
26
|
const START_ONLY_FLAGS = [
|
|
27
27
|
"all", "dry-run", "wait", "background", "bg", "fg", "foreground",
|
|
28
|
-
"
|
|
28
|
+
"timeout-extra",
|
|
29
29
|
"license-level", "license-account", "license-password",
|
|
30
30
|
] as const;
|
|
31
31
|
|
|
@@ -96,10 +96,15 @@ export const VALUE_FLAGS: ReadonlySet<string> = new Set([
|
|
|
96
96
|
* that `--add-network` already expresses in general (#164). Removing them silently would
|
|
97
97
|
* repeat the defect they were removed for: a flag that is accepted and does nothing. A
|
|
98
98
|
* removed name stays known for as long as anyone might still type it, and says what
|
|
99
|
-
* replaced it.
|
|
99
|
+
* replaced it.
|
|
100
|
+
*
|
|
101
|
+
* `--install-deps` is the same defect from the other side: `start` accepted it from
|
|
102
|
+
* the beginning and nothing ever read it (#193). It never installed anything, so the
|
|
103
|
+
* replacement is the command that names what to install. */
|
|
100
104
|
export const REMOVED_FLAGS: ReadonlyMap<string, string> = new Map([
|
|
101
105
|
["vmnet-shared", "--add-network shared"],
|
|
102
106
|
["vmnet-bridge", "--add-network bridged:<iface>"],
|
|
107
|
+
["install-deps", "quickchr doctor (it never installed anything; doctor lists missing host dependencies and how to install them)"],
|
|
103
108
|
]);
|
|
104
109
|
|
|
105
110
|
/** Value-taking flags with a documented `--no-` spelling, where `false` is a real
|
package/src/cli/index.ts
CHANGED
|
@@ -1390,7 +1390,13 @@ function printReadinessTip(name: string, version: string): void {
|
|
|
1390
1390
|
tip(`wait for readiness with: centrs retrieve --quickchr ${name} /system/resource${via} --wait 5m`);
|
|
1391
1391
|
}
|
|
1392
1392
|
|
|
1393
|
-
|
|
1393
|
+
/** Single-target start's status word: a machine this call created says so (#194). */
|
|
1394
|
+
function startStatusLine(created: boolean, noWait: boolean): string {
|
|
1395
|
+
if (noWait) return `${created ? "created" : "launched"} — not waiting for RouterOS (--no-wait)`;
|
|
1396
|
+
return created ? "created and started" : "started";
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
export async function cmdStart(argv: string[]) {
|
|
1394
1400
|
const { flags, positional } = parseFlags(argv);
|
|
1395
1401
|
applyTimeoutExtraShortFlag(argv, flags, positional);
|
|
1396
1402
|
rejectUnknownFlags(flags, START_FLAGS, "start");
|
|
@@ -1474,7 +1480,6 @@ async function cmdStart(argv: string[]) {
|
|
|
1474
1480
|
bootSize: flag(flags, "boot-size"),
|
|
1475
1481
|
extraDisks: flagList(flags, "add-disk").length > 0 ? flagList(flags, "add-disk") : undefined,
|
|
1476
1482
|
extraPorts: flagList(flags, "forward").flatMap(expandForwardSpec),
|
|
1477
|
-
installDeps: flagBool(flags, "install-deps"),
|
|
1478
1483
|
dryRun: flagBool(flags, "dry-run"),
|
|
1479
1484
|
timeoutExtra: await resolveTimeoutExtraMs(flags),
|
|
1480
1485
|
};
|
|
@@ -1505,10 +1510,10 @@ async function cmdStart(argv: string[]) {
|
|
|
1505
1510
|
}
|
|
1506
1511
|
|
|
1507
1512
|
opts.disableAdmin = flagOptionalBool(flags, "disable-admin");
|
|
1513
|
+
// No name, or a name no machine has: this call creates the machine (#194).
|
|
1514
|
+
const target = opts.name ? QuickCHR.get(opts.name) : null;
|
|
1508
1515
|
{
|
|
1509
|
-
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
1510
1516
|
const { isProvisioningWindowOpen } = await import("../lib/provisioning-window.ts");
|
|
1511
|
-
const target = opts.name ? QuickCHR.get(opts.name) : null;
|
|
1512
1517
|
// No machine yet, or one that was added and never booted: still a first boot,
|
|
1513
1518
|
// so the setting applies. Only a closed window suppresses it — otherwise a
|
|
1514
1519
|
// plain `quickchr start <booted-name>` would ask to provision a booted guest
|
|
@@ -1573,7 +1578,7 @@ async function cmdStart(argv: string[]) {
|
|
|
1573
1578
|
} else {
|
|
1574
1579
|
const creds = await resolveDisplayCredentials(instance.state);
|
|
1575
1580
|
const v6 = isRouterOs6(instance.state.version);
|
|
1576
|
-
console.log(`${statusIcon("running")} ${bold(instance.name)} ${opts.wait === false
|
|
1581
|
+
console.log(`${statusIcon("running")} ${bold(instance.name)} ${startStatusLine(!target, opts.wait === false)}`);
|
|
1577
1582
|
console.log(` Version: ${instance.state.version} (${instance.state.arch})${v6 ? ` ${dim("boot-only: no REST, no provisioning")}` : ""}`);
|
|
1578
1583
|
console.log(` Network: ${formatNetworks(instance.state.networks ?? [])}`);
|
|
1579
1584
|
console.log(` Ports: ${formatPorts(instance.state.ports)}`);
|
|
@@ -2236,7 +2241,7 @@ async function cmdDisk(argv: string[]) {
|
|
|
2236
2241
|
|
|
2237
2242
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
2238
2243
|
const { bold, dim, machineNotFoundMessage } = await import("./format.ts");
|
|
2239
|
-
const { getDiskInfo } = await import("../lib/disk.ts");
|
|
2244
|
+
const { bootDiskFormatOf, bootDiskPath, getDiskInfo } = await import("../lib/disk.ts");
|
|
2240
2245
|
const { join } = await import("node:path");
|
|
2241
2246
|
const { existsSync } = await import("node:fs");
|
|
2242
2247
|
const { findQemuImg } = await import("../lib/platform.ts");
|
|
@@ -2259,10 +2264,8 @@ async function cmdDisk(argv: string[]) {
|
|
|
2259
2264
|
console.log(bold(`Disks for ${name}\n`));
|
|
2260
2265
|
|
|
2261
2266
|
// Boot disk
|
|
2262
|
-
const
|
|
2263
|
-
|
|
2264
|
-
: join(state.machineDir, "disk.img");
|
|
2265
|
-
const bootFormat = state.bootDiskFormat ?? "raw";
|
|
2267
|
+
const bootFormat = bootDiskFormatOf(state);
|
|
2268
|
+
const bootPath = bootDiskPath(state.machineDir, bootFormat);
|
|
2266
2269
|
|
|
2267
2270
|
if (qemuImg && existsSync(bootPath)) {
|
|
2268
2271
|
try {
|
|
@@ -2835,7 +2838,7 @@ async function cmdCache(argv: string[]) {
|
|
|
2835
2838
|
return;
|
|
2836
2839
|
}
|
|
2837
2840
|
|
|
2838
|
-
const {
|
|
2841
|
+
const { cacheInventory, countEntries, pruneCache, parseSizeString, formatSizeBytes } = await import("../lib/cache.ts");
|
|
2839
2842
|
|
|
2840
2843
|
const parseDuration = (s: string): number => {
|
|
2841
2844
|
const m = s.match(/^(\d+(?:\.\d+)?)([smhdw])$/);
|
|
@@ -2847,6 +2850,11 @@ async function cmdCache(argv: string[]) {
|
|
|
2847
2850
|
};
|
|
2848
2851
|
const parseSize = parseSizeString;
|
|
2849
2852
|
const formatSize = formatSizeBytes;
|
|
2853
|
+
const printFailedDeletes = (failed: Array<{ entry: { basename: string }; error: string }>) => {
|
|
2854
|
+
if (failed.length === 0) return;
|
|
2855
|
+
console.error(`Could not delete ${countEntries(failed.length)} (kept, not counted as freed):`);
|
|
2856
|
+
for (const { entry, error } of failed) console.error(` - ${entry.basename}: ${error}`);
|
|
2857
|
+
};
|
|
2850
2858
|
const formatAge = (ms: number): string => {
|
|
2851
2859
|
const s = Math.floor(ms / 1000);
|
|
2852
2860
|
if (s < 60) return `${s}s`;
|
|
@@ -2897,13 +2905,14 @@ async function cmdCache(argv: string[]) {
|
|
|
2897
2905
|
}
|
|
2898
2906
|
case "list":
|
|
2899
2907
|
case "ls": {
|
|
2900
|
-
const entries =
|
|
2908
|
+
const { entries, unmanagedBytes } = cacheInventory();
|
|
2901
2909
|
if (flags.json === true) {
|
|
2902
2910
|
console.log(JSON.stringify(entries, null, 2));
|
|
2903
2911
|
return;
|
|
2904
2912
|
}
|
|
2905
2913
|
if (entries.length === 0) {
|
|
2906
|
-
console.log("No
|
|
2914
|
+
console.log("No cache entries.");
|
|
2915
|
+
if (unmanagedBytes > 0) console.log(`Other files: ${formatSize(unmanagedBytes)} (not written by quickchr; never pruned)`);
|
|
2907
2916
|
return;
|
|
2908
2917
|
}
|
|
2909
2918
|
const now = Date.now();
|
|
@@ -2930,7 +2939,8 @@ async function cmdCache(argv: string[]) {
|
|
|
2930
2939
|
);
|
|
2931
2940
|
}
|
|
2932
2941
|
const total = entries.reduce((s, e) => s + e.sizeBytes, 0);
|
|
2933
|
-
console.log(`\nTotal: ${entries.length}
|
|
2942
|
+
console.log(`\nTotal: ${countEntries(entries.length)}, ${formatSize(total)}`);
|
|
2943
|
+
if (unmanagedBytes > 0) console.log(`Other files: ${formatSize(unmanagedBytes)} (not written by quickchr; never pruned)`);
|
|
2934
2944
|
break;
|
|
2935
2945
|
}
|
|
2936
2946
|
case "prune": {
|
|
@@ -2944,29 +2954,33 @@ async function cmdCache(argv: string[]) {
|
|
|
2944
2954
|
}
|
|
2945
2955
|
const result = pruneCache(opts);
|
|
2946
2956
|
const verb = opts.dryRun ? "Would evict" : "Evicted";
|
|
2947
|
-
if (result.evicted.length === 0) {
|
|
2957
|
+
if (result.evicted.length === 0 && result.failed.length === 0) {
|
|
2948
2958
|
console.log("Nothing to evict.");
|
|
2949
|
-
} else {
|
|
2950
|
-
console.log(`${verb} ${result.evicted.length}
|
|
2959
|
+
} else if (result.evicted.length > 0) {
|
|
2960
|
+
console.log(`${verb} ${countEntries(result.evicted.length)}:`);
|
|
2951
2961
|
for (const e of result.evicted) {
|
|
2952
2962
|
const reason = result.reasons[e.basename] ?? "policy";
|
|
2953
2963
|
console.log(` - ${e.basename} (${formatSize(e.sizeBytes)}) — ${reason}`);
|
|
2954
2964
|
}
|
|
2955
|
-
console.log(
|
|
2965
|
+
console.log(`${opts.dryRun ? "Would free" : "Freed"}: ${formatSize(result.freedBytes)}`);
|
|
2956
2966
|
}
|
|
2967
|
+
printFailedDeletes(result.failed);
|
|
2968
|
+
if (result.failed.length > 0) process.exitCode = 1;
|
|
2957
2969
|
break;
|
|
2958
2970
|
}
|
|
2959
2971
|
case "clear": {
|
|
2960
2972
|
if (flags.all !== true) {
|
|
2961
|
-
console.error("cache clear: refuses without --all (deletes all non-in-use
|
|
2973
|
+
console.error("cache clear: refuses without --all (deletes all non-in-use cache entries)");
|
|
2962
2974
|
process.exit(1);
|
|
2963
2975
|
}
|
|
2964
2976
|
const result = pruneCache({ maxSizeBytes: 0, dryRun: flags["dry-run"] === true });
|
|
2965
2977
|
const verb = flags["dry-run"] === true ? "Would delete" : "Deleted";
|
|
2966
|
-
console.log(`${verb} ${result.evicted.length}
|
|
2978
|
+
console.log(`${verb} ${countEntries(result.evicted.length)}, ${flags["dry-run"] === true ? "would free" : "freed"} ${formatSize(result.freedBytes)}`);
|
|
2979
|
+
printFailedDeletes(result.failed);
|
|
2980
|
+
if (result.failed.length > 0) process.exitCode = 1;
|
|
2967
2981
|
if (result.kept.some((e) => e.inUse)) {
|
|
2968
2982
|
const inUse = result.kept.filter((e) => e.inUse);
|
|
2969
|
-
console.log(`Kept ${inUse.length} in-use
|
|
2983
|
+
console.log(`Kept ${inUse.length} in-use cache ${inUse.length === 1 ? "entry" : "entries"}:`);
|
|
2970
2984
|
for (const e of inUse) console.log(` - ${e.basename}`);
|
|
2971
2985
|
}
|
|
2972
2986
|
break;
|
|
@@ -2992,7 +3006,7 @@ async function cmdSettings(argv: string[]) {
|
|
|
2992
3006
|
|
|
2993
3007
|
const displayValue = (key: string, value: unknown): string => {
|
|
2994
3008
|
if (value === undefined) return "(unset)";
|
|
2995
|
-
if (key === "cache-max-size" && typeof value === "number") return formatSizeBytes(value);
|
|
3009
|
+
if ((key === "cache-max-size" || key === "storage-reserve") && typeof value === "number") return formatSizeBytes(value);
|
|
2996
3010
|
if (key === "timeout-extra" && typeof value === "number") return `${value}s`;
|
|
2997
3011
|
return String(value);
|
|
2998
3012
|
};
|
|
@@ -3079,7 +3093,7 @@ async function cmdDoctor(argv: string[] = []) {
|
|
|
3079
3093
|
const staleImages = await findStaleCacheImages();
|
|
3080
3094
|
if (staleImages.length > 0) {
|
|
3081
3095
|
const longTerm = staleImages[0]?.olderThan;
|
|
3082
|
-
console.log(`Note: ${staleImages.length}
|
|
3096
|
+
console.log(`Note: ${staleImages.length} cache item(s) older than current long-term (${longTerm}):`);
|
|
3083
3097
|
for (const e of staleImages) console.log(` - ${e.basename} (${e.version})`);
|
|
3084
3098
|
console.log(" Hint: 'quickchr cache prune --older-than " + longTerm + "' to evict them.");
|
|
3085
3099
|
console.log();
|
|
@@ -3174,6 +3188,7 @@ Commands:
|
|
|
3174
3188
|
console <name> Attach to serial console of a running instance
|
|
3175
3189
|
exec <name> <command> Run a RouterOS CLI command on a running instance
|
|
3176
3190
|
(raw /rest/execute — see 'quickchr exec --help' for centrs)
|
|
3191
|
+
qga <name> <operation> Query the QEMU Guest Agent (x86 only)
|
|
3177
3192
|
remove [<name>|--all] Remove instance(s) and disk
|
|
3178
3193
|
clean [<name>|--all] Reset instance disk to fresh image
|
|
3179
3194
|
get <name> [group] Show machine config (license, device-mode, admin)
|
|
@@ -3182,7 +3197,7 @@ Commands:
|
|
|
3182
3197
|
disk <name> Show disk details for an instance
|
|
3183
3198
|
snapshot <name> [cmd] Manage snapshots (list/save/load/delete)
|
|
3184
3199
|
networks Network discovery & socket management
|
|
3185
|
-
cache [add|key|list|prune|clear] Manage cached CHR images
|
|
3200
|
+
cache [add|key|list|prune|clear] Manage cached CHR images and packages
|
|
3186
3201
|
settings [print|get|set|reset] Manage quickchr's own global settings (quickchr.env)
|
|
3187
3202
|
completions Manage shell completions (Tab completion)
|
|
3188
3203
|
logs <name> Tail the QEMU log for an instance
|
|
@@ -3218,7 +3233,7 @@ options still work on older versions when the required host tools are installed.
|
|
|
3218
3233
|
${ROUTEROS6_HELP}
|
|
3219
3234
|
|
|
3220
3235
|
Options:
|
|
3221
|
-
--name <name> Instance name (
|
|
3236
|
+
--name <name> Instance name (auto-generated if omitted)
|
|
3222
3237
|
--version <ver> RouterOS version (e.g., 7.22.1)
|
|
3223
3238
|
--channel <ch> Channel: stable, long-term, testing, development
|
|
3224
3239
|
--arch <arch> Architecture: arm64, x86, auto (default: host native)
|
|
@@ -3242,6 +3257,7 @@ Options:
|
|
|
3242
3257
|
--install-all-packages Install all packages on first boot
|
|
3243
3258
|
--add-user <u:p> Create user on first boot (name:password)
|
|
3244
3259
|
--disable-admin Disable the default admin account on first boot
|
|
3260
|
+
--secure-login Create a managed login; use --disable-admin to disable admin
|
|
3245
3261
|
--no-secure-login Keep admin with no password (skip managed account)
|
|
3246
3262
|
--port-base <port> Starting port number (default: auto-allocated from 9100)
|
|
3247
3263
|
--no-winbox Exclude WinBox port mapping
|
|
@@ -3292,12 +3308,19 @@ Examples:
|
|
|
3292
3308
|
quickchr exec my-chr ":put [:serialize to=json [/ip/address/print]]"
|
|
3293
3309
|
quickchr exec my-chr "/log/info message=hello"
|
|
3294
3310
|
|
|
3311
|
+
Exit status:
|
|
3312
|
+
0 means the command was delivered, not that RouterOS accepted it. A rejected
|
|
3313
|
+
command (e.g. "failure: invalid user name") still exits 0, with
|
|
3314
|
+
RouterOS's message as the output. For a pass/fail exit code, use centrs
|
|
3315
|
+
(a write command needs --yes when not run at a TTY):
|
|
3316
|
+
centrs execute --quickchr <name> --yes <command>
|
|
3317
|
+
|
|
3295
3318
|
${CENTRS_SEE_ALSO}`);
|
|
3296
3319
|
break;
|
|
3297
3320
|
case "start":
|
|
3298
3321
|
console.log(`quickchr start [<name>] [options]
|
|
3299
3322
|
|
|
3300
|
-
<name> Restart
|
|
3323
|
+
<name> Restart a stopped machine; an unknown name creates a fresh machine.
|
|
3301
3324
|
Omit to see a list of stopped machines.
|
|
3302
3325
|
|
|
3303
3326
|
Creation flags below apply when using 'start' to create a new machine in one step.
|
|
@@ -3345,6 +3368,7 @@ Options:
|
|
|
3345
3368
|
btest:9200-9210:2000-2010/udp.
|
|
3346
3369
|
--add-user <u:p> Create user with name:password
|
|
3347
3370
|
--disable-admin Disable the default admin account
|
|
3371
|
+
--secure-login Create a managed login; use --disable-admin to disable admin
|
|
3348
3372
|
--no-secure-login Keep admin with no password (skip managed account)
|
|
3349
3373
|
--port-base <port> Starting port number (default: auto-allocated from 9100)
|
|
3350
3374
|
--no-winbox Exclude WinBox port mapping
|
|
@@ -3606,7 +3630,9 @@ Options:
|
|
|
3606
3630
|
case "cache":
|
|
3607
3631
|
console.log(`quickchr cache <add|key|list|prune|clear> [options]
|
|
3608
3632
|
|
|
3609
|
-
Manage cached CHR images in the data dir cache directory.
|
|
3633
|
+
Manage cached CHR images and package archives in the data dir cache directory.
|
|
3634
|
+
An all-packages archive and its extraction are one entry; an interrupted download
|
|
3635
|
+
is a partial entry. Files quickchr did not write are reported, never deleted.
|
|
3610
3636
|
|
|
3611
3637
|
Subcommands:
|
|
3612
3638
|
add [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
|
|
@@ -3614,14 +3640,16 @@ Subcommands:
|
|
|
3614
3640
|
key [--channel <ch> | --version <ver>] [--arch <arch>] [--json]
|
|
3615
3641
|
Print cache directory, resolved version, and architecture for CI.
|
|
3616
3642
|
Without --json, output is GITHUB_OUTPUT-compatible key=value lines.
|
|
3617
|
-
list [--json] List
|
|
3618
|
-
prune [options] Evict
|
|
3619
|
-
--older-than <ver> Evict
|
|
3620
|
-
--max-age <dur> Evict
|
|
3621
|
-
--max-size <size> Evict
|
|
3643
|
+
list [--json] List cache entries (basename, version, arch, size, age, in-use).
|
|
3644
|
+
prune [options] Evict cache entries per policy. Never evicts in-use entries.
|
|
3645
|
+
--older-than <ver> Evict entries with version < <ver>.
|
|
3646
|
+
--max-age <dur> Evict entries not used for longer than duration (e.g. 7d, 24h).
|
|
3647
|
+
--max-size <size> Evict entries until total size <= <size>: partial downloads
|
|
3648
|
+
first, then oldest RouterOS version.
|
|
3622
3649
|
--dry-run Show what would be evicted without deleting.
|
|
3623
|
-
clear --all Delete all non-in-use
|
|
3650
|
+
clear --all Delete all non-in-use cache entries.
|
|
3624
3651
|
|
|
3652
|
+
A failed delete is listed on stderr, is not counted as freed, and exits 1.
|
|
3625
3653
|
Duration: <N>(s|m|h|d|w) Size: <N>(K|M|G|T)[i][B] (case-insensitive, IEC binary).
|
|
3626
3654
|
|
|
3627
3655
|
Default auto-prune cap: 2 GiB — override via 'quickchr settings set cache-max-size <size>'
|
|
@@ -3641,6 +3669,8 @@ Managed keys:
|
|
|
3641
3669
|
auto|tcg|hvf|kvm (default: auto = detect)
|
|
3642
3670
|
cache-max-size Auto-prune cap applied after each successful start.
|
|
3643
3671
|
Size string (e.g. 2G, 512M). (default: 2G)
|
|
3672
|
+
storage-reserve Free space creating a machine or filling the cache must
|
|
3673
|
+
leave, beyond what it writes. Size string. (default: 2G)
|
|
3644
3674
|
timeout-extra Default for 'start's --timeout-extra/-T when omitted.
|
|
3645
3675
|
Non-negative integer seconds. (default: 0)
|
|
3646
3676
|
secure-login Default for 'add'/'start's --secure-login when neither
|
package/src/cli/wizard.ts
CHANGED
|
@@ -37,6 +37,9 @@ export function resolveUserChoiceOptions(
|
|
|
37
37
|
|
|
38
38
|
/** Run the interactive wizard using @clack/prompts. */
|
|
39
39
|
export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<void> {
|
|
40
|
+
const { requireQemuImg } = await import("../lib/platform.ts");
|
|
41
|
+
requireQemuImg();
|
|
42
|
+
|
|
40
43
|
// Dynamic import — only loaded when wizard is actually used
|
|
41
44
|
const clack = await import("@clack/prompts");
|
|
42
45
|
const { QuickCHR } = await import("../lib/quickchr.ts");
|
|
@@ -148,75 +151,68 @@ export async function runWizard(wizardOpts?: { firstRun?: boolean }): Promise<vo
|
|
|
148
151
|
});
|
|
149
152
|
if (clack.isCancel(mem)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
150
153
|
|
|
151
|
-
// 4b. Disk configuration —
|
|
154
|
+
// 4b. Disk configuration — qemu-img was checked before any prompts
|
|
152
155
|
let bootSize: string | undefined;
|
|
153
156
|
let bootDiskFormat: "qcow2" | "raw" = "qcow2";
|
|
154
157
|
const extraDisks: string[] = [];
|
|
155
158
|
|
|
156
|
-
const { findQemuImg, getQemuInstallHint } = await import("../lib/platform.ts");
|
|
157
159
|
const { isValidDiskSize } = await import("../lib/disk.ts");
|
|
158
|
-
const
|
|
160
|
+
const diskFormatChoice = await clack.select({
|
|
161
|
+
message: "Boot disk format:",
|
|
162
|
+
options: [
|
|
163
|
+
{ value: "qcow2", label: "qcow2 (recommended)", hint: "supports snapshots and resize" },
|
|
164
|
+
{ value: "raw", label: "raw", hint: "no resize support" },
|
|
165
|
+
],
|
|
166
|
+
initialValue: "qcow2",
|
|
167
|
+
});
|
|
168
|
+
if (clack.isCancel(diskFormatChoice)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
169
|
+
bootDiskFormat = diskFormatChoice as "qcow2" | "raw";
|
|
159
170
|
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
message: "Boot disk format:",
|
|
165
|
-
options: [
|
|
166
|
-
{ value: "qcow2", label: "qcow2 (recommended)", hint: "supports snapshots and resize" },
|
|
167
|
-
{ value: "raw", label: "raw", hint: "no resize support" },
|
|
168
|
-
],
|
|
169
|
-
initialValue: "qcow2",
|
|
171
|
+
if (bootDiskFormat === "qcow2") {
|
|
172
|
+
const wantBootResize = await clack.confirm({
|
|
173
|
+
message: "Resize the boot disk? (default is ~128 MB)",
|
|
174
|
+
initialValue: false,
|
|
170
175
|
});
|
|
171
|
-
if (clack.isCancel(
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
if (clack.isCancel(wantBootResize)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
177
|
+
|
|
178
|
+
if (wantBootResize) {
|
|
179
|
+
const size = await clack.text({
|
|
180
|
+
message: "Boot disk size (e.g., 512M, 1G, 2G):",
|
|
181
|
+
validate: (v) => {
|
|
182
|
+
if (!isValidDiskSize(v)) return "Size like 512M, 1G, or 2048";
|
|
183
|
+
},
|
|
178
184
|
});
|
|
179
|
-
if (clack.isCancel(
|
|
180
|
-
|
|
181
|
-
if (wantBootResize) {
|
|
182
|
-
const size = await clack.text({
|
|
183
|
-
message: "Boot disk size (e.g., 512M, 1G, 2G):",
|
|
184
|
-
validate: (v) => {
|
|
185
|
-
if (!isValidDiskSize(v)) return "Size like 512M, 1G, or 2048";
|
|
186
|
-
},
|
|
187
|
-
});
|
|
188
|
-
if (clack.isCancel(size)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
189
|
-
bootSize = size.trim();
|
|
190
|
-
}
|
|
191
|
-
} else {
|
|
192
|
-
clack.log.info("Raw boot disk selected — boot resize is disabled.");
|
|
185
|
+
if (clack.isCancel(size)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
186
|
+
bootSize = size.trim();
|
|
193
187
|
}
|
|
188
|
+
} else {
|
|
189
|
+
clack.log.info("Raw boot disk selected — boot resize is disabled.");
|
|
190
|
+
}
|
|
194
191
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
192
|
+
const wantExtraDisks = await clack.confirm({
|
|
193
|
+
message: "Add extra blank disks?",
|
|
194
|
+
initialValue: false,
|
|
195
|
+
});
|
|
196
|
+
if (clack.isCancel(wantExtraDisks)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
197
|
+
|
|
198
|
+
if (wantExtraDisks) {
|
|
199
|
+
let addMore = true;
|
|
200
|
+
while (addMore) {
|
|
201
|
+
const diskSize = await clack.text({
|
|
202
|
+
message: `Extra disk ${extraDisks.length + 1} size (e.g., 64M, 512M, 1G):`,
|
|
203
|
+
validate: (v) => {
|
|
204
|
+
if (!isValidDiskSize(v)) return "Size like 64M, 512M, or 1G";
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
if (clack.isCancel(diskSize)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
208
|
+
extraDisks.push(diskSize.trim());
|
|
212
209
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
210
|
+
const another = await clack.confirm({
|
|
211
|
+
message: "Add another disk?",
|
|
212
|
+
initialValue: false,
|
|
213
|
+
});
|
|
214
|
+
if (clack.isCancel(another)) { clack.cancel("Cancelled."); process.exit(0); }
|
|
215
|
+
addMore = another;
|
|
220
216
|
}
|
|
221
217
|
}
|
|
222
218
|
|