import { colors } from "@cliffy/ansi/colors"; import * as log from "./log.ts"; import { Select } from "@cliffy/prompt/select"; import { Confirm } from "@cliffy/prompt/confirm"; import { Input } from "@cliffy/prompt/input"; import { Table } from "@cliffy/table"; import { loginInteractive } from "./login.ts"; import { GlobalOptions } from "../types.ts"; import { getHeaders } from "../utils/utils.ts"; import { detectAuthGatewayChallenge } from "../utils/http_guards.ts"; import { getActiveWorkspace, getWorkspaceByName, Workspace, allWorkspaces, addWorkspace, } from "../commands/workspace/workspace.ts"; import { getLastUsedProfile, setLastUsedProfile } from "./branch-profiles.ts"; import { readConfigFile, peekWorkspaceEntry, findWorkspaceByGitBranch, getEffectiveWorkspaceId, getWmillYamlPath, WorkspaceEntryConfig, } from "./conf.ts"; import { existsSync, realpathSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, getWorkspaceIdForWorkspaceForkFromBranchName, isGitRepository, } from "../utils/git.ts"; import { WM_FORK_PREFIX } from "./constants.ts"; import { levenshteinDistance } from "@jsr/std__text/levenshtein-distance"; // 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.infoStderr( colors.green( `Using last used profile '${lastUsedProfile.name}' for ${context}` ) ); return lastUsedProfile; } } // No last used or it no longer exists - prompt for selection if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) { const selectedProfile = profiles[0]; log.infoStderr( 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.infoStderr( 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.infoStderr( colors.yellow( `\nNo workspace profile found for branch '${context.rawBranch}'\n` + `(${normalizedBaseUrl}, ${workspaceId})` ) ); } else { log.infoStderr( colors.yellow( `\nNo workspace profile was found for this forked workspace\n` + `(${normalizedBaseUrl}, ${workspaceId})` ) ); } if (!!!process.stdin.isTTY || !!!process.stdout.isTTY) { log.infoStderr( "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.infoStderr( colors.green( `✓ Created profile '${profileName}' for ${workspaceId} on ${normalizedBaseUrl}` ) ); log.infoStderr(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) { // First try: look up workspace by name in wmill.yaml workspaces config const config = await readConfigFile({ warnIfMissing: false }); const wsEntry = config.workspaces?.[opts.workspace] as WorkspaceEntryConfig | undefined; // What wmill.yaml said to target, kept for the fallback below: a profile // found by name can silently point somewhere else entirely. let configuredTarget: { workspaceId: string; baseUrl: string } | undefined; if (wsEntry?.baseUrl) { const workspaceId = getEffectiveWorkspaceId(opts.workspace, wsEntry); let normalizedBaseUrl: string; try { normalizedBaseUrl = new URL(wsEntry.baseUrl).toString(); } catch { return { isError: true, error: colors.red.underline(`Invalid baseUrl in workspace '${opts.workspace}' configuration: ${wsEntry.baseUrl}`), }; } configuredTarget = { workspaceId, baseUrl: normalizedBaseUrl }; // Find matching profile by baseUrl + workspaceId const allProfs = await allWorkspaces(opts.configDir); const matching = allProfs.filter( (w) => w.remote === normalizedBaseUrl && w.workspaceId === workspaceId ); if (matching.length >= 1) { const selected = matching.length === 1 ? matching[0] : await selectFromMultipleProfiles( matching, normalizedBaseUrl, workspaceId, `workspace '${opts.workspace}'`, opts.configDir ); log.infoStderr( colors.green( `Using workspace profile '${selected.name}' for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})` ) ); (opts as any).__secret_workspace = selected; return { isError: false, value: selected }; } // No matching profile — offer to create one log.infoStderr( `No profile found for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})` ); const ws = await createWorkspaceProfileInteractively( normalizedBaseUrl, workspaceId, opts.workspace, opts, { rawBranch: opts.workspace, isForked: false } ); if (ws) { (opts as any).__secret_workspace = ws; return { isError: false, value: ws }; } } // Fall back: look up profile by name directly (old behavior) const e = await getWorkspaceByName(opts.workspace, opts.configDir); if (!e) { return { isError: true, error: colors.red.underline( `Workspace '${opts.workspace}' not found in wmill.yaml config or local profiles.` ), }; } if ( configuredTarget && (e.workspaceId !== configuredTarget.workspaceId || e.remote !== configuredTarget.baseUrl) ) { log.warnStderr( colors.yellow( `⚠️ Falling back to the local profile named '${opts.workspace}' (${e.workspaceId} on ${e.remote}), which does NOT match wmill.yaml:\n` + ` wmill.yaml maps workspace '${opts.workspace}' to ${configuredTarget.workspaceId} on ${configuredTarget.baseUrl}, but no profile targets it.\n` + ` Run: wmill workspace add ${configuredTarget.workspaceId} ${configuredTarget.baseUrl}` ) ); } log.infoStderr( `Using local profile '${e.name}' → ${e.workspaceId} on ${e.remote}` ); (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, workspaceNameOverride?: string ): Promise { let rawBranch: string | null = null; let wsName: string | undefined; let wsEntry: WorkspaceEntryConfig | undefined; let originalBranchIfForked: string | null = null; let workspaceIdIfForked: string | null = null; // Read wmill.yaml (silent — just probing) const config = await readConfigFile({ warnIfMissing: false }); if (workspaceNameOverride) { // Direct lookup by workspace name wsEntry = config.workspaces?.[workspaceNameOverride] as WorkspaceEntryConfig | undefined; if (wsEntry) { wsName = workspaceNameOverride; log.infoStderr(`Using workspace override: ${workspaceNameOverride}`); } } else { // Only try branch-based resolution if in a Git repository if (!isGitRepository()) { return undefined; } rawBranch = getCurrentGitBranch(); if (!rawBranch) { return undefined; } originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawBranch); workspaceIdIfForked = getWorkspaceIdForWorkspaceForkFromBranchName(rawBranch); // The "matched via fork branch" reason logged below already explains the // base-branch lookup, so no extra message here. const branchToLookup = originalBranchIfForked ?? rawBranch; const match = findWorkspaceByGitBranch(config.workspaces, branchToLookup); if (match) { [wsName, wsEntry] = match; } } if (!wsName || !wsEntry) { return undefined; } if (!wsEntry.baseUrl) { if (workspaceNameOverride) { // User explicitly asked for this workspace but it has no baseUrl log.warnStderr( `⚠️ Workspace '${wsName}' has no baseUrl configured. Cannot resolve a profile.\n` + ` Add baseUrl to workspace '${wsName}' in wmill.yaml, or use --base-url flag.` ); } return undefined; } const workspaceId = getEffectiveWorkspaceId(wsName, wsEntry); const effectiveGitBranch = (wsEntry as any).gitBranch ?? wsName; const { baseUrl } = wsEntry; // Explain why this workspace was selected let reason: string; if (workspaceNameOverride) { reason = `selected via --workspace`; } else if (originalBranchIfForked) { reason = `matched via fork branch '${rawBranch}' → original branch '${originalBranchIfForked}'`; } else if (effectiveGitBranch !== wsName) { reason = `matched git branch '${effectiveGitBranch}' on current branch '${rawBranch}'`; } else { reason = `matched current git branch '${rawBranch}'`; } // Printed as part of the single final targeting line on the happy paths; // logged up front only when an interactive flow needs the context first. const workspaceLine = `Using workspace '${wsName}' (${reason}) → ${workspaceId} on ${baseUrl}`; let normalizedBaseUrl: string; try { normalizedBaseUrl = new URL(baseUrl).toString(); } catch (error) { log.error( colors.red(`Invalid baseUrl in workspace '${wsName}' 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 ); // Every branch below must flow into the shared fork handling at the end — // returning a profile directly would hand back the parent workspace profile // on a fork branch. let selectedProfile: Workspace; let profileNote: string; // Set when workspaceLine was already printed as context for the interactive // profile-creation flow, so it isn't printed a second time at the end. let workspaceLinePrinted = false; if (matchingProfiles.length === 0) { // No matching profile exists - prompt to create one log.infoStderr(workspaceLine); workspaceLinePrinted = true; const created = await createWorkspaceProfileInteractively( normalizedBaseUrl, workspaceId, wsName, opts, { rawBranch: rawBranch ?? wsName, isForked: !!originalBranchIfForked } ); if (!created) { return undefined; } selectedProfile = created; profileNote = `profile '${selectedProfile.name}'`; } else if (matchingProfiles.length === 1) { selectedProfile = matchingProfiles[0]; profileNote = `profile '${selectedProfile.name}'`; } else { const lastUsedName = await getLastUsedProfile( wsName, normalizedBaseUrl, workspaceId, opts.configDir ); const lastUsedProfile = lastUsedName ? matchingProfiles.find((p) => p.name === lastUsedName) : undefined; if (lastUsedProfile) { selectedProfile = lastUsedProfile; profileNote = `last used profile '${selectedProfile.name}'`; } else { // selectFromMultipleProfiles prints its own context header, and the // final summary line below names the chosen profile. selectedProfile = await selectFromMultipleProfiles( matchingProfiles, normalizedBaseUrl, workspaceId, `workspace '${wsName}'`, opts.configDir ); await setLastUsedProfile( wsName, normalizedBaseUrl, workspaceId, selectedProfile.name, opts.configDir ); profileNote = `profile '${selectedProfile.name}'`; } } if (workspaceIdIfForked) { selectedProfile.name = `${selectedProfile.name}/${workspaceIdIfForked}`; selectedProfile.workspaceId = workspaceIdIfForked; log.infoStderr( colors.green( `Automatically targeting fork workspace \`${workspaceIdIfForked}\` (fork of \`${workspaceId}\` on ${baseUrl}, ${profileNote}), resolved from git branch \`${rawBranch}\`. Use --workspace to override.` ) ); } else if (!workspaceLinePrinted) { log.infoStderr(`${workspaceLine} (${profileNote})`); } return selectedProfile; } export async function resolveWorkspace( opts: GlobalOptions, workspaceNameOverride?: string ): 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(); } catch (error) { log.infoStderr(colors.red(`Invalid base URL: ${opts.baseUrl}`)); return process.exit(-1); } let resolved: Workspace | undefined; // Try to find existing workspace profile by name, then by workspaceId + remote if (opts.workspace) { let existingWorkspace = await getWorkspaceByName( opts.workspace, opts.configDir ); if (!existingWorkspace) { const { allWorkspaces } = await import( "../commands/workspace/workspace.ts" ); const profiles = await allWorkspaces(opts.configDir); const matchingWorkspaces = profiles.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) { if (existingWorkspace.remote !== normalizedBaseUrl) { log.infoStderr( colors.red( `Base URL mismatch: --base-url is ${normalizedBaseUrl} but workspace profile "${opts.workspace}" uses ${existingWorkspace.remote}` ) ); return process.exit(-1); } resolved = { ...existingWorkspace, token: opts.token, }; } } resolved ??= { remote: normalizedBaseUrl, workspaceId: opts.workspace, name: opts.workspace, token: opts.token, }; // --base-url pins the target, so wmill.yaml's `workspaces` block is never // consulted and `--workspace` reaches the API as a workspace id. Name the // id being sent, and the mapping being skipped, before the request 404s // on an id the user never typed. // Only an explicit `workspaceId:` is worth reporting: an entry without one // maps the name to itself, leaving nothing to correct. const yamlEntry = await peekWorkspaceEntry(opts.workspace); const yamlWorkspaceId = yamlEntry?.workspaceId; if (yamlWorkspaceId && yamlWorkspaceId !== resolved.workspaceId) { log.warnStderr( colors.yellow( `⚠️ --base-url is set, so wmill.yaml is not consulted: workspace id '${resolved.workspaceId}' is sent to the API.\n` + ` wmill.yaml maps workspace '${opts.workspace}' to workspace id '${yamlWorkspaceId}'${yamlEntry!.baseUrl ? ` on ${yamlEntry!.baseUrl}` : ""}.\n` + ` Use '--workspace ${yamlWorkspaceId}', or drop --base-url/--token to resolve through wmill.yaml.` ) ); } log.infoStderr( `Using workspace id '${resolved.workspaceId}' on ${normalizedBaseUrl} (--base-url given` + (resolved.name !== resolved.workspaceId ? `, profile '${resolved.name}')` : ")") ); (opts as any).__secret_workspace = resolved; return resolved; } else { log.infoStderr( colors.red( "If you specify a base URL with --base-url, you must also specify a workspace (--workspace) and token (--token)." ) ); return process.exit(-1); } } const branch = workspaceNameOverride ? null : getCurrentGitBranch(); // Try explicit workspace flag first (should override branch-based resolution). Unless it's a // forked workspace, that we detect through the branch name (only when not using workspaceNameOverride // and --workspace was not explicitly provided) const res = await tryResolveWorkspace(opts); if (!res.isError) { const workspace = (res as { isError: false; value: Workspace }).value; if (workspaceNameOverride || opts.workspace || !branch || !branch.startsWith(WM_FORK_PREFIX)) { return workspace; } else { log.infoStderr( `You are on fork branch \`${branch}\`, which takes precedence over the active workspace profile \`${workspace.name}\`: resolving the fork workspace from the branch name instead. Use --workspace to override.` ); } } else if (opts.workspace) { // --workspace was explicitly provided but not found — fail immediately const profiles = await allWorkspaces(opts.configDir); const names = profiles.map((w) => w.name); let msg = `Workspace "${opts.workspace}" not found.`; const suggestions = names .map((n) => ({ name: n, dist: levenshteinDistance(opts.workspace!, n) })) .filter((s) => s.dist <= 3) .sort((a, b) => a.dist - b.dist) .slice(0, 3); if (suggestions.length > 0) { msg += ` Did you mean: ${suggestions.map((s) => `"${s.name}"`).join(", ")}?`; } log.infoStderr(colors.red.bold(msg)); if (profiles.length > 0) { log.infoStderr("\nAvailable workspaces:"); new Table() .header(["name", "remote", "workspace id"]) .padding(2) .border(true) .body(profiles.map((w) => [w.name, w.remote, w.workspaceId])) .render(); } return process.exit(-1); } // Try branch-based resolution (medium priority) const branchWorkspace = await tryResolveBranchWorkspace(opts, workspaceNameOverride); if (branchWorkspace) { (opts as any).__secret_workspace = branchWorkspace; return branchWorkspace; } else if (!workspaceNameOverride) { // Only check for fork errors when not using workspaceNameOverride const originalBranch = getOriginalBranchForWorkspaceForks(branch); if (originalBranch) { log.error( colors.red.bold( `Failed to resolve workspace profile for workspace fork. The original branch \`${originalBranch}\` (forked from \`${branch}\`) is not configured in wmill.yaml.\n` + ` Add to the 'workspaces' section: \`${originalBranch}: { baseUrl: "https://...", workspaceId: "..." }\`` ) ); return process.exit(-1); } } // If workspaces config exists, use it rather than falling back to active profile const config = await readConfigFile({ warnIfMissing: false }); const { getWorkspaceNames } = await import("./conf.ts"); const wsNames = getWorkspaceNames(config.workspaces); if (wsNames.length > 0) { let pickedWsName: string; const wsListStr = wsNames.map((n) => { const entry = (config.workspaces as any)[n]; const info = entry.baseUrl ? ` (${entry.workspaceId ?? n} on ${entry.baseUrl})` : ""; return ` - ${n}${info}`; }).join("\n"); if (wsNames.length === 1) { pickedWsName = wsNames[0]; log.infoStderr( `Auto-selected workspace '${pickedWsName}' (only workspace in config).\n` + `Use --workspace to override or 'wmill workspace bind' to add more workspaces.` ); } else if (process.stdin.isTTY) { log.infoStderr( `Multiple workspaces configured but none matched the current context.\n` + `Configured workspaces:\n${wsListStr}\n` + `Use --workspace to skip this prompt.` ); pickedWsName = await Select.prompt({ message: "Select workspace", options: wsNames.map((n) => { const entry = (config.workspaces as any)[n]; const info = entry.baseUrl ? ` (${entry.workspaceId ?? n} on ${entry.baseUrl})` : ""; return { name: `${n}${info}`, value: n }; }), }); } else { log.error( colors.red.bold( `Multiple workspaces configured but none matched the current context.\n` + `Configured workspaces:\n${wsListStr}\n` + `Use --workspace to select one.` ) ); return process.exit(-1); } // Resolve the picked workspace via config const pickedResult = await tryResolveBranchWorkspace(opts, pickedWsName); if (pickedResult) { (opts as any).__secret_workspace = pickedResult; return pickedResult; } } // Fall back to active workspace (only when no workspaces config) const activeWorkspace = await getActiveWorkspace(opts); if (activeWorkspace) { (opts as any).__secret_workspace = activeWorkspace; return activeWorkspace; } // Last resort: auto-configure from Windmill environment variables const envWorkspace = process.env["WM_WORKSPACE"]; const envToken = process.env["WM_TOKEN"]; const envBaseUrl = process.env["BASE_INTERNAL_URL"] ?? process.env["BASE_URL"]; if (envWorkspace && envToken && envBaseUrl) { let normalizedBaseUrl: string; try { normalizedBaseUrl = new URL(envBaseUrl).toString(); } catch { log.infoStderr(colors.red(`Invalid BASE_INTERNAL_URL: ${envBaseUrl}`)); return process.exit(-1); } log.debug( `Using workspace from environment variables: ${envWorkspace} on ${normalizedBaseUrl}` ); const ws: Workspace = { name: envWorkspace, workspaceId: envWorkspace, remote: normalizedBaseUrl, token: envToken, }; (opts as any).__secret_workspace = ws; return ws; } log.infoStderr(colors.red.bold("No workspace given and no default set. Run 'wmill workspace add' to configure one.")); return process.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 versionUrl = new URL(new URL(baseUrl).origin + "/api/version"); const response = await fetch(versionUrl, { headers: requestHeaders, method: "GET", }); await detectAuthGatewayChallenge(response, versionUrl.toString()); 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 workspace = (workspaceRes as { isError: false; value: Workspace }).value; const version = await fetchVersion(workspace.remote); try { return Number.parseInt( version.split("-", 1)[0].replaceAll(".", "").replace("v", "") ); } catch { return undefined; } } /** * Directory the local tree mirrors the workspace from: the one holding * wmill.yaml. Not `process.cwd()` — that only lands there once a config read * has chdir'd into it, which `--remote` and fully-flagged invocations skip. */ function syncRoot(): string { const wmillYaml = getWmillYamlPath(); return wmillYaml ? dirname(wmillYaml) : process.cwd(); } /** * Re-express a user-supplied file or folder argument as a path relative to the * sync root, so the Windmill path derived from it is the same whatever shape * the argument had (`./f/a/b.ts`, `/abs/repo/f/a/b.ts`, `b.ts` from inside * `f/a`). * * `cwdBeforeConfig` must be the working directory as it was *before* the * command read wmill.yaml: reading it chdirs into the directory holding it, and * a relative argument was written against the directory the user was in. * Arguments are resolved against that directory first and the sync root second, * so both readings work. * * Resolving the sync root leaves the process in it, which is what makes the * returned path readable by the caller — keep that in step if this ever stops * going through `getWmillYamlPath`. */ export function toSyncRootRelativePath( arg: string, cwdBeforeConfig: string ): string { const root = syncRoot(); const candidates = isAbsolute(arg) ? [arg] : [resolve(cwdBeforeConfig, arg), resolve(root, arg)]; // A descriptor-less dbt project is named by a file that is deliberately not // there, so an argument existing under neither reading is still a real one if // the directory holding it is; only a path whose directory is missing too // falls through to the sync-root reading for the caller to reject. const abs = candidates.find((c) => existsSync(c)) ?? candidates.find((c) => existsSync(dirname(c))) ?? candidates.at(-1)!; const rel = relative(root, abs); if (rel === "") return "."; if (!rel.startsWith("..")) return rel; // `relative` is purely lexical, so a root reached through a symlink (macOS' // /var -> /private/var, a symlinked checkout) makes an absolute argument look // like it escapes the tree. Resolve links only then, so a symlinked file // *inside* the tree keeps the path it is filed under. try { const resolved = relative(realpathSync(root), realpathOfNamed(abs)); return resolved === "" ? "." : resolved; } catch { return rel; } } /** * `realpathSync` needs its target to exist, and a dbt descriptor deliberately * does not. The directory naming it does, so resolve that and reattach. */ function realpathOfNamed(p: string): string { return existsSync(p) ? realpathSync(p) : join(realpathSync(dirname(p)), basename(p)); } /** Windmill workspace path: `u|f|g` followed by at least a folder and a name. */ const REMOTE_PATH_RE = /^[ufg](\/[^/]+){2,}$/; /** * Guard the Windmill path a preview run is pushed under. A preview job carries * no runnable of its own, so this path is the only identity it has: it is what * `WM_JOB_PATH` reports, what the runs page links to, and what relative imports * inside the previewed code resolve against. */ export function assertRemotePath(remotePath: string, arg: string): void { if (REMOTE_PATH_RE.test(remotePath)) return; throw new Error( `Cannot derive a Windmill path from '${arg}'` + (remotePath ? ` (it maps to '${remotePath}')` : "") + `: a preview runs under the path of the file it previews, which must sit inside the ` + `wmill.yaml root and be of the form //.` ); } export function validatePath(path: string): boolean { if (!(path.startsWith("g") || path.startsWith("u") || path.startsWith("f"))) { log.infoStderr( colors.red( "Given remote path looks invalid. Remote paths are typically of the form //..." ) ); return false; } return true; }