feat: add CLI workspace merge command and enhance fork with datatable/color support (#8756)

* feat: add CLI workspace merge command and enhance fork with datatable/color support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: abort fork on git branch failure, per-datatable error handling, guard resetDiffTally

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add fork/merge integration tests covering full cycle

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: support deploying deletions during fork merge (archive/delete in target)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: share deploy logic between CLI and frontend via windmill-utils-internal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: revert frontend to self-contained deploy, fix failure_module handling

The frontend imports windmill-utils-internal from npm (published v1.3.4)
which doesn't have the new deploy module yet. Revert frontend to its own
self-contained implementation with two improvements:
- Pass failure_module to getAllModules in flow deploy and getItemValue
- Add deleteItemInWorkspace for deploying deletions during merge

The shared deploy.ts in windmill-utils-internal remains for CLI use.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: share deploy logic via published windmill-utils-internal, add comprehensive integration tests

- Publish windmill-utils-internal v1.3.8 with DeployProvider interface
- Frontend now uses shared deploy module (deployItem, deleteItemInWorkspace,
  checkItemExists, getOnBehalfOf, getItemValue) via provider adapter
- Add 4 new integration test sub-tests: all item types, secret variables,
  special characters, partial deploy + resetDiffTally

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove unused folderName function from frontend utils_workspace_deploy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-08 01:29:48 -04:00
committed by GitHub
parent 01e6414ddb
commit 4342c18541
11 changed files with 1896 additions and 479 deletions

View File

@@ -3,19 +3,18 @@ import { colors } from "@cliffy/ansi/colors";
import { Input } from "@cliffy/prompt/input";
import * as log from "../../core/log.ts";
import { setClient } from "../../core/client.ts";
import { allWorkspaces, list, removeWorkspace } from "./workspace.ts";
import { allWorkspaces, list, removeWorkspace } from "./workspace.ts";
import * as wmill from "../../../gen/services.gen.ts";
import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, isGitRepository } from "../../utils/git.ts";
import { WM_FORK_PREFIX } from "../../core/constants.ts";
import { tryResolveBranchWorkspace } from "../../core/context.ts";
// NOTE: This import will work after regenerating the API client
// Run ./gen_wm_client.sh to regenerate after backend changes
// import * as wmill from "../../../gen/services.gen.ts";
async function createWorkspaceFork(
opts: GlobalOptions & {
createWorkspaceName: string | undefined;
color: string | undefined;
datatableBehavior: string | undefined;
yes: boolean | undefined;
},
workspaceName: string | undefined,
workspaceId: string | undefined = undefined,
@@ -104,21 +103,139 @@ async function createWorkspaceFork(
throw new Error(`This forked workspace '${workspaceId}' (${workspaceName}) already exists. Choose a different id`);
}
// --- Datatable cloning (matches ForkDatatableSection.svelte) ---
interface ForkedDatatableInfo {
name: string;
new_dbname: string;
}
const forkedDatatables: ForkedDatatableInfo[] = [];
let datatables: Awaited<ReturnType<typeof wmill.listDataTables>> = [];
try {
datatables = await wmill.listDataTables({
workspace: workspace.workspaceId,
});
} catch (e) {
log.info(
colors.yellow(
`Note: Could not list datatables: ${(e as Error).message}`
)
);
}
if (datatables && datatables.length > 0) {
const behavior = opts.datatableBehavior ?? (opts.yes ? "skip" : undefined);
if (behavior !== "skip") {
log.info(`\nFound ${datatables.length} datatable(s):`);
for (const dt of datatables) {
let dtBehavior: string;
if (behavior === "schema_only" || behavior === "schema_and_data") {
dtBehavior = behavior;
} else {
// Interactive prompt
const { Select } = await import("@cliffy/prompt/select");
dtBehavior = await Select.prompt({
message: `Datatable "${dt.name}" (${dt.resource_type}):`,
options: [
{ name: "Keep original (no cloning)", value: "keep_original" },
{ name: "Clone schema only", value: "schema_only" },
{ name: "Clone schema and data", value: "schema_and_data" },
],
});
}
if (dtBehavior === "keep_original") {
continue;
}
const newDbName = `${trueWorkspaceId.replace(/-/g, "_")}__${dt.name}`;
try {
log.info(
colors.blue(` Creating database "${newDbName}" for datatable "${dt.name}"...`)
);
await wmill.createPgDatabase({
workspace: workspace.workspaceId,
requestBody: {
source: `datatable://${dt.name}`,
target_dbname: newDbName,
},
});
log.info(
colors.blue(
` Importing ${dtBehavior === "schema_only" ? "schema" : "schema + data"}...`
)
);
await wmill.importPgDatabase({
workspace: workspace.workspaceId,
requestBody: {
source: `datatable://${dt.name}`,
target: `datatable://${dt.name}`,
target_dbname_override: newDbName,
fork_behavior: dtBehavior as "schema_only" | "schema_and_data",
},
});
log.info(colors.green(` ✓ Datatable "${dt.name}" cloned.`));
forkedDatatables.push({ name: dt.name, new_dbname: newDbName });
} catch (e) {
log.info(
colors.yellow(
` ✗ Failed to clone datatable "${dt.name}": ${(e as Error).message}`
)
);
}
}
}
}
// --- Create git branch for fork (matches UI: createWorkspaceForkGitBranch) ---
const forkColor = opts.color;
try {
const gitSyncJobIds = await wmill.createWorkspaceForkGitBranch({
workspace: workspace.workspaceId,
requestBody: {
id: trueWorkspaceId,
name: opts.createWorkspaceName ?? trueWorkspaceId,
color: forkColor,
},
});
if (gitSyncJobIds && gitSyncJobIds.length > 0) {
log.info(
colors.blue(
`Git sync branch creation triggered (${gitSyncJobIds.length} job(s)). These will complete asynchronously.`
)
);
}
} catch (e) {
log.error(
colors.red(
`Failed to create git branch for fork: ${(e as Error).message}`
)
);
throw e;
}
// --- Create the fork workspace ---
try {
// TODO: Update to createWorkspaceFork after regenerating client from new OpenAPI spec
const result = await wmill.createWorkspaceFork({
workspace: workspace.workspaceId,
requestBody: {
id: trueWorkspaceId,
name: opts.createWorkspaceName ?? trueWorkspaceId,
color: undefined,
color: forkColor,
forked_datatables: forkedDatatables,
},
});
log.info(colors.green(`${result}`));
} catch (error) {
// If workspace creation fails, we should clean up the git branch
log.error(
colors.red(`Failed to create forked workspace: ${(error as Error).message}`),
);
@@ -135,8 +252,8 @@ async function createWorkspaceFork(
When doing operations on the forked workspace, it will use the remote setup in gitBranches for the branch it was forked from.
To merge changes back to the parent workspace, you can:
- Use the CLI: ` + colors.white(`git checkout ${newBranchName} && wmill workspace merge`) + `
- Use the Merge UI from the forked workspace home page
- Deploy individual items via the Deploy to staging/prod UI
- Use git: ` + colors.white(`git checkout ${clonedBranchName} && git merge ${newBranchName} && wmill sync push`) + `
See: https://www.windmill.dev/docs/advanced/workspace_forks`);
}

View File

@@ -0,0 +1,427 @@
import { GlobalOptions } from "../../types.ts";
import { colors } from "@cliffy/ansi/colors";
import { Table } from "@cliffy/table";
import * as log from "../../core/log.ts";
import { setClient } from "../../core/client.ts";
import { tryResolveBranchWorkspace } from "../../core/context.ts";
import * as wmill from "../../../gen/services.gen.ts";
import {
deployItem,
deleteItemInWorkspace,
getOnBehalfOf,
type DeployKind,
type DeployProvider,
} from "../../../windmill-utils-internal/src/deploy.ts";
// ---------------------------------------------------------------------------
// Provider adapter — wraps CLI's standalone API functions
// ---------------------------------------------------------------------------
const provider: DeployProvider = {
existsFlowByPath: wmill.existsFlowByPath,
existsScriptByPath: wmill.existsScriptByPath,
existsApp: wmill.existsApp,
existsVariable: wmill.existsVariable,
existsResource: wmill.existsResource,
existsResourceType: wmill.existsResourceType,
existsFolder: wmill.existsFolder,
getFlowByPath: wmill.getFlowByPath,
createFlow: wmill.createFlow,
updateFlow: wmill.updateFlow,
archiveFlowByPath: wmill.archiveFlowByPath,
getScriptByPath: wmill.getScriptByPath,
createScript: wmill.createScript,
archiveScriptByPath: wmill.archiveScriptByPath,
getAppByPath: wmill.getAppByPath,
createApp: wmill.createApp,
updateApp: wmill.updateApp,
createAppRaw: wmill.createAppRaw,
updateAppRaw: wmill.updateAppRaw,
getPublicSecretOfLatestVersionOfApp:
wmill.getPublicSecretOfLatestVersionOfApp,
getRawAppData: wmill.getRawAppData,
deleteApp: wmill.deleteApp,
getVariable: wmill.getVariable,
createVariable: wmill.createVariable,
updateVariable: wmill.updateVariable,
deleteVariable: wmill.deleteVariable,
getResource: wmill.getResource,
createResource: wmill.createResource,
updateResource: wmill.updateResource,
deleteResource: wmill.deleteResource,
getResourceType: wmill.getResourceType,
createResourceType: wmill.createResourceType,
updateResourceType: wmill.updateResourceType,
deleteResourceType: wmill.deleteResourceType,
getFolder: wmill.getFolder,
createFolder: wmill.createFolder,
updateFolder: wmill.updateFolder,
deleteFolder: wmill.deleteFolder,
};
// ---------------------------------------------------------------------------
// Main merge command
// ---------------------------------------------------------------------------
async function mergeWorkspaces(
opts: GlobalOptions & {
direction?: string;
all?: boolean;
skipConflicts?: boolean;
include?: string;
exclude?: string;
preserveOnBehalfOf?: boolean;
yes?: boolean;
}
): Promise<void> {
// 1. Resolve fork workspace
const workspace = await tryResolveBranchWorkspace(opts);
if (!workspace) {
throw new Error(
"Could not resolve workspace from branch name. Make sure you are in a git repo with gitBranches configured."
);
}
const token = workspace.token;
if (!token) {
throw new Error("Not logged in. Please run 'wmill workspace add' first.");
}
const remote = workspace.remote;
setClient(
token,
remote.endsWith("/") ? remote.substring(0, remote.length - 1) : remote
);
const forkWorkspaceId = workspace.workspaceId;
// 2. Find parent workspace
const userWorkspaces = await wmill.listUserWorkspaces();
const forkEntry = userWorkspaces.workspaces?.find(
(w) => w.id === forkWorkspaceId
);
if (!forkEntry?.parent_workspace_id) {
throw new Error(
`Workspace '${forkWorkspaceId}' is not a fork (no parent_workspace_id). ` +
`You can only merge from a forked workspace.`
);
}
const parentWorkspaceId = forkEntry.parent_workspace_id;
log.info(
`Fork: ${colors.bold(forkWorkspaceId)} → Parent: ${colors.bold(parentWorkspaceId)}`
);
// 3. Compare workspaces
log.info("Comparing workspaces...");
const comparison = await wmill.compareWorkspaces({
workspace: parentWorkspaceId,
targetWorkspaceId: forkWorkspaceId,
});
if (comparison.skipped_comparison) {
log.info(
colors.yellow(
"This fork was created before change tracking was available. " +
"Use the UI or git-based merge instead."
)
);
return;
}
const summary = comparison.summary;
if (summary.total_diffs === 0) {
log.info(colors.green("Everything is up to date. No differences found."));
return;
}
// 4. Display summary
log.info("");
log.info(colors.bold("Comparison Summary:"));
const summaryRows: string[][] = [];
if (summary.scripts_changed > 0)
summaryRows.push(["Scripts", String(summary.scripts_changed)]);
if (summary.flows_changed > 0)
summaryRows.push(["Flows", String(summary.flows_changed)]);
if (summary.apps_changed > 0)
summaryRows.push(["Apps", String(summary.apps_changed)]);
if (summary.resources_changed > 0)
summaryRows.push(["Resources", String(summary.resources_changed)]);
if (summary.variables_changed > 0)
summaryRows.push(["Variables", String(summary.variables_changed)]);
if (summary.resource_types_changed > 0)
summaryRows.push(["Resource Types", String(summary.resource_types_changed)]);
if (summary.folders_changed > 0)
summaryRows.push(["Folders", String(summary.folders_changed)]);
summaryRows.push(["Total", String(summary.total_diffs)]);
if (summary.conflicts > 0)
summaryRows.push([
colors.red("Conflicts"),
colors.red(String(summary.conflicts)),
]);
new Table()
.header(["Type", "Changed"])
.padding(2)
.border(true)
.body(summaryRows)
.render();
// 5. Display diffs table
const diffs = comparison.diffs.filter((d) => d.has_changes !== false);
if (diffs.length === 0) {
log.info(colors.green("No effective changes to deploy."));
return;
}
log.info("");
log.info(colors.bold("Changed items:"));
new Table()
.header(["#", "Kind", "Path", "Ahead", "Behind", "Conflict"])
.padding(1)
.border(true)
.body(
diffs.map((d, i) => {
const isConflict = d.ahead > 0 && d.behind > 0;
return [
String(i + 1),
d.kind,
d.path,
d.ahead > 0 ? colors.green(String(d.ahead)) : "0",
d.behind > 0 ? colors.yellow(String(d.behind)) : "0",
isConflict ? colors.red("YES") : "",
];
})
)
.render();
// 6. Determine direction
let direction: "to-parent" | "to-fork";
if (opts.direction === "to-parent" || opts.direction === "to-fork") {
direction = opts.direction;
} else if (opts.direction) {
throw new Error(
`Invalid direction '${opts.direction}'. Use 'to-parent' or 'to-fork'.`
);
} else if (opts.yes) {
direction = "to-parent";
} else {
const { Select } = await import("@cliffy/prompt/select");
direction = (await Select.prompt({
message: "Deploy direction:",
options: [
{
name: `Deploy to parent (${parentWorkspaceId}) ← fork changes`,
value: "to-parent",
},
{
name: `Update fork (${forkWorkspaceId}) ← parent changes`,
value: "to-fork",
},
],
})) as "to-parent" | "to-fork";
}
log.info(
`\nDirection: ${colors.bold(direction === "to-parent" ? `Fork → Parent (${parentWorkspaceId})` : `Parent → Fork (${forkWorkspaceId})`)}`
);
// 7. Filter selectable diffs based on direction
const selectableDiffs = diffs.filter((d) => {
if (direction === "to-parent") {
return d.ahead > 0;
} else {
return d.behind > 0;
}
});
if (selectableDiffs.length === 0) {
log.info(
colors.yellow(`No items to deploy in the '${direction}' direction.`)
);
return;
}
// 8. Select items
let selectedDiffs = selectableDiffs;
if (opts.all) {
selectedDiffs = selectableDiffs;
} else if (opts.skipConflicts) {
selectedDiffs = selectableDiffs.filter(
(d) => !(d.ahead > 0 && d.behind > 0)
);
} else if (opts.yes && !opts.include && !opts.exclude) {
if (direction === "to-fork") {
selectedDiffs = selectableDiffs.filter(
(d) => !(d.ahead > 0 && d.behind > 0)
);
}
} else if (!opts.yes) {
const { Checkbox } = await import("@cliffy/prompt/checkbox");
const defaultForToFork = direction === "to-fork";
const selectedValues = await Checkbox.prompt({
message: `Select items to deploy (${selectableDiffs.length} available):`,
options: selectableDiffs.map((d) => {
const isConflict = d.ahead > 0 && d.behind > 0;
const label = `${d.kind}:${d.path}${isConflict ? colors.red(" [CONFLICT]") : ""}`;
return {
name: label,
value: `${d.kind}:${d.path}`,
checked: defaultForToFork ? !isConflict : true,
};
}),
});
selectedDiffs = selectableDiffs.filter((d) =>
selectedValues.includes(`${d.kind}:${d.path}`)
);
}
// Apply --include filter
if (opts.include) {
const includeSet = new Set(opts.include.split(",").map((s) => s.trim()));
selectedDiffs = selectedDiffs.filter((d) =>
includeSet.has(`${d.kind}:${d.path}`)
);
}
// Apply --exclude filter
if (opts.exclude) {
const excludeSet = new Set(opts.exclude.split(",").map((s) => s.trim()));
selectedDiffs = selectedDiffs.filter(
(d) => !excludeSet.has(`${d.kind}:${d.path}`)
);
}
if (selectedDiffs.length === 0) {
log.info(colors.yellow("No items selected for deployment."));
return;
}
// Warn about conflicts
const conflicts = selectedDiffs.filter(
(d) => d.ahead > 0 && d.behind > 0
);
if (conflicts.length > 0) {
log.info(
colors.yellow(
`\n⚠ ${conflicts.length} conflicting item(s) will be deployed (source will overwrite target):`
)
);
for (const c of conflicts) {
log.info(colors.yellow(` - ${c.kind}:${c.path}`));
}
if (!opts.yes) {
const { Confirm } = await import("@cliffy/prompt/confirm");
const proceed = await Confirm.prompt(
"Proceed with deploying conflicting items?"
);
if (!proceed) {
log.info("Aborted.");
return;
}
}
}
log.info(
`\nDeploying ${colors.bold(String(selectedDiffs.length))} item(s)...`
);
// 9. Sort: folders first
const sorted = [...selectedDiffs].sort((a, b) => {
const aFolder = (a.kind as string) === "folder" ? 0 : 1;
const bFolder = (b.kind as string) === "folder" ? 0 : 1;
return aFolder - bFolder;
});
// Determine workspaceFrom and workspaceTo based on direction
const workspaceFrom =
direction === "to-parent" ? forkWorkspaceId : parentWorkspaceId;
const workspaceTo =
direction === "to-parent" ? parentWorkspaceId : forkWorkspaceId;
// 10. Deploy
let successCount = 0;
let failCount = 0;
for (const diff of sorted) {
const label = `${diff.kind}:${diff.path}`;
// Check if the item was deleted in the source workspace
const itemDeletedInSource =
direction === "to-parent"
? diff.exists_in_fork === false
: diff.exists_in_source === false;
let result;
if (itemDeletedInSource) {
log.info(colors.yellow(`${label} (removing from target)`));
result = await deleteItemInWorkspace(
provider,
diff.kind as DeployKind,
diff.path,
workspaceTo
);
} else {
let onBehalfOf: string | undefined;
if (opts.preserveOnBehalfOf) {
onBehalfOf = await getOnBehalfOf(
provider,
diff.kind as DeployKind,
diff.path,
workspaceFrom
);
}
result = await deployItem(
provider,
diff.kind as DeployKind,
diff.path,
workspaceFrom,
workspaceTo,
onBehalfOf
);
}
if (result.success) {
log.info(colors.green(`${label}`));
successCount++;
} else {
log.info(colors.red(`${label}: ${result.error}`));
failCount++;
}
}
// 11. Reset diff tally (only if items were successfully deployed)
if (successCount > 0) {
try {
await wmill.resetDiffTally({
workspace: parentWorkspaceId,
forkWorkspaceId: forkWorkspaceId,
});
} catch {
// Non-critical
}
}
// 12. Summary
log.info("");
if (failCount === 0) {
log.info(
colors.green(
`✅ Successfully deployed ${successCount} item(s) from ${workspaceFrom} to ${workspaceTo}.`
)
);
} else {
log.info(
colors.yellow(
`Deployed ${successCount} item(s), ${colors.red(String(failCount) + " failed")} from ${workspaceFrom} to ${workspaceTo}.`
)
);
}
}
export { mergeWorkspaces };

View File

@@ -15,6 +15,7 @@ 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 * as wmill from "../../../gen/services.gen.ts";
@@ -651,11 +652,27 @@ const command = new Command()
"--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("-y --yes", "Skip interactive prompts (defaults datatable behavior to 'skip')")
.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);
.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);
export default command;

