mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
* fix(cli): say which workspace id is targeted and when wmill.yaml is bypassed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QaQ3UtkbHA6pxqQStqRQQj * fix(cli): make the wmill.yaml lookup for diagnostics side-effect free Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QaQ3UtkbHA6pxqQStqRQQj * fix(cli): only report a wmill.yaml mapping that sets an explicit workspaceId Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QaQ3UtkbHA6pxqQStqRQQj --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
897 lines
30 KiB
TypeScript
897 lines
30 KiB
TypeScript
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<Workspace> {
|
|
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<Workspace | undefined> {
|
|
// 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 <profile-name> ${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<Workspace | undefined> {
|
|
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<Workspace> {
|
|
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<string> {
|
|
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<number | undefined> {
|
|
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 <u|g|f>/<username|group|folder>/<name>.`
|
|
);
|
|
}
|
|
|
|
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 <u|g|f>/<username|group|folder>/..."
|
|
)
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|