// deno-lint-ignore-file no-explicit-any import { GlobalOptions, isSuperset } from "./types.ts"; import { Confirm, SEP, log, yamlStringify } from "./deps.ts"; import { colors, Command, Table, yamlParseFile } from "./deps.ts"; import * as wmill from "./gen/services.gen.ts"; import { requireLogin, resolveWorkspace, validatePath } from "./context.ts"; import { resolve, track_job } from "./script.ts"; import { defaultFlowDefinition } from "./bootstrap/flow_bootstrap.ts"; import { generateFlowLockInternal } from "./metadata.ts"; import { SyncOptions, mergeConfigWithConfigFile } from "./conf.ts"; import { FSFSElement, elementsToMap, ignoreF } from "./sync.ts"; import { readInlinePathSync } from "./utils.ts"; import { Flow, FlowModule } from "./gen/types.gen.ts"; export interface FlowFile { summary: string; description?: string; value: any; schema?: any; } const alreadySynced: string[] = []; export function replaceInlineScripts( modules: FlowModule[], localPath: string, removeLocks: string[] | undefined ) { modules.forEach((m) => { if (m.value.type == "rawscript") { if (m.value.content.startsWith("!inline")) { const path = m.value.content.split(" ")[1]; m.value.content = Deno.readTextFileSync(localPath + path); const lock = m.value.lock; if (removeLocks && removeLocks.includes(path)) { m.value.lock = undefined; } else if ( lock && typeof lock == "string" && lock.trimStart().startsWith("!inline ") ) { const path = lock.split(" ")[1]; try { m.value.lock = readInlinePathSync(localPath + path); } catch { log.error(`Lock file ${path} not found`); } } } } else if (m.value.type == "forloopflow") { replaceInlineScripts(m.value.modules, localPath, removeLocks); } else if (m.value.type == "whileloopflow") { replaceInlineScripts(m.value.modules, localPath, removeLocks); } else if (m.value.type == "branchall") { m.value.branches.forEach((b) => replaceInlineScripts(b.modules, localPath, removeLocks) ); } else if (m.value.type == "branchone") { m.value.branches.forEach((b) => replaceInlineScripts(b.modules, localPath, removeLocks) ); replaceInlineScripts(m.value.default, localPath, removeLocks); } }); } export async function pushFlow( workspace: string, remotePath: string, localPath: string, message?: string ): Promise { if (alreadySynced.includes(localPath)) { return; } alreadySynced.push(localPath); remotePath = remotePath.replaceAll(SEP, "/"); let flow: Flow | undefined = undefined; try { flow = await wmill.getFlowByPath({ workspace: workspace, path: remotePath, }); } catch { // flow doesn't exist } if (!localPath.endsWith(SEP)) { localPath += SEP; } const localFlow = (await yamlParseFile(localPath + "flow.yaml")) as FlowFile; replaceInlineScripts(localFlow.value.modules, localPath, undefined); if (flow) { if (isSuperset(localFlow, flow)) { log.info(colors.green(`Flow ${remotePath} is up to date`)); return; } log.info(colors.bold.yellow(`Updating flow ${remotePath}...`)); await wmill.updateFlow({ workspace: workspace, path: remotePath.replaceAll(SEP, "/"), requestBody: { path: remotePath.replaceAll(SEP, "/"), deployment_message: message, ...localFlow, }, }); } else { log.info(colors.bold.yellow("Creating new flow...")); try { await wmill.createFlow({ workspace: workspace, requestBody: { path: remotePath.replaceAll(SEP, "/"), deployment_message: message, ...localFlow, }, }); } catch (e) { throw new Error( //@ts-ignore `Failed to create flow ${remotePath}: ${e.body ?? e.message}` ); } } } type Options = GlobalOptions; async function push(opts: Options, filePath: string, remotePath: string) { if (!validatePath(remotePath)) { return; } const workspace = await resolveWorkspace(opts); await requireLogin(opts); await pushFlow(workspace.workspaceId, remotePath, filePath); log.info(colors.bold.underline.green("Flow pushed")); } async function list( opts: GlobalOptions & { showArchived?: boolean; includeDraftOnly?: boolean } ) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); let page = 0; const perPage = 10; const total: Flow[] = []; while (true) { const res = await wmill.listFlows({ workspace: workspace.workspaceId, page, perPage, showArchived: opts.showArchived ?? false, includeDraftOnly: opts.includeDraftOnly ?? false, }); page += 1; total.push(...res); if (res.length < perPage) { break; } } new Table() .header(["path", "summary", "edited by"]) .padding(2) .border(true) .body(total.map((x) => [x.path, x.summary, x.edited_by])) .render(); } async function run( opts: GlobalOptions & { data?: string; silent: boolean; }, path: string ) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); const input = opts.data ? await resolve(opts.data) : {}; const id = await wmill.runFlowByPath({ workspace: workspace.workspaceId, path, requestBody: input, }); let i = 0; while (true) { const jobInfo = await wmill.getJob({ workspace: workspace.workspaceId, id, }); if (jobInfo.flow_status!.modules.length <= i) { break; } const module = jobInfo.flow_status!.modules[i]; if (module.job) { if (!opts.silent) { log.info("====== Job " + (i + 1) + " ======"); await track_job(workspace.workspaceId, module.job); } } else { if (!opts.silent) { log.info(module.type); } await new Promise((resolve, _) => setTimeout(() => resolve(undefined), 100) ); continue; } i++; } if (!opts.silent) { log.info(colors.green.underline.bold("Flow ran to completion")); log.info("\n"); } const jobInfo = await wmill.getCompletedJob({ workspace: workspace.workspaceId, id, }); log.info(jobInfo.result ?? {}); } async function generateLocks( opts: GlobalOptions & { yes?: boolean; } & SyncOptions, folder: string | undefined ) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); opts = await mergeConfigWithConfigFile(opts); if (folder) { // read script metadata file await generateFlowLockInternal(folder, false, workspace); } else { const ignore = await ignoreF(opts); const elems = Object.keys( await elementsToMap( await FSFSElement(Deno.cwd(), [], true), (p, isD) => { return ( ignore(p, isD) || (!isD && !p.endsWith(SEP + "flow.yaml") && !p.endsWith(SEP + "flow.json")) ); }, false, {} ) ).map((x) => x.substring(0, x.lastIndexOf(SEP))); let hasAny = false; for (const folder of elems) { const candidate = await generateFlowLockInternal(folder, true, workspace); if (candidate) { hasAny = true; log.info(colors.green(`+ ${candidate}`)); } } if (hasAny) { if ( !opts.yes && !(await Confirm.prompt({ message: "Update the locks of the inline scripts of the above flows?", default: true, })) ) { return; } } else { log.info(colors.green.bold("No locks to update")); return; } for (const folder of elems) { await generateFlowLockInternal(folder, false, workspace); } } } export function bootstrap( opts: GlobalOptions & { summary: string; description: string }, flowPath: string ) { if (!validatePath(flowPath)) { return; } const flowDirFullPath = `${flowPath}.flow`; Deno.mkdirSync(flowDirFullPath, { recursive: false }); const newFlowDefinition = defaultFlowDefinition(); if (opts.summary !== undefined) { newFlowDefinition.summary = opts.summary; } if (opts.description !== undefined) { newFlowDefinition.description = opts.description; } const newFlowDefinitionYaml = yamlStringify( newFlowDefinition as Record ); const flowYamlPath = `${flowDirFullPath}/flow.yaml`; Deno.writeTextFile(flowYamlPath, newFlowDefinitionYaml, { createNew: true }); } const command = new Command() .description("flow related commands") .option("--show-archived", "Enable archived scripts in output") .action(list as any) .command( "push", "push a local flow spec. This overrides any remote versions." ) .arguments(" ") .action(push as any) .command("run", "run a flow by path.") .arguments("") .option( "-d --data ", "Inputs specified as a JSON string or a file using @ or stdin using @-." ) .option( "-s --silent", "Do not ouput anything other then the final output. Useful for scripting." ) .action(run as any) .command( "generate-locks", "re-generate the lock files of all inline scripts of all updated flows" ) .arguments("[flow:file]") .option("--yes", "Skip confirmation prompt") .option( "-i --includes ", "Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)" ) .option( "-e --excludes ", "Comma separated patterns to specify which file to NOT take into account." ) .action(generateLocks as any) .command("bootstrap", "create a new empty flow") .arguments("") .option("--summary ", "script summary") .option("--description ", "script description") .action(bootstrap as any); export default command;