535
cli/test/fork_merge.test.ts Normal file
View File

@@ -0,0 +1,535 @@
import { expect, test } from "bun:test";
import { withTestBackend, type TestBackend } from "./test_backend.ts";
// =============================================================================
// FORK & MERGE INTEGRATION TESTS
//
// Tests the full fork/merge cycle using a single test backend instance.
// All sub-tests share the same backend to avoid workspace-limit issues
// (CE limits to 2 non-admins workspaces).
//
// workspace_diff tracking is an EE feature (populated by git sync).
// We manually insert rows into workspace_diff to simulate what git sync
// does, matching the pattern in backend/.../workspace_comparison.rs.
// =============================================================================
const FORK_ID = "wm-fork-merge-test";
async function api(
backend: TestBackend,
path: string,
options: RequestInit = {}
): Promise<Response> {
return backend.apiRequest!(path, options);
}
async function runSQL(backend: TestBackend, query: string): Promise<void> {
const dbUrl =
process.env["DATABASE_URL"] ||
"postgres://postgres:changeme@localhost:5432";
const proc = Bun.spawn(
[
"psql",
`${dbUrl}/postgres?sslmode=disable`,
"-t",
"-c",
`SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%' ORDER BY datname DESC LIMIT 1`,
],
{ stdout: "pipe", stderr: "pipe" }
);
const dbName = (await new Response(proc.stdout).text()).trim();
await proc.exited;
if (!dbName) throw new Error("Could not find test database");
const sqlProc = Bun.spawn(
["psql", `${dbUrl}/${dbName}?sslmode=disable`, "-c", query],
{ stdout: "pipe", stderr: "pipe" }
);
await sqlProc.exited;
}
async function populateWorkspaceDiff(
backend: TestBackend,
parentWs: string,
forkWs: string,
diffs: Array<{ path: string; kind: string; ahead: number; behind: number }>
): Promise<void> {
const values = diffs
.map(
(d) =>
`('${parentWs}', '${forkWs}', '${d.path}', '${d.kind}', ${d.ahead}, ${d.behind})`
)
.join(",\n");
await runSQL(
backend,
`INSERT INTO workspace_diff (source_workspace_id, fork_workspace_id, path, kind, ahead, behind)
VALUES ${values}
ON CONFLICT (source_workspace_id, fork_workspace_id, path, kind)
DO UPDATE SET ahead = EXCLUDED.ahead, behind = EXCLUDED.behind, has_changes = NULL`
);
}
async function removeFromSkipTally(backend: TestBackend, workspaceId: string) {
await runSQL(backend, `DELETE FROM skip_workspace_diff_tally WHERE workspace_id = '${workspaceId}'`);
}
async function deleteFork(backend: TestBackend, forkId: string) {
try {
await api(backend, `/api/w/${forkId}/workspaces/delete`, { method: "POST" });
} catch {}
// Force-clean via SQL to ensure workspace slot is freed (CE limits to 2)
const esc = forkId.replace(/'/g, "''");
await runSQL(backend, `
SET session_replication_role = replica;
DO $$ DECLARE r RECORD; BEGIN
FOR r IN SELECT c.table_name FROM information_schema.columns c
JOIN information_schema.tables t ON c.table_name = t.table_name AND c.table_schema = t.table_schema
WHERE c.column_name = 'workspace_id' AND c.table_schema = 'public' AND t.table_type = 'BASE TABLE'
GROUP BY c.table_name
LOOP EXECUTE format('DELETE FROM %I WHERE workspace_id = ''${esc}''', r.table_name);
END LOOP;
END $$;
DELETE FROM workspace WHERE id = '${esc}';
DELETE FROM workspace_diff WHERE fork_workspace_id = '${esc}';
DELETE FROM skip_workspace_diff_tally WHERE workspace_id = '${esc}';
SET session_replication_role = DEFAULT;
`);
}
async function createTestItems(backend: TestBackend, workspace: string) {
await api(backend, `/api/w/${workspace}/folders/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "merge_test" }),
});
await api(backend, `/api/w/${workspace}/scripts/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "f/merge_test/script_a",
content: "export function main() { return 'parent v1'; }",
language: "bun",
summary: "Script A",
schema: { type: "object", properties: {}, required: [] },
}),
});
await api(backend, `/api/w/${workspace}/variables/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/var_a", value: "parent_value", is_secret: false, description: "Variable A" }),
});
await api(backend, `/api/w/${workspace}/resources/type/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "merge_test_type", schema: { type: "object" }, description: "Test type" }),
});
await api(backend, `/api/w/${workspace}/resources/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/resource_a", resource_type: "merge_test_type", value: { key: "parent" }, description: "Resource A" }),
});
}
async function createFork(backend: TestBackend, parentWs: string, forkId: string, color?: string) {
const r = await api(backend, `/api/w/${parentWs}/workspaces/create_fork`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: forkId, name: "Test Fork", color, forked_datatables: [] }),
});
if (!r.ok) {
const err = await r.text();
throw new Error(`Fork creation failed: ${r.status} ${err}`);
}
await removeFromSkipTally(backend, forkId);
await new Promise((resolve) => setTimeout(resolve, 500));
}
// =====================================================================
// All fork/merge sub-tests run inside a single withTestBackend to share
// the backend instance and avoid CE workspace limits.
// =====================================================================
test(
"Fork/Merge: full cycle integration tests",
async () => {
await withTestBackend(async (backend, _tempDir) => {
const parentWs = backend.workspace;
// ---------------------------------------------------------------
// Sub-test 1: Deploy changes from fork to parent
// ---------------------------------------------------------------
console.log("\n--- Sub-test 1: fork→parent deploy ---");
await deleteFork(backend, FORK_ID);
await createTestItems(backend, parentWs);
await createFork(backend, parentWs, FORK_ID, "#ff5500");
// Make changes in fork
const forkScript = await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/f/merge_test/script_a`)).json();
await api(backend, `/api/w/${FORK_ID}/scripts/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...forkScript, content: "export function main() { return 'fork v2 - modified!'; }", summary: "Script A (fork)", parent_hash: forkScript.hash }),
});
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/new_fork_var", value: "fork_only", is_secret: false, description: "New from fork" }),
});
await api(backend, `/api/w/${FORK_ID}/resources/update/f/merge_test/resource_a`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/resource_a", value: { key: "fork_modified" }, description: "Resource A (fork)" }),
});
// Populate workspace_diff
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/script_a", kind: "script", ahead: 1, behind: 0 },
{ path: "f/merge_test/new_fork_var", kind: "variable", ahead: 1, behind: 0 },
{ path: "f/merge_test/resource_a", kind: "resource", ahead: 1, behind: 0 },
]);
// Compare
const comp1 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
expect(comp1.skipped_comparison).toBe(false);
expect(comp1.summary.total_diffs).toBeGreaterThanOrEqual(3);
expect(comp1.summary.conflicts).toBe(0);
// Deploy fork→parent
for (const diff of comp1.diffs.filter((d: any) => d.ahead > 0)) {
const { kind, path } = diff;
if (kind === "script") {
const s = await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/${path}`)).json();
let parentHash;
try { parentHash = (await (await api(backend, `/api/w/${parentWs}/scripts/get/p/${path}`)).json()).hash; } catch {}
expect((await api(backend, `/api/w/${parentWs}/scripts/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...s, lock: s.lock, parent_hash: parentHash }),
})).ok).toBe(true);
} else if (kind === "variable") {
const v = await (await api(backend, `/api/w/${FORK_ID}/variables/get/${path}?decrypt_secret=true`)).json();
const exists = await (await api(backend, `/api/w/${parentWs}/variables/exists/${path}`)).json();
if (exists) {
await api(backend, `/api/w/${parentWs}/variables/update/${path}?already_encrypted=false`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, value: v.value ?? "", is_secret: v.is_secret, description: v.description ?? "" }),
});
} else {
await api(backend, `/api/w/${parentWs}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, value: v.value ?? "", is_secret: v.is_secret, description: v.description ?? "" }),
});
}
} else if (kind === "resource") {
const res = await (await api(backend, `/api/w/${FORK_ID}/resources/get/${path}`)).json();
const exists = await (await api(backend, `/api/w/${parentWs}/resources/exists/${path}`)).json();
if (exists) {
await api(backend, `/api/w/${parentWs}/resources/update/${path}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, value: res.value, description: res.description ?? "" }),
});
} else {
await api(backend, `/api/w/${parentWs}/resources/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, value: res.value, resource_type: res.resource_type, description: res.description ?? "" }),
});
}
}
}
// Verify
expect((await (await api(backend, `/api/w/${parentWs}/scripts/get/p/f/merge_test/script_a`)).json()).content).toContain("fork v2");
expect((await api(backend, `/api/w/${parentWs}/variables/get/f/merge_test/new_fork_var`)).ok).toBe(true);
expect(JSON.stringify((await (await api(backend, `/api/w/${parentWs}/resources/get/f/merge_test/resource_a`)).json()).value)).toContain("fork_modified");
console.log(" ✓ Sub-test 1 passed: fork→parent deploy");
// ---------------------------------------------------------------
// Sub-test 2: Deploy parent→fork direction
// ---------------------------------------------------------------
console.log("\n--- Sub-test 2: parent→fork deploy ---");
// Create new script in parent
await api(backend, `/api/w/${parentWs}/scripts/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/script_b", content: "export function main() { return 'parent only'; }", language: "bun", summary: "Script B", schema: { type: "object", properties: {}, required: [] } }),
});
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/script_b", kind: "script", ahead: 0, behind: 1 },
]);
const comp2 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
const behindDiffs = comp2.diffs.filter((d: any) => d.behind > 0);
expect(behindDiffs.length).toBeGreaterThanOrEqual(1);
// Deploy parent→fork
for (const diff of behindDiffs) {
if (diff.kind === "script") {
const s = await (await api(backend, `/api/w/${parentWs}/scripts/get/p/${diff.path}`)).json();
let forkHash;
try { forkHash = (await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/${diff.path}`)).json()).hash; } catch {}
await api(backend, `/api/w/${FORK_ID}/scripts/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...s, lock: s.lock, parent_hash: forkHash }),
});
}
}
expect((await api(backend, `/api/w/${FORK_ID}/scripts/get/p/f/merge_test/script_b`)).ok).toBe(true);
console.log(" ✓ Sub-test 2 passed: parent→fork deploy");
// ---------------------------------------------------------------
// Sub-test 3: Conflict detection
// ---------------------------------------------------------------
console.log("\n--- Sub-test 3: conflict detection ---");
// Create actual divergence: modify script_a differently in both workspaces
const parentScriptA = await (await api(backend, `/api/w/${parentWs}/scripts/get/p/f/merge_test/script_a`)).json();
await api(backend, `/api/w/${parentWs}/scripts/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...parentScriptA, content: "export function main() { return 'parent conflict version'; }", parent_hash: parentScriptA.hash }),
});
const forkScriptA = await (await api(backend, `/api/w/${FORK_ID}/scripts/get/p/f/merge_test/script_a`)).json();
await api(backend, `/api/w/${FORK_ID}/scripts/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...forkScriptA, content: "export function main() { return 'fork conflict version'; }", parent_hash: forkScriptA.hash }),
});
// Now populate the conflict diff
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/script_a", kind: "script", ahead: 2, behind: 1 },
]);
const comp3 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
expect(comp3.summary.conflicts).toBeGreaterThanOrEqual(1);
const conflict = comp3.diffs.find((d: any) => d.path === "f/merge_test/script_a" && d.ahead > 0 && d.behind > 0);
expect(conflict).toBeDefined();
console.log(" ✓ Sub-test 3 passed: conflict detected");
// ---------------------------------------------------------------
// Sub-test 4: Fork has correct parent_workspace_id
// ---------------------------------------------------------------
console.log("\n--- Sub-test 4: parent_workspace_id ---");
// Check parent_workspace_id via direct SQL (no REST endpoint exposes this directly)
const dbUrl = process.env["DATABASE_URL"] || "postgres://postgres:changeme@localhost:5432";
const dbProc = Bun.spawn(
["psql", `${dbUrl}/postgres?sslmode=disable`, "-t", "-c",
`SELECT datname FROM pg_database WHERE datname LIKE 'windmill_test_%' ORDER BY datname DESC LIMIT 1`],
{ stdout: "pipe", stderr: "pipe" }
);
const testDb = (await new Response(dbProc.stdout).text()).trim();
await dbProc.exited;
const parentProc = Bun.spawn(
["psql", `${dbUrl}/${testDb}?sslmode=disable`, "-t", "-c",
`SELECT parent_workspace_id FROM workspace WHERE id = '${FORK_ID}'`],
{ stdout: "pipe", stderr: "pipe" }
);
const parentId = (await new Response(parentProc.stdout).text()).trim();
await parentProc.exited;
expect(parentId).toBe(parentWs);
console.log(" ✓ Sub-test 4 passed: parent_workspace_id correct");
// ---------------------------------------------------------------
// Sub-test 5: resetDiffTally cleans unchanged items
// ---------------------------------------------------------------
console.log("\n--- Sub-test 5: resetDiffTally ---");
// Add a diff for an item that hasn't actually changed (var_a was deployed already)
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/var_a", kind: "variable", ahead: 1, behind: 0 },
]);
const resetResp = await api(backend, `/api/w/${parentWs}/workspaces/reset_diff_tally/${FORK_ID}`, { method: "POST" });
expect(resetResp.ok).toBe(true);
const comp5 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
const varDiff = comp5.diffs.find((d: any) => d.path === "f/merge_test/var_a" && d.kind === "variable");
// var_a was already deployed (same value in both), so it should be cleaned up
expect(varDiff).toBeUndefined();
console.log(" ✓ Sub-test 5 passed: resetDiffTally cleaned unchanged items");
// ---------------------------------------------------------------
// Sub-test 6: All item types in one merge
// ---------------------------------------------------------------
console.log("\n--- Sub-test 6: all item types (script, variable, resource, resource_type, flow, app) ---");
await deleteFork(backend, FORK_ID);
// Create resource type + resource in parent
await api(backend, `/api/w/${parentWs}/resources/type/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "merge_test_type", schema: { type: "object" }, description: "Type" }),
});
await api(backend, `/api/w/${parentWs}/resources/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/res_t6", resource_type: "merge_test_type", value: { key: "parent" }, description: "R" }),
});
// Create flow in parent
await api(backend, `/api/w/${parentWs}/flows/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "f/merge_test/flow_t6", summary: "Flow", schema: { type: "object", properties: {}, required: [] },
value: { modules: [{ id: "a", value: { type: "rawscript", content: "export function main() { return 1; }", language: "bun", input_transforms: {} } }], failure_module: null, same_worker: false },
}),
});
// Create app in parent
await api(backend, `/api/w/${parentWs}/apps/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: "f/merge_test/app_t6", summary: "App",
value: { type: "rawscript", content: { v: 1 } },
policy: { on_behalf_of: "", on_behalf_of_email: "", extra_perms: {}, execution_mode: "publisher" },
}),
});
await createFork(backend, parentWs, FORK_ID);
// Modify all in fork
const forkRes = await (await api(backend, `/api/w/${FORK_ID}/resources/get/f/merge_test/res_t6`)).json();
await api(backend, `/api/w/${FORK_ID}/resources/update/f/merge_test/res_t6`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: forkRes.path, value: { key: "fork" }, description: "Fork" }),
});
const forkFlow = await (await api(backend, `/api/w/${FORK_ID}/flows/get/f/merge_test/flow_t6`)).json();
forkFlow.value.modules[0].value.content = "export function main() { return 2; }";
await api(backend, `/api/w/${FORK_ID}/flows/update/f/merge_test/flow_t6`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(forkFlow),
});
const forkApp = await (await api(backend, `/api/w/${FORK_ID}/apps/get/p/f/merge_test/app_t6`)).json();
await api(backend, `/api/w/${FORK_ID}/apps/update/f/merge_test/app_t6`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...forkApp, summary: "Fork App" }),
});
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/res_t6", kind: "resource", ahead: 1, behind: 0 },
{ path: "f/merge_test/flow_t6", kind: "flow", ahead: 1, behind: 0 },
{ path: "f/merge_test/app_t6", kind: "app", ahead: 1, behind: 0 },
]);
const comp6 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
const ahead6 = comp6.diffs.filter((d: any) => d.ahead > 0);
expect(ahead6.length).toBeGreaterThanOrEqual(3);
// Deploy all
for (const d of ahead6) {
if (d.kind === "resource") {
const r = await (await api(backend, `/api/w/${FORK_ID}/resources/get/${d.path}`)).json();
await api(backend, `/api/w/${parentWs}/resources/update/${d.path}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: d.path, value: r.value, description: r.description ?? "" }),
});
} else if (d.kind === "flow") {
const f = await (await api(backend, `/api/w/${FORK_ID}/flows/get/${d.path}`)).json();
for (const m of f.value?.modules ?? []) { if (m.value?.hash) m.value.hash = undefined; }
await api(backend, `/api/w/${parentWs}/flows/update/${d.path}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(f),
});
} else if (d.kind === "app") {
const a = await (await api(backend, `/api/w/${FORK_ID}/apps/get/p/${d.path}`)).json();
await api(backend, `/api/w/${parentWs}/apps/update/${d.path}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(a),
});
}
}
// Verify
const pRes = await (await api(backend, `/api/w/${parentWs}/resources/get/f/merge_test/res_t6`)).json();
expect(JSON.stringify(pRes.value)).toContain("fork");
const pFlow = await (await api(backend, `/api/w/${parentWs}/flows/get/f/merge_test/flow_t6`)).json();
expect(pFlow.value.modules[0].value.content).toContain("return 2");
const pApp = await (await api(backend, `/api/w/${parentWs}/apps/get/p/f/merge_test/app_t6`)).json();
expect(pApp.summary).toBe("Fork App");
console.log(" ✓ Sub-test 6 passed: all item types merged");
// ---------------------------------------------------------------
// Sub-test 7: Secret variables preserved across fork/merge
// ---------------------------------------------------------------
console.log("\n--- Sub-test 7: secret variable ---");
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/secret_fork", value: "s3cret!", is_secret: true, description: "Secret" }),
});
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/secret_fork", kind: "variable", ahead: 1, behind: 0 },
]);
const comp7 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
const secDiff = comp7.diffs.find((d: any) => d.path === "f/merge_test/secret_fork");
expect(secDiff).toBeDefined();
const secVar = await (await api(backend, `/api/w/${FORK_ID}/variables/get/f/merge_test/secret_fork?decrypt_secret=true`)).json();
await api(backend, `/api/w/${parentWs}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: secVar.path, value: secVar.value ?? "", is_secret: secVar.is_secret, description: secVar.description ?? "" }),
});
const pSec = await (await api(backend, `/api/w/${parentWs}/variables/get/f/merge_test/secret_fork?decrypt_secret=true`)).json();
expect(pSec.value).toBe("s3cret!");
expect(pSec.is_secret).toBe(true);
console.log(" ✓ Sub-test 7 passed: secret variable preserved");
// ---------------------------------------------------------------
// Sub-test 8: Special characters in variable values
// ---------------------------------------------------------------
console.log("\n--- Sub-test 8: special characters ---");
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/special", value: "hello\nworld\t\"quotes\" 'single' \\back 日本語 🎉", is_secret: false, description: "" }),
});
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/special", kind: "variable", ahead: 1, behind: 0 },
]);
const forkSpecial = await (await api(backend, `/api/w/${FORK_ID}/variables/get/f/merge_test/special?decrypt_secret=true`)).json();
await api(backend, `/api/w/${parentWs}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: forkSpecial.path, value: forkSpecial.value ?? "", is_secret: false, description: "" }),
});
const pSpecial = await (await api(backend, `/api/w/${parentWs}/variables/get/f/merge_test/special?decrypt_secret=true`)).json();
expect(pSpecial.value).toBe(forkSpecial.value);
console.log(" ✓ Sub-test 8 passed: special characters preserved");
// ---------------------------------------------------------------
// Sub-test 9: Partial deploy — only deployed items cleaned by resetDiffTally
// ---------------------------------------------------------------
console.log("\n--- Sub-test 9: partial deploy + resetDiffTally ---");
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/partial_a", value: "a", is_secret: false, description: "A" }),
});
await api(backend, `/api/w/${FORK_ID}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "f/merge_test/partial_b", value: "b", is_secret: false, description: "B" }),
});
await populateWorkspaceDiff(backend, parentWs, FORK_ID, [
{ path: "f/merge_test/partial_a", kind: "variable", ahead: 1, behind: 0 },
{ path: "f/merge_test/partial_b", kind: "variable", ahead: 1, behind: 0 },
]);
// Deploy only partial_a
const va = await (await api(backend, `/api/w/${FORK_ID}/variables/get/f/merge_test/partial_a?decrypt_secret=true`)).json();
await api(backend, `/api/w/${parentWs}/variables/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: va.path, value: va.value ?? "", is_secret: false, description: "" }),
});
await api(backend, `/api/w/${parentWs}/workspaces/reset_diff_tally/${FORK_ID}`, { method: "POST" });
await new Promise(r => setTimeout(r, 500));
const comp9 = await (await api(backend, `/api/w/${parentWs}/workspaces/compare/${FORK_ID}`)).json();
const bAfter = comp9.diffs.find((d: any) => d.path === "f/merge_test/partial_b");
// partial_b was NOT deployed, so it must still appear in diffs
expect(bAfter).toBeDefined();
expect(bAfter?.ahead).toBeGreaterThan(0);
console.log(" ✓ Sub-test 9 passed: partial deploy + resetDiffTally");
// ---------------------------------------------------------------
// Cleanup
// ---------------------------------------------------------------
await deleteFork(backend, FORK_ID);
console.log("\n✅ All sub-tests passed!");
});
},
300_000
);

