mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
ba4b368706
* fix: prevent variable push from corrupting is_secret variables Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(cli): unit-test looksLikeWorkspaceCiphertext shape detection Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): scope is_secret downgrade to single-file push, not sync push Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): warn when variable push stores a secret value as already-encrypted Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): route workspace-resolution and auth diagnostics to stderr Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(cli): rephrase comments to describe current behavior, not history Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
146 lines
6.0 KiB
TypeScript
146 lines
6.0 KiB
TypeScript
import { colors } from "@cliffy/ansi/colors";
|
|
import * as log from "./log.ts";
|
|
import { setClient } from "./client.ts";
|
|
import * as wmill from "../../gen/services.gen.ts";
|
|
import { GlobalUserInfo, User } from "../../gen/types.gen.ts";
|
|
|
|
import { loginInteractive, tryGetLoginInfo } from "./login.ts";
|
|
import { GlobalOptions } from "../types.ts";
|
|
|
|
|
|
// Workspace-scoped tokens (tokens bound to a workspace via token.workspace_id)
|
|
// cannot call /api/users/whoami because the backend rejects any token with a
|
|
// workspace binding when the request path has no workspace in it. Fall back to
|
|
// the workspace-scoped whoami endpoint in that case so the CLI still works.
|
|
// Uses a name-based ApiError check rather than `instanceof` to match the
|
|
// pattern in cli/src/main.ts: bundling (bun build for npm, JSR dev path) can
|
|
// produce multiple module instances of gen/core/ApiError.ts, making
|
|
// `instanceof` silently return false and reintroducing the bug this fixes.
|
|
async function fetchWhoami(workspaceId: string): Promise<GlobalUserInfo> {
|
|
try {
|
|
return await wmill.globalWhoami();
|
|
} catch (error) {
|
|
if (
|
|
error && typeof error === "object" &&
|
|
"name" in error && (error as { name: unknown }).name === "ApiError" &&
|
|
(error as { status?: number }).status === 401
|
|
) {
|
|
const user = await wmill.whoami({ workspace: workspaceId });
|
|
return workspaceUserToGlobalUserInfo(user);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Adapter for the 401 fallback path. `login_type`, `verified`, `first_time_user`
|
|
// and `role_source` are NOT derivable from the workspace-scoped User response
|
|
// and are filled with best-effort defaults — do not trust them downstream after
|
|
// a fallback whoami. Today only cli/src/commands/hub/hub.ts reads this return
|
|
// value, and only `.email`.
|
|
function workspaceUserToGlobalUserInfo(user: User): GlobalUserInfo {
|
|
return {
|
|
email: user.email,
|
|
login_type: "password",
|
|
super_admin: user.is_super_admin,
|
|
verified: true,
|
|
name: user.name,
|
|
username: user.username,
|
|
operator_only: user.operator,
|
|
first_time_user: false,
|
|
role_source: "manual",
|
|
disabled: user.disabled,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<GlobalUserInfo> {
|
|
// 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 fetchWhoami(workspace.workspaceId);
|
|
} 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}`);
|
|
}
|
|
|
|
// If the user explicitly provided credentials via flags, fail immediately
|
|
// rather than falling back to interactive login — they expect their explicit
|
|
// credentials to work and should fix them if they don't.
|
|
if (opts.token || opts.baseUrl) {
|
|
const isApiError = error && typeof error === "object" &&
|
|
"name" in error && (error as { name: unknown }).name === "ApiError";
|
|
if (isApiError) {
|
|
const status = (error as { status?: number }).status;
|
|
const body = (error as { body?: unknown }).body;
|
|
let bodyStr = typeof body === "object" && body !== null
|
|
? JSON.stringify(body)
|
|
: String(body ?? "").trim();
|
|
// Strip backend source-file refs like "(flows.rs:1400)" or
|
|
// "@scopes.rs:509" from the surfaced message — same pattern used in
|
|
// main.ts for ApiError display.
|
|
bodyStr = bodyStr.replace(/\s*[@(]\w+\.rs:\d+[:\d]*\)?/g, "");
|
|
// The backend's `Error::PermissionDenied` formats with a
|
|
// "Permission denied: " prefix; the new CLI message also leads with
|
|
// that phrase, so strip it from the body to avoid duplication.
|
|
bodyStr = bodyStr.replace(/^(Permission denied|Not authorized): /, "");
|
|
if (status === 403) {
|
|
// 403 means the token authenticated but lacks scope — re-issuing
|
|
// won't help. Keep this distinct from the 401 message so the user
|
|
// doesn't waste time reproducing the token.
|
|
log.infoStderr(colors.red(
|
|
`Permission denied: the token is valid but lacks the required scope.${bodyStr ? `\n${bodyStr}` : ""}`
|
|
));
|
|
} else if (status === 401) {
|
|
log.infoStderr(colors.red(
|
|
`Could not authenticate with the provided credentials. Please check your --token and --base-url and try again.${bodyStr ? `\n${bodyStr}` : ""}`
|
|
));
|
|
} else {
|
|
log.infoStderr(colors.red(
|
|
`Request failed (${status ?? "unknown"}): ${bodyStr}`
|
|
));
|
|
}
|
|
return process.exit(1);
|
|
}
|
|
log.infoStderr(colors.red("Could not authenticate with the provided credentials. Please check your --token and --base-url and try again."));
|
|
return process.exit(1);
|
|
}
|
|
|
|
log.infoStderr(
|
|
"! 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
|
|
const { removeWorkspace, addWorkspace } = await import("../commands/workspace/workspace.ts");
|
|
removeWorkspace(workspace.name, false, opts);
|
|
workspace.token = newToken;
|
|
addWorkspace(workspace, opts);
|
|
|
|
setClient(
|
|
newToken,
|
|
workspace.remote.substring(0, workspace.remote.length - 1)
|
|
);
|
|
return await fetchWhoami(workspace.workspaceId);
|
|
}
|
|
}
|