fix(cli): say which workspace id is targeted, and when wmill.yaml is bypassed (#11006)

* 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>
This commit is contained in:
Ruben Fiszel
2026-09-07 16:18:39 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent f381acdb37
commit 7643e9bd77
4 changed files with 173 additions and 4 deletions
+12 -1
View File
@@ -150,7 +150,18 @@ export async function downloadZip(
}
if (zipResponse.status === 404 || body.includes("no rows returned")) {
log.info(colors.red(`Workspace '${workspace.workspaceId}' not found on ${workspace.remote}. Please check your --workspace and try again.`));
log.info(
colors.red(
`Workspace id '${workspace.workspaceId}' not found on ${workspace.remote}` +
(workspace.name !== workspace.workspaceId
? ` (resolved from profile '${workspace.name}')`
: "") +
`.\n` +
`Note this is the workspace *id* sent to the API, which is not necessarily what you passed to --workspace:\n` +
` - check 'wmill workspace list' (the 'workspace id' column)\n` +
` - check the 'workspaces' block of wmill.yaml ('workspaceId' overrides the workspace name)`
)
);
} else {
log.info(colors.red(`Failed to request tarball from API: ${zipResponse.status} ${zipResponse.statusText}`));
if (body) log.info(colors.red(body));
+41 -1
View File
@@ -145,7 +145,9 @@ function getGitRepoRoot(): string | null {
}
export const GLOBAL_CONFIG_OPT = { noCdToRoot: false };
function findWmillYaml(): string | null {
// Pure upward search: no chdir, no logging. findWmillYaml() adds the chdir.
function locateWmillYaml(): string | null {
const startDir = resolve(process.cwd());
const isInGitRepo = isGitRepository();
const gitRoot = isInGitRepo ? getGitRepoRoot() : null;
@@ -176,6 +178,13 @@ function findWmillYaml(): string | null {
currentDir = parentDir;
}
return foundPath;
}
function findWmillYaml(): string | null {
const startDir = resolve(process.cwd());
const foundPath = locateWmillYaml();
// If wmill.yaml was found in a parent directory, warn the user and change working directory
if (
!GLOBAL_CONFIG_OPT.noCdToRoot &&
@@ -198,6 +207,37 @@ export function getWmillYamlPath(): string | null {
return findWmillYaml();
}
/**
* Look up one `workspaces` entry, for diagnostics only. readConfigFile() must
* not be used for that: it chdirs to the config's directory, exits on an
* unsupported syncBehavior and throws on a malformed file. A diagnostic may
* never fail or relocate the command it is diagnosing.
*/
export async function peekWorkspaceEntry(
workspaceName: string
): Promise<WorkspaceEntryConfig | undefined> {
if (RESERVED_WORKSPACE_KEYS.has(workspaceName)) {
return undefined;
}
const wmillYamlPath = locateWmillYaml();
if (!wmillYamlPath) {
return undefined;
}
try {
const conf = (await yamlParseFile(wmillYamlPath)) as SyncOptions;
const workspaces =
conf?.workspaces ??
conf?.gitBranches ??
conf?.environments ??
conf?.git_branches;
const entry = (workspaces as any)?.[workspaceName];
return typeof entry === "object" && entry !== null ? entry : undefined;
} catch (e) {
log.debug(`Failed to parse ${wmillYamlPath} for workspace lookup: ${e}`);
return undefined;
}
}
let legacyConfigWarned = false;
export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promise<SyncOptions> {
+52 -2
View File
@@ -20,6 +20,7 @@ import {
import { getLastUsedProfile, setLastUsedProfile } from "./branch-profiles.ts";
import {
readConfigFile,
peekWorkspaceEntry,
findWorkspaceByGitBranch,
getEffectiveWorkspaceId,
getWmillYamlPath,
@@ -219,6 +220,9 @@ async function tryResolveWorkspace(
// 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;
@@ -231,6 +235,8 @@ async function tryResolveWorkspace(
};
}
configuredTarget = { workspaceId, baseUrl: normalizedBaseUrl };
// Find matching profile by baseUrl + workspaceId
const allProfs = await allWorkspaces(opts.configDir);
const matching = allProfs.filter(
@@ -283,6 +289,22 @@ async function tryResolveWorkspace(
),
};
}
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 };
}
@@ -486,6 +508,8 @@ export async function resolveWorkspace(
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(
@@ -523,19 +547,45 @@ export async function resolveWorkspace(
);
return process.exit(-1);
}
return {
resolved = {
...existingWorkspace,
token: opts.token,
};
}
}
return {
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(
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveWorkspace } from "../src/core/context.ts";
import { getWorkspaceConfigFilePath } from "../windmill-utils-internal/src/config/config.ts";
import type { GlobalOptions } from "../src/types.ts";
const BASE_URL = "http://localhost:9999/";
// --base-url pins the target: --workspace reaches the API as a workspace id and
// wmill.yaml is not consulted. The warning that says so may only peek at the
// file — readConfigFile() exits on an unsupported syncBehavior and throws on a
// malformed one, so resolving through it lets an unrelated config fail a
// command that never needed it.
async function withWmillYaml(
wmillYaml: string,
fn: (opts: GlobalOptions) => Promise<void>
): Promise<void> {
const repoDir = await mkdtemp(path.join(os.tmpdir(), "wmill_baseurl_repo_"));
const configDir = await mkdtemp(path.join(os.tmpdir(), "wmill_baseurl_conf_"));
const originalCwd = process.cwd();
try {
await writeFile(path.join(repoDir, "wmill.yaml"), wmillYaml);
await writeFile(await getWorkspaceConfigFilePath(configDir), "");
process.chdir(repoDir);
await fn({
configDir,
baseUrl: BASE_URL,
token: "sometoken",
workspace: "staging",
} as GlobalOptions);
} finally {
process.chdir(originalCwd);
await rm(repoDir, { recursive: true, force: true });
await rm(configDir, { recursive: true, force: true });
}
}
describe("--base-url workspace resolution", () => {
const rejectedConfigs: [string, string][] = [
["an unsupported syncBehavior", "syncBehavior: v2\n"],
["a malformed file", 'workspaces:\n staging:\n baseUrl: "unterminated\n'],
];
for (const [label, wmillYaml] of rejectedConfigs) {
test(`resolves despite ${label}`, async () => {
await withWmillYaml(wmillYaml, async (opts) => {
const workspace = await resolveWorkspace(opts);
expect(workspace.workspaceId).toBe("staging");
expect(workspace.remote).toBe(BASE_URL);
});
});
}
test("a workspaces mapping never overrides the explicit workspace id", async () => {
await withWmillYaml(
"workspaces:\n staging:\n baseUrl: http://elsewhere.example/\n workspaceId: admins\n",
async (opts) => {
const workspace = await resolveWorkspace(opts);
expect(workspace.workspaceId).toBe("staging");
expect(workspace.remote).toBe(BASE_URL);
}
);
});
});