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:
@@ -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`);
|
||||
}
|
||||
|
||||
427
cli/src/commands/workspace/merge.ts
Normal file
427
cli/src/commands/workspace/merge.ts
Normal 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 };
|
||||
@@ -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
535
cli/test/fork_merge.test.ts
Normal 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
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
624
cli/windmill-utils-internal/src/deploy.ts
Normal file
624
cli/windmill-utils-internal/src/deploy.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
58
frontend/package-lock.json
generated
58
frontend/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user