diff --git a/cli/apps.ts b/cli/apps.ts index ca966e2112..324275d3ed 100644 --- a/cli/apps.ts +++ b/cli/apps.ts @@ -1,5 +1,6 @@ // deno-lint-ignore-file no-explicit-any -import { requireLogin, resolveWorkspace, validatePath } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { resolveWorkspace, validatePath } from "./context.ts"; import { colors, Command, log, SEP, Table, yamlParseFile } from "./deps.ts"; import * as wmill from "./gen/services.gen.ts"; import { ListableApp, Policy } from "./gen/types.gen.ts"; diff --git a/cli/auth.ts b/cli/auth.ts new file mode 100644 index 0000000000..fa50e1f3ec --- /dev/null +++ b/cli/auth.ts @@ -0,0 +1,61 @@ +// deno-lint-ignore-file no-explicit-any +import { colors, log, setClient } from "./deps.ts"; +import * as wmill from "./gen/services.gen.ts"; +import { GlobalUserInfo } from "./gen/types.gen.ts"; + +import { loginInteractive, tryGetLoginInfo } from "./login.ts"; +import { GlobalOptions } from "./types.ts"; +import { + addWorkspace, + removeWorkspace, + Workspace +} from "./workspace.ts"; + +/** + * Main authentication function - moved from context.ts to break circular dependencies + * This function maintains the original API signature from context.ts + */ +export async function requireLogin( + opts: GlobalOptions +): Promise { + // Import resolveWorkspace to avoid circular dependency at module level + const { resolveWorkspace } = await import("./context.ts"); + const workspace = await resolveWorkspace(opts); + + let token = await tryGetLoginInfo(opts); + + if (!token) { + token = workspace.token; + } + + setClient(token, workspace.remote.substring(0, workspace.remote.length - 1)); + + try { + return await wmill.globalWhoami(); + } catch (error) { + // Check for network errors and provide clearer messages + const errorMsg = error instanceof Error ? error.message : String(error); + if (errorMsg.includes('fetch') || errorMsg.includes('connection') || errorMsg.includes('ECONNREFUSED') || errorMsg.includes('refused')) { + throw new Error(`Network error: Could not connect to Windmill server at ${workspace.remote}`); + } + + log.info( + "! Could not reach API given existing credentials. Attempting to reauth..." + ); + const newToken = await loginInteractive(workspace.remote); + if (!newToken) { + throw new Error("Unauthorized: Could not authenticate with the provided credentials"); + } + + // Update workspace token + removeWorkspace(workspace.name, false, opts); + workspace.token = newToken; + addWorkspace(workspace, opts); + + setClient( + newToken, + workspace.remote.substring(0, workspace.remote.length - 1) + ); + return await wmill.globalWhoami(); + } +} diff --git a/cli/context.ts b/cli/context.ts index c5fd7a4571..359854c880 100644 --- a/cli/context.ts +++ b/cli/context.ts @@ -58,12 +58,12 @@ export async function resolveWorkspace( if (opts.baseUrl) { if (opts.workspace && opts.token) { const normalizedBaseUrl = new URL(opts.baseUrl).toString(); // add trailing slash if not present - + // 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("./workspace.ts"); @@ -71,13 +71,13 @@ export async function resolveWorkspace( const matchingWorkspaces = workspaces.filter( w => w.workspaceId === opts.workspace && w.remote === normalizedBaseUrl ); - + // Due to uniqueness constraint, there can only be 0 or 1 match if (matchingWorkspaces.length === 1) { existingWorkspace = matchingWorkspaces[0]; } } - + if (existingWorkspace) { // Validate that the base URL matches the profile's remote if (existingWorkspace.remote !== normalizedBaseUrl) { @@ -95,7 +95,7 @@ export async function resolveWorkspace( }; } } - + // No existing profile found, create temporary workspace return { remote: normalizedBaseUrl, @@ -121,45 +121,6 @@ export async function resolveWorkspace( } } -export async function requireLogin( - opts: GlobalOptions -): Promise { - const workspace = await resolveWorkspace(opts); - let token = await tryGetLoginInfo(opts); - - if (!token) { - token = workspace.token; - } - - setClient(token, workspace.remote.substring(0, workspace.remote.length - 1)); - - try { - return await wmill.globalWhoami(); - } catch (error) { - // Check for network errors and provide clearer messages - const errorMsg = error instanceof Error ? error.message : String(error); - if (errorMsg.includes('fetch') || errorMsg.includes('connection') || errorMsg.includes('ECONNREFUSED') || errorMsg.includes('refused')) { - throw new Error(`Network error: Could not connect to Windmill server at ${workspace.remote}`); - } - - log.info( - "! Could not reach API given existing credentials. Attempting to reauth..." - ); - const newToken = await loginInteractive(workspace.remote); - if (!newToken) { - throw new Error("Unauthorized: Could not authenticate with the provided credentials"); - } - removeWorkspace(workspace.name, false, opts); - workspace.token = newToken; - addWorkspace(workspace, opts); - - setClient( - newToken, - workspace.remote.substring(0, workspace.remote.length - 1) - ); - return await wmill.globalWhoami(); - } -} export async function fetchVersion(baseUrl: string): Promise { const requestHeaders = new Headers(); @@ -176,13 +137,13 @@ export async function fetchVersion(baseUrl: string): Promise { 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( diff --git a/cli/dev.ts b/cli/dev.ts index 7cc92c45ff..658a1a057a 100644 --- a/cli/dev.ts +++ b/cli/dev.ts @@ -12,7 +12,8 @@ import { } from "./deps.ts"; import { getTypeStrFromPath, GlobalOptions } from "./types.ts"; import { ignoreF } from "./sync.ts"; -import { requireLogin, resolveWorkspace } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { resolveWorkspace } from "./context.ts"; import { SyncOptions, mergeConfigWithConfigFile, diff --git a/cli/flow.ts b/cli/flow.ts index 8b44011b06..bf3acd67ad 100644 --- a/cli/flow.ts +++ b/cli/flow.ts @@ -4,7 +4,8 @@ import { Confirm, SEP, log, yamlStringify } from "./deps.ts"; import { colors, Command, Table, yamlParseFile } from "./deps.ts"; import * as wmill from "./gen/services.gen.ts"; -import { requireLogin, resolveWorkspace, validatePath } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { resolveWorkspace, validatePath } from "./context.ts"; import { resolve, track_job } from "./script.ts"; import { defaultFlowDefinition } from "./bootstrap/flow_bootstrap.ts"; import { blueColor, generateFlowLockInternal } from "./metadata.ts"; diff --git a/cli/folder.ts b/cli/folder.ts index 5cf2812670..e60dc08903 100644 --- a/cli/folder.ts +++ b/cli/folder.ts @@ -2,7 +2,8 @@ import { colors, Command, log, SEP, Table } from "./deps.ts"; import * as wmill from "./gen/services.gen.ts"; -import { requireLogin, resolveWorkspace, validatePath } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { resolveWorkspace, validatePath } from "./context.ts"; import { GlobalOptions, isSuperset, parseFromFile } from "./types.ts"; import { Folder } from "./gen/types.gen.ts"; diff --git a/cli/gitsync-settings.ts b/cli/gitsync-settings.ts index 3daeaa0415..d48105c9f3 100644 --- a/cli/gitsync-settings.ts +++ b/cli/gitsync-settings.ts @@ -1,6 +1,7 @@ import { colors, Command, log, yamlStringify } from "./deps.ts"; import { GlobalOptions } from "./types.ts"; -import { requireLogin, resolveWorkspace } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { resolveWorkspace } from "./context.ts"; import * as wmill from "./gen/services.gen.ts"; import { DEFAULT_SYNC_OPTIONS, diff --git a/cli/hub.ts b/cli/hub.ts index 6943369ec0..2f0eb0c567 100644 --- a/cli/hub.ts +++ b/cli/hub.ts @@ -2,7 +2,8 @@ import { Command, log } from "./deps.ts"; import * as wmill from "./gen/services.gen.ts"; -import { requireLogin, resolveWorkspace } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { resolveWorkspace } from "./context.ts"; import { pushResourceType } from "./resource-type.ts"; import { GlobalOptions } from "./types.ts"; import { deepEqual } from "./utils.ts"; diff --git a/cli/main.ts b/cli/main.ts index 214a54a566..df61a212e2 100644 --- a/cli/main.ts +++ b/cli/main.ts @@ -159,9 +159,8 @@ const command = new Command() // Check for backend git-sync settings unless --use-default is specified if (!opts.useDefault) { try { - const { requireLogin, resolveWorkspace } = await import( - "./context.ts" - ); + const { requireLogin } = await import("./auth.ts"); + const { resolveWorkspace } = await import("./context.ts"); // Check if user has workspace configured const { getActiveWorkspace } = await import( diff --git a/cli/resource-type.ts b/cli/resource-type.ts index 7a25c5a36e..aef94d2249 100644 --- a/cli/resource-type.ts +++ b/cli/resource-type.ts @@ -5,7 +5,8 @@ import { parseFromFile, removeType, } from "./types.ts"; -import { requireLogin, resolveWorkspace } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { resolveWorkspace } from "./context.ts"; import { colors, Command, log, Table } from "./deps.ts"; import * as wmill from "./gen/services.gen.ts"; import { ResourceType } from "./gen/types.gen.ts"; diff --git a/cli/resource.ts b/cli/resource.ts index 1b65cf9420..0d620f68e3 100644 --- a/cli/resource.ts +++ b/cli/resource.ts @@ -5,7 +5,8 @@ import { parseFromFile, removeType, } from "./types.ts"; -import { requireLogin, resolveWorkspace, validatePath } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { resolveWorkspace, validatePath } from "./context.ts"; import { colors, Command, log, SEP, Table } from "./deps.ts"; import * as wmill from "./gen/services.gen.ts"; import { Resource } from "./gen/types.gen.ts"; diff --git a/cli/schedule.ts b/cli/schedule.ts index 77052e371d..34ec378a18 100644 --- a/cli/schedule.ts +++ b/cli/schedule.ts @@ -1,6 +1,7 @@ // deno-lint-ignore-file no-explicit-any import { colors, Command, log, SEP, Table } from "./deps.ts"; -import { requireLogin, resolveWorkspace, validatePath } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { resolveWorkspace, validatePath } from "./context.ts"; import * as wmill from "./gen/services.gen.ts"; import { diff --git a/cli/script.ts b/cli/script.ts index b5d978e13f..8ae5e3f6e6 100644 --- a/cli/script.ts +++ b/cli/script.ts @@ -1,6 +1,7 @@ // deno-lint-ignore-file no-explicit-any import { GlobalOptions } from "./types.ts"; -import { requireLogin, resolveWorkspace, validatePath } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { resolveWorkspace, validatePath } from "./context.ts"; import { colors, Command, diff --git a/cli/sync.ts b/cli/sync.ts index 2fe403e2c5..162c48282e 100644 --- a/cli/sync.ts +++ b/cli/sync.ts @@ -1,4 +1,5 @@ -import { fetchVersion, requireLogin, resolveWorkspace } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { fetchVersion, resolveWorkspace } from "./context.ts"; import { colors, Command, diff --git a/cli/trigger.ts b/cli/trigger.ts index 2edd6b1674..df21755794 100644 --- a/cli/trigger.ts +++ b/cli/trigger.ts @@ -16,9 +16,8 @@ import { parseFromFile, removeType, } from "./types.ts"; -import { requireLogin } from "./context.ts"; -import { validatePath } from "./context.ts"; -import { resolveWorkspace } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { validatePath, resolveWorkspace } from "./context.ts"; type Trigger = { http: HttpTrigger; diff --git a/cli/user.ts b/cli/user.ts index 70e069f303..668f294d67 100644 --- a/cli/user.ts +++ b/cli/user.ts @@ -1,5 +1,5 @@ // deno-lint-ignore-file no-explicit-any -import { requireLogin } from "./context.ts"; +import { requireLogin } from "./auth.ts"; import { GlobalOptions, isSuperset, diff --git a/cli/variable.ts b/cli/variable.ts index 789334c44c..8524959c32 100644 --- a/cli/variable.ts +++ b/cli/variable.ts @@ -1,5 +1,6 @@ // deno-lint-ignore-file no-explicit-any -import { requireLogin, resolveWorkspace, validatePath } from "./context.ts"; +import { requireLogin } from "./auth.ts"; +import { resolveWorkspace, validatePath } from "./context.ts"; import { GlobalOptions, isSuperset, diff --git a/cli/workspace.ts b/cli/workspace.ts index b2af05d089..9c0b865a00 100644 --- a/cli/workspace.ts +++ b/cli/workspace.ts @@ -3,9 +3,9 @@ import { GlobalOptions } from "./types.ts"; import { getRootStore } from "./store.ts"; import { loginInteractive, tryGetLoginInfo } from "./login.ts"; import { colors, Command, Confirm, Input, log, setClient, Table } from "./deps.ts"; +import { requireLogin } from "./auth.ts"; import * as wmill from "./gen/services.gen.ts"; -import { requireLogin } from "./context.ts"; export interface Workspace { remote: string; @@ -258,11 +258,11 @@ export async function add( export async function addWorkspace(workspace: Workspace, opts: any) { workspace.remote = new URL(workspace.remote).toString(); // add trailing slash in all cases! - + // Check for conflicts before adding const existingWorkspaces = await allWorkspaces(opts.configDir); const isInteractive = Deno.stdin.isTerminal() && Deno.stdout.isTerminal() && !opts.force; - + // Check 1: Workspace name already exists const nameConflict = existingWorkspaces.find(w => w.name === workspace.name); if (nameConflict) { @@ -274,7 +274,7 @@ export async function addWorkspace(workspace: Workspace, opts: any) { log.info(colors.red.bold(`❌ Workspace name "${workspace.name}" already exists!`)); log.info(` Existing: ${nameConflict.workspaceId} on ${nameConflict.remote}`); log.info(` New: ${workspace.workspaceId} on ${workspace.remote}`); - + if (!isInteractive) { // In non-interactive mode (tests, scripts), auto-overwrite with force flag if (opts.force) { @@ -287,7 +287,7 @@ export async function addWorkspace(workspace: Workspace, opts: any) { message: "Do you want to overwrite the existing workspace?", default: false, }); - + if (!overwrite) { log.info(colors.yellow("Operation cancelled.")); return; @@ -295,20 +295,20 @@ export async function addWorkspace(workspace: Workspace, opts: any) { } } } - + // Check 2: Same (remote, workspaceId) tuple already exists under different name - const tupleConflict = existingWorkspaces.find(w => - w.remote === workspace.remote && - w.workspaceId === workspace.workspaceId && + const tupleConflict = existingWorkspaces.find(w => + w.remote === workspace.remote && + w.workspaceId === workspace.workspaceId && w.name !== workspace.name ); - + if (tupleConflict) { log.info(colors.red.bold(`❌ Workspace ${workspace.workspaceId} on ${workspace.remote} already exists!`)); log.info(` Existing name: "${tupleConflict.name}"`); log.info(` New name: "${workspace.name}"`); log.info(colors.yellow(`\nNote: Backend constraint prevents duplicate (remote, workspaceId) combinations.`)); - + if (!isInteractive) { // In non-interactive mode (tests, scripts), auto-overwrite with force flag if (opts.force) { @@ -321,20 +321,20 @@ export async function addWorkspace(workspace: Workspace, opts: any) { message: `Do you want to overwrite the existing workspace "${tupleConflict.name}"?`, default: false, }); - + if (!overwrite) { log.info(colors.yellow("Operation cancelled.")); return; } } - + // Remove the conflicting workspace await removeWorkspace(tupleConflict.name, true, opts); } - + // Remove existing workspace with same name (if updating) await removeWorkspace(workspace.name, true, opts); - + // Add the new workspace const file = await Deno.open((await getRootStore(opts.configDir)) + "remotes.ndjson", { append: true,