mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-20 16:03:21 +00:00
* docs(cli): clarify workspace fork naming and parent-workspace context Expand `wmill workspace fork`'s help and interactive prompts so the two positional arguments are self-explanatory: - Command description now explains that the fork is created from the currently active (parent) workspace, that `workspace_name` is a friendly display name that may contain spaces (quote it), and that `workspace_id` is a bare slug auto-prefixed with `wm-fork-` which also determines the git branch name. - Interactive name/id prompts reworded to match. Regenerated system_prompts CLI guidance to reflect the new description. Fixes WIN-2148 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(cli): default fork name to "<parent>'s fork", make it optional The fork's display name is no longer effectively required — it now defaults to "<parent workspace name>'s fork" (fetched via get_workspace_name, falling back to the local profile name / id) and stays fully overridable via the positional argument or interactive prompt. To produce this default, `setClient` and the parent-name lookup are moved ahead of the name/id resolution. The id default is decoupled from the possessive display name: when auto-naming, the id/branch slug is derived from "<parent>-fork" (e.g. wm-fork-acme-fork) rather than the awkward "<parent>-s-fork". Branch-rename forks keep their branch-derived id. Regenerated system_prompts CLI guidance. Fixes WIN-2148 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(cli): fix workspace fork help — parent is branch-resolved, not active profile Addresses the codex/pi review: the fork help said the parent is the "currently active" workspace and told users to `wmill workspace switch`, but createWorkspaceFork resolves the parent from the current git branch's wmill.yaml mapping (tryResolveBranchWorkspace) and ignores the active profile. Reword to describe the actual branch-based resolution. Regenerated system_prompts CLI guidance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(cli): note branch-derived fork id default in rename workflows Addresses the codex review: the `[workspace_id]` help and the interactive prompt said the default id is derived from the name, but rename workflows (non-base branch / --from-branch) keep the branch-derived default (`branchDefaultId ?? branchToForkId(idBasis)`) to keep the id/branch aligned with the branch being converted. Document that special case rather than changing the intentional behavior. Regenerated system_prompts CLI guidance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): cap auto fork name at 50 chars for long parent names Addresses the codex review: the "<parent>'s fork" default appended "'s fork" to a parent name that can itself be up to 50 chars (varchar(50)), so a parent name over 43 chars produced a default exceeding the limit and tripped the effectiveName.length > 50 guard — failing `wmill workspace fork --yes` (or accepting the interactive default) for a valid parent. Truncate the parent portion so the generated default stays within 50. Verified end-to-end: a 48-char parent name now yields a 49-char default ("... Team's fork") and the fork is created successfully. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
911 lines
31 KiB
TypeScript
911 lines
31 KiB
TypeScript
import { writeFile, open as fsOpen } from "node:fs/promises";
|
|
import { readTextFile } from "../../utils/utils.ts";
|
|
import process from "node:process";
|
|
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 } from "@cliffy/ansi/colors";
|
|
import { Command } from "@cliffy/command";
|
|
import { Confirm } from "@cliffy/prompt/confirm";
|
|
import { Input } from "@cliffy/prompt/input";
|
|
import { Table } from "@cliffy/table";
|
|
import * as log from "../../core/log.ts";
|
|
import { setClient } from "../../core/client.ts";
|
|
import { requireLogin } from "../../core/auth.ts";
|
|
import { createWorkspaceFork, deleteWorkspaceFork } from "./fork.ts";
|
|
import { mergeWorkspaces } from "./merge.ts";
|
|
import { connectSlack, disconnectSlack } from "./slack.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 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 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;
|
|
}
|
|
|
|
// When the current git branch is a fork branch (wm-fork/<base>/<id>), commands
|
|
// resolve the fork workspace from the branch name and ignore the active
|
|
// profile — surface that wherever we display the active workspace, so users
|
|
// don't act on the wrong "Active:" line.
|
|
async function forkBranchAutoTargetNote(): Promise<string | undefined> {
|
|
const {
|
|
getCurrentGitBranch,
|
|
getOriginalBranchForWorkspaceForks,
|
|
getWorkspaceIdForWorkspaceForkFromBranchName,
|
|
} = await import("../../utils/git.ts");
|
|
const branch = getCurrentGitBranch();
|
|
if (!branch || !getOriginalBranchForWorkspaceForks(branch)) {
|
|
return undefined;
|
|
}
|
|
const forkWorkspaceId = getWorkspaceIdForWorkspaceForkFromBranchName(branch);
|
|
if (!forkWorkspaceId) {
|
|
return undefined;
|
|
}
|
|
return (
|
|
`Note: you are on fork branch \`${branch}\` — wmill commands automatically target the fork workspace \`${forkWorkspaceId}\` here, regardless of the active profile. Use --workspace to override.`
|
|
);
|
|
}
|
|
|
|
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"));
|
|
|
|
const forkNote = await forkBranchAutoTargetNote();
|
|
if (forkNote) {
|
|
log.info(colors.yellow(forkNote));
|
|
}
|
|
}
|
|
|
|
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})`
|
|
)
|
|
);
|
|
const forkNote = await forkBranchAutoTargetNote();
|
|
if (forkNote) {
|
|
log.info(colors.yellow(forkNote));
|
|
}
|
|
return;
|
|
}
|
|
|
|
export async function setActiveWorkspace(
|
|
workspaceName: string,
|
|
configDirOverride?: string
|
|
) {
|
|
const file = await getActiveWorkspaceConfigFilePath(configDirOverride);
|
|
await writeFile(file, workspaceName, "utf-8");
|
|
}
|
|
|
|
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 && !(process.stdin.isTTY ?? false)) {
|
|
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();
|
|
if (workspaces.length === 0) {
|
|
log.info(" (none)");
|
|
} else {
|
|
for (const workspace of workspaces) {
|
|
log.info(`- ${workspace.id} (name: ${workspace.name})`);
|
|
}
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
const added = await addWorkspace(
|
|
{
|
|
name: workspaceName,
|
|
remote: remote,
|
|
workspaceId: workspaceId,
|
|
token: token,
|
|
},
|
|
opts
|
|
);
|
|
if (!added) {
|
|
return;
|
|
}
|
|
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): Promise<boolean> {
|
|
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 =
|
|
(process.stdin.isTTY ?? false) && (process.stdout.isTTY ?? false) && !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 false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check 2: Same (remote, workspaceId) already exists with a different name
|
|
const backendConflict = existingWorkspaces.find(
|
|
(w) =>
|
|
w.remote === workspace.remote &&
|
|
w.workspaceId === workspace.workspaceId &&
|
|
w.name !== workspace.name
|
|
);
|
|
if (backendConflict) {
|
|
if (opts.force) {
|
|
// Remove the conflicting workspace before adding the new one
|
|
await removeWorkspace(backendConflict.name, true, opts);
|
|
} else {
|
|
throw new Error(
|
|
`Backend constraint violation: (${workspace.remote}, ${workspace.workspaceId}) already exists as "${backendConflict.name}". Use --force to overwrite.`
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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 fh = await fsOpen(filePath, "a");
|
|
await fh.write(JSON.stringify(workspace) + "\n");
|
|
await fh.close();
|
|
return true;
|
|
}
|
|
|
|
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 writeFile(
|
|
filePath,
|
|
orgWorkspaces
|
|
.filter((x) => x.name !== name)
|
|
.map((x) => JSON.stringify(x))
|
|
.join("\n") + "\n",
|
|
"utf-8"
|
|
);
|
|
|
|
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) {
|
|
const whoamiInfo = await requireLogin(_opts);
|
|
log.info(JSON.stringify(whoamiInfo, null, 2));
|
|
const activeName = await getActiveWorkspaceName(_opts);
|
|
const { getCurrentGitBranch, getOriginalBranchForWorkspaceForks } = await import("../../utils/git.ts");
|
|
const branch = getCurrentGitBranch();
|
|
const originalBranch = branch ? getOriginalBranchForWorkspaceForks(branch) : null;
|
|
if (originalBranch) {
|
|
const { resolveWorkspace } = await import("../../core/context.ts");
|
|
try {
|
|
const ws = await resolveWorkspace(_opts);
|
|
log.info("Active: " + colors.green.bold(ws.workspaceId) + ` (fork of ${activeName || "unknown"})`);
|
|
} catch {
|
|
log.info("Active: " + colors.green.bold(activeName || "none") + " (fork branch)");
|
|
}
|
|
} else {
|
|
log.info("Active: " + colors.green.bold(activeName || "none"));
|
|
}
|
|
}
|
|
|
|
async function listRemote(
|
|
_opts: GlobalOptions & { asSuperadmin?: boolean }
|
|
) {
|
|
let remote: string;
|
|
|
|
if (_opts.baseUrl && _opts.token && !_opts.workspace) {
|
|
// Allow listing workspaces with just --base-url and --token (no --workspace needed)
|
|
const { setClient } = await import("../../core/client.ts");
|
|
remote = new URL(_opts.baseUrl).toString();
|
|
setClient(_opts.token, remote.replace(/\/$/, ""));
|
|
} else {
|
|
const { resolveWorkspace } = await import("../../core/context.ts");
|
|
const workspace = await resolveWorkspace(_opts);
|
|
await requireLogin(_opts);
|
|
remote = workspace.remote;
|
|
}
|
|
|
|
if (_opts.asSuperadmin) {
|
|
const perPage = 100;
|
|
let page = 1;
|
|
const all: Awaited<ReturnType<typeof wmill.listWorkspacesAsSuperAdmin>> = [];
|
|
while (true) {
|
|
const batch = await wmill.listWorkspacesAsSuperAdmin({ page, perPage });
|
|
all.push(...batch);
|
|
if (batch.length < perPage) break;
|
|
page++;
|
|
}
|
|
|
|
const hasForks = all.some((x) => x.parent_workspace_id);
|
|
const headers = hasForks
|
|
? ["id", "name", "owner", "fork of"]
|
|
: ["id", "name", "owner"];
|
|
|
|
new Table()
|
|
.header(headers)
|
|
.padding(2)
|
|
.border(true)
|
|
.body(
|
|
all.map((x) => {
|
|
const row = [x.id, x.name, x.owner];
|
|
if (hasForks) row.push(x.parent_workspace_id ?? "-");
|
|
return row;
|
|
})
|
|
)
|
|
.render();
|
|
|
|
log.info(`Remote: ${colors.bold(remote)}`);
|
|
log.info(`Total workspaces: ${colors.green.bold(all.length.toString())} (superadmin)`);
|
|
return;
|
|
}
|
|
|
|
const userWorkspaces = await wmill.listUserWorkspaces();
|
|
|
|
const hasForks = userWorkspaces.workspaces.some((x) => x.parent_workspace_id);
|
|
const headers = hasForks
|
|
? ["id", "name", "username", "fork of", "disabled"]
|
|
: ["id", "name", "username", "disabled"];
|
|
|
|
new Table()
|
|
.header(headers)
|
|
.padding(2)
|
|
.border(true)
|
|
.body(
|
|
userWorkspaces.workspaces.map((x) => {
|
|
const row = [
|
|
x.id,
|
|
x.name,
|
|
x.username,
|
|
];
|
|
if (hasForks) row.push(x.parent_workspace_id ?? "-");
|
|
row.push(x.disabled ? colors.red("true") : "false");
|
|
return row;
|
|
})
|
|
)
|
|
.render();
|
|
|
|
log.info(`Remote: ${colors.bold(remote)}`);
|
|
log.info(`Logged in as: ${colors.green.bold(userWorkspaces.email)}`);
|
|
}
|
|
|
|
async function listForks(_opts: GlobalOptions) {
|
|
const { resolveWorkspace } = await import("../../core/context.ts");
|
|
const workspace = await resolveWorkspace(_opts);
|
|
await requireLogin(_opts);
|
|
|
|
const userWorkspaces = await wmill.listUserWorkspaces();
|
|
const forks = userWorkspaces.workspaces.filter((w) => w.parent_workspace_id);
|
|
|
|
if (forks.length === 0) {
|
|
log.info("No forked workspaces found.");
|
|
return;
|
|
}
|
|
|
|
new Table()
|
|
.header(["id", "name", "fork of", "username"])
|
|
.padding(2)
|
|
.border(true)
|
|
.body(
|
|
forks.map((x) => [
|
|
x.id,
|
|
x.name,
|
|
x.parent_workspace_id ?? "",
|
|
x.username,
|
|
])
|
|
)
|
|
.render();
|
|
|
|
log.info(`Remote: ${colors.bold(workspace.remote)}`);
|
|
}
|
|
|
|
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 & { workspace?: string; branch?: string },
|
|
doBind: boolean
|
|
) {
|
|
const { isGitRepository, getCurrentGitBranch } = await import(
|
|
"../../utils/git.ts"
|
|
);
|
|
const { readConfigFile, findWorkspaceByGitBranch, getWorkspaceNames } = await import("../../core/conf.ts");
|
|
const { stringify: yamlStringify } = await import("yaml");
|
|
const config = await readConfigFile();
|
|
|
|
if (!config.workspaces) {
|
|
config.workspaces = {} as any;
|
|
}
|
|
|
|
const isInteractive = !!process.stdin.isTTY;
|
|
const inGitRepo = isGitRepository();
|
|
const currentBranch = inGitRepo ? getCurrentGitBranch() : null;
|
|
|
|
if (doBind) {
|
|
// ── BIND ──────────────────────────────────────────────────
|
|
|
|
// Step 1: Pick workspace profile (the source of baseUrl/workspaceId/token)
|
|
const profiles = await allWorkspaces(opts.configDir);
|
|
let selectedProfile: Workspace | undefined;
|
|
|
|
if (profiles.length === 0) {
|
|
// No profiles — run wmill workspace add flow
|
|
log.info(colors.yellow("No workspace profiles found. Let's create one."));
|
|
await add(opts as any, undefined, undefined, undefined);
|
|
// Re-read profiles after add
|
|
const updatedProfiles = await allWorkspaces(opts.configDir);
|
|
selectedProfile = updatedProfiles.length > 0
|
|
? await getActiveWorkspace(opts)
|
|
: undefined;
|
|
if (!selectedProfile) {
|
|
log.error(colors.red("Profile creation failed or was cancelled."));
|
|
return;
|
|
}
|
|
} else if (opts.workspace && profiles.find((p) => p.name === opts.workspace)) {
|
|
// --workspace flag matches a profile name: use it directly
|
|
selectedProfile = profiles.find((p) => p.name === opts.workspace);
|
|
} else if (isInteractive) {
|
|
const { Select } = await import("@cliffy/prompt/select");
|
|
const activeProfile = await getActiveWorkspace(opts);
|
|
const selectedName = await Select.prompt({
|
|
message: "Select workspace profile to bind",
|
|
options: profiles.map((p) => ({
|
|
name: `${p.name} (${p.workspaceId} on ${p.remote})`,
|
|
value: p.name,
|
|
})),
|
|
default: activeProfile?.name,
|
|
});
|
|
selectedProfile = profiles.find((p) => p.name === selectedName);
|
|
} else {
|
|
// Non-interactive: use active profile
|
|
selectedProfile = await getActiveWorkspaceOrFallback(opts);
|
|
}
|
|
|
|
if (!selectedProfile) {
|
|
log.error(colors.red("No workspace profile selected. Aborting."));
|
|
return;
|
|
}
|
|
|
|
// Step 2: Pick workspace name (the key in wmill.yaml workspaces section)
|
|
let wsName: string;
|
|
if (opts.workspace) {
|
|
wsName = opts.workspace;
|
|
} else if (isInteractive) {
|
|
const { Input } = await import("@cliffy/prompt/input");
|
|
wsName = await Input.prompt({
|
|
message: "Workspace name (key in wmill.yaml)",
|
|
default: selectedProfile.workspaceId,
|
|
});
|
|
} else {
|
|
wsName = selectedProfile.workspaceId;
|
|
}
|
|
|
|
// Step 3: Pick git branch (only in git repos)
|
|
let gitBranch: string | undefined;
|
|
if (inGitRepo) {
|
|
if (opts.branch) {
|
|
if (opts.branch !== wsName) {
|
|
gitBranch = opts.branch;
|
|
}
|
|
} else if (isInteractive) {
|
|
const { Input } = await import("@cliffy/prompt/input");
|
|
const branchInput = await Input.prompt({
|
|
message: "Git branch to associate",
|
|
default: currentBranch ?? wsName,
|
|
});
|
|
if (branchInput !== wsName) {
|
|
gitBranch = branchInput;
|
|
}
|
|
} else if (currentBranch && currentBranch !== wsName) {
|
|
gitBranch = currentBranch;
|
|
}
|
|
}
|
|
|
|
// Step 4: Write the entry
|
|
const entry = (config.workspaces as any)[wsName] ?? {};
|
|
entry.baseUrl = selectedProfile.remote;
|
|
if (selectedProfile.workspaceId !== wsName) {
|
|
entry.workspaceId = selectedProfile.workspaceId;
|
|
} else {
|
|
delete entry.workspaceId; // clean up if it matches
|
|
}
|
|
if (gitBranch) {
|
|
entry.gitBranch = gitBranch;
|
|
} else {
|
|
delete entry.gitBranch; // clean up if it matches
|
|
}
|
|
(config.workspaces as any)[wsName] = entry;
|
|
|
|
// Drop empty `{}` placeholder entries (older `wmill init` scaffolded one):
|
|
// they resolve to nothing (no baseUrl) yet count as configured workspaces,
|
|
// so leaving one beside the real binding makes every later command demand
|
|
// --workspace to disambiguate.
|
|
for (const [name, e] of Object.entries(
|
|
config.workspaces as Record<string, unknown>
|
|
)) {
|
|
if (
|
|
name !== wsName &&
|
|
!!e &&
|
|
typeof e === "object" &&
|
|
Object.keys(e).length === 0
|
|
) {
|
|
delete (config.workspaces as any)[name];
|
|
log.info(
|
|
colors.gray(`Removed empty placeholder workspace entry '${name}'`)
|
|
);
|
|
}
|
|
}
|
|
|
|
log.info(
|
|
colors.green(
|
|
`✓ Bound workspace '${wsName}'` +
|
|
(gitBranch ? ` (gitBranch: ${gitBranch})` : "") +
|
|
` → ${selectedProfile.workspaceId} on ${selectedProfile.remote}`
|
|
)
|
|
);
|
|
} else {
|
|
// ── UNBIND ────────────────────────────────────────────────
|
|
let wsName: string | undefined;
|
|
|
|
if (opts.workspace) {
|
|
wsName = opts.workspace;
|
|
} else if (currentBranch) {
|
|
const match = findWorkspaceByGitBranch(config.workspaces, currentBranch);
|
|
wsName = match?.[0];
|
|
}
|
|
|
|
if (!wsName && isInteractive) {
|
|
const names = getWorkspaceNames(config.workspaces);
|
|
if (names.length === 0) {
|
|
log.error(colors.red("No workspaces configured in wmill.yaml."));
|
|
return;
|
|
}
|
|
const { Select } = await import("@cliffy/prompt/select");
|
|
wsName = await Select.prompt({
|
|
message: "Select workspace to unbind",
|
|
options: names,
|
|
});
|
|
}
|
|
|
|
if (!wsName || !(config.workspaces as any)[wsName]) {
|
|
log.error(colors.red(
|
|
wsName
|
|
? `Workspace '${wsName}' not found in wmill.yaml.`
|
|
: "Could not determine workspace. Use --workspace to specify."
|
|
));
|
|
return;
|
|
}
|
|
|
|
const entry = (config.workspaces as any)[wsName];
|
|
delete entry.baseUrl;
|
|
delete entry.workspaceId;
|
|
log.info(colors.green(`✓ Removed binding from workspace '${wsName}'`));
|
|
}
|
|
|
|
try {
|
|
await writeFile("wmill.yaml", yamlStringify(config), "utf-8");
|
|
} catch (error) {
|
|
log.error(colors.red(`Failed to save configuration: ${(error as Error).message}`));
|
|
return;
|
|
}
|
|
|
|
// After a successful bind, offer to generate resource type namespace
|
|
if (doBind && isInteractive) {
|
|
const { stat: statFile } = await import("node:fs/promises");
|
|
const rtExists = await statFile("rt.d.ts").then(() => true, () => false);
|
|
const { Confirm } = await import("@cliffy/prompt/confirm");
|
|
const generate = await Confirm.prompt({
|
|
message: "Generate rt.d.ts? (TypeScript types for your workspace's resource types, useful for autocompletion)",
|
|
default: !rtExists,
|
|
});
|
|
if (generate) {
|
|
try {
|
|
const { generateRTNamespace } = await import("../resource-type/resource-type.ts");
|
|
await generateRTNamespace(opts);
|
|
} catch (error) {
|
|
log.warn(
|
|
`Could not generate resource type namespace: ${
|
|
error instanceof Error ? error.message : error
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 local workspace profiles")
|
|
.action(list as any)
|
|
.command("list-remote")
|
|
.description("List workspaces on the remote server that you have access to")
|
|
.option(
|
|
"--as-superadmin",
|
|
"List ALL workspaces on the instance (requires the token to belong to a superadmin/devops user)"
|
|
)
|
|
.action(listRemote as any)
|
|
.command("list-forks")
|
|
.description("List forked workspaces on the remote server")
|
|
.action(listForks as any)
|
|
.command("bind")
|
|
.description("Create or update a workspace entry in wmill.yaml from the active profile")
|
|
.option("--workspace <name:string>", "Workspace name (default: current branch or workspaceId)")
|
|
.option("--branch <branch:string>", "Git branch to associate (default: workspace name)")
|
|
.action((opts) => bind(opts as any, true))
|
|
.command("unbind")
|
|
.description("Remove baseUrl and workspaceId from a workspace entry")
|
|
.option("--workspace <name:string>", "Workspace to unbind")
|
|
.action((opts) => bind(opts as any, false))
|
|
.command("fork")
|
|
.description(
|
|
`Create a forked workspace from its parent workspace.
|
|
|
|
The parent is resolved from your current git branch, not from the active profile: run this from a git repo checked out on the branch mapped to the parent workspace in wmill.yaml's \`workspaces:\` section (a fork branch of it resolves to the same parent). \`wmill workspace switch\` does not change which workspace is forked.
|
|
|
|
Arguments (omit both to be prompted interactively):
|
|
[workspace_name] Friendly display name for the fork, shown in the UI. May contain spaces, so quote it in the shell (e.g. "My Fork"). Max 50 chars. Defaults to "<parent workspace name>'s fork".
|
|
[workspace_id] Id for the fork. Must be a slug (no spaces or special characters) and is automatically prefixed with \`wm-fork-\`, so pass just the bare slug (e.g. \`my-fork\` becomes \`wm-fork-my-fork\`). This id also determines the fork's git branch name. Defaults to a slug derived from the name — or, when you are converting an existing branch into the fork branch, from that branch.`
|
|
)
|
|
.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."
|
|
)
|
|
.option("--color <color:string>", "Workspace color (hex code, e.g. #ff0000)")
|
|
.option(
|
|
"--datatable-behavior <behavior:string>",
|
|
"How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)"
|
|
)
|
|
.option(
|
|
"--from-branch <branch:string>",
|
|
"Non-interactive override for the 'turn my current working branch into the fork' workflow: base the fork on <branch> (its bound workspace is the parent) and rename the current branch onto wm-fork/<branch>/<id>. Usually unneeded — from a working branch `wmill workspace fork` offers this interactively; from a base branch it creates a fresh fork branch."
|
|
)
|
|
.option("-y --yes", "Skip interactive prompts (defaults datatable behavior to 'skip'). On a non-base branch, requires --from-branch since the base branch can't be prompted for.")
|
|
.action(createWorkspaceFork as any)
|
|
.command("delete-fork")
|
|
.description("Delete a forked workspace")
|
|
.arguments("<fork_name:string>")
|
|
.option("-y --yes", "Skip confirmation prompt")
|
|
.action(deleteWorkspaceFork as any)
|
|
.command("merge")
|
|
.description("Compare and deploy changes between a fork and its parent workspace")
|
|
.option("--direction <direction:string>", "Deploy direction: to-parent or to-fork")
|
|
.option("--all", "Deploy all changed items including conflicts")
|
|
.option("--skip-conflicts", "Skip items modified in both workspaces")
|
|
.option("--include <items:string>", "Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)")
|
|
.option("--exclude <items:string>", "Comma-separated kind:path items to exclude")
|
|
.option("--preserve-on-behalf-of", "Preserve original on_behalf_of/permissioned_as values")
|
|
.option("-y --yes", "Non-interactive mode (deploy without prompts)")
|
|
.action(mergeWorkspaces as any)
|
|
.command("connect-slack")
|
|
.description(
|
|
"Non-interactively connect Slack to the active workspace using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: workspace_settings fields, g/slack group, f/slack_bot folder, and the encrypted bot token variable + resource at f/slack_bot/bot_token."
|
|
)
|
|
.option("--bot-token <bot_token:string>", "Slack bot token (xoxb-...)", { required: true })
|
|
.option("--team-id <team_id:string>", "Slack team id", { required: true })
|
|
.option("--team-name <team_name:string>", "Slack team name", { required: true })
|
|
.action(connectSlack as any)
|
|
.command("disconnect-slack")
|
|
.description(
|
|
"Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push."
|
|
)
|
|
.action(disconnectSlack as any);
|
|
|
|
export default command;
|