// deno-lint-ignore-file no-explicit-any import { colors, log, Select, Confirm, Input } from "../../deps.ts"; import { loginInteractive } from "./login.ts"; import { GlobalOptions } from "../types.ts"; import { getHeaders } from "../utils/utils.ts"; import { getActiveWorkspace, getWorkspaceByName, Workspace, allWorkspaces, addWorkspace, } from "../commands/workspace/workspace.ts"; import { getLastUsedProfile, setLastUsedProfile } from "./branch-profiles.ts"; import { readConfigFile } from "./conf.ts"; import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, getWorkspaceIdForWorkspaceForkFromBranchName, isGitRepository, } from "../utils/git.ts"; import { WM_FORK_PREFIX } from "./constants.ts"; // Helper function to select from multiple matching profiles async function selectFromMultipleProfiles( profiles: Workspace[], baseUrl: string, workspaceId: string, context: string, configDir?: string ): Promise { if (profiles.length === 1) { return profiles[0]; } // Check for last used profile const lastUsedProfileName = await getLastUsedProfile( "", baseUrl, workspaceId, configDir ); if (lastUsedProfileName) { const lastUsedProfile = profiles.find( (p) => p.name === lastUsedProfileName ); if (lastUsedProfile) { log.info( colors.green( `Using last used profile '${lastUsedProfile.name}' for ${context}` ) ); return lastUsedProfile; } } // No last used or it no longer exists - prompt for selection if (!Deno.stdin.isTerminal() || !Deno.stdout.isTerminal()) { const selectedProfile = profiles[0]; log.info( colors.yellow( `Multiple profiles found for ${context}. Using first available profile: '${selectedProfile.name}'` ) ); // Save selection for next time await setLastUsedProfile( "", // No branch context for general workspace selection baseUrl, workspaceId, selectedProfile.name, configDir ); return selectedProfile; } log.info( colors.yellow(`\nMultiple workspace profiles found for ${context}:`) ); const selectedName = await Select.prompt({ message: "Select profile", options: profiles.map((p) => ({ name: `${p.name} (${p.workspaceId} on ${p.remote})`, value: p.name, })), }); const selectedProfile = profiles.find((p) => p.name === selectedName)!; // Save selection for next time await setLastUsedProfile( "", // No branch context for general workspace selection baseUrl, workspaceId, selectedProfile.name, configDir ); return selectedProfile; } /** * Prompts the user to create a new workspace profile interactively */ async function createWorkspaceProfileInteractively( normalizedBaseUrl: string, workspaceId: string, currentBranch: string, opts: GlobalOptions, context: { rawBranch: string; isForked: boolean } ): Promise { // Log appropriate message based on context if (!context.isForked) { log.info( colors.yellow( `\nNo workspace profile found for branch '${context.rawBranch}'\n` + `(${normalizedBaseUrl}, ${workspaceId})` ) ); } else { log.info( colors.yellow( `\nNo workspace profile was found for this forked workspace\n` + `(${normalizedBaseUrl}, ${workspaceId})` ) ); } if (!Deno.stdin.isTerminal() || !Deno.stdout.isTerminal()) { log.info( "Not a TTY, cannot create profile interactively. Use 'wmill workspace add' first." ); return undefined; } const shouldCreate = await Confirm.prompt({ message: "Would you like to create a new workspace profile?", default: true, }); if (!shouldCreate) { return undefined; } // Prompt for profile details const profileName = await Input.prompt({ message: "Profile name", default: workspaceId, }); const token = await loginInteractive(normalizedBaseUrl); if (!token) { log.error("Failed to obtain token"); return undefined; } // Create the new profile const newWorkspace: Workspace = { name: profileName, remote: normalizedBaseUrl, workspaceId: workspaceId, token: token, }; await addWorkspace(newWorkspace, opts); // Set as last used for this branch await setLastUsedProfile( currentBranch, normalizedBaseUrl, workspaceId, profileName, opts.configDir ); log.info( colors.green( `✓ Created profile '${profileName}' for ${workspaceId} on ${normalizedBaseUrl}` ) ); log.info(colors.green(`✓ Profile '${profileName}' is now active`)); return newWorkspace; } export type Context = { workspace: string; baseUrl: string; urlStore: string; token: string; }; async function tryResolveWorkspace( opts: GlobalOptions ): Promise< { isError: false; value: Workspace } | { isError: true; error: string } > { const cache = (opts as any).__secret_workspace; if (cache) return { isError: false, value: cache }; if (opts.workspace) { const e = await getWorkspaceByName(opts.workspace, opts.configDir); if (!e) { return { isError: true, error: colors.red.underline("Given workspace does not exist."), }; } (opts as any).__secret_workspace = e; return { isError: false, value: e }; } // Only check for explicit workspace, don't fallback to active workspace here return { isError: true, error: colors.red.underline("No explicit workspace given."), }; } export async function tryResolveBranchWorkspace( opts: GlobalOptions ): Promise { // Only try branch-based resolution if in a Git repository if (!isGitRepository()) { return undefined; } const rawBranch = getCurrentGitBranch(); if (!rawBranch) { return undefined; } let currentBranch: string; const originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawBranch); const workspaceIdIfForked = getWorkspaceIdForWorkspaceForkFromBranchName(rawBranch); if (originalBranchIfForked) { log.info( `Using original branch \`${originalBranchIfForked}\` for finding workspace profile from gitBranches section in wmill.yaml` ); currentBranch = originalBranchIfForked; } else { currentBranch = rawBranch; } // Read wmill.yaml to check for branch workspace configuration const config = await readConfigFile(); const branchConfig = config.gitBranches?.[currentBranch]; // Check if branch has workspace configuration if (!branchConfig?.baseUrl || !branchConfig?.workspaceId) { return undefined; } log.info( `Using branch configuration for branch \`${currentBranch}\` set in gitBranches` ); const { baseUrl, workspaceId } = branchConfig; let normalizedBaseUrl: string; try { normalizedBaseUrl = new URL(baseUrl).toString(); } catch (error) { log.error( colors.red(`Invalid baseUrl in branch configuration: ${baseUrl}`) ); return undefined; } // Find all profiles matching this baseUrl and workspaceId const allProfiles = await allWorkspaces(opts.configDir); const matchingProfiles = allProfiles.filter( (w) => w.remote === normalizedBaseUrl && w.workspaceId === workspaceId ); if (matchingProfiles.length === 0) { // No matching profile exists - prompt to create one return await createWorkspaceProfileInteractively( normalizedBaseUrl, workspaceId, currentBranch, opts, { rawBranch, isForked: !!originalBranchIfForked } ); } // Handle multiple profiles - use special branch-aware logic let selectedProfile: Workspace; if (matchingProfiles.length === 1) { selectedProfile = matchingProfiles[0]; log.info( colors.green( `Using workspace profile '${selectedProfile.name}' for branch '${currentBranch}' with workspace id \`${workspaceId}\`` ) ); } else { // For multiple profiles, check branch-specific last used first const lastUsedName = await getLastUsedProfile( currentBranch, normalizedBaseUrl, workspaceId, opts.configDir ); if (lastUsedName) { const lastUsedProfile = matchingProfiles.find( (p) => p.name === lastUsedName ); if (lastUsedProfile) { log.info( colors.green( `Using workspace profile '${lastUsedProfile.name}' for branch '${currentBranch}' (last used)` ) ); return lastUsedProfile; } } // Fall back to general selection logic selectedProfile = await selectFromMultipleProfiles( matchingProfiles, normalizedBaseUrl, workspaceId, `branch '${currentBranch}'`, opts.configDir ); // Save branch-specific selection await setLastUsedProfile( currentBranch, normalizedBaseUrl, workspaceId, selectedProfile.name, opts.configDir ); log.info( colors.green( `Using workspace profile '${selectedProfile.name}' for branch '${currentBranch}'` ) ); } if (workspaceIdIfForked) { selectedProfile.name = `${selectedProfile.name}/${workspaceIdIfForked}`; selectedProfile.workspaceId = workspaceIdIfForked; log.info( `Inferred workspace id \`${workspaceId}\` from branch name because this is a workspace fork branch (\`${rawBranch}\`). ` ); } return selectedProfile; } export async function resolveWorkspace( opts: GlobalOptions ): Promise { const cache = (opts as any).__secret_workspace; if (cache) return cache; if (opts.baseUrl) { if (opts.workspace && opts.token) { let normalizedBaseUrl: string; try { normalizedBaseUrl = new URL(opts.baseUrl).toString(); // add trailing slash if not present } catch (error) { log.info(colors.red(`Invalid base URL: ${opts.baseUrl}`)); return Deno.exit(-1); } // Try to find existing workspace profile by name, then by workspaceId + remote if (opts.workspace) { // Try by workspace name first let existingWorkspace = await getWorkspaceByName( opts.workspace, opts.configDir ); // If not found by name, try to find by workspaceId + remote match if (!existingWorkspace) { const { allWorkspaces } = await import( "../commands/workspace/workspace.ts" ); const workspaces = await allWorkspaces(opts.configDir); const matchingWorkspaces = workspaces.filter( (w) => w.workspaceId === opts.workspace && w.remote === normalizedBaseUrl ); if (matchingWorkspaces.length >= 1) { existingWorkspace = await selectFromMultipleProfiles( matchingWorkspaces, normalizedBaseUrl, opts.workspace!, `workspace "${opts.workspace}" on ${normalizedBaseUrl}`, opts.configDir ); } } if (existingWorkspace) { // Validate that the base URL matches the profile's remote if (existingWorkspace.remote !== normalizedBaseUrl) { log.info( colors.red( `Base URL mismatch: --base-url is ${normalizedBaseUrl} but workspace profile "${opts.workspace}" uses ${existingWorkspace.remote}` ) ); return Deno.exit(-1); } // Use the existing workspace profile (preserves workspace name) return { ...existingWorkspace, token: opts.token, // Use the provided token }; } } // No existing profile found, create temporary workspace return { remote: normalizedBaseUrl, workspaceId: opts.workspace, name: opts.workspace, token: opts.token, }; } else { log.info( colors.red( "If you specify a base URL with --base-url, you must also specify a workspace (--workspace) and token (--token)." ) ); return Deno.exit(-1); } } const branch = getCurrentGitBranch(); // Try explicit workspace flag first (should override branch-based resolution). Unless it's a // forked workspace, that we detect through the branch name const res = await tryResolveWorkspace(opts); if (!res.isError) { if (!branch || !branch.startsWith(WM_FORK_PREFIX)) { return res.value; } else { log.info( `Found an active workspace \`${res.value.name}\` but the branch name indicates this is a forked workspace. Ignoring active workspace and trying to resolve the correct workspace from the branch name \`${branch}\`` ); } } // Try branch-based resolution (medium priority) const branchWorkspace = await tryResolveBranchWorkspace(opts); if (branchWorkspace) { (opts as any).__secret_workspace = branchWorkspace; return branchWorkspace; } else { const originalBranch = getOriginalBranchForWorkspaceForks(branch); if (originalBranch) { log.error( colors.red.bold( `Failed to resolve workspace profile for workspace fork. This most likely means that the original branch \`${originalBranch}\` where \`${branch}\` is originally forked from, is not setup in the wmill.yaml. You need to update the \`gitBranches\` section for \`${originalBranch}\` to include workspaceId and baseUrl.` ) ); return Deno.exit(-1); } } // Fall back to active workspace (lowest priority) const activeWorkspace = await getActiveWorkspace(opts); if (activeWorkspace) { (opts as any).__secret_workspace = activeWorkspace; return activeWorkspace; } // If everything failed, show error log.info(colors.red.bold("No workspace given and no default set.")); return Deno.exit(-1); } export async function fetchVersion(baseUrl: string): Promise { const requestHeaders = new Headers(); const extraHeaders = getHeaders(); if (extraHeaders) { for (const [key, value] of Object.entries(extraHeaders)) { requestHeaders.set(key, value); } } const response = await fetch( new URL(new URL(baseUrl).origin + "/api/version"), { headers: requestHeaders, method: "GET" } ); if (!response.ok) { // Consume response body even on error to avoid resource leak await response.text(); throw new Error( `Failed to fetch version: ${response.status} ${response.statusText}` ); } return await response.text(); } export async function tryResolveVersion( opts: GlobalOptions ): Promise { if ((opts as any).__cache_version) { return (opts as any).__cache_version; } const workspaceRes = await tryResolveWorkspace(opts); if (workspaceRes.isError) return undefined; const version = await fetchVersion(workspaceRes.value.remote); try { return Number.parseInt( version.split("-", 1)[0].replaceAll(".", "").replace("v", "") ); } catch { return undefined; } } export function validatePath(path: string): boolean { if (!(path.startsWith("g") || path.startsWith("u") || path.startsWith("f"))) { log.info( colors.red( "Given remote path looks invalid. Remote paths are typically of the form //..." ) ); return false; } return true; }