// deno-lint-ignore-file no-explicit-any import { GlobalOptions } from "./types.ts"; import { requireLogin, resolveWorkspace, validatePath } from "./context.ts"; import { JobService, Script, } from "https://deno.land/x/windmill@v1.50.0/windmill-api/index.ts"; import { colors, Command, readAll, ScriptService, Table } from "./deps.ts"; type ScriptFile = { parent_hash?: string; summary: string; description: string; schema?: any; is_template?: boolean; lock?: Array; kind?: "script" | "failure" | "trigger" | "command" | "approval"; }; type PushOptions = GlobalOptions; async function push( opts: PushOptions, filePath: string, remotePath: string, contentPath?: string, ) { const workspace = await resolveWorkspace(opts); if (!await validatePath(opts, remotePath)) { return; } const fstat = await Deno.stat(filePath); if (!fstat.isFile) { throw new Error("file path must refer to a file."); } if (!contentPath) { contentPath = await findContentFile(filePath); } else { const fstat = await Deno.stat(filePath); if (!fstat.isFile) { throw new Error("content path must refer to a file."); } } await requireLogin(opts); await pushScript(filePath, contentPath, workspace.workspaceId, remotePath); console.log(colors.bold.underline.green("Script successfully pushed")); } export async function findContentFile(filePath: string) { const candidates = [ filePath.replace(".script.json", ".ts"), filePath.replace(".script.json", ".py"), filePath.replace(".script.json", ".go"), ]; const validCandidates = ( await Promise.all( candidates.map((x) => { return Deno.stat(x) .catch(() => undefined) .then((x) => x?.isFile) .then((e) => { return { path: x, file: e }; }); }), ) ) .filter((x) => x.file) .map((x) => x.path); if (validCandidates.length > 1) { throw new Error( "No content path given and more then one candidate found: " + validCandidates.join(", "), ); } if (validCandidates.length < 1) { throw new Error("No content path given and no content file found."); } return validCandidates[0]; } export async function pushScript( filePath: string, contentPath: string, workspace: string, remotePath: string, ) { const data: ScriptFile = JSON.parse(await Deno.readTextFile(filePath)); const content = await Deno.readTextFile(contentPath); let language = contentPath.substring(contentPath.lastIndexOf(".")); if (language == ".ts") language = "deno"; if (language == ".py") language = "python3"; if (language == ".go") language = "go"; if (language != "python3" && language != "deno" && language != "go") { throw new Error("Invalid language: " + language); } let parent_hash = data.parent_hash; if (!parent_hash) { try { parent_hash = ( await ScriptService.getScriptByPath({ workspace: workspace, path: remotePath, }) ).hash; } catch { /* no parent. New Script. */ } } console.log(colors.bold.yellow("Pushing script...")); await ScriptService.createScript({ workspace: workspace, requestBody: { path: remotePath, summary: data.summary, content: content, description: data.description, language: language, is_template: data.is_template, kind: data.kind, lock: data.lock, parent_hash: parent_hash, schema: data.schema, }, }); } async function list(opts: GlobalOptions & { showArchived?: boolean }) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); let page = 0; const perPage = 10; const total: Script[] = []; while (true) { const res = await ScriptService.listScripts({ workspace: workspace.workspaceId, page, perPage, showArchived: opts.showArchived ?? false, }); page += 1; total.push(...res); if (res.length < perPage) { break; } } new Table() .header(["path", "hash", "kind", "language", "created at", "created by"]) .padding(2) .border(true) .body( total.map((x) => [ x.path, x.hash, x.kind, x.language, x.created_at, x.created_by, ]), ) .render(); } export async function resolve(inputs: string[]): Promise> { let result = {}; if (!inputs) { return result; } for (const input of inputs) { let data: string; if (input.startsWith("@")) { if (input == "@-") { data = new TextDecoder().decode(await readAll(Deno.stdin)); } else { data = await Deno.readTextFile(input.substring(1)); } } else { if (input.startsWith("{")) { data = input; } else { const key = input.split("=", 1)[0]; const value = input.substring(key.length + 1); let o; try { o = JSON.parse(value); } catch { o = value; } data = JSON.stringify(Object.fromEntries([[key, o]])); } } let jsonObj; try { jsonObj = JSON.parse(data); } catch { jsonObj = data; } result = { ...result, ...jsonObj }; } return result; } async function run( opts: GlobalOptions & { input: string[]; silent: boolean; }, path: string, ) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); const input = await resolve(opts.input); const id = await JobService.runScriptByPath({ workspace: workspace.workspaceId, path, requestBody: input, }); if (!opts.silent) { await track_job(workspace.workspaceId, id); } while (true) { try { const result = ( await JobService.getCompletedJob({ workspace: workspace.workspaceId, id, }) ).result ?? {}; console.log(result); break; } catch { new Promise((resolve, _) => setTimeout(() => resolve(undefined), 100)); } } } export async function track_job(workspace: string, id: string) { try { const result = await JobService.getCompletedJob({ workspace, id }); console.log(result.logs); console.log(colors.bold.underline.green("Job Completed")); return; } catch { /* ignore */ } console.log(colors.yellow("Waiting for Job " + id + " to start...")); let logOffset = 0; let running = false; let retry = 0; while (true) { let updates: { running?: boolean | undefined; completed?: boolean | undefined; new_logs?: string | undefined; }; try { updates = await JobService.getJobUpdates({ workspace, id, logOffset, running, }); } catch { retry++; if (retry > 3) { console.log("failed to get job updated. skipping log streaming."); break; } continue; } if (!running && updates.running === true) { running = true; console.log(colors.green("Job running. Streaming logs...")); } if (updates.new_logs) { console.log(updates.new_logs); logOffset += updates.new_logs.length; } if (updates.completed === true) { running = false; break; } if (running && updates.running === false) { running = false; console.log( colors.yellow("Job suspended. Waiting for it to continue..."), ); } } await new Promise((resolve, _) => setTimeout(() => resolve(undefined), 1000)); try { const final_job = await JobService.getCompletedJob({ workspace, id }); if ((final_job.logs?.length ?? -1) > logOffset) { console.log(final_job.logs!.substring(logOffset)); } if (final_job.success) { console.log(colors.bold.underline.green("Job Completed")); } else { console.log(colors.bold.underline.red("Job Completed")); } } catch { console.log("Job appears to have completed, but no data can be retrieved"); } } async function show(opts: GlobalOptions, path: string) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); const s = await ScriptService.getScriptByPath({ workspace: workspace.workspaceId, path, }); console.log(colors.underline(s.path)); if (s.description) console.log(s.description); console.log(""); console.log(s.content); } const command = new Command() .description("script related commands") .option("--show-archived", "Enable archived scripts in output") .action(list as any) .command( "push", "push a local script spec. This overrides any remote versions.", ) .arguments(" [content_path:string]") .action(push as any) .command("show", "show a scripts content") .arguments("") .action(show as any) .command("run", "run a script by path") .arguments("") .option( "-i --input [inputs...:string]", "Inputs specified as JSON objects or simply as =. Supports file inputs using @ and stdin using @- these also need to be formatted as JSON. Later inputs override earlier ones.", ) .option( "-s --silent", "Do not ouput anything other then the final output. Useful for scripting.", ) .action(run as any); export default command;