Files
windmill/frontend/src/lib/utils_workspace_deploy.ts
T
Ruben Fiszel 4342c18541 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>
2026-04-08 05:29:48 +00:00

238 lines
7.5 KiB
TypeScript

import {
AppService,
FlowService,
FolderService,
ResourceService,
ScheduleService,
ScriptService,
VariableService
} from '$lib/gen'
import {
existsTrigger,
getTriggersDeployData,
getTriggerPermissionedAs,
getTriggerValue,
type AdditionalInformation,
type Kind
} from '$lib/utils_deployable'
import type { TriggerKind } from './components/triggers'
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
workspaceFrom: string
workspaceTo: string
additionalInformation?: AdditionalInformation
/**
* The value to use for on_behalf_of when deploying.
* Format varies by item kind:
* - For flows/scripts/apps: an email address (on_behalf_of_email)
* - For triggers/schedules: permissioned_as format (u/username or g/group)
* If set, preserve_on_behalf_of / preserve_permissioned_as will be true.
* If undefined, the deploying user's identity is used.
*/
onBehalfOf?: 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
// 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 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)
}
/**
* Check if an item already exists in the target workspace.
*/
export async function checkItemExists(
kind: Kind,
path: string,
workspace: string,
additionalInformation?: AdditionalInformation
): Promise<boolean> {
// Triggers and schedules are frontend-specific
if (kind === 'schedule') {
return ScheduleService.existsSchedule({ workspace, path })
} else if (kind === 'trigger') {
const triggersKind: TriggerKind[] = [
'kafka',
'mqtt',
'nats',
'postgres',
'routes',
'schedules',
'sqs',
'websockets',
'gcp'
]
if (
additionalInformation?.triggers &&
triggersKind.includes(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}`
)
}
}
return sharedCheckItemExists(makeProvider(), kind as DeployKind, path, workspace)
}
/**
* Get the value of an item for diff comparison.
*/
export async function getItemValue(
kind: Kind,
path: string,
workspace: string,
additionalInformation?: AdditionalInformation
): Promise<unknown> {
// Triggers are frontend-specific
if (kind === 'trigger') {
if (additionalInformation?.triggers) {
try {
return await getTriggerValue(additionalInformation.triggers.kind, path, workspace)
} catch {
return {}
}
}
return {}
}
return sharedGetItemValue(makeProvider(), kind as DeployKind, path, workspace)
}
/**
* Get the on_behalf_of value for a deployable item.
*/
export async function getOnBehalfOf(
kind: Kind,
path: string,
workspace: string,
additionalInformation?: AdditionalInformation
): Promise<string | undefined> {
// Triggers are frontend-specific
if (kind === 'trigger' && additionalInformation?.triggers) {
try {
return await getTriggerPermissionedAs(additionalInformation.triggers.kind, path, workspace)
} catch {
return undefined
}
}
return sharedGetOnBehalfOf(makeProvider(), kind as DeployKind, path, workspace)
}