Adds `wmill workspace list` command that fetches and displays all workspaces the user has access to on the remote server. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
569 lines
16 KiB
TypeScript
569 lines
16 KiB
TypeScript
// deno-lint-ignore-file no-explicit-any
|
|
import { GlobalOptions } from "../../types.ts";
|
|
import {
|
|
getActiveWorkspaceConfigFilePath,
|
|
getWorkspaceConfigFilePath,
|
|
} from "../../../windmill-utils-internal/src/config/config.ts";
|
|
import { loginInteractive, tryGetLoginInfo } from "../../core/login.ts";
|
|
import {
|
|
colors,
|
|
Command,
|
|
Confirm,
|
|
Input,
|
|
log,
|
|
setClient,
|
|
Table,
|
|
} from "../../../deps.ts";
|
|
import { requireLogin } from "../../core/auth.ts";
|
|
import { createWorkspaceFork, deleteWorkspaceFork } from "./fork.ts";
|
|
|
|
import * as wmill from "../../../gen/services.gen.ts";
|
|
|
|
export interface Workspace {
|
|
remote: string;
|
|
workspaceId: string;
|
|
name: string;
|
|
token: string;
|
|
}
|
|
|
|
export async function allWorkspaces(
|
|
configDirOverride?: string
|
|
): Promise<Workspace[]> {
|
|
try {
|
|
const file = await getWorkspaceConfigFilePath(configDirOverride);
|
|
const txt = await Deno.readTextFile(file);
|
|
return txt
|
|
.split("\n")
|
|
.map((line) => {
|
|
if (line.length <= 2) {
|
|
return;
|
|
}
|
|
const instance = JSON.parse(line) as Workspace;
|
|
return instance;
|
|
})
|
|
.filter(Boolean) as Workspace[];
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function getActiveWorkspaceName(
|
|
opts: GlobalOptions | undefined
|
|
): Promise<string | undefined> {
|
|
if (opts?.workspace) {
|
|
return opts?.workspace;
|
|
}
|
|
try {
|
|
const file = await getActiveWorkspaceConfigFilePath(opts?.configDir);
|
|
return await Deno.readTextFile(file);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export async function getActiveWorkspace(
|
|
opts: GlobalOptions | undefined = undefined
|
|
): Promise<Workspace | undefined> {
|
|
const name = await getActiveWorkspaceName(opts);
|
|
if (!name) {
|
|
return undefined;
|
|
}
|
|
return await getWorkspaceByName(name, opts?.configDir);
|
|
}
|
|
|
|
export async function getWorkspaceByName(
|
|
workspaceName: string,
|
|
configDirOverride?: string
|
|
): Promise<Workspace | undefined> {
|
|
const workspaceStream = await allWorkspaces(configDirOverride);
|
|
for await (const workspace of workspaceStream) {
|
|
if (workspace.name === workspaceName) {
|
|
return workspace;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export async function list(opts: GlobalOptions) {
|
|
const workspaces = await allWorkspaces(opts.configDir);
|
|
const activeName = await getActiveWorkspaceName(opts);
|
|
|
|
new Table()
|
|
.header(["name", "remote", "workspace id"])
|
|
.padding(2)
|
|
.border(true)
|
|
.body(
|
|
workspaces.map((x) => {
|
|
const a = [x.name, x.remote, x.workspaceId];
|
|
if (x.name === activeName) {
|
|
return a.map((x) => colors.underline(x));
|
|
} else {
|
|
return a;
|
|
}
|
|
})
|
|
)
|
|
.render();
|
|
|
|
log.info("Active: " + colors.green.bold(activeName || "none"));
|
|
}
|
|
|
|
async function switchC(opts: GlobalOptions, workspaceName: string) {
|
|
if (opts.workspace) {
|
|
log.info(
|
|
colors.red.bold(
|
|
"! Workspace needs to be specified as positional argument, not as option."
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
const all = await allWorkspaces(opts.configDir);
|
|
if (all.findIndex((x) => x.name === workspaceName) === -1) {
|
|
log.info(
|
|
colors.red.bold(
|
|
`! This workspace profile ${workspaceName} does not exist locally.`
|
|
)
|
|
);
|
|
log.info("available workspace profiles:");
|
|
for (const w of all) {
|
|
log.info(" - " + w.name);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await setActiveWorkspace(workspaceName, opts.configDir);
|
|
const workspace = await getWorkspaceByName(workspaceName, opts.configDir);
|
|
log.info(
|
|
colors.green.bold(
|
|
`Switched to workspace ${workspaceName} (${workspace?.workspaceId} on ${workspace?.remote})`
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
export async function setActiveWorkspace(
|
|
workspaceName: string,
|
|
configDirOverride?: string
|
|
) {
|
|
const file = await getActiveWorkspaceConfigFilePath(configDirOverride);
|
|
await Deno.writeTextFile(file, workspaceName);
|
|
}
|
|
|
|
export async function add(
|
|
opts: GlobalOptions & {
|
|
create: boolean;
|
|
createWorkspaceName: string | undefined;
|
|
createUsername: string | undefined;
|
|
},
|
|
workspaceName: string | undefined,
|
|
workspaceId: string | undefined,
|
|
remote: string | undefined
|
|
) {
|
|
if (opts.workspace) {
|
|
log.info(
|
|
colors.red.bold(
|
|
"! Workspace needs to be specified as positional argument, not as option."
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
while (workspaceName === undefined) {
|
|
if (!workspaceName) {
|
|
workspaceName = await Input.prompt("Name this workspace:");
|
|
}
|
|
}
|
|
|
|
if (!workspaceId) {
|
|
workspaceId = await Input.prompt({
|
|
message: "Enter the ID of this workspace",
|
|
default: workspaceName,
|
|
suggestions: [workspaceName],
|
|
});
|
|
}
|
|
|
|
if (!remote) {
|
|
// first check whether workspaceId is actually a URL
|
|
try {
|
|
const url = new URL(workspaceId);
|
|
workspaceId = workspaceName;
|
|
remote = url.toString();
|
|
} catch {
|
|
// not a url
|
|
remote = new URL(
|
|
await Input.prompt({
|
|
message: "Enter the Remote URL",
|
|
suggestions: ["https://app.windmill.dev/"],
|
|
default: "https://app.windmill.dev/",
|
|
})
|
|
).toString();
|
|
}
|
|
}
|
|
remote = new URL(remote).toString(); // add trailing slash in all cases!
|
|
|
|
let token = await tryGetLoginInfo(opts);
|
|
if (!token && Deno.stdin.isTerminal && !Deno.stdin.isTerminal()) {
|
|
log.info("Not a TTY, can't login interactively. Pass the token in --token");
|
|
return;
|
|
}
|
|
while (!token) {
|
|
token = await loginInteractive(remote);
|
|
}
|
|
|
|
setClient(
|
|
token,
|
|
remote.endsWith("/") ? remote.substring(0, remote.length - 1) : remote
|
|
);
|
|
let alreadyExists = false;
|
|
try {
|
|
alreadyExists = await wmill.existsWorkspace({
|
|
requestBody: { id: workspaceId },
|
|
});
|
|
} catch (e) {
|
|
log.info(
|
|
colors.red.bold("! Credentials or instance is invalid. Aborting.")
|
|
);
|
|
throw e;
|
|
}
|
|
if (opts.create) {
|
|
if (!alreadyExists) {
|
|
log.info(
|
|
colors.yellow(
|
|
`Workspace at id ${workspaceId} on ${remote} does not exist. Creating...`
|
|
)
|
|
);
|
|
const automateUsernameCreation: boolean =
|
|
((await wmill.getGlobal({
|
|
key: "automate_username_creation",
|
|
})) as any) ?? false;
|
|
await wmill.createWorkspace({
|
|
requestBody: {
|
|
id: workspaceId,
|
|
name: opts.createWorkspaceName ?? workspaceName,
|
|
username: automateUsernameCreation ? undefined : opts.createUsername,
|
|
},
|
|
});
|
|
}
|
|
} else if (!alreadyExists) {
|
|
log.info(
|
|
colors.red.bold(
|
|
`! Workspace at id ${workspaceId} on ${remote} does not exist. Re-run with --create to create it. Aborting.`
|
|
)
|
|
);
|
|
log.info(
|
|
"On that instance and with those credentials, the workspaces that you can access are:"
|
|
);
|
|
const workspaces = await wmill.listWorkspaces();
|
|
for (const workspace of workspaces) {
|
|
log.info(`- ${workspace.id} (name: ${workspace.name})`);
|
|
}
|
|
Deno.exit(1);
|
|
}
|
|
|
|
await addWorkspace(
|
|
{
|
|
name: workspaceName,
|
|
remote: remote,
|
|
workspaceId: workspaceId,
|
|
token: token,
|
|
},
|
|
opts
|
|
);
|
|
await setActiveWorkspace(workspaceName, opts.configDir);
|
|
|
|
log.info(
|
|
colors.green.underline(
|
|
`Added workspace ${workspaceName} for ${workspaceId} on ${remote}!`
|
|
)
|
|
);
|
|
}
|
|
|
|
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) {
|
|
// If it's the exact same workspace (same remote + workspaceId), just update the token
|
|
if (
|
|
nameConflict.remote === workspace.remote &&
|
|
nameConflict.workspaceId === workspace.workspaceId
|
|
) {
|
|
log.info(
|
|
colors.yellow(
|
|
`Updating token for existing workspace "${workspace.name}"`
|
|
)
|
|
);
|
|
} else {
|
|
// Different remote or workspaceId - this is a conflict
|
|
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) {
|
|
log.info(
|
|
colors.yellow("Force flag enabled, overwriting existing workspace.")
|
|
);
|
|
} else {
|
|
throw new Error(
|
|
"Workspace name conflict. Use --force to overwrite or choose a different name."
|
|
);
|
|
}
|
|
} else {
|
|
const overwrite = await Confirm.prompt({
|
|
message: "Do you want to overwrite the existing workspace?",
|
|
default: false,
|
|
});
|
|
|
|
if (!overwrite) {
|
|
log.info(colors.yellow("Operation cancelled."));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove existing workspace with same name (if updating)
|
|
await removeWorkspace(workspace.name, true, opts);
|
|
|
|
// Add the new workspace
|
|
const filePath = await getWorkspaceConfigFilePath(opts.configDir);
|
|
const file = await Deno.open(filePath, {
|
|
append: true,
|
|
write: true,
|
|
read: true,
|
|
create: true,
|
|
});
|
|
await file.write(new TextEncoder().encode(JSON.stringify(workspace) + "\n"));
|
|
|
|
file.close();
|
|
}
|
|
|
|
export async function removeWorkspace(
|
|
name: string,
|
|
silent: boolean,
|
|
opts: any
|
|
) {
|
|
const orgWorkspaces = await allWorkspaces(opts.configDir);
|
|
if (orgWorkspaces.findIndex((x) => x.name === name) === -1) {
|
|
if (!silent) {
|
|
log.info(
|
|
colors.red.bold(`! Workspace profile ${name} does not exist locally`)
|
|
);
|
|
log.info("available workspace profiles:");
|
|
await list(opts);
|
|
}
|
|
return;
|
|
}
|
|
if (!silent) {
|
|
log.info(colors.yellow(`Removing existing workspace ${name}`));
|
|
}
|
|
|
|
const filePath = await getWorkspaceConfigFilePath(opts.configDir);
|
|
await Deno.writeTextFile(
|
|
filePath,
|
|
orgWorkspaces
|
|
.filter((x) => x.name !== name)
|
|
.map((x) => JSON.stringify(x))
|
|
.join("\n") + "\n"
|
|
);
|
|
|
|
if (!silent) {
|
|
log.info(colors.green.underline(`Succesfully removed workspace ${name}!`));
|
|
}
|
|
}
|
|
|
|
async function remove(_opts: GlobalOptions, name: string) {
|
|
await removeWorkspace(name, false, _opts);
|
|
}
|
|
|
|
async function whoami(_opts: GlobalOptions) {
|
|
await requireLogin(_opts);
|
|
log.info(await wmill.globalWhoami());
|
|
const activeName = await getActiveWorkspaceName(_opts);
|
|
log.info("Active: " + colors.green.bold(activeName || "none"));
|
|
}
|
|
|
|
async function listRemote(_opts: GlobalOptions) {
|
|
await requireLogin(_opts);
|
|
const userWorkspaces = await wmill.listUserWorkspaces();
|
|
|
|
new Table()
|
|
.header(["id", "name", "username", "disabled"])
|
|
.padding(2)
|
|
.border(true)
|
|
.body(
|
|
userWorkspaces.workspaces.map((x) => [
|
|
x.id,
|
|
x.name,
|
|
x.username,
|
|
x.disabled ? colors.red("true") : "false",
|
|
])
|
|
)
|
|
.render();
|
|
|
|
log.info(`Logged in as: ${colors.green.bold(userWorkspaces.email)}`);
|
|
}
|
|
|
|
export async function getActiveWorkspaceOrFallback(opts: GlobalOptions) {
|
|
let activeWorkspace = await getActiveWorkspace(opts);
|
|
if (!activeWorkspace && opts.baseUrl && opts.workspace) {
|
|
activeWorkspace = {
|
|
name: opts.workspace,
|
|
remote: opts.baseUrl,
|
|
workspaceId: opts.workspace,
|
|
token: "",
|
|
};
|
|
}
|
|
return activeWorkspace;
|
|
}
|
|
async function bind(
|
|
opts: GlobalOptions & { branch?: string },
|
|
bindWorkspace?: boolean
|
|
) {
|
|
const { isGitRepository, getCurrentGitBranch } = await import(
|
|
"../../utils/git.ts"
|
|
);
|
|
|
|
if (!isGitRepository()) {
|
|
log.error(colors.red("Not in a Git repository"));
|
|
return;
|
|
}
|
|
|
|
const branch = opts.branch || getCurrentGitBranch();
|
|
if (!branch) {
|
|
log.error(colors.red("Could not determine current Git branch"));
|
|
return;
|
|
}
|
|
|
|
const { readConfigFile } = await import("../../core/conf.ts");
|
|
const config = await readConfigFile();
|
|
|
|
const activeWorkspace = await getActiveWorkspaceOrFallback(opts);
|
|
if (!activeWorkspace && bindWorkspace) {
|
|
log.error(
|
|
colors.red(
|
|
"No active workspace. Use 'wmill workspace add' or 'wmill workspace switch' first"
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
// For unbind, check if branch exists
|
|
if (!bindWorkspace && (!config.gitBranches || !config.gitBranches[branch])) {
|
|
log.error(
|
|
colors.red(`Branch '${branch}' not found in wmill.yaml gitBranches`)
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Update the branch configuration with workspace binding
|
|
if (!config.gitBranches) {
|
|
config.gitBranches = {};
|
|
}
|
|
if (!config.gitBranches[branch]) {
|
|
config.gitBranches[branch] = { overrides: {} };
|
|
}
|
|
|
|
if (bindWorkspace && activeWorkspace) {
|
|
config.gitBranches[branch].baseUrl = activeWorkspace.remote;
|
|
config.gitBranches[branch].workspaceId = activeWorkspace.workspaceId;
|
|
|
|
log.info(
|
|
colors.green(
|
|
`✓ Bound branch '${branch}' to workspace '${activeWorkspace.name}'\n` +
|
|
` ${activeWorkspace.workspaceId} on ${activeWorkspace.remote}`
|
|
)
|
|
);
|
|
} else {
|
|
// Unbind
|
|
delete config.gitBranches[branch].baseUrl;
|
|
delete config.gitBranches[branch].workspaceId;
|
|
|
|
log.info(
|
|
colors.green(`✓ Removed workspace binding from branch '${branch}'`)
|
|
);
|
|
}
|
|
|
|
// Write back the updated config
|
|
const { yamlStringify } = await import("../../../deps.ts");
|
|
try {
|
|
await Deno.writeTextFile("wmill.yaml", yamlStringify(config));
|
|
} catch (error) {
|
|
log.error(colors.red(`Failed to save configuration: ${(error as Error).message}`));
|
|
return;
|
|
}
|
|
}
|
|
|
|
const command = new Command()
|
|
.alias("profile")
|
|
.description("workspace related commands")
|
|
.action(list as any)
|
|
.command("switch")
|
|
.complete("workspace", async () => (await allWorkspaces()).map((x) => x.name))
|
|
.description("Switch to another workspace")
|
|
.arguments("<workspace_name:string:workspace>")
|
|
.action(switchC as any)
|
|
.command("add")
|
|
.description("Add a workspace")
|
|
.arguments("[workspace_name:string] [workspace_id:string] [remote:string]")
|
|
.option("-c --create", "Create the workspace if it does not exist")
|
|
.option(
|
|
"--create-workspace-name <workspace_name:string>",
|
|
"Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id."
|
|
)
|
|
.option(
|
|
"--create-username <username:string>",
|
|
"Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.",
|
|
{
|
|
default: "admin",
|
|
}
|
|
)
|
|
.action(add as any)
|
|
.command("remove")
|
|
.description("Remove a workspace")
|
|
.arguments("<workspace_name:string>")
|
|
.action(remove as any)
|
|
.command("whoami")
|
|
.description("Show the currently active user")
|
|
.action(whoami as any)
|
|
.command("list")
|
|
.description("List workspaces on the remote server that you have access to")
|
|
.action(listRemote as any)
|
|
.command("bind")
|
|
.description("Bind the current Git branch to the active workspace")
|
|
.option("--branch <branch:string>", "Specify branch (defaults to current)")
|
|
.action((opts) => bind(opts as any, true))
|
|
.command("unbind")
|
|
.description("Remove workspace binding from the current Git branch")
|
|
.option("--branch <branch:string>", "Specify branch (defaults to current)")
|
|
.action((opts) => bind(opts as any, false))
|
|
.command("fork")
|
|
.description("Create a forked workspace")
|
|
.arguments("[workspace_name:string] [workspace_id:string]")
|
|
.option(
|
|
"--create-workspace-name <workspace_name:string>",
|
|
"Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id."
|
|
)
|
|
.action(createWorkspaceFork as any)
|
|
.command("delete-fork")
|
|
.description("Delete a forked workspace and git branch")
|
|
.arguments("<fork_name:string>")
|
|
.option("-y --yes", "Skip confirmation prompt")
|
|
.action(deleteWorkspaceFork as any);
|
|
|
|
export default command;
|