View File

@@ -1,6 +1,6 @@
{
"name": "windmill-utils-internal",
"version": "1.3.7",
"version": "1.3.8",
"description": "Internal utility functions for Windmill",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",

View File

@@ -0,0 +1,624 @@
/**
* Shared deploy logic for workspace fork/merge operations.
*
* Used by both the CLI (`wmill workspace merge`) and the frontend
* (`CompareWorkspaces.svelte`). The caller provides a {@link DeployProvider}
* that wraps the concrete API client (class-based for the frontend,
* standalone functions for the CLI).
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type DeployKind =
| "script"
| "flow"
| "app"
| "raw_app"
| "resource"
| "variable"
| "resource_type"
| "folder";
export interface DeployResult {
success: boolean;
error?: string;
}
/**
* Abstraction over the generated API client.
* Both the frontend (class-based services) and the CLI (standalone functions)
* can satisfy this interface with a thin adapter.
*/
export interface DeployProvider {
// Existence checks
existsFlowByPath(p: { workspace: string; path: string }): Promise<boolean>;
existsScriptByPath(p: { workspace: string; path: string }): Promise<boolean>;
existsApp(p: { workspace: string; path: string }): Promise<boolean>;
existsVariable(p: { workspace: string; path: string }): Promise<boolean>;
existsResource(p: { workspace: string; path: string }): Promise<boolean>;
existsResourceType(p: { workspace: string; path: string }): Promise<boolean>;
existsFolder(p: { workspace: string; name: string }): Promise<boolean>;
// Flows
getFlowByPath(p: { workspace: string; path: string }): Promise<any>;
createFlow(p: { workspace: string; requestBody: any }): Promise<any>;
updateFlow(p: {
workspace: string;
path: string;
requestBody: any;
}): Promise<any>;
archiveFlowByPath(p: {
workspace: string;
path: string;
requestBody: any;
}): Promise<any>;
// Scripts
getScriptByPath(p: { workspace: string; path: string }): Promise<any>;
createScript(p: { workspace: string; requestBody: any }): Promise<any>;
archiveScriptByPath(p: {
workspace: string;
path: string;
}): Promise<any>;
// Apps
getAppByPath(p: { workspace: string; path: string }): Promise<any>;
createApp(p: { workspace: string; requestBody: any }): Promise<any>;
updateApp(p: {
workspace: string;
path: string;
requestBody: any;
}): Promise<any>;
createAppRaw(p: { workspace: string; formData: any }): Promise<any>;
updateAppRaw(p: {
workspace: string;
path: string;
formData: any;
}): Promise<any>;
getPublicSecretOfLatestVersionOfApp(p: {
workspace: string;
path: string;
}): Promise<any>;
getRawAppData(p: {
secretWithExtension: string;
workspace: string;
}): Promise<any>;
deleteApp(p: { workspace: string; path: string }): Promise<any>;
// Variables
getVariable(p: {
workspace: string;
path: string;
decryptSecret?: boolean;
}): Promise<any>;
createVariable(p: { workspace: string; requestBody: any }): Promise<any>;
updateVariable(p: {
workspace: string;
path: string;
requestBody: any;
alreadyEncrypted?: boolean;
}): Promise<any>;
deleteVariable(p: { workspace: string; path: string }): Promise<any>;
// Resources
getResource(p: { workspace: string; path: string }): Promise<any>;
createResource(p: { workspace: string; requestBody: any }): Promise<any>;
updateResource(p: {
workspace: string;
path: string;
requestBody: any;
}): Promise<any>;
deleteResource(p: { workspace: string; path: string }): Promise<any>;
// Resource types
getResourceType(p: { workspace: string; path: string }): Promise<any>;
createResourceType(p: { workspace: string; requestBody: any }): Promise<any>;
updateResourceType(p: {
workspace: string;
path: string;
requestBody: any;
}): Promise<any>;
deleteResourceType(p: { workspace: string; path: string }): Promise<any>;
// Folders
getFolder(p: { workspace: string; name: string }): Promise<any>;
createFolder(p: { workspace: string; requestBody: any }): Promise<any>;
updateFolder(p: {
workspace: string;
name: string;
requestBody: any;
}): Promise<any>;
deleteFolder(p: { workspace: string; name: string }): Promise<any>;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Folder diff paths carry the `f/` prefix; folder API endpoints expect just the name. */
export function folderName(path: string): string {
return path.replace(/^f\//, "");
}
function getSubModules(flowModule: any): any[][] {
const type = flowModule?.value?.type;
if (type === "forloopflow" || type === "whileloopflow") {
return [flowModule.value.modules ?? []];
} else if (type === "branchall") {
return (flowModule.value.branches ?? []).map(
(branch: any) => branch.modules ?? []
);
} else if (type === "branchone") {
return [
...(flowModule.value.branches ?? []).map((b: any) => b.modules ?? []),
flowModule.value.default ?? [],
];
} else if (type === "aiagent") {
if (flowModule.value.tools) {
return [
flowModule.value.tools
.filter(
(t: any) =>
t.value?.type === "script" || t.value?.type === "flow"
)
.map((t: any) => ({
id: t.id,
value: t.value,
summary: t.summary,
})),
];
}
}
return [];
}
function getAllSubmodules(flowModule: any): any[] {
return getSubModules(flowModule)
.map((modules) => modules.flatMap((m: any) => [m, ...getAllSubmodules(m)]))
.flat();
}
/** Recursively collect all modules from a flow definition, including the failure module. */
export function getAllModules(
flowModules: any[],
failureModule?: any
): any[] {
return [
...flowModules,
...flowModules.flatMap((x) => getAllSubmodules(x)),
...(failureModule ? [failureModule] : []),
];
}
function toError(e: unknown): string {
const err = e as { body?: string; message?: string };
return err.body || err.message || String(e);
}
// ---------------------------------------------------------------------------
// checkItemExists
// ---------------------------------------------------------------------------
export async function checkItemExists(
provider: DeployProvider,
kind: DeployKind,
path: string,
workspace: string
): Promise<boolean> {
if (kind === "flow") {
return provider.existsFlowByPath({ workspace, path });
} else if (kind === "script") {
return provider.existsScriptByPath({ workspace, path });
} else if (kind === "app" || kind === "raw_app") {
return provider.existsApp({ workspace, path });
} else if (kind === "variable") {
return provider.existsVariable({ workspace, path });
} else if (kind === "resource") {
return provider.existsResource({ workspace, path });
} else if (kind === "resource_type") {
return provider.existsResourceType({ workspace, path });
} else if (kind === "folder") {
return provider.existsFolder({ workspace, name: folderName(path) });
}
throw new Error(`Unknown kind: ${kind}`);
}
// ---------------------------------------------------------------------------
// deployItem
// ---------------------------------------------------------------------------
export async function deployItem(
provider: DeployProvider,
kind: DeployKind,
path: string,
workspaceFrom: string,
workspaceTo: string,
onBehalfOf?: string
): Promise<DeployResult> {
const preserveOnBehalfOf = onBehalfOf !== undefined;
try {
const alreadyExists = await checkItemExists(
provider,
kind,
path,
workspaceTo
);
if (kind === "flow") {
const flow = await provider.getFlowByPath({
workspace: workspaceFrom,
path,
});
// Clear inline script hashes so the target workspace resolves by path
getAllModules(
flow.value?.modules ?? [],
flow.value?.failure_module
).forEach((x: any) => {
if (x.value?.type === "script" && x.value.hash != undefined) {
x.value.hash = undefined;
}
});
if (alreadyExists) {
await provider.updateFlow({
workspace: workspaceTo,
path,
requestBody: {
...flow,
preserve_on_behalf_of: preserveOnBehalfOf,
on_behalf_of_email: onBehalfOf,
},
});
} else {
await provider.createFlow({
workspace: workspaceTo,
requestBody: {
...flow,
preserve_on_behalf_of: preserveOnBehalfOf,
on_behalf_of_email: onBehalfOf,
},
});
}
} else if (kind === "script") {
const script = await provider.getScriptByPath({
workspace: workspaceFrom,
path,
});
let parentHash: string | undefined;
if (alreadyExists) {
const existing = await provider.getScriptByPath({
workspace: workspaceTo,
path,
});
parentHash = existing.hash;
}
await provider.createScript({
workspace: workspaceTo,
requestBody: {
...script,
lock: script.lock,
parent_hash: parentHash,
preserve_on_behalf_of: preserveOnBehalfOf,
on_behalf_of_email: onBehalfOf,
},
});
} else if (kind === "app" || kind === "raw_app") {
const app = await provider.getAppByPath({
workspace: workspaceFrom,
path,
});
if (alreadyExists) {
if (app.raw_app) {
const secret = await provider.getPublicSecretOfLatestVersionOfApp({
workspace: workspaceFrom,
path: app.path,
});
const js = await provider.getRawAppData({
secretWithExtension: `${secret}.js`,
workspace: workspaceFrom,
});
const css = await provider.getRawAppData({
secretWithExtension: `${secret}.css`,
workspace: workspaceFrom,
});
await provider.updateAppRaw({
workspace: workspaceTo,
path,
formData: {
app: { ...app, preserve_on_behalf_of: preserveOnBehalfOf },
css,
js,
},
});
} else {
await provider.updateApp({
workspace: workspaceTo,
path,
requestBody: {
...app,
preserve_on_behalf_of: preserveOnBehalfOf,
},
});
}
} else {
if (app.raw_app) {
const secret = await provider.getPublicSecretOfLatestVersionOfApp({
workspace: workspaceFrom,
path: app.path,
});
const js = await provider.getRawAppData({
secretWithExtension: `${secret}.js`,
workspace: workspaceFrom,
});
const css = await provider.getRawAppData({
secretWithExtension: `${secret}.css`,
workspace: workspaceFrom,
});
await provider.createAppRaw({
workspace: workspaceTo,
formData: {
app: { ...app, preserve_on_behalf_of: preserveOnBehalfOf },
css,
js,
},
});
} else {
await provider.createApp({
workspace: workspaceTo,
requestBody: {
...app,
preserve_on_behalf_of: preserveOnBehalfOf,
},
});
}
}
} else if (kind === "variable") {
const variable = await provider.getVariable({
workspace: workspaceFrom,
path,
decryptSecret: true,
});
if (alreadyExists) {
await provider.updateVariable({
workspace: workspaceTo,
path,
requestBody: {
path,
value: variable.value ?? "",
is_secret: variable.is_secret,
description: variable.description ?? "",
},
alreadyEncrypted: false,
});
} else {
await provider.createVariable({
workspace: workspaceTo,
requestBody: {
path,
value: variable.value ?? "",
is_secret: variable.is_secret,
description: variable.description ?? "",
},
});
}
} else if (kind === "resource") {
const resource = await provider.getResource({
workspace: workspaceFrom,
path,
});
if (alreadyExists) {
await provider.updateResource({
workspace: workspaceTo,
path,
requestBody: {
path,
value: resource.value ?? "",
description: resource.description ?? "",
},
});
} else {
await provider.createResource({
workspace: workspaceTo,
requestBody: {
path,
value: resource.value ?? "",
resource_type: resource.resource_type,
description: resource.description ?? "",
},
});
}
} else if (kind === "resource_type") {
const rt = await provider.getResourceType({
workspace: workspaceFrom,
path,
});
if (alreadyExists) {
await provider.updateResourceType({
workspace: workspaceTo,
path,
requestBody: {
schema: rt.schema,
description: rt.description ?? "",
},
});
} else {
await provider.createResourceType({
workspace: workspaceTo,
requestBody: {
name: rt.name,
schema: rt.schema,
description: rt.description ?? "",
},
});
}
} else if (kind === "folder") {
const name = folderName(path);
const folder = await provider.getFolder({
workspace: workspaceFrom,
name,
});
if (alreadyExists) {
await provider.updateFolder({
workspace: workspaceTo,
name,
requestBody: {
owners: folder.owners,
extra_perms: folder.extra_perms,
summary: folder.summary ?? undefined,
},
});
} else {
await provider.createFolder({
workspace: workspaceTo,
requestBody: {
name,
owners: folder.owners,
extra_perms: folder.extra_perms,
summary: folder.summary ?? undefined,
},
});
}
} else {
throw new Error(`Unknown kind: ${kind}`);
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: toError(e) };
}
}
// ---------------------------------------------------------------------------
// deleteItemInWorkspace
// ---------------------------------------------------------------------------
/**
* Delete/archive an item in a workspace.
* Scripts and flows are archived (reversible). Other types are deleted.
*/
export async function deleteItemInWorkspace(
provider: DeployProvider,
kind: DeployKind,
path: string,
workspace: string
): Promise<DeployResult> {
try {
if (kind === "script") {
await provider.archiveScriptByPath({ workspace, path });
} else if (kind === "flow") {
await provider.archiveFlowByPath({
workspace,
path,
requestBody: { archived: true },
});
} else if (kind === "app" || kind === "raw_app") {
await provider.deleteApp({ workspace, path });
} else if (kind === "variable") {
await provider.deleteVariable({ workspace, path });
} else if (kind === "resource") {
await provider.deleteResource({ workspace, path });
} else if (kind === "resource_type") {
await provider.deleteResourceType({ workspace, path });
} else if (kind === "folder") {
await provider.deleteFolder({ workspace, name: folderName(path) });
} else {
throw new Error(`Deletion not supported for kind: ${kind}`);
}
return { success: true };
} catch (e: unknown) {
return { success: false, error: toError(e) };
}
}
// ---------------------------------------------------------------------------
// getOnBehalfOf
// ---------------------------------------------------------------------------
/**
* Get the value of an item for diff comparison.
* Returns a normalized representation suitable for JSON comparison.
*/
export async function getItemValue(
provider: DeployProvider,
kind: DeployKind,
path: string,
workspace: string
): Promise<unknown> {
try {
if (kind === "flow") {
const flow = await provider.getFlowByPath({ workspace, path });
getAllModules(flow.value?.modules ?? [], flow.value?.failure_module).forEach(
(x: any) => {
if (x.value?.type === "script" && x.value.hash != undefined) {
x.value.hash = undefined;
}
}
);
return {
summary: flow.summary,
description: flow.description,
value: flow.value,
};
} else if (kind === "script") {
const script = await provider.getScriptByPath({ workspace, path });
return {
content: script.content,
lock: script.lock,
schema: script.schema,
summary: script.summary,
language: script.language,
};
} else if (kind === "app" || kind === "raw_app") {
return await provider.getAppByPath({ workspace, path });
} else if (kind === "variable") {
const variable = await provider.getVariable({
workspace,
path,
decryptSecret: true,
});
return variable.value;
} else if (kind === "resource") {
const resource = await provider.getResource({ workspace, path });
return resource.value;
} else if (kind === "resource_type") {
const rt = await provider.getResourceType({ workspace, path });
return rt.schema;
} else if (kind === "folder") {
const folder = await provider.getFolder({
workspace,
name: folderName(path),
});
return {
name: folder.name,
owners: folder.owners,
extra_perms: folder.extra_perms,
summary: folder.summary,
};
}
} catch {
// Item may not exist
}
return {};
}
/**
* Fetch the on_behalf_of value for a deployable item.
* Returns an email for flows/scripts/apps, or undefined if not applicable.
*/
export async function getOnBehalfOf(
provider: DeployProvider,
kind: DeployKind,
path: string,
workspace: string
): Promise<string | undefined> {
try {
if (kind === "flow") {
const flow = await provider.getFlowByPath({ workspace, path });
return flow.on_behalf_of_email;
} else if (kind === "script") {
const script = await provider.getScriptByPath({ workspace, path });
return script.on_behalf_of_email;
} else if (kind === "app" || kind === "raw_app") {
const app = await provider.getAppByPath({ workspace, path });
return app.policy?.on_behalf_of_email;
}
} catch {
// Item may not exist
}
return undefined;
}

View File

@@ -12,4 +12,5 @@ export * from "./inline-scripts";
export * from "./path-utils";
export * from "./parse";
export * from "./config";
export * from "./deploy";
export { SEP, DELIMITER } from "./constants";

View File

@@ -90,7 +90,7 @@
"windmill-parser-wasm-wac": "1.668.6",
"windmill-parser-wasm-yaml": "1.593.0",
"windmill-sql-datatype-parser-wasm": "1.512.0",
"windmill-utils-internal": "^1.3.4",
"windmill-utils-internal": "^1.3.8",
"xterm": "^5.3.0",
"xterm-readline": "^1.1.2",
"y-monaco": "^0.1.4",
@@ -844,7 +844,6 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -856,7 +855,6 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -867,7 +865,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1357,7 +1354,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1514,7 +1510,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1531,7 +1526,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1548,7 +1542,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1565,7 +1558,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1582,7 +1574,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1599,7 +1590,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1616,7 +1606,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1633,7 +1622,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1650,7 +1638,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1667,7 +1654,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1684,7 +1670,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1701,7 +1686,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1718,7 +1702,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1735,7 +1718,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1752,7 +1734,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2058,7 +2039,6 @@
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -6867,7 +6847,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -7366,7 +7346,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7387,7 +7366,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7408,7 +7386,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7429,7 +7406,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7450,7 +7426,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7471,7 +7446,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7492,7 +7466,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7513,7 +7486,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7534,7 +7506,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7555,7 +7526,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7576,7 +7546,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -12152,21 +12121,6 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -12897,7 +12851,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -13723,9 +13677,9 @@
"integrity": "sha512-uHNL8F72/Tf96xF3hOHnPDjkEyqXw7fNjcPJiUhth9sTQkcwUIoJMOdwm8/cs+j9kKVRJ4tgNYMHEBLylazp6g=="
},
"node_modules/windmill-utils-internal": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.3.4.tgz",
"integrity": "sha512-XVypDKIZ6P4fwIjZwvuvq1m+j0rtAA7BDp1rI2F7hQ+VBKZUHsLskP+jgstXs+kN1LqGGsQJj4ecMYDImpIZ6A==",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.3.8.tgz",
"integrity": "sha512-FtVEvAI2PIqPTEpowTjo5c5JkYe09Scu9zcwzJutOWMEh4aDdzOejaG7EZTac0pk+dK4JB46+nbl82hhLsL8Mw==",
"license": "Apache 2.0"
},
"node_modules/word-wrap": {

View File

@@ -163,7 +163,7 @@
"windmill-parser-wasm-wac": "1.668.6",
"windmill-parser-wasm-yaml": "1.593.0",
"windmill-sql-datatype-parser-wasm": "1.512.0",
"windmill-utils-internal": "^1.3.4",
"windmill-utils-internal": "^1.3.8",
"xterm": "^5.3.0",
"xterm-readline": "^1.1.2",
"y-monaco": "^0.1.4",
@@ -592,4 +592,4 @@
"@rollup/rollup-linux-x64-gnu": "^4.35.0",
"fsevents": "^2.3.3"
}
}
}

