"use strict"; function createUnraidPreflightMethods({ safeRemoteFolder, path, bash, parseInspection, dockerIgnoreHasPath, checksSummary, inventoryRemoteIdentity, deriveDetectedProfile, decodeBase64Json, shellQuote, assertFullCommitSha, nativePath, safeRelativeRemoteFile, fs, }) { class UnraidPreflightMethods { async saveOperation(operation) { const saved = await this.store.addOperation(operation); this.onOperationChange?.({ operations: [saved] }); return saved; } resolve(repository, profileId) { const profile = this.store.getDeploymentProfile( repository.fullName, profileId, ); if (!profile || profile.provider !== "ssh-unraid") throw new Error("The SSH / Unraid deployment profile no longer exists."); const server = this.store.getServer(profile.serverId); if (!server) throw new Error("The deployment server no longer exists."); const remoteFolder = safeRemoteFolder( profile.remoteFolder || repository.name, ); const remotePath = path.join(server.basePath, remoteFolder); if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error( "Remote project path escapes the configured server base path.", ); const effectiveProfile = { ...profile, deploymentMode: ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) ? profile.deploymentMode : "push-bundle", }; return { profile: effectiveProfile, server, remoteFolder, remotePath }; } async discoverExisting({ repository, serverId, remoteFolder = "" }) { const server = this.store.getServer(serverId); if (!server) throw new Error("The deployment server no longer exists."); const folder = safeRemoteFolder(remoteFolder || repository.name); const remotePath = path.join(server.basePath, folder); const inventory = await this.scanServerInventory(serverId, [repository], { autoLink: false }); const workload = inventory.workloads.find((item) => item.remoteFolderCandidate === folder || item.compose?.workingDir === remotePath || item.containers.some((container) => (container.mounts || []).some((mount) => { const source = String(mount.source || "").replace(/\/+$/, ""); return source === remotePath || source.startsWith(`${remotePath}/`); })) ); if (!workload) { const error = new Error(`No Docker or Compose workload could be matched to ${remotePath}. Use Server Inventory to select the running container directly.`); error.code = "SERVER_WORKLOAD_NOT_FOUND"; throw error; } const profile = this.profileFromWorkload(repository, server, workload, { linkSource: "manual", deploymentMode: "server-git", remoteFolder: folder, }); const source = (value, origin, confidence = "confirmed") => ({ value, origin, confidence, detectedAt: new Date().toISOString(), overridden: false, }); const provenance = { remoteFolder: source(folder, "server-inventory"), cloneUrl: source(profile.cloneUrl, workload.metadata?.sourceRepository ? "container-provenance" : "repository"), branch: source(profile.branch, workload.metadata?.branch ? "container-provenance" : "repository"), composeFile: source(profile.composeFile, "docker-compose-labels"), composeService: source(profile.composeService, "docker-compose-labels"), containerName: source(profile.containerName, "docker-inspect"), hostPort: source(profile.hostPort, "docker-inspect"), containerPort: source(profile.containerPort, "docker-inspect"), webUiUrl: source(profile.webUiUrl, workload.dockerMan?.webUiUrl ? "unraid-dockerman" : "docker-labels"), iconUrl: source(profile.serverIconReference, workload.dockerMan?.iconUrl ? "unraid-dockerman" : "docker-labels"), dockerShell: source(profile.dockerShell, workload.dockerMan?.shell ? "unraid-dockerman" : "docker-labels"), }; return { repository: repository.fullName, profile: { ...profile, id: undefined, provenance }, provenance, workload, runtime: { remotePath, containerRunning: workload.runtime.running, containers: workload.containers.length, services: workload.compose?.services?.length || workload.containers.length, ports: workload.runtime.ports, mounts: workload.containers.flatMap((container) => container.mounts || []), networks: [...new Set(workload.containers.flatMap((container) => container.networks || []))], envNames: [], }, }; } async inspect({ repository, profileId }) { const { profile, server, remotePath } = this.resolve(repository, profileId); const preserveProbe = (profile.preservePaths || []) .map( (relativePath) => `if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`, ) .join("\n"); const script = ` root=${shellQuote(remotePath)} exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths="" if [ -d "$root" ]; then exists=true if [ -d "$root/.git" ]; then root_git=true head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) branch=$(git -C "$root" branch --show-current 2>/dev/null || true) remote=$(git -C "$root" remote get-url origin 2>/dev/null || true) tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true) fi compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true) nested_git=$(find "$root" -mindepth 2 -maxdepth 4 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true) [ -f "$root/Dockerfile" ] && dockerfile=true [ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true) existing_preserve_paths=$({ ${preserveProbe || ":"}; } | sort -u | base64 | tr -d '\\r\\n' || true) fi printf '__FORGEFLOW_KV__\\n' printf 'exists=%s\\n' "$exists" printf 'rootGit=%s\\n' "$root_git" printf 'head=%s\\n' "$head" printf 'branch=%s\\n' "$branch" printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" printf 'trackedChanges=%s\\n' "$tracked_changes" printf 'composeFiles=%s\\n' "$compose_files" printf 'nestedGit=%s\\n' "$nested_git" printf 'dockerfile=%s\\n' "$dockerfile" printf 'dockerignoreContent=%s\\n' "$dockerignore_content" printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" `; const wrapped = bash(script); const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 }); const parsed = parseInspection(result.stdout); const contextCandidates = [ ...new Set([ ...(parsed.existingPreservePaths || []), ...(parsed.nestedGit || []), ]), ]; const inspection = { ...parsed, dockerignore: Boolean(parsed.dockerignoreContent), dockerignoreGitExcluded: dockerIgnoreHasPath( parsed.dockerignoreContent, ".git", ), dockerContextExclusionsMissing: parsed.dockerfile ? contextCandidates.filter( (item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item), ) : [], serverId: server.id, serverName: server.name, remotePath, profileId: profile.id, }; await this.diagnostics?.info("unraid.inspected", { repository: repository.fullName, serverId: server.id, remotePath, exists: inspection.exists, rootGit: inspection.rootGit, head: inspection.head, composeFiles: inspection.composeFiles, nestedGitCount: inspection.nestedGit.length, trackedChangeCount: inspection.trackedChanges.length, dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing, }); return inspection; } async preflight({ repository, profileId, sha = null }) { const { profile, server, remotePath } = this.resolve(repository, profileId); const deploymentMode = ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode) ? profile.deploymentMode : "push-bundle"; let requestedSha = sha || repository.localStatus?.head; if (deploymentMode === "server-git" && !sha) { const [owner, repo] = String(repository.fullName || "").split("/"); const branch = await this.gitea.getBranch(owner, repo, profile.branch); requestedSha = branch?.commit?.id || branch?.commit?.sha || null; } const targetSha = assertFullCommitSha(requestedSha); const checks = []; let inspection = null; let connectionCapabilities = null; let permissions = null; if (deploymentMode === "monitor-only") checks.push({ id: "deployment-mode", label: "Deployment mode", status: "fail", detail: "This workload is linked for monitoring only. Select Server pull or Direct copy before deploying.", }); else checks.push({ id: "deployment-mode", label: "Deployment mode", status: "pass", detail: deploymentMode === "server-git" ? "Unraid fetches the exact Gitea commit with a repository-scoped read-only deploy key." : "ForgeFlow copies the exact committed local project directly to Unraid and runs Docker Compose there.", }); if (!repository.localPath) { checks.push({ id: "local-repository", label: "Local repository", status: deploymentMode === "server-git" ? "pass" : "fail", detail: deploymentMode === "server-git" ? "Not required: the exact commit is fetched from Gitea by the server." : "Link or clone the repository locally before using Direct copy.", }); } else { try { const localStatus = await this.git.status(repository.localPath); checks.push({ id: "local-repository", label: "Local repository", status: "pass", detail: localStatus.root, }); checks.push({ id: "local-branch", label: "Allowed branch", status: localStatus.branch.head === profile.branch ? "pass" : deploymentMode === "server-git" ? "warning" : "fail", detail: `Current: ${localStatus.branch.head || "detached"}; required: ${profile.branch}.`, }); checks.push({ id: "local-clean", label: "Clean local working tree", status: localStatus.clean ? "pass" : deploymentMode === "server-git" ? "warning" : "fail", detail: localStatus.clean ? "No uncommitted changes." : `${localStatus.counts.changed} changed file(s) remain.`, }); checks.push({ id: "deployment-source", label: deploymentMode === "server-git" ? "Gitea deployment source" : "Direct deployment source", status: "pass", detail: deploymentMode === "server-git" ? "Local files are not uploaded; the exact requested commit is fetched from Gitea." : "The exact committed local HEAD is archived and copied directly to Unraid. No server-side repository access is involved.", }); const localDeploymentFiles = deploymentMode === "push-bundle" && profile.generatedCompose ? [nativePath.join(repository.localPath, "Dockerfile")] : deploymentMode === "push-bundle" ? this.deploymentComposeFiles(profile).map((file) => nativePath.join(repository.localPath, safeRelativeRemoteFile(file)), ) : []; const missingDeploymentFiles = []; for (const file of localDeploymentFiles) { if (!(await fs.stat(file).catch(() => null))?.isFile()) missingDeploymentFiles.push(file); } if (deploymentMode === "push-bundle") checks.push({ id: "local-deployment-file", label: profile.generatedCompose ? "Dockerfile in repository" : localDeploymentFiles.length > 1 ? "Compose files in repository" : "Compose file in repository", status: missingDeploymentFiles.length ? "fail" : "pass", detail: missingDeploymentFiles.length ? `Missing from the exact local checkout: ${missingDeploymentFiles.join(", ")}` : localDeploymentFiles.join(", "), }); } catch (error) { checks.push({ id: "local-repository", label: "Local repository", status: "fail", detail: error.message, }); } } if (deploymentMode === "server-git") { const [owner, repo] = String(repository.fullName || "").split("/"); const deploymentFiles = profile.generatedCompose ? ["Dockerfile"] : this.deploymentComposeFiles(profile); try { const existence = await Promise.all(deploymentFiles.map(async (filePath) => ({ filePath, exists: await this.gitea.repositoryFileExists({ owner, repo, filePath, ref: targetSha }), }))); const missing = existence.filter((item) => !item.exists).map((item) => item.filePath); checks.push({ id: "gitea-deployment-files", label: profile.generatedCompose ? "Dockerfile at Gitea commit" : "Compose files at Gitea commit", status: missing.length ? "fail" : "pass", detail: missing.length ? `Missing at exact commit ${targetSha.slice(0, 12)}: ${missing.join(", ")}.` : `${deploymentFiles.join(", ")} verified at exact commit ${targetSha.slice(0, 12)}.`, }); } catch (error) { checks.push({ id: "gitea-deployment-files", label: "Deployment files at Gitea commit", status: "fail", detail: error.message, }); } } try { const connection = await this.ssh.test(server.id, { trustOnFirstUse: false }); connectionCapabilities = connection.capabilities || {}; checks.push({ id: "ssh", label: "Desktop → Unraid SSH", status: "pass", detail: `${server.username}@${server.host}:${server.port}`, }); checks.push({ id: "docker-runtime", label: "Docker runtime", status: connectionCapabilities.docker && connectionCapabilities.dockerReady ? "pass" : "fail", detail: connectionCapabilities.dockerReady ? "Docker is reachable by the configured SSH user." : connectionCapabilities.docker ? "Docker is installed, but the configured SSH user cannot query the daemon." : "Docker was not detected on the server.", }); checks.push({ id: "compose-command", label: "Docker Compose", status: connectionCapabilities.compose ? "pass" : "fail", detail: connectionCapabilities.composeVersion || "Docker Compose was not detected on the server.", }); checks.push({ id: "bundle-tools", label: deploymentMode === "server-git" ? "Server pull tools" : "Direct copy tools", status: connectionCapabilities.tar && connectionCapabilities.checksum && (deploymentMode !== "server-git" || connectionCapabilities.git) ? "pass" : "fail", detail: deploymentMode === "server-git" ? `Git ${connectionCapabilities.git ? "available" : "missing"}; tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum ${connectionCapabilities.checksum ? "available" : "missing"}.` : connectionCapabilities.tar && connectionCapabilities.checksum ? "tar and a SHA-256 checksum tool are available." : `tar ${connectionCapabilities.tar ? "available" : "missing"}; checksum tool ${connectionCapabilities.checksum ? "available" : "missing"}.`, }); checks.push({ id: "server-base-writable", label: "Deployment storage writable", status: connectionCapabilities.baseWritable ? "pass" : "fail", detail: connectionCapabilities.baseWritable ? `${server.basePath} is writable.` : `${server.basePath} cannot be created or written by this SSH user.`, }); } catch (error) { checks.push({ id: "ssh", label: "Desktop → Unraid SSH", status: "fail", detail: error.message, }); } if (!server.hostFingerprint) checks.push({ id: "host-key", label: "Server identity", status: "fail", detail: "Test and trust the SSH host key first.", }); else checks.push({ id: "host-key", label: "Server identity", status: "pass", detail: server.hostFingerprint, }); if (deploymentMode === "server-git") { const access = await this.probeServerGitAccess({ repository, profile, server }); checks.push({ id: "server-git-access", label: "Unraid → Gitea read access", status: access.ready ? "pass" : "fail", detail: access.ready ? `Read-only deploy key verified${access.remoteSha ? ` at ${access.remoteSha.slice(0, 7)}` : ""}.` : access.error, repairAction: access.ready ? null : "configure-server-git-access", repairLabel: "Configure read-only deploy key", }); } else checks.push({ id: "transfer-path", label: "Desktop → Unraid transfer", status: "pass", detail: "Files are copied over the configured server connection. No Gitea credential is required on Unraid.", }); try { permissions = await this.inspectWriteAccess({ repository, profileId }); const blockingPaths = permissions.blocking.map((target) => target.path); checks.push({ id: "project-write-access", label: "Project write access", status: permissions.ready ? "pass" : "fail", detail: permissions.ready ? `${permissions.identity.user} can create and atomically replace deployment files in ${remotePath}.` : `No safe write access for ${permissions.identity.user}: ${blockingPaths.join(", ")}`, help: permissions.ready ? "ForgeFlow rechecks these paths immediately before every upload and Compose activation." : "Use Fix write access to repair only the linked project source and ForgeFlow state folders. Preserved runtime data is excluded.", repairAction: permissions.ready ? null : "repair-deployment-write-access", repairLabel: "Fix write access", }); for (const target of permissions.targets.filter( (item) => item.required && !item.effectiveWritable, )) { checks.push({ id: `write-path:${target.id}`, label: target.label, status: "fail", detail: `${target.path} · owner ${target.owner || "unknown"}:${target.group || "unknown"} · mode ${target.mode || "unknown"}. ${target.detail}`, repairAction: "repair-deployment-write-access", repairLabel: "Fix write access", }); } } catch (error) { checks.push({ id: "project-write-access", label: "Project write access", status: "fail", detail: error.message, }); } try { inspection = await this.inspect({ repository, profileId }); if (!inspection.exists) { checks.push({ id: "remote-folder", label: "Remote project folder", status: "pass", detail: `${remotePath} will be created.`, }); } else { checks.push({ id: "remote-folder", label: inspection.rootGit ? "Remote project folder" : "Existing server installation", status: "pass", detail: inspection.rootGit ? `${remotePath} currently contains Git commit ${String(inspection.head || "").slice(0, 7) || "unknown"}.` : `${remotePath} will receive managed release files while preserved and unknown runtime data remains untouched.`, }); } if (inspection.rootGit) { checks.push({ id: "tracked-changes", label: "Server-side tracked changes", status: inspection.trackedChanges.length ? "warning" : "pass", detail: inspection.trackedChanges.length ? `${inspection.trackedChanges.length} tracked server edit(s) exist. Direct copy preserves unknown runtime data and does not depend on the server Git checkout.` : "No tracked server-only edits detected.", }); } if (inspection.nestedGit.length) { checks.push({ id: "nested-git", label: "Nested Git repositories", status: "warning", detail: `Detected: ${inspection.nestedGit.join(", ")}. ForgeFlow will not delete them automatically.`, }); } if (inspection.dockerfile && !inspection.dockerignore) { checks.push({ id: "dockerignore", label: "Docker build context", status: "warning", detail: "A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.", }); } else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) { checks.push({ id: "dockerignore-git", label: "Git metadata excluded from Docker", status: "warning", detail: ".dockerignore does not explicitly exclude .git.", }); } else if (inspection.dockerfile) { checks.push({ id: "dockerignore-git", label: "Git metadata excluded from Docker", status: "pass", detail: ".git is excluded from the Docker build context.", }); } if (inspection.dockerContextExclusionsMissing.length) { checks.push({ id: "dockerignore-runtime", label: "Runtime data excluded from Docker", status: "warning", detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(", ")}.`, }); } else if ( inspection.dockerfile && inspection.existingPreservePaths.length ) { checks.push({ id: "dockerignore-runtime", label: "Runtime data excluded from Docker", status: "pass", detail: "Detected preserved runtime paths are excluded from the Docker build context.", }); } const composeFiles = profile.generatedCompose ? [".forgeflow/compose.forgeflow.yml"] : (profile.composeFiles?.length ? profile.composeFiles : [profile.composeFile || "docker-compose.yml"]) .map((value) => safeRelativeRemoteFile(value)); const missingRemoteCompose = composeFiles.filter((composeFile) => !inspection.composeFiles.includes(composeFile)); checks.push({ id: "compose-file", label: "Compose configuration", status: "pass", detail: profile.generatedCompose ? "ForgeFlow will generate an isolated Compose file." : missingRemoteCompose.length ? `${composeFiles.join(", ")} will be uploaded from the exact local commit.` : composeFiles.join(", "), }); } catch (error) { checks.push({ id: "inspection", label: "Server project inspection", status: "fail", detail: error.message, }); } const iconMode = profile.iconMode || (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); if (iconMode === "upload") { const iconStat = await fs.stat(profile.iconFilePath).catch(() => null); checks.push({ id: "dockerman-icon-file", label: "DockerMan icon upload", status: iconStat?.isFile() && nativePath.extname(profile.iconFilePath).toLowerCase() === ".png" ? "pass" : "fail", detail: iconStat?.isFile() ? profile.iconFilePath : "The selected local PNG icon file was not found.", }); } else if (iconMode === "builtin") { const builtinIcon = nativePath.join( this.sourcePath, "src", "renderer", "assets", "itworx-mark.png", ); const iconStat = await fs.stat(builtinIcon).catch(() => null); checks.push({ id: "dockerman-icon-builtin", label: "DockerMan icon", status: iconStat?.isFile() ? "pass" : "fail", detail: iconStat?.isFile() ? "Built-in high-contrast ITWorx mark." : "The built-in ITWorx icon asset is missing.", }); } else if (iconMode === "url") checks.push({ id: "dockerman-icon", label: "DockerMan icon", status: profile.iconUrl ? "pass" : "fail", detail: profile.iconUrl || "Icon URL mode requires an HTTPS or HTTP PNG URL.", }); else checks.push({ id: "dockerman-icon", label: "DockerMan icon", status: "warning", detail: "Custom DockerMan icon disabled.", }); const webUiLabel = this.dockerManWebUi(profile); checks.push({ id: "dockerman-webui", label: "DockerMan Web UI action", status: webUiLabel ? "pass" : "warning", detail: webUiLabel || "No Web UI URL or host port is configured.", }); checks.push({ id: "compose-identity", label: "Safe Docker Compose identity", status: "pass", detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.`, }); checks.push({ id: "exact-sha", label: "Exact deployment commit", status: "pass", detail: targetSha, }); return { provider: "ssh-unraid", repository: repository.fullName, environment: profile.environment, sha: targetSha, server: { id: server.id, name: server.name, host: server.host }, remotePath, inspection, permissions, checks, summary: checksSummary(checks), }; } } return UnraidPreflightMethods.prototype; } module.exports = { createUnraidPreflightMethods };