// deno-lint-ignore-file no-explicit-any import { GlobalOptions } from "./types.ts"; import { requireLogin, resolveWorkspace, validatePath } from "./context.ts"; import { colors, Command, JobService, log, NewScript, readAll, Script, ScriptService, Table, writeAllSync, yamlParse, } from "./deps.ts"; export interface 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) { const workspace = await resolveWorkspace(opts); if (!validatePath(filePath)) { return; } const fstat = await Deno.stat(filePath); if (!fstat.isFile) { throw new Error("file path must refer to a file."); } if (filePath.endsWith(".script.json") || filePath.endsWith(".script.yaml")) { throw Error( "Cannot push a script metadata file, point to the script content file instead (.py, .ts, .go|.sh)" ); } await requireLogin(opts); await handleFile(filePath, workspace.workspaceId, []); log.info(colors.bold.underline.green(`Script ${filePath} pushed`)); } export async function handleScriptMetadata( path: string, workspace: string, alreadySynced: string[] ): Promise { if (path.endsWith(".script.json") || path.endsWith(".script.yaml")) { const contentPath = await findContentFile(path); return handleFile(contentPath, workspace, alreadySynced); } else { return false; } } export async function handleFile( path: string, workspace: string, alreadySynced: string[] ): Promise { if ( !path.includes(".inline_script.") && (path.endsWith(".ts") || path.endsWith(".py") || path.endsWith(".go") || path.endsWith(".sh")) ) { if (alreadySynced.includes(path)) { return true; } log.debug(`Processing local script ${path}`); alreadySynced.push(path); const remotePath = path.substring(0, path.length - 3); const metaPath = remotePath + ".script.json"; let typed = undefined; try { await Deno.stat(metaPath); typed = JSON.parse(await Deno.readTextFile(metaPath)); } catch { const metaPath = remotePath + ".script.yaml"; try { await Deno.stat(metaPath); typed = yamlParse(await Deno.readTextFile(metaPath)); } catch { // no meta file } } const language = inferContentTypeFromFilePath(path); let remote = undefined; try { remote = await ScriptService.getScriptByPath({ workspace, path: remotePath, }); log.debug(`Script ${remotePath} exists on remote`); } catch { log.debug(`Script ${remotePath} does not exist on remote`); } const content = await Deno.readTextFile(path); if (remote) { if (content === remote.content) { if ( typed == undefined || (typed.description === remote.description && typed.summary === remote.summary && typed.is_template === remote.is_template && typed.kind == remote.kind && !remote.archived && remote?.lock == typed.lock?.join("\n") && JSON.stringify(typed.schema) == JSON.stringify(remote.schema)) ) { log.info( colors.yellow( `No change to push for script ${remotePath}, skipping` ) ); return true; } } log.info( colors.yellow.bold(`Creating script with a parent ${remotePath}`) ); await ScriptService.createScript({ workspace, requestBody: { content, description: typed?.description ?? "", language: language as NewScript.language, path: remotePath, summary: typed?.summary ?? "", is_template: typed?.is_template, kind: typed?.kind, lock: typed?.lock, parent_hash: remote.hash, schema: typed?.schema, }, }); } else { log.info( colors.yellow.bold(`Creating script without parent ${remotePath}`) ); // no parent hash await ScriptService.createScript({ workspace: workspace, requestBody: { content, description: typed?.description ?? "", language: language as NewScript.language, path: remotePath, summary: typed?.summary ?? "", is_template: typed?.is_template, kind: typed?.kind, lock: typed?.lock, parent_hash: undefined, schema: typed?.schema, }, }); } return true; } return false; } export async function findContentFile(filePath: string) { const candidates = filePath.endsWith("script.json") ? [ filePath.replace(".script.json", ".ts"), filePath.replace(".script.json", ".py"), filePath.replace(".script.json", ".go"), filePath.replace(".script.json", ".sh"), ] : [ filePath.replace(".script.yaml", ".ts"), filePath.replace(".script.yaml", ".py"), filePath.replace(".script.yaml", ".go"), filePath.replace(".script.yaml", ".sh"), ]; 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 than 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 function inferContentTypeFromFilePath( contentPath: string ): "python3" | "deno" | "go" | "bash" { let language = contentPath.substring(contentPath.lastIndexOf(".")); if (language == ".ts") language = "deno"; if (language == ".py") language = "python3"; if (language == ".sh") language = "bash"; if (language == ".go") language = "go"; if ( language != "python3" && language != "deno" && language != "go" && language != "bash" ) { throw new Error("Invalid language: " + language); } return language; } 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", "summary", "language", "created by"]) .padding(2) .border(true) .body(total.map((x) => [x.path, x.summary, x.language, x.created_by])) .render(); } export async function resolve(input: string): Promise> { if (!input) { throw new Error("No data given"); } if (input == "@-") { input = new TextDecoder().decode(await readAll(Deno.stdin)); } if (input[0] == "@") { input = await Deno.readTextFile(input.substring(1)); } try { return JSON.parse(input); } catch (e) { console.error("Impossible to parse input as JSON", input); throw e; } } 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 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 ?? {}; log.info(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 }); log.info(result.logs); log.info("\n"); log.info(colors.bold.underline.green("Job Completed")); log.info("\n"); return; } catch { /* ignore */ } log.info(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) { log.info("failed to get job updated. skipping log streaming."); break; } continue; } if (!running && updates.running === true) { running = true; log.info(colors.green("Job running. Streaming logs...")); } if (updates.new_logs) { writeAllSync(Deno.stdout, new TextEncoder().encode(updates.new_logs)); logOffset += updates.new_logs.length; } if (updates.completed === true) { running = false; break; } if (running && updates.running === false) { running = false; log.info(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) { log.info(final_job.logs!.substring(logOffset)); } log.info("\n"); if (final_job.success) { log.info(colors.bold.underline.green("Job Completed")); } else { log.info(colors.bold.underline.red("Job Completed")); } log.info("\n"); } catch { log.info("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, }); log.info(colors.underline(s.path)); if (s.description) log.info(s.description); log.info(""); log.info(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. Use the script file (.ts, .js, .py, .sh)" ) .arguments("") .action(push as any) .command("show", "show a scripts content") .arguments("") .action(show as any) .command("run", "run a script 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); export default command;