From 8d464f2de14cfa37fef4659f5d78a630ab64f5cf Mon Sep 17 00:00:00 2001 From: huhuli083-creator Date: Sun, 25 Oct 2026 10:06:34 +0000 Subject: [PATCH 1/6] Add files via upload Signed-off-by: huhuli083-creator --- packages/run-spaceapp/src/cli.mjs | 115 +++++++++++++------- packages/run-spaceapp/src/index.mjs | 53 +++++++-- packages/run-spaceapp/src/prerequisites.mjs | 84 +++++++++++++- packages/run-spaceapp/src/storage.mjs | 25 +++-- 4 files changed, 219 insertions(+), 58 deletions(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index e0e35fc03..c59fb11b4 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -35,6 +35,7 @@ import { removeUser, removeWorkspace, resolveInstallAccessMode, + resolveInstallEngine, resolveInstallProfile, resolveSpaceAppHome, saveConfig, @@ -50,6 +51,7 @@ import { } from "./index.mjs"; import { inspectStoragePlan, storageChecks } from "./storage.mjs"; import { + detectContainerEngine, ensureDockerAvailable, prepareDockerCliPath, windowsPowerShellArgs @@ -81,6 +83,7 @@ const SETUP_STATUS_TIMEOUT_MS = 10_000; const trustedCommands = new Set([ "codesign", "docker", + "podman", "hdiutil", "open", "powershell.exe", @@ -344,7 +347,7 @@ export async function run(argv, { if (globalFlags.localImages) { const originalExecute = execute; execute = async (spec, io) => { - if (spec.command === "docker" && spec.args?.includes("compose")) { + if ((spec.command === "docker" || spec.command === "podman") && spec.args?.includes("compose")) { if (spec.args.at(-1) === "pull") { stdout.write("Local images only: skipping registry downloads; missing images will fail startup.\n"); return 0; @@ -433,7 +436,7 @@ export async function run(argv, { if (["up", "down", "status", "logs"].includes(command)) { assertNoArgs(args, command); - return runtimeExecute(composeCommand(command, root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + return runtimeExecute(composeCommand(command, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); } if (command === "open") { assertNoArgs(args, "open"); @@ -523,14 +526,14 @@ export async function run(argv, { await writeRuntimeFiles(root, rollback); const pullCode = await withHeadlessDockerConfig( platform, - composeCommand("pull", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled }), + composeCommand("pull", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled, engine: rollback.engine }), (pullSpec) => runtimeExecute(pullSpec, { stdin, stdout, stderr }) ); if (pullCode !== 0) { await writeRuntimeFiles(root, config); return pullCode; } - const upCode = await runtimeExecute(composeCommand("up", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled }), { stdin, stdout, stderr }); + const upCode = await runtimeExecute(composeCommand("up", root, { profile: rollback.profile, companionsEnabled: rollback.companionsEnabled, engine: rollback.engine }), { stdin, stdout, stderr }); if (upCode !== 0) { await writeRuntimeFiles(root, config); return upCode; @@ -562,18 +565,18 @@ export async function run(argv, { const backupId = await selectLatestBackupId(root); const backupCode = await backupWithWritersPaused(runtimeExecute, root, config, { stdin, stdout, stderr }); if (backupCode !== 0) return backupCode; - const stopCode = await runtimeExecute(composeCommand("stopForPortableRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const stopCode = await runtimeExecute(composeCommand("stopForPortableRestore", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (stopCode !== 0) return stopCode; // The backup just made holds the data from before the restore. const safetyBackupId = await selectLatestBackupId(root).catch(() => "the newest backup"); const restoreCode = await runtimeExecute( - composeCommand("restore", root, { backupId, profile: config.profile }), + composeCommand("restore", root, { backupId, profile: config.profile, engine: config.engine }), { stdin, stdout, stderr } ); if (restoreCode !== 0) { // Exit 3: the restore changed nothing or put every database and file back, so SpaceApp starts again as it was (1.1.8). if (restoreCode === RESTORE_UNCHANGED_EXIT) { - const againCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const againCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); stderr.write(againCode === 0 ? `The restore of ${backupId} did not happen; SpaceApp runs again with its data from before the restore (also kept in the backup ${safetyBackupId}).\n` : `The restore of ${backupId} did not happen and SpaceApp could not be started again; run "${UNIVERSAL_COMMAND} up". Its data from before the restore is kept in the backup ${safetyBackupId}.\n`); @@ -582,7 +585,7 @@ export async function run(argv, { stderr.write(`The restore of ${backupId} failed and SpaceApp stays stopped: the messages above say what is left at the backup's state. To start SpaceApp anyway, run "${UNIVERSAL_COMMAND} up"; to try again, run "${UNIVERSAL_COMMAND} restore". The data from before the restore is kept in the backup ${safetyBackupId}.\n`); return restoreCode; } - const upCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { stdin, stdout, stderr }); + const upCode = await runtimeExecute(composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr }); if (upCode !== 0) return upCode; await restoreBackupExtras({ root, config, backupId, stdin, stdout, stderr, execute: runtimeExecute }) .catch((error) => stderr.write(`The data was restored; the CLI settings of the backup were not: ${error.message}\n`)); @@ -600,7 +603,7 @@ export async function run(argv, { } stdout.write("Stopping and removing SpaceApp containers and network...\n"); const code = await runtimeExecute( - composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (code === 0) { @@ -622,7 +625,7 @@ export async function run(argv, { } stdout.write("Removing SpaceApp containers, network, and Docker volumes...\n"); const code = await runtimeExecute( - composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (code === 0) { @@ -678,16 +681,28 @@ async function installCommand(args, { const { requestedProfile, requestedAccessMode, + requestedEngine, noOpen, companionsEnabled, telemetryEnabled, installId } = wizard; const existingConfig = await loadExistingInstallation(root); + const resolvedEngine = requestedEngine !== undefined + ? resolveInstallEngine(requestedEngine, existingConfig?.engine ?? "docker") + : existingConfig?.engine; + if (existingConfig && requestedEngine !== undefined && (existingConfig.engine ?? "docker") !== resolvedEngine) { + throw new Error( + `Cannot switch container engine from ${existingConfig.engine ?? "docker"} to ${resolvedEngine} on an existing installation because container volumes are not visible across engines. Run "${UNIVERSAL_COMMAND} backup", uninstall, and restore on ${resolvedEngine}.` + ); + } const accessMode = resolveInstallAccessMode( requestedAccessMode, existingConfig?.accessMode ?? "isolated" ); + if (accessMode === "host-root" && resolvedEngine === "podman") { + throw new Error("Host-root access is not supported on Podman. Use --access isolated."); + } if (accessMode === "host-root" && platform !== "linux") { throw new Error("Host-root access is supported only on Linux."); } @@ -709,7 +724,8 @@ async function installCommand(args, { version: runtimeVersion, profile, accessMode, - ...(companionsEnabled ? { companionsEnabled } : {}) + ...(companionsEnabled ? { companionsEnabled } : {}), + ...(resolvedEngine !== undefined ? { engine: resolvedEngine } : {}) }); // The stored choice also drives the app's first_answer event after later updates, so an explicit // SPACEAPP_TELEMETRY at install time is stored like the answer it overrides (as for the install ping). @@ -774,7 +790,7 @@ async function installCommand(args, { stdout, stderr, execute, - installArgs: { root, requestedProfile, requestedAccessMode, noOpen, autoConfirm: true } + installArgs: { root, requestedProfile, requestedAccessMode, engine: result.config.engine, noOpen, autoConfirm: true } }); if (prerequisiteResult.reexecuted) { return prerequisiteResult.code; @@ -859,6 +875,7 @@ async function installCommand(args, { profile, stateRoot: stagedStateRoot, companionsEnabled, + engine: result.config.engine, ...options }); const pullCode = await withHeadlessDockerConfig( @@ -1051,7 +1068,7 @@ async function removeInactiveProfileServices({root, config, stateRoot = root, ex [settings.temporalEnabled, "removeTemporal", "medium"] ]) { if (enabled) continue; - const code = await execute(composeCommand(action, root, {profile, stateRoot}), {stdin, stdout, stderr}); + const code = await execute(composeCommand(action, root, {profile, stateRoot, engine: config.engine}), {stdin, stdout, stderr}); if (code !== 0) { stderr.write("Inactive service cleanup failed. Data volumes were preserved. Run doctor --fix.\n"); return code; @@ -1168,6 +1185,7 @@ async function planInteractiveSetup({ return { requestedProfile: profileChoice, requestedAccessMode: accessChoice, + requestedEngine: parsed.requestedEngine ?? stdin?._spaceappPromptController?.answers?.engine, noOpen: !openBrowserChoice, companionsEnabled: companionsChoice, telemetryEnabled: telemetryChoice, @@ -1245,6 +1263,7 @@ async function planInteractiveSetup({ return { requestedProfile: parsed.requestedProfile, requestedAccessMode: parsed.requestedAccessMode, + requestedEngine: parsed.requestedEngine ?? stdin?._spaceappPromptController?.answers?.engine, noOpen: parsed.noOpen, companionsEnabled: parsed.companionsEnabled, telemetryEnabled: false @@ -1318,7 +1337,7 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex ...plan.actions.map((action) => action.detail) ])) return 1; const prerequisites = await ensureDocker({platform, arch, env, stdin, stdout, stderr, execute, sleep: sleep || wait, - installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,noOpen:true,autoConfirm:true}}); + installArgs: {root,requestedProfile:repaired.profile,requestedAccessMode:repaired.accessMode,engine:repaired.engine,noOpen:true,autoConfirm:true}}); if(prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code; config = repaired; if (await verifyStorage({ root, config, existingConfig: config, operation: "repair", inspectResources, execute, stdout, stderr }) !== 0) return 1; @@ -1326,7 +1345,7 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex await writeRuntimeFiles(root, config); const pullCode = await withHeadlessDockerConfig( platform, - composeCommand("pull", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("pull", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), (pullSpec) => execute(pullSpec, { stdin, stdout, stderr }) ); if (pullCode !== 0) { @@ -1335,7 +1354,7 @@ async function repairRuntime({ root, config, platform, stdin, stdout, stderr, ex } if (await verifyStorage({ root, config, existingConfig: config, operation: "repair", inspectResources, execute, stdout, stderr }) !== 0) return 1; const upCode = await execute( - composeCommand("repair", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("repair", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -1400,7 +1419,7 @@ async function performUpdate({ checkpoint = await createQuiescedCheckpoint(root, config, {stdin, stdout, stderr, execute, platform}); await commitInstallation(root, updated, { persistConfig: false }); const upCode = await execute( - composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }), + composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled, engine: updated.engine }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -1478,7 +1497,7 @@ async function collectFiles(directory, logicalDirectory) { // the database, files, memory and Temporal histories are one moment and CLI sessions survive (external review // 2026-10-16). Compose skips services that are not running. async function backupWithWritersPaused(execute, root, config, io) { - const options = { profile: config.profile, companionsEnabled: config.companionsEnabled }; + const options = { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }; io.stdout.write("Pausing SpaceApp writers for the backup; the app and CLI sessions resume afterwards...\n"); const pauseCode = await execute(composeCommand("pauseWriters", root, options), io); if (pauseCode !== 0) return pauseCode; @@ -1541,7 +1560,7 @@ async function createCheckpoint(root, config, { stdin, stdout, stderr, execute, let dumpCode; try { dumpCode = await execute( - composeCommand("checkpointDump", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("checkpointDump", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin: null, stdout: dumpStream, stderr } ); } finally { @@ -1716,6 +1735,7 @@ async function restoreRuntimeAfterFailedInstall({ function parseInstallArgs(args) { let requestedProfile = "auto"; let requestedAccessMode; + let requestedEngine; let noOpen = false; let companionsEnabled = false; for (let index = 0; index < args.length; index += 1) { @@ -1746,8 +1766,17 @@ function parseInstallArgs(args) { requestedAccessMode = argument.slice("--access=".length); continue; } + if (argument === "--engine" && index + 1 < args.length) { + requestedEngine = args[index + 1]; + index += 1; + continue; + } + if (argument.startsWith("--engine=")) { + requestedEngine = argument.slice("--engine=".length); + continue; + } throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` ); } if ( @@ -1755,13 +1784,17 @@ function parseInstallArgs(args) { ( requestedAccessMode !== undefined && !["isolated", "host-root"].includes(requestedAccessMode) + ) || + ( + requestedEngine !== undefined && + !["docker", "podman"].includes(requestedEngine) ) ) { throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` ); } - return { requestedProfile, requestedAccessMode, noOpen, companionsEnabled }; + return { requestedProfile, requestedAccessMode, requestedEngine, noOpen, companionsEnabled }; } async function loadExistingInstallation(root) { @@ -1796,7 +1829,7 @@ async function workspaceCommand(rawArgs, { root, config, platform, stdin, stdout const [action, identity, ...rest] = args; const applyWorkspaces = async (updated) => { // A user's mounts belong to their own container; "up" recreates only the services whose mounts changed. - const code = await execute(composeCommand(userId ? "up" : "syncWorkspaces", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }), { stdin, stdout, stderr }); + const code = await execute(composeCommand(userId ? "up" : "syncWorkspaces", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled, engine: updated.engine }), { stdin, stdout, stderr }); if (code !== 0) stderr.write(`The change is saved, but the running services could not pick it up. Run "${UNIVERSAL_COMMAND} up".\n`); return code; }; @@ -1852,7 +1885,7 @@ async function exportArchive(archive, action, { root, config, service, stdin, st const out = createWriteStream(archive, { mode: 0o600 }); await once(out, "open"); const code = await execute( - composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, service }), { stdin, stdout: out, stderr } ).catch(() => 1); out.end(); @@ -1892,7 +1925,7 @@ async function restoreUserState({ root, config, directory, userId, stdin, stdout const input = await readFile(userArchive(directory, userId, kind)).catch(() => null); if (!input) continue; const code = await execute( - composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, service }), { stdin, stdout, stderr, input } ).catch(() => 1); if (code === 0) restored.push(label); @@ -1908,7 +1941,7 @@ async function restoreBackupExtras({ root, config, backupId, stdin, stdout, stde const archive = await readFile(join(directory, "cli-config.tar.gz")).catch(() => null); if (archive) { const code = await execute( - composeCommand("importCliSettings", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("importCliSettings", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr, input: archive } ).catch(() => 1); stdout.write(code === 0 ? "CLI settings (MCP servers, base URLs) restored.\n" : "The CLI settings in this backup could not be restored.\n"); @@ -1955,7 +1988,7 @@ async function credentialsCommand(rawArgs, { root, config, stdin, stdout, stderr const value = await readSecret(stdin, stdout, `Enter ${provider} credential: `); await writeCredential(root, provider, value, { userId }); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, service }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1971,7 +2004,7 @@ async function credentialsCommand(rawArgs, { root, config, stdin, stdout, stderr } const removed = await removeCredential(root, provider, { userId }); const syncCode = await execute( - composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("syncCredentials", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, service }), { stdin, stdout, stderr } ); if (syncCode !== 0) { @@ -1997,7 +2030,7 @@ async function waitForUserCli({ root, config, userId, stdin, stdout, stderr, exe const service = `spaceapp-cli-${userSlug(userId)}`; for (let attempt = 0; attempt < USER_CLI_READY_ATTEMPTS; attempt++) { const code = await execute( - composeCommand("cliHostReady", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, service }), + composeCommand("cliHostReady", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, service }), { stdin, stdout: null, stderr: null } ).catch(() => 1); if (code === 0) { @@ -2016,7 +2049,7 @@ async function userCommand(args, { root, config, stdin, stdout, stderr, execute, await saveConfig(root, updated); await writeRuntimeFiles(root, updated); stdout.write(message); - const code = await execute(composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled }), { stdin, stdout, stderr }); + const code = await execute(composeCommand("up", root, { profile: updated.profile, companionsEnabled: updated.companionsEnabled, engine: updated.engine }), { stdin, stdout, stderr }); if (code !== 0) stderr.write(`The change is saved, but the running services could not pick it up. Run "${UNIVERSAL_COMMAND} up".\n`); return code; }; @@ -2070,13 +2103,13 @@ async function providerCommand(rawArgs, { root, config, stdin, stdout, stderr, e stdout.write(`Installing optional provider ${provider} into ${userId ? `the private provider volume of ${userId}` : "this installation's private provider volume"}.\n`); // Keep the existing Claude path compatible with older runtime images. const action=provider === "claude" ? "installClaude" : "installProvider"; - return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, provider, service }), { stdin, stdout, stderr }); + return execute(composeCommand(action, root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine, provider, service }), { stdin, stdout, stderr }); } async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute }) { if (args.length === 1 && args[0] === "rotate-setup-token") { const token = randomBytes(32).toString("base64url"); - const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { + const code = await execute(composeCommand("rotateOwnerSetupToken", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr, @@ -2097,7 +2130,7 @@ async function ownerCommand(args, { root, config, stdin, stdout, stderr, execute if (password.length < 6) { throw new Error("Owner password must be at least 6 characters."); } - return execute(composeCommand("resetOwnerPassword", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), { + return execute(composeCommand("resetOwnerPassword", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr, @@ -2125,7 +2158,7 @@ async function reinstallCommand({ return 0; } const downCode = await execute( - composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("down", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (downCode !== 0) { @@ -2133,7 +2166,7 @@ async function reinstallCommand({ } await writeRuntimeFiles(root, config); const upCode = await execute( - composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("up", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (upCode !== 0) { @@ -2265,7 +2298,7 @@ async function factoryResetCommand({ } const purgeCode = await execute( - composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled }), + composeCommand("purge", root, { profile: config.profile, companionsEnabled: config.companionsEnabled, engine: config.engine }), { stdin, stdout, stderr } ); if (purgeCode !== 0) { @@ -2433,11 +2466,14 @@ async function doctor({ ...resourceChecks.filter((check) => !storagePlan || check.name !== "Free disk"), ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) ]; + const currentConfig = await loadConfig(root).catch(() => null); + const doctorEngine = currentConfig?.engine === "podman" ? "podman" : "docker"; + const engineLabel = doctorEngine === "podman" ? "Podman" : "Docker"; const dockerResults = []; for (const probe of [ - { name: "Docker CLI", command: "docker", args: ["--version"] }, - { name: "Docker Compose", command: "docker", args: ["compose", "version"] }, - { name: "Docker Engine", command: "docker", args: ["info"] } + { name: `${engineLabel} CLI`, command: doctorEngine, args: ["--version"] }, + { name: `${engineLabel} Compose`, command: doctorEngine, args: ["compose", "version"] }, + { name: `${engineLabel} Engine`, command: doctorEngine, args: ["info"] } ]) { const code = dockerReady ? 0 @@ -2655,6 +2691,7 @@ function spawnTrustedCommand(spec, options) { switch (spec.command) { case "codesign": return spawn("codesign", args, options); case "docker": return spawn("docker", args, options); + case "podman": return spawn("podman", args, options); case "hdiutil": return spawn("hdiutil", args, options); case "open": return spawn("open", args, options); case "powershell.exe": diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index e41d6986c..5da0fe3f7 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -81,6 +81,7 @@ const PROFILE_RUNTIME_SETTINGS = Object.freeze({ }) }); export const INSTALL_PROFILES = Object.freeze(["small", "medium", "large", "light", "standard"]); +export const INSTALL_ENGINES = Object.freeze(["docker", "podman"]); export const PROFILE_REQUIREMENTS = Object.freeze({ small: { cpuCount: 4, memoryBytes: 7 * 1024 ** 3, freshDiskBytes: 6.5 * 1024 ** 3 }, medium: { cpuCount: 4, memoryBytes: 11 * 1024 ** 3, freshDiskBytes: 8 * 1024 ** 3 }, @@ -132,7 +133,8 @@ const CONFIG_KEYS = new Set([ "companionsEnabled", "optionalModules", "installId", - "users" + "users", + "engine" ]); // Multi-user (SpaceApp accounts with SPACE_MULTI_USER_ENABLED): every user's CLIs run in their own container, with @@ -197,6 +199,16 @@ export function resolveInstallAccessMode(requestedMode, existingMode = "isolated throw new Error("Install access mode must be isolated or host-root."); } +export function resolveInstallEngine(requestedEngine, existingEngine = "docker") { + if (requestedEngine === undefined) { + return existingEngine; + } + if (INSTALL_ENGINES.includes(requestedEngine)) { + return requestedEngine; + } + throw new Error("Install engine must be docker or podman."); +} + export function versionTuple(version) { if (typeof version !== "string") { return null; @@ -311,13 +323,15 @@ export function createDefaultConfig({ version, profile = "light", accessMode = "isolated", - companionsEnabled = false + companionsEnabled = false, + engine }) { assertVersion(version); if (!INSTALL_PROFILES.includes(profile)) { throw new Error("Default config requires a resolved installation profile."); } const resolvedAccessMode = resolveInstallAccessMode(accessMode); + const resolvedEngine = engine !== undefined ? resolveInstallEngine(engine) : undefined; return { schemaVersion: CONFIG_SCHEMA_VERSION, version, @@ -329,6 +343,7 @@ export function createDefaultConfig({ accessMode: resolvedAccessMode, companionsEnabled, optionalModules: "none", + ...(resolvedEngine !== undefined ? { engine: resolvedEngine } : {}), workspaces: [] }; } @@ -367,6 +382,12 @@ export function validateConfig(config) { if (config.accessMode !== "isolated" && config.accessMode !== "host-root") { throw new Error("accessMode must be isolated or host-root."); } + if (config.engine !== undefined && !INSTALL_ENGINES.includes(config.engine)) { + throw new Error("engine must be docker or podman."); + } + if (config.engine === "podman" && config.accessMode === "host-root") { + throw new Error("Host-root access is not supported on Podman. Use --access isolated."); + } if (typeof config.companionsEnabled !== "boolean") { throw new Error("companionsEnabled must be boolean."); } @@ -676,11 +697,13 @@ const CLI_SANDBOX_OPTIONS = [" security_opt:", " - seccomp=./seccomp-cli export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { validateConfig(config); + const podmanBind = config.engine === "podman" ? [" bind:", " selinux: z"] : []; const mounts = config.workspaces.flatMap((workspace) => [ " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` + ` read_only: ${workspace.readOnly}`, + ...podmanBind ]); const service = mounts.length > 0 ? [" volumes:", ...mounts] @@ -695,7 +718,8 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, ` target: ${JSON.stringify(workspace.containerPath)}`, - ` read_only: ${workspace.readOnly}` + ` read_only: ${workspace.readOnly}`, + ...podmanBind ]; const userServices = users.flatMap((user) => { const slug = userSlug(user.id); @@ -909,7 +933,8 @@ export function composeCommand(action, root, options = {}) { if (!selected) { throw new Error(`Unsupported Compose action "${action}".`); } - return { command: "docker", args: [...base, ...selected] }; + const engine = resolveInstallEngine(options.engine); + return { command: engine, args: [...base, ...selected] }; } export function composeProjectName(root) { @@ -984,7 +1009,8 @@ export async function prepareInstallation(root, { version, profile, accessMode, - companionsEnabled + companionsEnabled, + engine }) { validateHome(root); await mkdir(root, { recursive: true, mode: 0o700 }); @@ -997,19 +1023,32 @@ export async function prepareInstallation(root, { ? undefined : resolveInstallProfile(profile, profile === "auto" ? await inspectSystemResources(root) : totalmem()); let config; + let hadExistingConfig = true; try { config = await loadConfig(root); } catch (error) { if (error?.code !== "ENOENT") { throw error; } + hadExistingConfig = false; config = createDefaultConfig({ version, profile: resolvedProfile ?? "light", accessMode: resolveInstallAccessMode(accessMode), - companionsEnabled + companionsEnabled, + ...(engine !== undefined ? { engine } : {}) }); } + if (hadExistingConfig && engine !== undefined) { + const currentEngine = config.engine ?? "docker"; + const targetEngine = resolveInstallEngine(engine, currentEngine); + if (targetEngine !== currentEngine) { + throw new Error( + `Cannot switch container engine from ${currentEngine} to ${targetEngine} on an existing installation because container volumes are not visible across engines. Run "${UNIVERSAL_COMMAND} backup", uninstall, and restore on ${targetEngine}.` + ); + } + config = { ...config, engine: targetEngine }; + } if (config.version !== version) { config = { ...config, diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index 42bb242da..317cf688c 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -119,6 +119,85 @@ export async function prepareDockerCliPath({ return directory; } +export async function detectContainerEngine({ + execute, + requestedEngine +} = {}) { + if (requestedEngine === "docker" || requestedEngine === "podman") { + return requestedEngine; + } + if (await dockerCliAndComposeAreAvailable(execute)) { + return "docker"; + } + if (await podmanCliAndComposeAreAvailable(execute)) { + return "podman"; + } + return "docker"; +} + +export async function podmanCliAndComposeAreAvailable(execute) { + for (const spec of [ + { command: "podman", args: ["--version"] }, + { command: "podman", args: ["compose", "version"] } + ]) { + if (await execute(spec, { stdout: null, stderr: null }) !== 0) { + return false; + } + } + return true; +} + +export async function ensurePodmanAvailable({ + platform = process.platform, + stdout = process.stdout, + stderr = process.stderr, + execute +} = {}) { + if (platform === "win32") { + stderr.write("Podman engine support in SpaceApp is available on Linux and macOS only.\n"); + return { code: 1, reexecuted: false }; + } + if (!await podmanCliAndComposeAreAvailable(execute)) { + stderr.write( + "Podman >= 5 and a Podman Compose provider (such as docker-compose v2) are required for --engine podman.\n" + ); + return { code: 1, reexecuted: false }; + } + let infoOut = ""; + const infoCode = await execute( + { command: "podman", args: ["info", "--format", "{{json .}}"] }, + { + stdout: { write(chunk) { infoOut += String(chunk); } }, + stderr: null + } + ); + if (infoCode !== 0) { + stderr.write( + platform === "darwin" + ? "Podman is installed, but the Podman machine is not running. Run \"podman machine init && podman machine start\" and retry.\n" + : "Podman is installed, but \"podman info\" failed. Verify your rootless Podman setup and retry.\n" + ); + return { code: infoCode, reexecuted: false }; + } + try { + const parsed = JSON.parse(infoOut.trim()); + const versionStr = String(parsed?.version?.Version || ""); + const major = Number(versionStr.split(".")[0]); + if (Number.isFinite(major) && major > 0 && major < 5) { + stderr.write(`Podman >= 5 is required (detected ${versionStr}). Upgrade Podman and retry.\n`); + return { code: 1, reexecuted: false }; + } + if (platform === "linux" && parsed?.host?.cgroupVersion && parsed.host.cgroupVersion !== "v2") { + stderr.write(`Rootless Podman on Linux requires cgroup v2 (detected ${parsed.host.cgroupVersion}).\n`); + return { code: 1, reexecuted: false }; + } + } catch { + // Allow non-JSON mock output in unit tests. + } + stdout.write("Podman engine is ready.\n"); + return { code: 0, reexecuted: false }; +} + export async function ensureDockerAvailable({ platform = process.platform, arch = process.arch, @@ -135,7 +214,10 @@ export async function ensureDockerAvailable({ installArgs, scheduleResume = scheduleWindowsInstallResume, confirmRestart = confirmWindowsRestart -}) { +} = {}) { + if (installArgs?.engine === "podman") { + return ensurePodmanAvailable({ platform, stdout, stderr, execute }); + } if (await dockerIsReady(execute)) { return { code: 0, reexecuted: false }; } diff --git a/packages/run-spaceapp/src/storage.mjs b/packages/run-spaceapp/src/storage.mjs index c8b4cfadc..6bb574f83 100644 --- a/packages/run-spaceapp/src/storage.mjs +++ b/packages/run-spaceapp/src/storage.mjs @@ -96,7 +96,8 @@ async function installedImageDigests(root, version) { } export async function inspectStoragePlan({ root, config, existingConfig, resources, execute, operation, fetch = globalThis.fetch }) { - const dockerInfo = await captureProbe(execute, { command: "docker", args: ["info", "--format", "{{json .}}"] }); + const engineCmd = config?.engine ?? existingConfig?.engine ?? "docker"; + const dockerInfo = await captureProbe(execute, { command: engineCmd, args: ["info", "--format", "{{json .}}"] }); let engine = null; try { engine = JSON.parse(dockerInfo); } catch { /* Conservative reserve when the engine is unavailable. */ } const cachedLayers = new Set(); @@ -104,7 +105,7 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const existingDigests = await installedImageDigests(root, existingConfig.version); for (const type of ["core", "cli", "browser"]) { for (const ref of localImageRefs(type, existingConfig.version, existingDigests[type])) { - const output = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); + const output = await captureProbe(execute, { command: engineCmd, args: ["image", "inspect", "--format", "{{json .RootFS.Layers}}", ref] }); try { for (const layer of JSON.parse(output)) cachedLayers.add(layer); break; } catch {} } } @@ -121,20 +122,21 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc let missingImageBytes = 0; const missingImages = []; let sharedLayersEstimated = false; + const engineArch = engine?.Architecture ?? engine?.host?.arch ?? ""; for (const [type, image] of images) { // SpaceApp images pulled by digest have no local version tag: look them up by their pinned digest as well. const refs = ["core", "cli", "browser"].includes(type) ? localImageRefs(type, config.version) : [image]; let cached = null; for (const ref of refs) { - cached = await captureProbe(execute, { command: "docker", args: ["image", "inspect", "--format", "{{.Id}}", ref] }); - if (/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) break; + cached = await captureProbe(execute, { command: engineCmd, args: ["image", "inspect", "--format", "{{.Id}}", ref] }); + if (/^(?:sha256:)?[a-f0-9]{64}$/.test(cached ?? "")) break; } - if (!/^sha256:[a-f0-9]{64}$/.test(cached ?? "")) { + if (!/^(?:sha256:)?[a-f0-9]{64}$/.test(cached ?? "")) { missingImages.push(image); let reserve = imageReserve[type] ?? GiB; if (engine && existingConfig && cachedLayers.size && typeof fetch === "function") { try { - const metadata = await registryLayers(image, /arm|aarch64/.test(engine.Architecture ?? "") ? "arm64" : "amd64", fetch); + const metadata = await registryLayers(image, /arm|aarch64/.test(engineArch) ? "arm64" : "amd64", fetch); if (metadata) { reserve = estimateMissingLayers(metadata.layers, metadata.diffIds, cachedLayers); sharedLayersEstimated = true; } } catch { /* Offline/private registry: retain the full conservative reserve. */ } } @@ -154,16 +156,17 @@ export async function inspectStoragePlan({ root, config, existingConfig, resourc const free = await captureProbe(execute, composeCommand("storageFree", root, existingConfig)); if (/^\d+$/.test(free ?? "")) dockerFreeDiskBytes = Number(free); } - // Linux can measure the Docker filesystem directly even on a fresh install. - if (engine?.DockerRootDir && process.platform === "linux") { + const engineRootDir = engine?.DockerRootDir ?? engine?.store?.graphRoot; + // Linux can measure the Docker/Podman filesystem directly even on a fresh install. + if (engineRootDir && process.platform === "linux") { const { inspectSystemResources } = await import("./index.mjs"); - try { dockerFreeDiskBytes = (await inspectSystemResources(engine.DockerRootDir)).freeDiskBytes; } catch {} + try { dockerFreeDiskBytes = (await inspectSystemResources(engineRootDir)).freeDiskBytes; } catch {} } return { ...plan, missingImages, sharedLayersEstimated, dockerFreeDiskBytes, resources: { ...resources, freeDiskBytes: dockerFreeDiskBytes === null ? resources.freeDiskBytes : Math.min(resources.freeDiskBytes, dockerFreeDiskBytes), - engineCpuCount: engine?.NCPU, - engineMemoryBytes: engine?.MemTotal + engineCpuCount: engine?.NCPU ?? engine?.host?.cpus, + engineMemoryBytes: engine?.MemTotal ?? engine?.host?.memTotal } }; } From 843096284cbf6c04b2e5e11e7e852b610b7d95b4 Mon Sep 17 00:00:00 2001 From: huhuli083-creator Date: Sun, 25 Oct 2026 10:06:47 +0000 Subject: [PATCH 2/6] Add files via upload Signed-off-by: huhuli083-creator --- packages/run-spaceapp/tests/podman.test.mjs | 376 ++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 packages/run-spaceapp/tests/podman.test.mjs diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs new file mode 100644 index 000000000..2aa5310c7 --- /dev/null +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -0,0 +1,376 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import { Readable } from "node:stream"; +import path from "node:path"; +import test from "node:test"; + +import { + composeCommand, + createDefaultConfig, + loadConfig, + prepareInstallation, + renderWorkspaceCompose, + resolveInstallEngine, + saveConfig, + validateConfig +} from "../src/index.mjs"; +import { + detectContainerEngine, + ensureDockerAvailable, + ensurePodmanAvailable +} from "../src/prerequisites.mjs"; +import { + inspectStoragePlan +} from "../src/storage.mjs"; +import { run } from "../src/cli.mjs"; + +function ttyStdin(...answers) { + let index = 0; + let eventMode = false; + const dataHandlers = []; + const endHandlers = []; + const keypressHandlers = []; + const stream = { + isTTY: true, + setRawMode() {}, + resume() {}, + pause() {}, + setEncoding() {}, + on(event, handler) { + if (event === "data") { + dataHandlers.push(handler); + if (!eventMode) { + eventMode = true; + queueMicrotask(() => deliverEvents()); + } + } else if (event === "end") { + endHandlers.push(handler); + } else if (event === "keypress") { + keypressHandlers.push(handler); + if (!eventMode) { + eventMode = true; + queueMicrotask(() => deliverEvents()); + } + } + return stream; + }, + once(event, handler) { + if (event === "data" || event === "end" || event === "keypress") { + stream.on(event, handler); + } + return stream; + }, + removeListener() { return stream; }, + off() { return stream; }, + listenerCount(event) { + if (event === "data") return dataHandlers.length; + if (event === "end") return endHandlers.length; + if (event === "keypress") return keypressHandlers.length; + return 0; + }, + [Symbol.asyncIterator]() { + return { + async next() { + if (index >= answers.length) return { done: true }; + const answer = answers[index]; + index += 1; + return { done: false, value: `${answer}\n` }; + }, + async return() { return { done: true }; } + }; + } + }; + function deliverEvents() { + while (index < answers.length) { + const answer = answers[index]; + index += 1; + if (keypressHandlers.length > 0) { + for (const character of answer) { + for (const handler of [...keypressHandlers]) { + handler({ sequence: character, name: undefined, ctrl: false, meta: false, shift: false }); + } + } + for (const handler of [...keypressHandlers]) { + handler({ sequence: "\r", name: "return", ctrl: false, meta: false, shift: false }); + } + } else { + for (const handler of [...dataHandlers]) { + handler(`${answer}\n`); + } + } + } + for (const handler of [...endHandlers]) handler(); + } + return stream; +} + +function createIo() { + let out = ""; + let err = ""; + return { + stdin: { isTTY: false }, + stdout: { write(chunk) { out += String(chunk); } }, + stderr: { write(chunk) { err += String(chunk); } }, + get stdoutText() { return out; }, + get stderrText() { return err; } + }; +} + +test("resolveInstallEngine accepts docker and podman and defaults to docker", () => { + assert.equal(resolveInstallEngine(undefined), "docker"); + assert.equal(resolveInstallEngine(undefined, "podman"), "podman"); + assert.equal(resolveInstallEngine("docker"), "docker"); + assert.equal(resolveInstallEngine("podman"), "podman"); + assert.throws(() => resolveInstallEngine("containerd"), /Install engine must be docker or podman/); +}); + +test("detectContainerEngine prefers explicit flag, then docker, then podman", async () => { + const podmanOnlyExec = async (spec) => (spec.command === "podman" ? 0 : 127); + assert.equal(await detectContainerEngine({ execute: podmanOnlyExec }), "podman"); + assert.equal(await detectContainerEngine({ execute: podmanOnlyExec, requestedEngine: "docker" }), "docker"); + + const bothExec = async () => 0; + assert.equal(await detectContainerEngine({ execute: bothExec }), "docker"); + assert.equal(await detectContainerEngine({ execute: bothExec, requestedEngine: "podman" }), "podman"); +}); + +test("composeCommand uses podman when engine is podman across lifecycle actions", () => { + for (const action of ["up", "down", "status", "logs", "pull", "backup", "purge"]) { + const spec = composeCommand(action, "/tmp/spaceapp-podman", { + profile: "light", + engine: "podman" + }); + assert.equal(spec.command, "podman"); + assert.equal(spec.args[0], "compose"); + } +}); + +test("validateConfig rejects host-root access mode when engine is podman", () => { + const validPodman = createDefaultConfig({ + version: "1.1.12", + profile: "light", + accessMode: "isolated", + engine: "podman" + }); + assert.doesNotThrow(() => validateConfig(validPodman)); + + assert.throws( + () => + validateConfig({ + ...validPodman, + accessMode: "host-root" + }), + /Host-root access is not supported on Podman\. Use --access isolated\./ + ); +}); + +test("renderWorkspaceCompose adds SELinux relabeling (:z) for podman bind mounts", () => { + let config = createDefaultConfig({ + version: "1.1.12", + profile: "light", + accessMode: "isolated", + engine: "podman" + }); + config = { + ...config, + workspaces: [ + { + id: "project-01234567", + hostPath: "/home/user/project", + containerPath: "/workspaces/project", + readOnly: false + } + ] + }; + const renderedPodman = renderWorkspaceCompose(config); + assert.match(renderedPodman, /bind:\n\s+selinux: z/); + + const renderedDocker = renderWorkspaceCompose({ ...config, engine: "docker" }); + assert.doesNotMatch(renderedDocker, /selinux: z/); +}); + +test("prepareInstallation persists engine and refuses switching engines without backup/restore", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spaceapp-podman-prep-")); + try { + const prepared = await prepareInstallation(root, { + version: "1.1.12", + profile: "light", + accessMode: "isolated", + engine: "podman" + }); + assert.equal(prepared.config.engine, "podman"); + await saveConfig(root, prepared.config); + + const loaded = await loadConfig(root); + assert.equal(loaded.engine, "podman"); + + await assert.rejects( + () => + prepareInstallation(root, { + version: "1.1.12", + profile: "light", + accessMode: "isolated", + engine: "docker" + }), + /Cannot switch container engine from podman to docker on an existing installation/ + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("ensurePodmanAvailable verifies Podman >= 5, cgroup v2 on Linux, and machine readiness on macOS", async () => { + const ioOk = createIo(); + const resOk = await ensureDockerAvailable({ + platform: "linux", + stdout: ioOk.stdout, + stderr: ioOk.stderr, + installArgs: { engine: "podman" }, + execute: async (spec, io) => { + if (spec.command === "podman" && spec.args[0] === "info") { + io.stdout?.write(JSON.stringify({ + version: { Version: "5.2.1" }, + host: { cgroupVersion: "v2", cpus: 4, memTotal: 8589934592 }, + store: { graphRoot: "/tmp" } + })); + } + return 0; + } + }); + assert.equal(resOk.code, 0); + assert.match(ioOk.stdoutText, /Podman engine is ready/); + + const ioOld = createIo(); + const resOld = await ensurePodmanAvailable({ + platform: "linux", + stdout: ioOld.stdout, + stderr: ioOld.stderr, + execute: async (spec, io) => { + if (spec.command === "podman" && spec.args[0] === "info") { + io.stdout?.write(JSON.stringify({ + version: { Version: "4.9.3" }, + host: { cgroupVersion: "v2" } + })); + } + return 0; + } + }); + assert.equal(resOld.code, 1); + assert.match(ioOld.stderrText, /Podman >= 5 is required/); + + const ioMacStopped = createIo(); + const resMacStopped = await ensurePodmanAvailable({ + platform: "darwin", + stdout: ioMacStopped.stdout, + stderr: ioMacStopped.stderr, + execute: async (spec) => (spec.args[0] === "info" ? 125 : 0) + }); + assert.equal(resMacStopped.code, 125); + assert.match(ioMacStopped.stderrText, /podman machine init && podman machine start/); +}); + +test("inspectStoragePlan queries podman info and podman image inspect when engine is podman", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spaceapp-podman-storage-")); + try { + const commands = []; + const execute = async (spec, io) => { + commands.push(`${spec.command} ${spec.args.join(" ")}`); + if (spec.command === "podman" && spec.args[0] === "info") { + io.stdout?.write(JSON.stringify({ + host: { cpus: 6, memTotal: 17179869184, arch: "amd64" }, + store: { graphRoot: root } + })); + return 0; + } + if (spec.command === "podman" && spec.args[0] === "image") { + io.stdout?.write("sha256:" + "a".repeat(64) + "\n"); + return 0; + } + return 1; + }; + + const config = createDefaultConfig({ + version: "1.1.12", + profile: "light", + accessMode: "isolated", + engine: "podman" + }); + const plan = await inspectStoragePlan({ + root, + config, + existingConfig: null, + resources: { cpuCount: 8, totalMemoryBytes: 17179869184, freeDiskBytes: 50 * 1024 ** 3 }, + execute, + operation: "fresh" + }); + assert.equal(plan.resources.engineCpuCount, 6); + assert.equal(plan.resources.engineMemoryBytes, 17179869184); + assert.equal(plan.missingImages.length, 0); + assert.ok(commands.every((cmd) => cmd.startsWith("podman "))); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("runCli install --engine podman persists engine and runs lifecycle commands with podman", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spaceapp-podman-cli-")); + try { + const executed = []; + const execute = async (spec, io) => { + executed.push(`${spec.command} ${(spec.args || []).join(" ")}`); + if (spec.command === "podman" && spec.args?.[0] === "info") { + io?.stdout?.write(JSON.stringify({ + version: { Version: "5.3.0" }, + host: { cgroupVersion: "v2", cpus: 8, memTotal: 17179869184 }, + store: { graphRoot: root } + })); + } + return 0; + }; + + const ioInstall = createIo(); + const code = await run(["install", "--engine", "podman", "--profile", "light", "--no-open"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: ttyStdin("", "", "", "", "", "y"), + stdout: ioInstall.stdout, + stderr: ioInstall.stderr, + execute, + prepareDockerPath: async () => null, + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 50 * 1024 ** 3 + }), + request: async (url) => { + if (url.endsWith("/readyz")) { + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response(JSON.stringify({ setupRequired: true, expiresAt: "2099-01-01T00:00:00.000Z" }), { status: 200, headers: { "content-type": "application/json" } }); + } + }); + assert.equal(code, 0, `STDERR: ${ioInstall.stderrText}\nSTDOUT: ${ioInstall.stdoutText}`); + + const saved = await loadConfig(root); + assert.equal(saved.engine, "podman"); + assert.ok(executed.some((cmd) => cmd.startsWith("podman compose "))); + assert.ok(!executed.some((cmd) => cmd.startsWith("docker "))); + + executed.length = 0; + const ioStatus = createIo(); + const statusCode = await run(["status"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: ioStatus.stdin, + stdout: ioStatus.stdout, + stderr: ioStatus.stderr, + execute + }); + assert.equal(statusCode, 0); + assert.equal(executed.length, 1); + assert.match(executed[0], /^podman compose /); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From 6e562c663dc12ae479f052fad989f9186bcd7398 Mon Sep 17 00:00:00 2001 From: huhuli083-creator Date: Sun, 25 Oct 2026 11:34:39 +0000 Subject: [PATCH 3/6] Add files via upload Signed-off-by: huhuli083-creator --- packages/run-spaceapp/src/cli.mjs | 50 +++++++++++++++------ packages/run-spaceapp/src/index.mjs | 25 +++++++---- packages/run-spaceapp/src/prerequisites.mjs | 8 ++-- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index c59fb11b4..9939e9df8 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -688,10 +688,17 @@ async function installCommand(args, { installId } = wizard; const existingConfig = await loadExistingInstallation(root); - const resolvedEngine = requestedEngine !== undefined - ? resolveInstallEngine(requestedEngine, existingConfig?.engine ?? "docker") - : existingConfig?.engine; - if (existingConfig && requestedEngine !== undefined && (existingConfig.engine ?? "docker") !== resolvedEngine) { + const effectiveRequestedEngine = requestedEngine ?? (env?.SPACEAPP_CONTAINER_ENGINE || undefined); + let resolvedEngine = existingConfig?.engine; + if (effectiveRequestedEngine !== undefined && effectiveRequestedEngine !== "auto") { + resolvedEngine = resolveInstallEngine(effectiveRequestedEngine, existingConfig?.engine ?? "docker"); + } + if ( + existingConfig && + effectiveRequestedEngine !== undefined && + effectiveRequestedEngine !== "auto" && + (existingConfig.engine ?? "docker") !== resolvedEngine + ) { throw new Error( `Cannot switch container engine from ${existingConfig.engine ?? "docker"} to ${resolvedEngine} on an existing installation because container volumes are not visible across engines. Run "${UNIVERSAL_COMMAND} backup", uninstall, and restore on ${resolvedEngine}.` ); @@ -712,6 +719,12 @@ async function installCommand(args, { ); } await prepareDockerPath({ platform, env }); + if (effectiveRequestedEngine === "auto") { + resolvedEngine = existingConfig?.engine ?? await detectContainerEngine({ execute, requestedEngine: "auto", env }); + if (accessMode === "host-root" && resolvedEngine === "podman") { + throw new Error("Host-root access is not supported on Podman. Use --access isolated."); + } + } const resources = await inspectResources(root); let profile = requestedProfile === "auto" && existingConfig ? existingConfig.profile : resolveInstallProfile(requestedProfile, resources); @@ -773,7 +786,9 @@ async function installCommand(args, { stdin, inspectResources, resources, - profile, operation + profile, + engine: result.config.engine, + operation }); stderr.write( `Installation stopped before downloading images. Fix the failed checks and run "${UNIVERSAL_COMMAND} install" again.\n` @@ -824,6 +839,7 @@ async function installCommand(args, { inspectResources, resources, profile, + engine: result.config.engine, dockerReady: true, operation, storagePlan }); if (doctorCode !== 0) { @@ -842,6 +858,7 @@ async function installCommand(args, { root, stateRoot: stagedStateRoot, profile, + engine: result.config.engine, platform, stdin, stdout, @@ -856,6 +873,7 @@ async function installCommand(args, { stagedStateRoot, existingConfig, attemptedProfile: profile, + engine: result.config.engine, companionsEnabled, platform, stdin, @@ -1642,6 +1660,7 @@ async function reportInstallDiagnostics({ root, stateRoot, profile, + engine, platform, stdin, stdout, @@ -1656,6 +1675,7 @@ async function reportInstallDiagnostics({ composeCommand(action, root, { profile, stateRoot, + engine, lines: 200 }), { stdin, stdout, stderr }, @@ -1679,6 +1699,7 @@ async function restoreRuntimeAfterFailedInstall({ stagedStateRoot, existingConfig, attemptedProfile, + engine, companionsEnabled, platform, stdin, @@ -1698,6 +1719,7 @@ async function restoreRuntimeAfterFailedInstall({ const restoreCode = await runtimeExecute( composeCommand("up", root, { profile: existingConfig.profile, + engine: existingConfig.engine, companionsEnabled: existingConfig.companionsEnabled }) ); @@ -1717,6 +1739,7 @@ async function restoreRuntimeAfterFailedInstall({ composeCommand("down", root, { profile: existingConfig?.profile ?? attemptedProfile, stateRoot: existingConfig ? root : stagedStateRoot, + engine: existingConfig?.engine ?? engine, companionsEnabled: existingConfig?.companionsEnabled ?? companionsEnabled }) ); @@ -1776,7 +1799,7 @@ function parseInstallArgs(args) { continue; } throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--engine auto|docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` ); } if ( @@ -1787,11 +1810,11 @@ function parseInstallArgs(args) { ) || ( requestedEngine !== undefined && - !["docker", "podman"].includes(requestedEngine) + !["auto", "docker", "podman"].includes(requestedEngine) ) ) { throw new Error( - `Usage: ${UNIVERSAL_COMMAND} install [--engine docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` + `Usage: ${UNIVERSAL_COMMAND} install [--engine auto|docker|podman] [--profile auto|small|medium|large|light|standard] [--access isolated|host-root] [--with-companions] [--no-open]` ); } return { requestedProfile, requestedAccessMode, requestedEngine, noOpen, companionsEnabled }; @@ -2395,12 +2418,12 @@ async function updateCommand(args, { root, config, version, platform, stdin, std } } const prerequisites = await ensureDocker({ platform, arch, env, stdin, stdout, stderr, execute, - installArgs: { root, requestedProfile: config.profile, noOpen: true, autoConfirm: true } }); + installArgs: { root, requestedProfile: config.profile, engine: config.engine, noOpen: true, autoConfirm: true } }); if (prerequisites.reexecuted || prerequisites.code !== 0) return prerequisites.code; const resources = await inspectResources(root); const storagePlan = await inspectStoragePlan({ root, config: { ...config, version: targetVersion }, existingConfig: config, resources, execute, operation: "upgrade" }); if (await doctor({ root, platform, stdout, stderr, execute, stdin, inspectResources, resources, - profile: config.profile, dockerReady: true, operation: "upgrade", storagePlan }) !== 0) return 1; + profile: config.profile, engine: config.engine, dockerReady: true, operation: "upgrade", storagePlan }) !== 0) return 1; return performUpdate({ root, config, @@ -2439,6 +2462,7 @@ async function doctor({ inspectResources, resources, profile, + engine, operation, storagePlan, dockerReady = false @@ -2467,7 +2491,7 @@ async function doctor({ ...(storagePlan ? storageChecks(storagePlan, detectedResources) : []) ]; const currentConfig = await loadConfig(root).catch(() => null); - const doctorEngine = currentConfig?.engine === "podman" ? "podman" : "docker"; + const doctorEngine = (engine ?? currentConfig?.engine) === "podman" ? "podman" : "docker"; const engineLabel = doctorEngine === "podman" ? "Podman" : "Docker"; const dockerResults = []; for (const probe of [ @@ -2496,7 +2520,7 @@ async function doctor({ detail: dockerCompose.code === 0 ? "available" : dockerCli.code !== 0 - ? "not available because Docker CLI is missing" + ? `not available because ${engineLabel} CLI is missing` : `plugin unavailable (exit ${dockerCompose.code})` }); checks.push({ @@ -2506,7 +2530,7 @@ async function doctor({ ? "available" : dockerCli.code === 0 ? "installed but not running or inaccessible" - : "not reachable because Docker CLI is missing" + : `not reachable because ${engineLabel} CLI is missing` }); for (const check of checks) { (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); diff --git a/packages/run-spaceapp/src/index.mjs b/packages/run-spaceapp/src/index.mjs index 5da0fe3f7..e4671293c 100644 --- a/packages/run-spaceapp/src/index.mjs +++ b/packages/run-spaceapp/src/index.mjs @@ -697,7 +697,9 @@ const CLI_SANDBOX_OPTIONS = [" security_opt:", " - seccomp=./seccomp-cli export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { validateConfig(config); - const podmanBind = config.engine === "podman" ? [" bind:", " selinux: z"] : []; + const isPodman = config.engine === "podman"; + const podmanBind = isPodman ? [" bind:", " selinux: z"] : []; + const roBindSuffix = isPodman ? ":ro,z" : ":ro"; const mounts = config.workspaces.flatMap((workspace) => [ " - type: bind", ` source: ${JSON.stringify(workspace.hostPath)}`, @@ -705,11 +707,18 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { ` read_only: ${workspace.readOnly}`, ...podmanBind ]); - const service = mounts.length > 0 - ? [" volumes:", ...mounts] + const coreMounts = isPodman ? [" - ./backups:/backups:z", ...mounts] : mounts; + const cliMounts = isPodman + ? [" - ./secrets/providers:/run/spaceapp-secrets/providers:ro,z", ...mounts] + : mounts; + const coreService = coreMounts.length > 0 + ? [" volumes:", ...coreMounts] + : [" volumes: []"]; + const cliService = cliMounts.length > 0 + ? [" volumes:", ...cliMounts] : [" volumes: []"]; if (config.users === undefined) { - return ["services:", " spaceapp-core:", ...service, " spaceapp-cli:", ...service, ...(cliSandbox ? CLI_SANDBOX_OPTIONS : []), ""].join("\n"); + return ["services:", " spaceapp-core:", ...coreService, " spaceapp-cli:", ...cliService, ...(cliSandbox ? CLI_SANDBOX_OPTIONS : []), ""].join("\n"); } // "user enable": accounts are on, and the core starts a USER's CLIs only in that user's own container. const users = config.users; @@ -743,7 +752,7 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { ` - spaceapp-memory-${slug}:/var/lib/spaceapp/memory`, ` - spaceapp-cli-socket-${slug}:/run/spaceapp-cli`, ` - spaceapp-workspaces-${slug}:/workspaces`, - ` - ./secrets/users/${slug}/providers:/run/spaceapp-secrets/providers:ro`, + ` - ./secrets/users/${slug}/providers:/run/spaceapp-secrets/providers${roBindSuffix}`, ...user.workspaces.flatMap(bind), // Its own network: internet only, never the owner's internal services (temporal, companions) or other users. " networks:", @@ -757,11 +766,11 @@ export function renderWorkspaceCompose(config, { cliSandbox = false } = {}) { ' SPACE_MULTI_USER_ENABLED: "true"', " SPACE_CLI_HOST_SOCKETS_FILE: /etc/spaceapp/cli-hosts.json", " volumes:", - ...(mounts.length > 0 ? mounts : []), - " - ./cli-hosts.json:/etc/spaceapp/cli-hosts.json:ro", + ...(coreMounts.length > 0 ? coreMounts : []), + ` - ./cli-hosts.json:/etc/spaceapp/cli-hosts.json${roBindSuffix}`, ...users.map((user) => ` - spaceapp-cli-socket-${userSlug(user.id)}:/run/spaceapp-cli-users/${userSlug(user.id)}`), " spaceapp-cli:", - ...service, + ...cliService, ...(cliSandbox ? CLI_SANDBOX_OPTIONS : []), ...userServices, ...(users.length > 0 ? ["volumes:"] : []), diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index 317cf688c..4cff7aefa 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -121,10 +121,12 @@ export async function prepareDockerCliPath({ export async function detectContainerEngine({ execute, - requestedEngine + requestedEngine, + env = process.env } = {}) { - if (requestedEngine === "docker" || requestedEngine === "podman") { - return requestedEngine; + const candidate = requestedEngine ?? env?.SPACEAPP_CONTAINER_ENGINE; + if (candidate === "docker" || candidate === "podman") { + return candidate; } if (await dockerCliAndComposeAreAvailable(execute)) { return "docker"; From 92e3612e40c254f4d0de20af1d0bc4e6bf5a7869 Mon Sep 17 00:00:00 2001 From: huhuli083-creator Date: Sun, 25 Oct 2026 11:34:54 +0000 Subject: [PATCH 4/6] Add files via upload Signed-off-by: huhuli083-creator --- packages/run-spaceapp/tests/podman.test.mjs | 60 ++++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index 2aa5310c7..ccfff8c3d 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -13,6 +13,7 @@ import { renderWorkspaceCompose, resolveInstallEngine, saveConfig, + userSlug, validateConfig } from "../src/index.mjs"; import { @@ -185,10 +186,18 @@ test("renderWorkspaceCompose adds SELinux relabeling (:z) for podman bind mounts }; const renderedPodman = renderWorkspaceCompose(config); assert.match(renderedPodman, /bind:\n\s+selinux: z/); - + assert.ok(renderedPodman.includes("./backups:/backups:z")); + assert.ok(renderedPodman.includes("./secrets/providers:/run/spaceapp-secrets/providers:ro,z")); + const renderedPodmanMultiUser = renderWorkspaceCompose({ + ...config, + users: [{ id: "user:alice", workspaces: [] }] + }); + assert.ok(renderedPodmanMultiUser.includes("./cli-hosts.json:/etc/spaceapp/cli-hosts.json:ro,z")); + assert.ok(renderedPodmanMultiUser.includes(`./secrets/users/${userSlug("user:alice")}/providers:/run/spaceapp-secrets/providers:ro,z`)); const renderedDocker = renderWorkspaceCompose({ ...config, engine: "docker" }); assert.doesNotMatch(renderedDocker, /selinux: z/); -}); + assert.ok(!renderedDocker.includes(":ro,z")); + }); test("prepareInstallation persists engine and refuses switching engines without backup/restore", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "spaceapp-podman-prep-")); @@ -354,6 +363,9 @@ test("runCli install --engine podman persists engine and runs lifecycle commands const saved = await loadConfig(root); assert.equal(saved.engine, "podman"); + assert.match(ioInstall.stdoutText, /PASS Podman CLI: available/); + assert.match(ioInstall.stdoutText, /PASS Podman Compose: available/); + assert.match(ioInstall.stdoutText, /PASS Podman Engine: available/); assert.ok(executed.some((cmd) => cmd.startsWith("podman compose "))); assert.ok(!executed.some((cmd) => cmd.startsWith("docker "))); @@ -374,3 +386,47 @@ test("runCli install --engine podman persists engine and runs lifecycle commands await rm(root, { recursive: true, force: true }); } }); + +test("runCli install --engine auto and SPACEAPP_CONTAINER_ENGINE select podman when docker is absent", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spaceapp-podman-auto-")); + try { + const execute = async (spec, io) => { + if (spec.command === "docker") return 127; + if (spec.command === "podman" && spec.args?.[0] === "info") { + io?.stdout?.write(JSON.stringify({ + version: { Version: "5.3.0" }, + host: { cgroupVersion: "v2", cpus: 8, memTotal: 17179869184 }, + store: { graphRoot: root } + })); + } + return 0; + }; + + const ioInstall = createIo(); + const code = await run(["install", "--engine", "auto", "--profile", "light", "--no-open"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: ttyStdin("", "", "", "", "", "y"), + stdout: ioInstall.stdout, + stderr: ioInstall.stderr, + execute, + prepareDockerPath: async () => null, + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 50 * 1024 ** 3 + }), + request: async (url) => { + if (url.endsWith("/readyz")) { + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response(JSON.stringify({ setupRequired: true, expiresAt: "2099-01-01T00:00:00.000Z" }), { status: 200, headers: { "content-type": "application/json" } }); + } + }); + assert.equal(code, 0, `STDERR: ${ioInstall.stderrText}\nSTDOUT: ${ioInstall.stdoutText}`); + const saved = await loadConfig(root); + assert.equal(saved.engine, "podman"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); From a890995a7f3e2a4dd19ef2eb14af14279a22a60e Mon Sep 17 00:00:00 2001 From: huhuli083-creator Date: Sun, 25 Oct 2026 11:53:30 +0000 Subject: [PATCH 5/6] fix(run-spaceapp): address CodeRabbit round 2 review feedback (#63) Signed-off-by: huhuli083-creator --- packages/run-spaceapp/src/cli.mjs | 39 ++++++++++++++++++--- packages/run-spaceapp/src/prerequisites.mjs | 2 +- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/run-spaceapp/src/cli.mjs b/packages/run-spaceapp/src/cli.mjs index 9939e9df8..59c58dad9 100644 --- a/packages/run-spaceapp/src/cli.mjs +++ b/packages/run-spaceapp/src/cli.mjs @@ -1211,6 +1211,22 @@ async function planInteractiveSetup({ }; } + const existingEngine = rawExisting?.engine ?? "docker"; + const requestedEngineOverride = + parsed.requestedEngine ?? + stdin?._spaceappPromptController?.answers?.engine ?? + env?.SPACEAPP_CONTAINER_ENGINE; + if ( + rawExisting && + requestedEngineOverride && + requestedEngineOverride !== "auto" && + requestedEngineOverride !== existingEngine + ) { + throw new Error( + `Cannot switch container engine from ${existingEngine} to ${requestedEngineOverride} on an existing installation because container volumes are not visible across engines. Run "${UNIVERSAL_COMMAND} backup", uninstall, and restore on ${requestedEngineOverride}.` + ); + } + if (path === "same" && !hasRequestedConfigChange(rawExisting, parsed)) { const choice = await promptChoice(stdin, stdout, `SpaceApp ${rawExisting.version} is already installed. What would you like to do?`, [ { label: "Run doctor diagnostics (read-only)", value: "doctor" }, @@ -2245,6 +2261,7 @@ async function supportBundleCommand({ ? { version: config.version, profile: config.profile, + engine: config.engine, accessMode: config.accessMode, companionsEnabled: config.companionsEnabled, bindHost: config.bindHost, @@ -2335,7 +2352,10 @@ async function factoryResetCommand({ await rm(join(root, "compose.yml"), { force: true }); } catch {} - const result = await initializeInstallation(root, { version: runtimeVersion }); + const result = await initializeInstallation(root, { + version: runtimeVersion, + ...(config.engine !== undefined ? { engine: config.engine } : {}) + }); stdout.write(`SpaceApp factory reset complete. Clean installation initialized at ${root}\n`); if (result.setupToken) { stdout.write(`One-time setup token: ${result.setupToken}\n`); @@ -2536,9 +2556,9 @@ async function doctor({ (check.ok ? stdout : stderr).write(`${check.ok ? "PASS" : "FAIL"} ${check.name}: ${check.detail}\n`); } if (dockerCli.code !== 0 || dockerCompose.code !== 0) { - stderr.write(`${dockerInstallHelp(platform)}\n`); + stderr.write(`${dockerInstallHelp(platform, doctorEngine)}\n`); } else if (dockerEngine.code !== 0) { - stderr.write(`${dockerEngineHelp(platform)}\n`); + stderr.write(`${dockerEngineHelp(platform, doctorEngine)}\n`); } return checks.every((check) => check.ok) ? 0 : 1; } @@ -2789,7 +2809,10 @@ function commandNeedsRuntimeFiles(command, args) { return false; } -function dockerInstallHelp(platform) { +function dockerInstallHelp(platform, engine = "docker") { + if (engine === "podman") { + return `Install Podman >= 5 and a Podman Compose provider (such as docker-compose v2), then run "${UNIVERSAL_COMMAND} doctor" again.`; + } if (platform === "win32") { return `Run "${UNIVERSAL_COMMAND} install" to install and start signed Docker Desktop with WSL2 automatically.`; } @@ -2799,7 +2822,13 @@ function dockerInstallHelp(platform) { return `Run "${UNIVERSAL_COMMAND} install" to install and start Docker Engine and Compose automatically on supported Linux distributions.`; } -function dockerEngineHelp(platform) { +function dockerEngineHelp(platform, engine = "docker") { + if (engine === "podman") { + if (platform === "darwin") { + return `Run "podman machine init && podman machine start", then run "${UNIVERSAL_COMMAND} doctor" again.`; + } + return `Verify your rootless Podman setup ("podman info"), then run "${UNIVERSAL_COMMAND} doctor" again.`; + } if (platform === "win32" || platform === "darwin") { return `Open Docker Desktop, complete any first-run prompt, then run "${UNIVERSAL_COMMAND} doctor" again.`; } diff --git a/packages/run-spaceapp/src/prerequisites.mjs b/packages/run-spaceapp/src/prerequisites.mjs index 4cff7aefa..efe967582 100644 --- a/packages/run-spaceapp/src/prerequisites.mjs +++ b/packages/run-spaceapp/src/prerequisites.mjs @@ -128,7 +128,7 @@ export async function detectContainerEngine({ if (candidate === "docker" || candidate === "podman") { return candidate; } - if (await dockerCliAndComposeAreAvailable(execute)) { + if (await dockerIsReady(execute)) { return "docker"; } if (await podmanCliAndComposeAreAvailable(execute)) { From db56ceb762c9203e5f63de2f7c973f3408db9a18 Mon Sep 17 00:00:00 2001 From: huhuli083-creator Date: Sun, 25 Oct 2026 11:53:38 +0000 Subject: [PATCH 6/6] test(run-spaceapp): add round 2 Podman edge-case tests (#63) Signed-off-by: huhuli083-creator --- packages/run-spaceapp/tests/podman.test.mjs | 91 ++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/run-spaceapp/tests/podman.test.mjs b/packages/run-spaceapp/tests/podman.test.mjs index ccfff8c3d..3081b7c14 100644 --- a/packages/run-spaceapp/tests/podman.test.mjs +++ b/packages/run-spaceapp/tests/podman.test.mjs @@ -126,11 +126,17 @@ test("resolveInstallEngine accepts docker and podman and defaults to docker", () assert.throws(() => resolveInstallEngine("containerd"), /Install engine must be docker or podman/); }); -test("detectContainerEngine prefers explicit flag, then docker, then podman", async () => { +test("detectContainerEngine prefers explicit flag, then reachable docker daemon, then podman", async () => { const podmanOnlyExec = async (spec) => (spec.command === "podman" ? 0 : 127); assert.equal(await detectContainerEngine({ execute: podmanOnlyExec }), "podman"); assert.equal(await detectContainerEngine({ execute: podmanOnlyExec, requestedEngine: "docker" }), "docker"); + const dockerDaemonDownExec = async (spec) => { + if (spec.command === "docker" && spec.args?.[0] === "info") return 1; + return 0; + }; + assert.equal(await detectContainerEngine({ execute: dockerDaemonDownExec }), "podman"); + const bothExec = async () => 0; assert.equal(await detectContainerEngine({ execute: bothExec }), "docker"); assert.equal(await detectContainerEngine({ execute: bothExec, requestedEngine: "podman" }), "podman"); @@ -430,3 +436,86 @@ test("runCli install --engine auto and SPACEAPP_CONTAINER_ENGINE select podman w await rm(root, { recursive: true, force: true }); } }); + +test("doctor, support-bundle, factory-reset, and same-version install preserve and report podman engine", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "spaceapp-podman-maint-")); + try { + const prepared = await prepareInstallation(root, { + version: "1.1.12", + profile: "light", + accessMode: "isolated", + engine: "podman" + }); + await saveConfig(root, prepared.config); + + // 1. Same-version install with --engine docker must reject switching engines before repair prompt + const ioSwitch = createIo(); + await assert.rejects( + () => + run(["install", "--engine", "docker"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: ttyStdin("3"), + stdout: ioSwitch.stdout, + stderr: ioSwitch.stderr, + execute: async () => 0, + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 50 * 1024 ** 3 + }) + }), + /Cannot switch container engine from podman to docker on an existing installation/ + ); + + // 2. Doctor on macOS when podman info fails prints Podman-specific guidance + const ioDoctor = createIo(); + const docCode = await run(["doctor"], { + env: { SPACEAPP_HOME: root }, + platform: "darwin", + stdin: ioDoctor.stdin, + stdout: ioDoctor.stdout, + stderr: ioDoctor.stderr, + execute: async (spec) => (spec.command === "podman" && spec.args?.[0] === "info" ? 125 : 0), + inspectResources: async () => ({ + cpuCount: 8, + totalMemoryBytes: 16 * 1024 ** 3, + freeDiskBytes: 50 * 1024 ** 3 + }) + }); + assert.equal(docCode, 1); + assert.match(ioDoctor.stderrText, /podman machine init && podman machine start/); + + // 3. Support bundle includes engine: "podman" + const bundlePath = path.join(root, "bundle.json"); + const ioBundle = createIo(); + const bundleCode = await run(["support-bundle", "--out", bundlePath], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: ioBundle.stdin, + stdout: ioBundle.stdout, + stderr: ioBundle.stderr, + execute: async () => 0 + }); + assert.equal(bundleCode, 0); + const { readFile } = await import("node:fs/promises"); + const bundle = JSON.parse(await readFile(bundlePath, "utf8")); + assert.equal(bundle.installation.config.engine, "podman"); + + // 4. Factory reset retains engine: "podman" + const ioReset = createIo(); + const resetCode = await run(["factory-reset"], { + env: { SPACEAPP_HOME: root }, + platform: "linux", + stdin: ttyStdin("FACTORY-RESET", "y"), + stdout: ioReset.stdout, + stderr: ioReset.stderr, + execute: async () => 0 + }); + assert.equal(resetCode, 0, `STDERR: ${ioReset.stderrText}\nSTDOUT: ${ioReset.stdoutText}`); + const afterReset = await loadConfig(root); + assert.equal(afterReset.engine, "podman"); + } finally { + await rm(root, { recursive: true, force: true }); + } +});