View File

@@ -44,7 +44,13 @@
import { userWorkspaces, workspaceStore } from '$lib/stores'
import type { Kind } from '$lib/utils_deployable'
import { deployItem, getItemValue, getOnBehalfOf } from '$lib/utils_workspace_deploy'
import {
deployItem,
deleteItemInWorkspace,
getItemValue,
getOnBehalfOf,
type DeployResult
} from '$lib/utils_workspace_deploy'
import Tooltip from './Tooltip.svelte'
import OnBehalfOfSelector, {
needsOnBehalfOfSelection,
@@ -307,13 +313,27 @@
) {
deploymentStatus[statusPath] = { status: 'loading' }
const result = await deployItem({
kind,
path,
workspaceFrom,
workspaceTo: workspaceToDeployTo,
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind)
})
// Check if the item was deleted in the source workspace.
// If so, archive/delete it in the target workspace instead of copying.
const diff = comparison?.diffs.find((d) => getItemKey(d) === statusPath)
const itemDeletedInSource = diff
? mergeIntoParent
? diff.exists_in_fork === false
: diff.exists_in_source === false
: false
let result: DeployResult
if (itemDeletedInSource) {
result = await deleteItemInWorkspace(kind, path, workspaceToDeployTo)
} else {
result = await deployItem({
kind,
path,
workspaceFrom,
workspaceTo: workspaceToDeployTo,
onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind)
})
}
if (result.success) {
deploymentStatus[statusPath] = { status: 'deployed' }

View File

@@ -7,7 +7,6 @@ import {
ScriptService,
VariableService
} from '$lib/gen'
import { getAllModules } from './components/flows/flowExplorer'
import {
existsTrigger,
getTriggersDeployData,
@@ -18,11 +17,70 @@ import {
} from '$lib/utils_deployable'
import type { TriggerKind } from './components/triggers'
/** Folder diff paths carry the `f/` prefix (e.g. `f/test`), but folder API endpoints expect just the name. */
function folderName(path: string): string {
return path.replace(/^f\//, '')
import {
deployItem as sharedDeployItem,
deleteItemInWorkspace as sharedDeleteItem,
checkItemExists as sharedCheckItemExists,
getOnBehalfOf as sharedGetOnBehalfOf,
getItemValue as sharedGetItemValue,
type DeployProvider,
type DeployKind,
type DeployResult
} from 'windmill-utils-internal'
export type { DeployResult }
// ---------------------------------------------------------------------------
// Provider adapter — wraps frontend's class-based services
// ---------------------------------------------------------------------------
function makeProvider(): DeployProvider {
return {
existsFlowByPath: (p) => FlowService.existsFlowByPath(p),
existsScriptByPath: (p) => ScriptService.existsScriptByPath(p),
existsApp: (p) => AppService.existsApp(p),
existsVariable: (p) => VariableService.existsVariable(p),
existsResource: (p) => ResourceService.existsResource(p),
existsResourceType: (p) => ResourceService.existsResourceType(p),
existsFolder: (p) => FolderService.existsFolder(p),
getFlowByPath: (p) => FlowService.getFlowByPath(p),
createFlow: (p) => FlowService.createFlow(p),
updateFlow: (p) => FlowService.updateFlow(p),
archiveFlowByPath: (p) => FlowService.archiveFlowByPath(p),
getScriptByPath: (p) => ScriptService.getScriptByPath(p),
createScript: (p) => ScriptService.createScript(p),
archiveScriptByPath: (p) => ScriptService.archiveScriptByPath(p),
getAppByPath: (p) => AppService.getAppByPath(p),
createApp: (p) => AppService.createApp(p),
updateApp: (p) => AppService.updateApp(p),
createAppRaw: (p) => AppService.createAppRaw(p),
updateAppRaw: (p) => AppService.updateAppRaw(p),
getPublicSecretOfLatestVersionOfApp: (p) => AppService.getPublicSecretOfLatestVersionOfApp(p),
getRawAppData: (p) => AppService.getRawAppData(p),
deleteApp: (p) => AppService.deleteApp(p),
getVariable: (p) => VariableService.getVariable(p),
createVariable: (p) => VariableService.createVariable(p),
updateVariable: (p) => VariableService.updateVariable(p),
deleteVariable: (p) => VariableService.deleteVariable(p),
getResource: (p) => ResourceService.getResource(p),
createResource: (p) => ResourceService.createResource(p),
updateResource: (p) => ResourceService.updateResource(p),
deleteResource: (p) => ResourceService.deleteResource(p),
getResourceType: (p) => ResourceService.getResourceType(p),
createResourceType: (p) => ResourceService.createResourceType(p),
updateResourceType: (p) => ResourceService.updateResourceType(p),
deleteResourceType: (p) => ResourceService.deleteResourceType(p),
getFolder: (p) => FolderService.getFolder(p),
createFolder: (p) => FolderService.createFolder(p),
updateFolder: (p) => FolderService.updateFolder(p),
deleteFolder: (p) => FolderService.deleteFolder(p)
}
}
// ---------------------------------------------------------------------------
// Public API — thin wrappers that add trigger handling (frontend-specific)
// ---------------------------------------------------------------------------
export interface DeployItemParams {
kind: Kind
path: string
@@ -40,282 +98,58 @@ export interface DeployItemParams {
onBehalfOf?: string
}
export interface DeployResult {
success: boolean
error?: string
}
/**
* Deploy an item from one workspace to another.
* Handles all item kinds: flow, script, app, variable, resource, resource_type, folder, trigger.
*/
export async function deployItem(params: DeployItemParams): Promise<DeployResult> {
const { kind, path, workspaceFrom, workspaceTo, additionalInformation, onBehalfOf } = params
// When onBehalfOf is set, we preserve the on_behalf_of setting with the specified value
const preserveOnBehalfOf = onBehalfOf !== undefined
try {
const alreadyExists = await checkItemExists(kind, path, workspaceTo, additionalInformation)
if (kind === 'flow') {
const flow = await FlowService.getFlowByPath({
workspace: workspaceFrom,
path: path
})
getAllModules(flow.value.modules).forEach((x) => {
if (x.value.type === 'script' && x.value.hash != undefined) {
x.value.hash = undefined
}
})
if (alreadyExists) {
await FlowService.updateFlow({
workspace: workspaceTo,
path: path,
requestBody: {
...flow,
preserve_on_behalf_of: preserveOnBehalfOf,
on_behalf_of_email: onBehalfOf
}
})
} else {
await FlowService.createFlow({
workspace: workspaceTo,
requestBody: {
...flow,
preserve_on_behalf_of: preserveOnBehalfOf,
on_behalf_of_email: onBehalfOf
}
})
}
} else if (kind === 'script') {
const script = await ScriptService.getScriptByPath({
workspace: workspaceFrom,
path: path
})
await ScriptService.createScript({
workspace: workspaceTo,
requestBody: {
...script,
lock: script.lock,
parent_hash: alreadyExists
? (
await ScriptService.getScriptByPath({
workspace: workspaceTo,
path: path
})
).hash
: undefined,
preserve_on_behalf_of: preserveOnBehalfOf,
on_behalf_of_email: onBehalfOf
}
})
} else if (kind === 'app' || kind === 'raw_app') {
const app = await AppService.getAppByPath({
workspace: workspaceFrom,
path: path
})
if (alreadyExists) {
if (app.raw_app) {
const secret = await AppService.getPublicSecretOfLatestVersionOfApp({
workspace: workspaceFrom,
path: app.path
})
const js = await AppService.getRawAppData({
secretWithExtension: `${secret}.js`,
workspace: workspaceFrom
})
const css = await AppService.getRawAppData({
secretWithExtension: `${secret}.css`,
workspace: workspaceFrom
})
await AppService.updateAppRaw({
workspace: workspaceTo,
path: path,
formData: {
app: { ...app, preserve_on_behalf_of: preserveOnBehalfOf },
css,
js
}
})
} else {
await AppService.updateApp({
workspace: workspaceTo,
path: path,
requestBody: {
...app,
preserve_on_behalf_of: preserveOnBehalfOf
}
})
}
} else {
if (app.raw_app) {
const secret = await AppService.getPublicSecretOfLatestVersionOfApp({
workspace: workspaceFrom,
path: app.path
})
const js = await AppService.getRawAppData({
secretWithExtension: `${secret}.js`,
workspace: workspaceFrom
})
const css = await AppService.getRawAppData({
secretWithExtension: `${secret}.css`,
workspace: workspaceFrom
})
await AppService.createAppRaw({
workspace: workspaceTo,
formData: {
app: { ...app, preserve_on_behalf_of: preserveOnBehalfOf },
css,
js
}
})
} else {
await AppService.createApp({
workspace: workspaceTo,
requestBody: {
...app,
preserve_on_behalf_of: preserveOnBehalfOf
}
})
}
}
} else if (kind === 'variable') {
const variable = await VariableService.getVariable({
workspace: workspaceFrom,
path: path,
decryptSecret: true
})
if (alreadyExists) {
await VariableService.updateVariable({
workspace: workspaceTo,
path: path,
requestBody: {
path: path,
value: variable.value ?? '',
is_secret: variable.is_secret,
description: variable.description ?? ''
},
alreadyEncrypted: false
})
} else {
await VariableService.createVariable({
workspace: workspaceTo,
requestBody: {
path: path,
value: variable.value ?? '',
is_secret: variable.is_secret,
description: variable.description ?? ''
}
})
}
} else if (kind === 'resource') {
const resource = await ResourceService.getResource({
workspace: workspaceFrom,
path: path
})
if (alreadyExists) {
await ResourceService.updateResource({
workspace: workspaceTo,
path: path,
requestBody: {
path: path,
value: resource.value ?? '',
description: resource.description ?? ''
}
})
} else {
await ResourceService.createResource({
workspace: workspaceTo,
requestBody: {
path: path,
value: resource.value ?? '',
resource_type: resource.resource_type,
description: resource.description ?? ''
}
})
}
} else if (kind === 'resource_type') {
const resource = await ResourceService.getResourceType({
workspace: workspaceFrom,
path: path
})
if (alreadyExists) {
await ResourceService.updateResourceType({
workspace: workspaceTo,
path: path,
requestBody: {
schema: resource.schema,
description: resource.description ?? ''
}
})
} else {
await ResourceService.createResourceType({
workspace: workspaceTo,
requestBody: {
description: resource.description ?? '',
schema: resource.schema,
name: resource.name
}
})
}
} else if (kind === 'folder') {
const name = folderName(path)
const folder = await FolderService.getFolder({
workspace: workspaceFrom,
name
})
if (alreadyExists) {
await FolderService.updateFolder({
workspace: workspaceTo,
name,
requestBody: {
owners: folder.owners,
extra_perms: folder.extra_perms as any,
summary: folder.summary ?? undefined
}
})
} else {
await FolderService.createFolder({
workspace: workspaceTo,
requestBody: {
name,
owners: folder.owners,
extra_perms: folder.extra_perms as any,
summary: folder.summary ?? undefined
}
})
}
} else if (kind === 'trigger') {
if (additionalInformation?.triggers) {
const { data, createFn, updateFn } = await getTriggersDeployData(
additionalInformation.triggers.kind,
path,
workspaceFrom,
onBehalfOf
)
if (alreadyExists) {
await updateFn({
path,
workspace: workspaceTo,
requestBody: data
} as any)
} else {
await createFn({
workspace: workspaceTo,
requestBody: data
} as any)
}
} else {
throw new Error('Missing triggers kind')
}
} else {
throw new Error(`Unknown kind ${kind}`)
// Triggers are frontend-specific (not in the shared module)
if (kind === 'trigger') {
if (!additionalInformation?.triggers) {
return { success: false, error: 'Missing triggers kind' }
}
try {
const alreadyExists = await checkItemExists(kind, path, workspaceTo, additionalInformation)
const { data, createFn, updateFn } = await getTriggersDeployData(
additionalInformation.triggers.kind,
path,
workspaceFrom,
onBehalfOf
)
if (alreadyExists) {
await updateFn({ path, workspace: workspaceTo, requestBody: data } as any)
} else {
await createFn({ workspace: workspaceTo, requestBody: data } as any)
}
return { success: true }
} catch (e: any) {
return { success: false, error: e.body || e.message || String(e) }
}
return { success: true }
} catch (e: any) {
return { success: false, error: e.body || e.message }
}
return sharedDeployItem(
makeProvider(),
kind as DeployKind,
path,
workspaceFrom,
workspaceTo,
onBehalfOf
)
}
/**
* Delete/archive an item in a workspace.
* Used when deploying a deletion from one workspace to another.
* Scripts and flows are archived (reversible). Other types are deleted.
*/
export async function deleteItemInWorkspace(
kind: Kind,
path: string,
workspace: string
): Promise<DeployResult> {
return sharedDeleteItem(makeProvider(), kind as DeployKind, path, workspace)
}
/**
@@ -327,46 +161,9 @@ export async function checkItemExists(
workspace: string,
additionalInformation?: AdditionalInformation
): Promise<boolean> {
if (kind === 'flow') {
return await FlowService.existsFlowByPath({
workspace: workspace,
path: path
})
} else if (kind === 'script') {
return await ScriptService.existsScriptByPath({
workspace: workspace,
path: path
})
} else if (kind === 'app' || kind === 'raw_app') {
return await AppService.existsApp({
workspace: workspace,
path: path
})
} else if (kind === 'variable') {
return await VariableService.existsVariable({
workspace: workspace,
path: path
})
} else if (kind === 'resource') {
return await ResourceService.existsResource({
workspace: workspace,
path: path
})
} else if (kind === 'schedule') {
return await ScheduleService.existsSchedule({
workspace: workspace,
path: path
})
} else if (kind === 'resource_type') {
return await ResourceService.existsResourceType({
workspace: workspace,
path: path
})
} else if (kind === 'folder') {
return await FolderService.existsFolder({
workspace: workspace,
name: folderName(path)
})
// Triggers and schedules are frontend-specific
if (kind === 'schedule') {
return ScheduleService.existsSchedule({ workspace, path })
} else if (kind === 'trigger') {
const triggersKind: TriggerKind[] = [
'kafka',
@@ -383,20 +180,15 @@ export async function checkItemExists(
additionalInformation?.triggers &&
triggersKind.includes(additionalInformation.triggers.kind)
) {
return await existsTrigger(
{ workspace: workspace, path },
additionalInformation.triggers.kind
)
return existsTrigger({ workspace, path }, additionalInformation.triggers.kind)
} else {
throw new Error(
`Unexpected triggers kind, expected one of: '${triggersKind.join(', ')}' got: ${
additionalInformation?.triggers?.kind
}`
`Unexpected triggers kind, expected one of: '${triggersKind.join(', ')}' got: ${additionalInformation?.triggers?.kind}`
)
}
} else {
throw new Error(`Unknown kind ${kind}`)
}
return sharedCheckItemExists(makeProvider(), kind as DeployKind, path, workspace)
}
/**
@@ -408,86 +200,23 @@ export async function getItemValue(
workspace: string,
additionalInformation?: AdditionalInformation
): Promise<unknown> {
try {
if (kind === 'flow') {
const flow = await FlowService.getFlowByPath({
workspace: workspace,
path: path
})
getAllModules(flow.value.modules).forEach((x) => {
if (x.value.type === 'script' && x.value.hash != undefined) {
x.value.hash = undefined
}
})
return { summary: flow.summary, description: flow.description, value: flow.value }
} else if (kind === 'script') {
const script = await ScriptService.getScriptByPath({
workspace: workspace,
path: path
})
return {
content: script.content,
lock: script.lock,
schema: script.schema,
summary: script.summary,
language: script.language
}
} else if (kind === 'app' || kind === 'raw_app') {
const app = await AppService.getAppByPath({
workspace: workspace,
path: path
})
return app
} else if (kind === 'variable') {
const variable = await VariableService.getVariable({
workspace: workspace,
path: path,
decryptSecret: true
})
return variable.value
} else if (kind === 'resource') {
const resource = await ResourceService.getResource({
workspace: workspace,
path: path
})
return resource.value
} else if (kind === 'resource_type') {
const resource = await ResourceService.getResourceType({
workspace: workspace,
path: path
})
return resource.schema
} else if (kind === 'folder') {
const folder = await FolderService.getFolder({
workspace: workspace,
name: folderName(path)
})
return {
name: folder.name,
owners: folder.owners,
extra_perms: folder.extra_perms,
summary: folder.summary
}
} else if (kind === 'trigger') {
if (additionalInformation?.triggers) {
// Triggers are frontend-specific
if (kind === 'trigger') {
if (additionalInformation?.triggers) {
try {
return await getTriggerValue(additionalInformation.triggers.kind, path, workspace)
} else {
throw new Error(`Missing trigger information`)
} catch {
return {}
}
} else {
throw new Error(`Unknown kind ${kind}`)
}
} catch {
return {}
}
return sharedGetItemValue(makeProvider(), kind as DeployKind, path, workspace)
}
/**
* Get the on_behalf_of value for a deployable item.
*
* Return type varies by item kind:
* - For flows/scripts/apps: returns on_behalf_of_email (an email address)
* - For triggers/schedules: returns permissioned_as (u/username or g/group format)
*/
export async function getOnBehalfOf(
kind: Kind,
@@ -495,21 +224,14 @@ export async function getOnBehalfOf(
workspace: string,
additionalInformation?: AdditionalInformation
): Promise<string | undefined> {
try {
if (kind === 'flow') {
const flow = await FlowService.getFlowByPath({ workspace, path })
return flow.on_behalf_of_email
} else if (kind === 'script') {
const script = await ScriptService.getScriptByPath({ workspace, path })
return script.on_behalf_of_email
} else if (kind === 'app' || kind === 'raw_app') {
const app = await AppService.getAppByPath({ workspace, path })
return app.policy.on_behalf_of_email
} else if (kind === 'trigger' && additionalInformation?.triggers) {
// Triggers are frontend-specific
if (kind === 'trigger' && additionalInformation?.triggers) {
try {
return await getTriggerPermissionedAs(additionalInformation.triggers.kind, path, workspace)
} catch {
return undefined
}
} catch {
// Item may not exist in the workspace
}
return undefined
return sharedGetOnBehalfOf(makeProvider(), kind as DeployKind, path, workspace)
}