feat: new wmill CLI #831

This commit is contained in:
Kai Jellinghaus
2022-11-01 15:53:28 +01:00
committed by GitHub
parent a1859ca34a
commit 28cd0c3fa1
24 changed files with 1594 additions and 0 deletions

View File

@@ -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

4
cli/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"deno.enable": true,
"deno.unstable": true
}

77
cli/README.md Normal file
View File

@@ -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.\<type\>.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 <type> push <file_name> \<remote_name\>`. 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/<hash>/token` (or `C:\Users\<username>\AppData\Roaming\windmill\<hash>\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 <name> <base_url>` & Remove them using `wmill remote remove <name>`.
You can use a remote by either setting it as default using `wmill remote set-default <name>` or by overriding the remote using `--remote <name>` on any command.
If you don't want to save the URL locally, you can always override the command using `--base-url <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 <token>`/`--username <username> --password <password>`.
## Completion
The CLI comes with completions out of the box via `wmill completions <shell>`. (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)
```

49
cli/context.ts Normal file
View File

@@ -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<Context> {
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,
};
}

View File

@@ -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": {}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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": []
}

View File

@@ -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];
}

View File

@@ -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
}

View File

@@ -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="
]
}

115
cli/flow.ts Normal file
View File

@@ -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 <u|g>/<username|group>/..."
)
);
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("<file_path:string> <remote_path:string>")
.action(push as any);
export default command;

65
cli/login.ts Normal file
View File

@@ -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<string> {
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;

88
cli/main.ts Normal file
View File

@@ -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 <baseUrl:string>",
"Specify the base url to use when interacting with the API.",
{
conflicts: ["remote"],
}
)
.globalOption(
"--workspace <workspace_id:string>",
"Specify the target workspace. This overrides the default workspace."
)
.globalOption(
"--remote <remote_name:string>",
"Specify the target remote, add to this list via `wmill remote add`.",
{
conflicts: ["base-url"],
}
)
.globalOption(
"--token <token:string>",
"Specify a token to use for authentication. This will not be stored. Takes presedence over username/password",
{
conflicts: ["email", "password"],
}
)
.globalOption(
"--email <email:string>",
"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 <password:string>",
"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);

138
cli/push.ts Normal file
View File

@@ -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;

112
cli/remote.ts Normal file
View File

@@ -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<Remote | undefined> {
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<Remote | undefined> {
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<string, Remote> = 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("<name:string> <base_url:string>")
.action(add as any)
.command("remove", "Remove a remote windmill server")
.arguments("<name:string>")
.command("set-default", "Set a remote as default")
.arguments("<name:string>")
.action(setDefault as any)
.action(remove as any);
export default command;

137
cli/resource.ts Normal file
View File

@@ -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 <u|g>/<username|group>/..."
)
);
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("<file_path:string> <remote_path:string>")
.action(push as any);
export default command;

203
cli/script.ts Normal file
View File

@@ -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<string>;
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 <u|g>/<username|group>/..."
)
);
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("<file_path:string> <remote_path:string> [content_path:string]")
.action(push as any)
.command("show", "show a scripts content")
.arguments("<path:string>")
.action(show as any);
export default command;

100
cli/setup.ts Normal file
View File

@@ -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;

28
cli/store.ts Normal file
View File

@@ -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<string> {
const store = dir("config") + "/windmill/";
await fs.ensureDir(store);
return store;
}
export async function getStore(baseUrl: string): Promise<string> {
const baseHash = Math.abs(hash_string(baseUrl)).toString(16);
const baseStore = (await getRootStore()) + baseHash + "/";
await fs.ensureDir(baseStore);
return baseStore;
}

8
cli/types.ts Normal file
View File

@@ -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;
};

89
cli/user.ts Normal file
View File

@@ -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("<email:string> [password:string]")
.option("--superadmin", "Specify to make the new user superadmin.")
.option(
"--company <company:string>",
"Specify to set the company of the new user."
)
.option("--name <name:string>", "Specify to set the name of the new user.")
.action(add as any)
.command("remove", "Delete a user")
.arguments("<email:string>")
.action(remove as any);
export default command;

143
cli/variable.ts Normal file
View File

@@ -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 <u|g>/<username|group>/..."
)
);
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("<file_path:string> <remote_path:string>")
.action(push as any);
export default command;

100
cli/workspace.ts Normal file
View File

@@ -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<string | null> {
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 <workspace_id> 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("<workspace_id:string>")
.action(setDefault as any);
export default command;