diff --git a/.github/change-versions.sh b/.github/change-versions.sh index c5c22a05f8..0e10fde777 100755 --- a/.github/change-versions.sh +++ b/.github/change-versions.sh @@ -4,6 +4,7 @@ VERSION=$1 echo "Updating versions to: $VERSION" sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" backend/Cargo.toml +sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" cli/main.ts sed -i -e "/version: /s/: .*/: $VERSION/" backend/windmill-api/openapi.yaml sed -i -e "/version: /s/: .*/: $VERSION/" openflow.openapi.yaml sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" frontend/package.json diff --git a/cli/.vscode/settings.json b/cli/.vscode/settings.json new file mode 100644 index 0000000000..aa1c94e25c --- /dev/null +++ b/cli/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "deno.enable": true, + "deno.unstable": true +} diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000000..d0e69b023f --- /dev/null +++ b/cli/README.md @@ -0,0 +1,77 @@ +# Windmill CLI + +A simple CLI allowing interactions with windmill from the command line. + +[![asciicast](https://asciinema.org/a/533968.svg)](https://asciinema.org/a/533968) + +## Setup + +Setup the CLI by running `wmill setup`. This will guide you through the setup process easily. + +## Pushing Resources + +The CLI can push resource specifications to a windmill instance. See the [examples/](./examples/) folder for formats. + +### Pushing a folder + +You can push all files in a folder at once using `wmill push` +Files MUST be named resource_name.\.json. They will be pushed to the remote path they are in, for example the file `u/admin/fib/fib.script.json` will be pushed as a script to u/admin/fib/fib. + +### Pushing individual files + +You can push individual resources using `wmill push \`. This does not require a special folder layout or file name, as this is given at runtime. + +## Listing + +All commands support listing by just not providing a subcommand, ie `wmill script` will result in a list of scripts. Some allow additional options, learn about this by specifying `--help`. + +## User Management + +You can add & remove users via `wmill user add/remove`, and list them using `wmill user` + +## Login + +Logging in using `wmill login` or the setup will save a token to your local computer, into `~/.config/windmill//token` (or `C:\Users\\AppData\Roaming\windmill\\token` on windows). +This is inherently unsafe, so do not log into the CLI on untrusted devices. + +## Managing Remotes + +Advanced users may use multiple remotes at once, which the CLI supports using `wmill remote`. + +Add remotes using `wmill remote add ` & Remove them using `wmill remote remove `. + +You can use a remote by either setting it as default using `wmill remote set-default ` or by overriding the remote using `--remote ` on any command. + +If you don't want to save the URL locally, you can always override the command using `--base-url ` on any command. + +### Login on multiple remotes + +You will have to login on each remote, either do this using `wmill login` or override the token/credentials on each command using `--token `/`--username --password `. + +## Completion + +The CLI comes with completions out of the box via `wmill completions `. (Via [cliffy](https://cliffy.io/)) + +### Bash + +To enable bash completions add the following line to your `~/.bashrc`: + +``` +source <(wmill completions bash) +``` + +### Fish + +To enable fish completions add the following line to your `~/.config/fish/config.fish`: + +``` +source (wmill completions fish | psub) +``` + +### Zsh + +To enable zsh completions add the following line to your `~/.zshrc`: + +``` +source <(wmill completions zsh) +``` diff --git a/cli/context.ts b/cli/context.ts new file mode 100644 index 0000000000..ed094d1445 --- /dev/null +++ b/cli/context.ts @@ -0,0 +1,49 @@ +import { colors } from "https://deno.land/x/cliffy@v0.25.4/ansi/colors.ts"; +import { + setClient, + UserService, +} from "https://deno.land/x/windmill@v1.41.0/mod.ts"; +import { getToken } from "./login.ts"; +import { getDefaultRemote, getRemote } from "./remote.ts"; +import { getStore } from "./store.ts"; +import { GlobalOptions } from "./types.ts"; +import { getDefaultWorkspaceId } from "./workspace.ts"; + +export type Context = { + workspace: string; + baseUrl: string; + urlStore: string; +}; + +export async function getContext({ + baseUrl, + remote, + workspace, + token, + email, + password, +}: GlobalOptions): Promise { + if (remote) { + baseUrl = baseUrl ?? (await getRemote(remote))?.baseUrl; + } + baseUrl = baseUrl ?? (await getDefaultRemote())?.baseUrl; + baseUrl = baseUrl ?? "https://app.windmill.dev"; + if (email && password) { + setClient("no-token", baseUrl); + token = + token ?? (await UserService.login({ requestBody: { email, password } })); + } + token = token ?? (await getToken(baseUrl)); + setClient(token, baseUrl); + const urlStore = await getStore(baseUrl); + const workspaceId = workspace ?? (await getDefaultWorkspaceId(urlStore)); + if (!workspaceId) { + console.log(colors.red("No default workspace set and no override given.")); + Deno.exit(-2); + } + return { + workspace: workspaceId, + baseUrl: baseUrl, + urlStore: urlStore, + }; +} diff --git a/cli/examples/u/admin/example.flow.json b/cli/examples/u/admin/example.flow.json new file mode 100644 index 0000000000..4631d6de27 --- /dev/null +++ b/cli/examples/u/admin/example.flow.json @@ -0,0 +1,37 @@ +{ + "summary": "", + "description": "", + "value": { + "modules": [ + { + "value": { + "path": "u/admin/test_script", + "type": "script" + }, + "input_transforms": {} + }, + { + "value": { + "path": "u/admin/fib", + "type": "script" + }, + "input_transforms": { + "element": { + "type": "static", + "value": 100 + } + } + } + ] + }, + "edited_by": "admin", + "edited_at": "2022-10-17T16:08:38.000148Z", + "archived": false, + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": {} + }, + "extra_perms": {} +} diff --git a/cli/examples/u/admin/example.resource.json b/cli/examples/u/admin/example.resource.json new file mode 100755 index 0000000000..f473f2aea7 --- /dev/null +++ b/cli/examples/u/admin/example.resource.json @@ -0,0 +1,13 @@ +{ + "value": { + "host": "$var:g/all/not_secret", + "port": 124, + "user": "aaaa", + "dbname": "bbbb", + "sslmode": "disable", + "password": "$var:g/all/pretty_secret" + }, + "description": "", + "resource_type": "postgres", + "is_oauth": false +} diff --git a/cli/examples/u/admin/example.variable.json b/cli/examples/u/admin/example.variable.json new file mode 100755 index 0000000000..8b77625a98 --- /dev/null +++ b/cli/examples/u/admin/example.variable.json @@ -0,0 +1,9 @@ +{ + "value": "finland does not actually exist", + "is_secret": false, + "description": "This item is not secret", + "extra_perms": {}, + "account": null, + "is_oauth": false, + "is_expired": false +} diff --git a/cli/examples/u/admin/fib/fib.script.json b/cli/examples/u/admin/fib/fib.script.json new file mode 100755 index 0000000000..5e89c8e508 --- /dev/null +++ b/cli/examples/u/admin/fib/fib.script.json @@ -0,0 +1,19 @@ +{ + "summary": "", + "description": "", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "element": { + "description": "", + "type": "number", + "default": null, + "format": "" + } + }, + "required": ["element"], + "type": "object" + }, + "is_template": false, + "lock": [] +} diff --git a/cli/examples/u/admin/fib/fib.script.ts b/cli/examples/u/admin/fib/fib.script.ts new file mode 100755 index 0000000000..73f69b5ce5 --- /dev/null +++ b/cli/examples/u/admin/fib/fib.script.ts @@ -0,0 +1,7 @@ +export function main(element: number) { + const sequence = [0, 1]; + for (let i = 2; i <= element; i++) { + sequence[i] = sequence[i - 2] + sequence[i - 1]; + } + return sequence[element]; +} diff --git a/cli/examples/u/admin/go_test/go_test.script.go b/cli/examples/u/admin/go_test/go_test.script.go new file mode 100755 index 0000000000..1989acfc90 --- /dev/null +++ b/cli/examples/u/admin/go_test/go_test.script.go @@ -0,0 +1,14 @@ +package inner + +import ( + "fmt" + "rsc.io/quote" + // wmill "github.com/windmill-labs/windmill-go-client" +) + +func main(x string) (interface{}, error) { + fmt.Println("Hello, World") + fmt.Println(quote.Opt()) + // v, _ := wmill.GetVariable("g/all/pretty_secret") + return x, nil +} diff --git a/cli/examples/u/admin/go_test/go_test.script.json b/cli/examples/u/admin/go_test/go_test.script.json new file mode 100755 index 0000000000..324a58b86b --- /dev/null +++ b/cli/examples/u/admin/go_test/go_test.script.json @@ -0,0 +1,38 @@ +{ + "summary": "", + "description": "", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "x": { + "description": "", + "type": "string", + "default": null, + "format": "" + } + }, + "required": ["x"], + "type": "object" + }, + "is_template": false, + "lock": [ + "module mymod", + "", + "go 1.19", + "", + "require rsc.io/quote v1.5.2", + "", + "require (", + "\tgolang.org/x/text v0.0.0-20170915032832-14c0d48ead0c // indirect", + "\trsc.io/sampler v1.3.0 // indirect", + ")", + "", + "//go.sum", + "golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c h1:qgOY6WgZOaTkIIMiVjBQcw93ERBE4m30iBm00nkL0i8=", + "golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=", + "rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y=", + "rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0=", + "rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4=", + "rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=" + ] +} diff --git a/cli/flow.ts b/cli/flow.ts new file mode 100644 index 0000000000..3464e3e21b --- /dev/null +++ b/cli/flow.ts @@ -0,0 +1,115 @@ +import { Command } from "https://deno.land/x/cliffy@v0.25.4/command/command.ts"; +import { FlowService } from "https://deno.land/x/windmill@v1.41.0/mod.ts"; +import { GlobalOptions } from "./types.ts"; +import { + Flow, + OpenFlow, +} from "https://deno.land/x/windmill@v1.41.0/windmill-api/index.ts"; +import { colors } from "https://deno.land/x/cliffy@v0.25.4/ansi/colors.ts"; +import { getContext } from "./context.ts"; +import { Table } from "https://deno.land/x/cliffy@v0.25.4/table/table.ts"; + +type Options = GlobalOptions; + +async function push(opts: Options, filePath: string, remotePath: string) { + const { workspace } = await getContext(opts); + + if (!(remotePath.startsWith("g") || remotePath.startsWith("u"))) { + console.log( + colors.red( + "Given remote path looks invalid. Remote paths are typicall of the form //..." + ) + ); + return; + } + await pushFlow(filePath, workspace, remotePath); + console.log(colors.bold.underline.green("Flow successfully pushed")); +} + +export async function pushFlow( + filePath: string, + workspace: string, + remotePath: string +) { + const data: OpenFlow = JSON.parse(await Deno.readTextFile(filePath)); + if ( + await FlowService.existsFlowByPath({ + workspace: workspace, + path: remotePath, + }) + ) { + console.log(colors.bold.yellow("Updating existing flow...")); + await FlowService.updateFlow({ + workspace: workspace, + path: remotePath, + requestBody: { + path: remotePath, + summary: data.summary, + value: data.value, + schema: data.schema, + description: data.description, + }, + }); + } else { + console.log(colors.bold.yellow("Creating new flow...")); + await FlowService.createFlow({ + workspace: workspace, + requestBody: { + path: remotePath, + summary: data.summary, + value: data.value, + schema: data.schema, + description: data.description, + }, + }); + } +} + +async function list(opts: GlobalOptions & { showArchived?: boolean }) { + const { workspace } = await getContext(opts); + + let page = 0; + const perPage = 10; + const total: Flow[] = []; + while (true) { + const res = await FlowService.listFlows({ + workspace, + page, + perPage, + showArchived: opts.showArchived ?? false, + }); + page += 1; + total.push(...res); + if (res.length < perPage) { + break; + } + } + + new Table() + .header(["path", "summary", "edited at", "edited by", "description"]) + .padding(2) + .border(true) + .body( + total.map((x) => [ + x.path, + x.summary, + x.edited_at, + x.edited_by, + x.description ?? "-", + ]) + ) + .render(); +} + +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); + +export default command; diff --git a/cli/login.ts b/cli/login.ts new file mode 100644 index 0000000000..172a70657b --- /dev/null +++ b/cli/login.ts @@ -0,0 +1,65 @@ +import { Command } from "https://deno.land/x/cliffy@v0.25.4/command/mod.ts"; +import { + Input, + Secret, +} from "https://deno.land/x/cliffy@v0.25.4/prompt/mod.ts"; +import { GlobalOptions } from "./types.ts"; +import { + setClient, + UserService, +} from "https://deno.land/x/windmill@v1.41.0/mod.ts"; +import { colors } from "https://deno.land/x/cliffy@v0.25.4/ansi/colors.ts"; +import { getStore } from "./store.ts"; +import { getDefaultRemote, getRemote } from "./remote.ts"; + +export type Options = GlobalOptions; + +async function login( + { remote, baseUrl }: Options, + email?: string, + password?: string +) { + if (remote) { + baseUrl = baseUrl ?? (await getRemote(remote))?.baseUrl; + } + baseUrl = baseUrl ?? (await getDefaultRemote())?.baseUrl; + baseUrl = baseUrl ?? "https://app.windmill.dev"; + setClient("no-token", baseUrl); + const urlStore = await getStore(baseUrl); + email = email ?? (await Input.prompt({ message: "Input your Email" })); + password = + password ?? (await Secret.prompt({ message: "Input your Password" })); + + const token = await UserService.login({ + requestBody: { + email: email, + password: password, + }, + }); + + await Deno.writeTextFile(urlStore + "token", token); + console.log(colors.bold.underline.green("Successfully logged in!")); +} + +export async function getToken(baseUrl: string): Promise { + const baseStore = await getStore(baseUrl); + try { + return await Deno.readTextFile(baseStore + "token"); + } catch { + console.log( + colors.bold.underline.red( + "You need to be logged in to do this! Run 'windmill login' to login." + ) + ); + return Deno.exit(-1); + } +} + +const command = new Command() + .description( + "Log into windmill. The credentials are not stored, but the token they are exchanged for will be." + ) + .arguments("[email:string] [password:string]") + .action(login as any); + +export default command; diff --git a/cli/main.ts b/cli/main.ts new file mode 100644 index 0000000000..e97771cd22 --- /dev/null +++ b/cli/main.ts @@ -0,0 +1,88 @@ +import { Command } from "https://deno.land/x/cliffy@v0.25.4/command/mod.ts"; +import { + DenoLandProvider, + UpgradeCommand, +} from "https://deno.land/x/cliffy@v0.25.4/command/upgrade/mod.ts"; +import login from "./login.ts"; +import flow from "./flow.ts"; +import script from "./script.ts"; +import workspace from "./workspace.ts"; +import resource from "./resource.ts"; +import remote from "./remote.ts"; +import user from "./user.ts"; +import setup from "./setup.ts"; +import variable from "./variable.ts"; +import push from "./push.ts"; + +const VERSION = "v1.42.1"; + +await new Command() + .name("wmill") + .description("A simple CLI tool for windmill.") + .globalOption( + "--base-url ", + "Specify the base url to use when interacting with the API.", + { + conflicts: ["remote"], + } + ) + .globalOption( + "--workspace ", + "Specify the target workspace. This overrides the default workspace." + ) + .globalOption( + "--remote ", + "Specify the target remote, add to this list via `wmill remote add`.", + { + conflicts: ["base-url"], + } + ) + .globalOption( + "--token ", + "Specify a token to use for authentication. This will not be stored. Takes presedence over username/password", + { + conflicts: ["email", "password"], + } + ) + .globalOption( + "--email ", + "Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.", + { + depends: ["password"], + conflicts: ["token"], + } + ) + .globalOption( + "--password ", + "Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.", + { + depends: ["email"], + conflicts: ["token"], + } + ) + .version(VERSION) + .command("login", login) + .command("flow", flow) + .command("script", script) + .command("workspace", workspace) + .command("resource", resource) + .command("remote", remote) + .command("user", user) + .command("setup", setup) + .command("variable", variable) + .command("push", push) + .command( + "upgrade", + new UpgradeCommand({ + main: "main.ts", + args: [ + "--allow-net", + "--allow-read", + "--allow-write", + "--allow-env", + "--unstable", + ], + provider: new DenoLandProvider({ name: "wmill" }), + }) + ) + .parse(Deno.args); diff --git a/cli/push.ts b/cli/push.ts new file mode 100644 index 0000000000..4dca33fef2 --- /dev/null +++ b/cli/push.ts @@ -0,0 +1,138 @@ +import { colors } from "https://deno.land/x/cliffy@v0.25.4/ansi/colors.ts"; +import { Command } from "https://deno.land/x/cliffy@v0.25.4/command/command.ts"; +import { getContext } from "./context.ts"; +import { pushFlow } from "./flow.ts"; +import { pushResource } from "./resource.ts"; +import { findContentFile, pushScript } from "./script.ts"; +import { GlobalOptions } from "./types.ts"; +import { pushVariable } from "./variable.ts"; + +async function push(opts: GlobalOptions, dir?: string) { + dir = dir ?? Deno.cwd(); + const { workspace } = await getContext(opts); + + const candidates: { + path: string; + group: boolean; + groupOrUsername: string; + }[] = []; + console.log(colors.blue("Searching Directory...")); + for await (const e of Deno.readDir(dir)) { + if (e.isDirectory) { + if (e.name == "u" || e.name == "g") { + const newDir = dir + (dir.endsWith("/") ? "" : "/") + e.name; + for await (const e2 of Deno.readDir(newDir)) { + if (e2.isDirectory) { + const groupOrUserName = e2.name; + const stack: string[] = []; + stack.push(newDir + "/" + groupOrUserName + "/"); + + while (stack.length > 0) { + const dir2 = stack.pop()!; + for await (const e3 of Deno.readDir(dir2)) { + if (e3.isFile) { + candidates.push({ + path: dir2 + e3.name, + group: e.name == "g", + groupOrUsername: groupOrUserName, + }); + } else { + stack.push(dir2 + e3.name + "/"); + } + } + } + } + } + } + } + } + console.log(colors.blue("Found " + candidates.length + " candidates")); + for (const candidate of candidates) { + // full file name. No leading /. includes .type.json + const fileName = candidate.path.substring( + candidate.path.lastIndexOf("/") + 1 + ); + // figure out just the path after ...../u|g/username|group/ (in extra dir) + const dirParts = candidate.path.split("/").filter((x) => x.length > 0); + const gIndex = dirParts.findIndex((x) => x == "u" || x == "g"); + const extraDir = dirParts.slice(gIndex + 2, -1).join("/"); + + // file name parts has .json (hopefully) at -1, type at -2, and the actual name at -3. Dots in names are not allowed. + const fileNameParts = fileName.split("."); + + // filter out script content files + if ( + fileNameParts.at(-1) == "ts" || + fileNameParts.at(-1) == "py" || + fileNameParts.at(-1) == "go" + ) { + // probably part of a script. Silent ignore. + continue; + } + + // invalid file names, like my.cool.script.script.json. Not valid. + if (fileNameParts.length != 3) { + console.log( + colors.yellow("invalid file name found at " + candidate.path) + ); + continue; + } + + // filter out non-json files. Note that we filter out script contents above, so this is really an error. + if (fileNameParts.at(-1) != "json") { + console.log(colors.yellow("non-JSON file found at " + candidate.path)); + continue; + } + + // get the type & filter it for valid ones. + const type = fileNameParts.at(-2); + if ( + type != "flow" && + type != "resource" && + type != "script" && + type != "variable" + ) { + console.log( + colors.yellow( + "file with invalid type " + type + " found at " + candidate.path + ) + ); + continue; + } + + // create the remotePath for the API + const remotePath = + (candidate.group ? "g/" : "u/") + + candidate.groupOrUsername + + "/" + + (extraDir.length > 0 ? extraDir + "/" : "") + + fileNameParts.at(-3); + + console.log("pushing " + type + " to " + remotePath); + + if (type == "flow") { + await pushFlow(candidate.path, workspace, remotePath); + } else if (type == "resource") { + await pushResource(workspace, candidate.path, remotePath); + } else if (type == "script") { + let contentPath: string; + try { + contentPath = await findContentFile(candidate.path); + } catch (e) { + console.log(colors.red(e)); + continue; + } + await pushScript(candidate.path, contentPath, workspace, remotePath); + } else if (type == "variable") { + await pushVariable(workspace, candidate.path, remotePath); + } + } + console.log(colors.underline.bold.green("Successfully Pushed all files.")); +} + +const command = new Command() + .description("Push all files from a folder") + .arguments("[dir:string]") + .action(push as any); + +export default command; diff --git a/cli/remote.ts b/cli/remote.ts new file mode 100644 index 0000000000..ad6197fb7f --- /dev/null +++ b/cli/remote.ts @@ -0,0 +1,112 @@ +import { ensureDir } from "https://deno.land/std@0.161.0/fs/ensure_dir.ts"; +import { colors } from "https://deno.land/x/cliffy@v0.25.4/ansi/colors.ts"; +import { Command } from "https://deno.land/x/cliffy@v0.25.4/command/command.ts"; +import { Table } from "https://deno.land/x/cliffy@v0.25.4/table/table.ts"; +import { getRootStore } from "./store.ts"; +import { GlobalOptions } from "./types.ts"; + +export type Remote = { + baseUrl: string; +}; + +export async function getRemote(name: string): Promise { + const store = (await getRootStore()) + "remotes/"; + await ensureDir(store); + try { + return JSON.parse(await Deno.readTextFile(store + name + "/info")); + } catch { + return undefined; + } +} + +export async function getDefaultRemote(): Promise { + const store = await getRootStore(); + try { + const name = await Deno.readTextFile(store + "/default"); + return JSON.parse( + await Deno.readTextFile(store + "remotes/" + name + "/info") + ); + } catch { + return undefined; + } +} + +export async function setDefault(_opts: GlobalOptions, name: string) { + const store = await getRootStore(); + try { + const _info = await Deno.stat(store + "remotes/" + name); + } catch { + console.log(colors.red("Remote " + name + " does not exist")); + return; + } + await Deno.writeTextFile(store + "/default", name); +} + +export async function add(_opts: GlobalOptions, name: string, baseUrl: string) { + const store = (await getRootStore()) + "remotes/"; + await ensureDir(store); + try { + await Deno.mkdir(store + name); + } catch { + console.log(colors.red("This remote already exists")); + return; + } + const remote: Remote = { + baseUrl, + }; + await Deno.writeTextFile(store + name + "/info", JSON.stringify(remote)); + console.log(colors.green("Successfully added remote!")); +} + +async function remove(_opts: GlobalOptions, name: string) { + const store = (await getRootStore()) + "remotes/"; + await ensureDir(store); + try { + await Deno.remove(store + name, { recursive: true }); + } catch { + console.log(colors.yellow("This remote doesn't exist.")); + } + console.log(colors.green("Successfully removed remote!")); +} + +async function list(_opts: GlobalOptions) { + const store = (await getRootStore()) + "remotes/"; + await ensureDir(store); + const infos: Map = new Map(); + for await (const e of Deno.readDir(store)) { + if (!e.isDirectory) continue; + const name = e.name; + try { + infos.set( + name, + JSON.parse(await Deno.readTextFile(store + name + "/info")) + ); + } catch { + continue; + } + } + + new Table() + .header(["name", "URL"]) + .border(true) + .padding(2) + .body(Array.from(infos.entries()).map(([name, r]) => [name, r.baseUrl])) + .render(); +} + +const command = new Command() + .description( + "remote related commands. Provide no subcommand to list local remotes." + ) + .action(list as any) + .command("add", "Add a remote windmill server to interact with.") + .arguments(" ") + .action(add as any) + .command("remove", "Remove a remote windmill server") + .arguments("") + .command("set-default", "Set a remote as default") + .arguments("") + .action(setDefault as any) + .action(remove as any); + +export default command; diff --git a/cli/resource.ts b/cli/resource.ts new file mode 100644 index 0000000000..db95211fd4 --- /dev/null +++ b/cli/resource.ts @@ -0,0 +1,137 @@ +import { Command } from "https://deno.land/x/cliffy@v0.25.4/command/command.ts"; +import { ResourceService } from "https://deno.land/x/windmill@v1.41.0/mod.ts"; +import { GlobalOptions } from "./types.ts"; +import { colors } from "https://deno.land/x/cliffy@v0.25.4/ansi/colors.ts"; +import { getContext } from "./context.ts"; +import { Resource } from "https://deno.land/x/windmill@v1.41.0/windmill-api/index.ts"; +import { Table } from "https://deno.land/x/cliffy@v0.25.4/table/table.ts"; + +type ResourceFile = { + value: any; + description?: string; + resource_type: string; + is_oauth?: boolean; +}; + +export async function pushResource( + workspace: string, + filePath: string, + remotePath: string +) { + const data: ResourceFile = JSON.parse(await Deno.readTextFile(filePath)); + if ( + await ResourceService.existsResource({ + workspace: workspace, + path: remotePath, + }) + ) { + console.log(colors.yellow("Updating existing resource...")); + const existing = await ResourceService.getResource({ + workspace: workspace, + path: remotePath, + }); + if (existing.resource_type != data.resource_type) { + console.log( + colors.red.underline.bold( + "Remote resource at " + + remotePath + + " exists & has a different resource type. This cannot be updated. If you wish to do this anyways, consider deleting the remote resource." + ) + ); + return; + } + if (existing.is_oauth != data.is_oauth) { + console.log( + colors.red.underline.bold( + "Remote resource at " + + remotePath + + " exists & has a different oauth state. This cannot be updated. If you wish to do this anyways, consider deleting the remote resource." + ) + ); + return; + } + await ResourceService.updateResource({ + workspace: workspace, + path: remotePath, + requestBody: { + path: remotePath, + value: data.value, + description: data.description, + }, + }); + } else { + console.log(colors.yellow("Creating new resource...")); + await ResourceService.createResource({ + workspace: workspace, + requestBody: { + path: remotePath, + resource_type: data.resource_type, + value: data.value, + description: data.description, + is_oauth: data.is_oauth, + }, + }); + } +} + +type PushOptions = GlobalOptions; +async function push(opts: PushOptions, filePath: string, remotePath: string) { + const { workspace } = await getContext(opts); + + if (!(remotePath.startsWith("g") || remotePath.startsWith("u"))) { + console.log( + colors.red( + "Given remote path looks invalid. Remote paths are typicall of the form //..." + ) + ); + return; + } + + const fstat = await Deno.stat(filePath); + if (!fstat.isFile) { + throw new Error("file path must refer to a file."); + } + + console.log(colors.bold.yellow("Pushing resource...")); + + await pushResource(workspace, filePath, remotePath); + console.log(colors.bold.underline.green("Resource successfully pushed")); +} + +async function list(opts: GlobalOptions) { + const { workspace } = await getContext(opts); + let page = 0; + const perPage = 10; + const total: Resource[] = []; + while (true) { + const res = await ResourceService.listResource({ + workspace, + page, + perPage, + }); + total.push(...res); + page += 1; + if (res.length < perPage) { + break; + } + } + + new Table() + .header(["Path", "Resource Type", "Description"]) + .padding(2) + .border(true) + .body(total.map((x) => [x.path, x.resource_type, x.description])) + .render(); +} + +const command = new Command() + .description("resource related commands") + .action(list as any) + .command( + "push", + "push a local resource spec. This overrides any remote versions." + ) + .arguments(" ") + .action(push as any); + +export default command; diff --git a/cli/script.ts b/cli/script.ts new file mode 100644 index 0000000000..af943d6a3e --- /dev/null +++ b/cli/script.ts @@ -0,0 +1,203 @@ +import { Command } from "https://deno.land/x/cliffy@v0.25.4/command/command.ts"; +import { ScriptService } from "https://deno.land/x/windmill@v1.41.0/mod.ts"; +import { GlobalOptions } from "./types.ts"; +import { colors } from "https://deno.land/x/cliffy@v0.25.4/ansi/colors.ts"; +import { getContext } from "./context.ts"; +import { Script } from "https://deno.land/x/windmill@v1.41.0/windmill-api/index.ts"; +import { Table } from "https://deno.land/x/cliffy@v0.25.4/table/table.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 getContext(opts); + if (!(remotePath.startsWith("g") || remotePath.startsWith("u"))) { + console.log( + colors.red( + "Given remote path looks invalid. Remote paths are typicall of the form //..." + ) + ); + 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 pushScript(filePath, contentPath, workspace, remotePath); + console.log(colors.bold.underline.green("Script successfully pushed")); +} + +export async function findContentFile(filePath: string) { + const candidates = [ + filePath.replace(".json", ".ts"), + filePath.replace(".json", ".py"), + filePath.replace(".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 getContext(opts); + + let page = 0; + const perPage = 10; + const total: Script[] = []; + while (true) { + const res = await ScriptService.listScripts({ + workspace, + 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", + "description", + ]) + .padding(2) + .border(true) + .body( + total.map((x) => [ + x.path, + x.hash, + x.kind, + x.language, + x.created_at, + x.created_by, + x.description ?? "-", + ]) + ) + .render(); +} + +async function show(opts: GlobalOptions, path: string) { + const { workspace } = await getContext(opts); + const s = await ScriptService.getScriptByPath({ workspace, 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); + +export default command; diff --git a/cli/setup.ts b/cli/setup.ts new file mode 100644 index 0000000000..30b23851fa --- /dev/null +++ b/cli/setup.ts @@ -0,0 +1,100 @@ +import { colors } from "https://deno.land/x/cliffy@v0.25.4/ansi/colors.ts"; +import { Command } from "https://deno.land/x/cliffy@v0.25.4/command/command.ts"; +import { + Input, + Select, + Secret, + prompt, +} from "https://deno.land/x/cliffy@v0.25.4/prompt/mod.ts"; +import { + setClient, + UserService, + WorkspaceService, +} from "https://deno.land/x/windmill@v1.41.0/mod.ts"; +import { getStore } from "./store.ts"; +import { add as addRemote, setDefault as setDefaultRemote } from "./remote.ts"; + +async function setup(_opts: never) { + let baseUrl = await Input.prompt({ + message: "What's your base url?", + suggestions: ["http://app.windmill.dev", "http://localhost"], + default: "http://app.windmill.dev", + }); + const remoteName = await Input.prompt({ + message: "What do you want to name this remote?", + suggestions: ["origin", "local", "cloud"], + default: + baseUrl == "http://localhost" + ? "local" + : baseUrl == "http://app.windmill.dev" + ? "cloud" + : undefined, + }); + if (baseUrl.endsWith("/")) baseUrl = baseUrl.substring(0, baseUrl.length - 1); + setClient("no-token", baseUrl); + const m = await Select.prompt({ + message: "Login Method", + options: [ + { + name: "Username/Password", + value: "up", + }, + { + name: "Token", + value: "t", + }, + ], + }); + let token: string; + if (m == "up") { + const { email, password } = await prompt([ + { + name: "email", + type: Input, + message: "What's your email?", + after: async ({ email }, next) => { + if (email) await next(); + else await next("email"); + }, + }, + { + name: "password", + type: Secret, + message: "What's your password?", + after: async ({ password }, next) => { + if (password) await next(); + else await next("password"); + }, + }, + ]); + token = await UserService.login({ + requestBody: { + email: email!, + password: password!, + }, + }); + } else { + token = await Secret.prompt("What's your token?"); + } + setClient(token, baseUrl); + const workspaces = await WorkspaceService.listWorkspaces(); + const urlStore = await getStore(baseUrl); + const defaultWorkspaceId = await Select.prompt({ + message: "Select a default workspace", + options: workspaces.map((x) => ({ name: x.name, value: x.id })), + }); + await addRemote(_opts, remoteName, baseUrl); + await setDefaultRemote(_opts, remoteName); + await Deno.writeTextFile(urlStore + "token", token); + await Deno.writeTextFile( + urlStore + "default_workspace_id", + defaultWorkspaceId + ); + console.log(colors.green.bold.underline("Everything setup. Ready to use.")); +} + +const command = new Command() + .description("setup windmill access") + .action(setup as any); + +export default command; diff --git a/cli/store.ts b/cli/store.ts new file mode 100644 index 0000000000..0c1875682f --- /dev/null +++ b/cli/store.ts @@ -0,0 +1,28 @@ +import dir from "https://deno.land/x/dir@1.5.1/mod.ts"; +import * as fs from "https://deno.land/std@0.161.0/fs/mod.ts"; + +function hash_string(str: string): number { + let hash = 0, + i, + chr; + if (str.length === 0) return hash; + for (i = 0; i < str.length; i++) { + chr = str.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash |= 0; // Convert to 32bit integer + } + return hash; +} + +export async function getRootStore(): Promise { + const store = dir("config") + "/windmill/"; + await fs.ensureDir(store); + return store; +} + +export async function getStore(baseUrl: string): Promise { + const baseHash = Math.abs(hash_string(baseUrl)).toString(16); + const baseStore = (await getRootStore()) + baseHash + "/"; + await fs.ensureDir(baseStore); + return baseStore; +} diff --git a/cli/types.ts b/cli/types.ts new file mode 100644 index 0000000000..6ca0e8a2a8 --- /dev/null +++ b/cli/types.ts @@ -0,0 +1,8 @@ +export type GlobalOptions = { + baseUrl: string | undefined; + remote: string | undefined; + workspace: string | undefined; + token: string | undefined; + email: string | undefined; + password: string | undefined; +}; diff --git a/cli/user.ts b/cli/user.ts new file mode 100644 index 0000000000..d8328ac1e4 --- /dev/null +++ b/cli/user.ts @@ -0,0 +1,89 @@ +import { reset } from "https://deno.land/std@0.161.0/fmt/colors.ts"; +import { Command } from "https://deno.land/x/cliffy@v0.25.4/command/command.ts"; +import { Table } from "https://deno.land/x/cliffy@v0.25.4/table/table.ts"; +import { UserService } from "https://deno.land/x/windmill@v1.41.0/mod.ts"; +import { GlobalUserInfo } from "https://deno.land/x/windmill@v1.41.0/windmill-api/index.ts"; +import { passwordGenerator } from "https://deno.land/x/password_generator@latest/mod.ts"; // TODO: I think the version is called latest, but it's still pinned. +import { getContext } from "./context.ts"; +import { GlobalOptions } from "./types.ts"; +import { colors } from "https://deno.land/x/cliffy@v0.25.4/ansi/colors.ts"; + +async function list(opts: GlobalOptions) { + const _ = await getContext(opts); + + const perPage = 10; + let page = 0; + const total: GlobalUserInfo[] = []; + while (true) { + const res = await UserService.listUsersAsSuperAdmin({ page, perPage }); + total.push(...res); + page += 0; + + if (res.length < perPage) { + break; + } + } + + new Table() + .header(["email", "name", "company", "verified", "super admin"]) + .padding(2) + .border(true) + .body( + total.map((x) => [ + x.email, + x.name ?? "-", + x.company ?? "-", + x.verified ? "true" : "false", + x.super_admin ? "true" : "false", + ]) + ) + .render(); +} + +async function add( + opts: GlobalOptions & { + superAdmin?: boolean; + company?: string; + name?: string; + }, + email: string, + password?: string +) { + const _ = await getContext(opts); + const password_final = password ?? passwordGenerator("*", 15); + await UserService.createUserGlobally({ + requestBody: { + email, + password: password_final, + super_admin: opts.superAdmin ?? false, + company: opts.company, + name: opts.name, + }, + }); + if (!password) { + console.log(colors.red("New Password: " + password_final)); + } + console.log(colors.underline.green("New User Created.")); +} +async function remove(opts: GlobalOptions, email: string) { + const _ = await getContext(opts); + throw new Error("API unsupported"); +} + +const command = new Command() + .description("user related commands") + .action(list as any) + .command("add", "Create a user") + .arguments(" [password:string]") + .option("--superadmin", "Specify to make the new user superadmin.") + .option( + "--company ", + "Specify to set the company of the new user." + ) + .option("--name ", "Specify to set the name of the new user.") + .action(add as any) + .command("remove", "Delete a user") + .arguments("") + .action(remove as any); + +export default command; diff --git a/cli/variable.ts b/cli/variable.ts new file mode 100644 index 0000000000..50079bd11a --- /dev/null +++ b/cli/variable.ts @@ -0,0 +1,143 @@ +import { colors } from "https://deno.land/x/cliffy@v0.25.4/ansi/colors.ts"; +import { Command } from "https://deno.land/x/cliffy@v0.25.4/command/command.ts"; +import { Table } from "https://deno.land/x/cliffy@v0.25.4/table/table.ts"; +import { VariableService } from "https://deno.land/x/windmill@v1.41.0/mod.ts"; +import { getContext } from "./context.ts"; +import { GlobalOptions } from "./types.ts"; + +async function list(opts: GlobalOptions) { + const { workspace } = await getContext(opts); + + const variables = await VariableService.listVariable({ workspace }); + + new Table() + .header(["Path", "Is Secret", "Account", "Value", "Description"]) + .padding(2) + .border(true) + .body( + variables.map((x) => [ + x.path, + x.is_secret ? "true" : "false", + x.account ?? "-", + x.value ?? "-", + x.description ?? "-", + ]) + ) + .render(); +} + +type VariableFile = { + value: string; + is_secret: boolean; + description: string; + account?: number; + is_oauth?: boolean; +}; + +async function push(opts: GlobalOptions, filePath: string, remotePath: string) { + const { workspace } = await getContext(opts); + + if (!(remotePath.startsWith("g") || remotePath.startsWith("u"))) { + console.log( + colors.red( + "Given remote path looks invalid. Remote paths are typicall of the form //..." + ) + ); + return; + } + + const fstat = await Deno.stat(filePath); + if (!fstat.isFile) { + throw new Error("file path must refer to a file."); + } + + console.log(colors.bold.yellow("Pushing variable...")); + + await pushVariable(workspace, filePath, remotePath); + console.log(colors.bold.underline.green("Variable successfully pushed")); +} + +export async function pushVariable( + workspace: string, + filePath: string, + remotePath: string +) { + const data: VariableFile = JSON.parse(await Deno.readTextFile(filePath)); + if (await VariableService.existsVariable({ workspace, path: remotePath })) { + const existing = await VariableService.getVariable({ + workspace: workspace, + path: remotePath, + }); + if (existing.is_oauth != data.is_oauth) { + console.log( + colors.red.underline.bold( + "Remote variable at " + + remotePath + + " exists & has a different oauth state. This cannot be updated. If you wish to do this anyways, consider deleting the remote resource." + ) + ); + return; + } + + if (existing.account != data.account) { + console.log( + colors.red.underline.bold( + "Remote variable at " + + remotePath + + " exists & has a different account state. This cannot be updated. If you wish to do this anyways, consider deleting the remote resource." + ) + ); + return; + } + + if (existing.is_secret && !data.is_secret) { + console.log( + colors.red.underline.bold( + "Remote variable at " + + remotePath + + " exists & is secret. Variables cannot be updated to be no longer secret. If you wish to do this anyways, consider deleting the remote resource." + ) + ); + return; + } + + const actual_secret = data.is_secret ? true : undefined; + + console.log(colors.yellow("Updating existing variable...")); + await VariableService.updateVariable({ + workspace, + path: remotePath, + requestBody: { + description: data.description, + is_secret: actual_secret, + path: remotePath, + value: data.value, + }, + }); + } else { + console.log(colors.yellow("Creating new variable...")); + await VariableService.createVariable({ + workspace, + requestBody: { + path: remotePath, + description: data.description, + is_secret: data.is_secret, + value: data.value, + account: data.account, + is_oauth: data.is_oauth, + }, + }); + } +} + +const command = new Command() + .description("variable related commands") + .action(list as any) + .command( + "push", + "Push a local variable spec. This overrides any remote versions." + ) + .arguments(" ") + .action(push as any); + +export default command; diff --git a/cli/workspace.ts b/cli/workspace.ts new file mode 100644 index 0000000000..49f54d7912 --- /dev/null +++ b/cli/workspace.ts @@ -0,0 +1,100 @@ +import { Command } from "https://deno.land/x/cliffy@v0.25.4/command/command.ts"; +import { GlobalOptions } from "./types.ts"; +import { WorkspaceService } from "https://deno.land/x/windmill@v1.41.0/windmill-api/index.ts"; +import { colors } from "https://deno.land/x/cliffy@v0.25.4/ansi/colors.ts"; +import { Table } from "https://deno.land/x/cliffy@v0.25.4/table/mod.ts"; +import { getContext } from "./context.ts"; + +export async function getDefaultWorkspaceId( + urlStore: string +): Promise { + try { + return await Deno.readTextFile(urlStore + "default_workspace_id"); + } catch { + return null; + } +} + +type ListOptions = GlobalOptions; +async function list(opts: ListOptions) { + const { workspace: defaultId } = await getContext(opts); + const workspaces = await WorkspaceService.listWorkspaces(); + new Table() + .header(["id", "name"]) + .body( + workspaces.map((w) => { + if (w.id == defaultId) + return [colors.underline.green(w.id), colors.underline.green(w.name)]; + else return [w.id, w.name]; + }) + ) + .padding(2) + .align("center") + .border(true) + .render(); +} + +type GetDefaultOptions = GlobalOptions; +async function getDefault(opts: GetDefaultOptions) { + const { urlStore } = await getContext(opts); + const id = await getDefaultWorkspaceId(urlStore); + if (!id) { + console.log( + colors.red( + "No default workspace set. Run windmill workspace set-default to set." + ) + ); + return; + } + const info = (await WorkspaceService.listWorkspaces()).find( + (x) => x.id == id + ); + if (!info) { + console.log( + colors.underline.red( + "Default workspace is set, but cannot be found on remote. Maybe it has been deleted?" + ) + ); + return; + } + console.log(colors.green("Id: " + info.id + " Name: " + info.name)); +} + +type SetDefaultOptions = GlobalOptions; +async function setDefault(opts: SetDefaultOptions, workspace_id: string) { + if (opts.workspace) { + console.log( + colors.underline.bold.red( + "!! --workspace option set, but this command expects the workspace to be passed as a positional argument. !!" + ) + ); + return; + } + + const { urlStore } = await getContext(opts); + const info = (await WorkspaceService.listWorkspaces()).find( + (x) => x.id == workspace_id + ); + if (!info) { + console.log( + colors.underline.red( + "Given workspace id " + + workspace_id + + " cannot be found on remote. Maybe it has been deleted?" + ) + ); + return; + } + await Deno.writeTextFile(urlStore + "default_workspace_id", workspace_id); +} + +const command = new Command() + .description("workspace related commands") + .action(list as any) + .command("get-default", "get the current default workspace") + .action(getDefault as any) + .command("set-default", "set the current default workspace") + .arguments("") + .action(setDefault as any); + +export default command;