feat: new wmill CLI #831
This commit is contained in:
1
.github/change-versions.sh
vendored
1
.github/change-versions.sh
vendored
@@ -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
4
cli/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"deno.enable": true,
|
||||
"deno.unstable": true
|
||||
}
|
||||
77
cli/README.md
Normal file
77
cli/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Windmill CLI
|
||||
|
||||
A simple CLI allowing interactions with windmill from the command line.
|
||||
|
||||
[](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
49
cli/context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
37
cli/examples/u/admin/example.flow.json
Normal file
37
cli/examples/u/admin/example.flow.json
Normal 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": {}
|
||||
}
|
||||
13
cli/examples/u/admin/example.resource.json
Executable file
13
cli/examples/u/admin/example.resource.json
Executable 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
|
||||
}
|
||||
9
cli/examples/u/admin/example.variable.json
Executable file
9
cli/examples/u/admin/example.variable.json
Executable 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
|
||||
}
|
||||
19
cli/examples/u/admin/fib/fib.script.json
Executable file
19
cli/examples/u/admin/fib/fib.script.json
Executable 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": []
|
||||
}
|
||||
7
cli/examples/u/admin/fib/fib.script.ts
Executable file
7
cli/examples/u/admin/fib/fib.script.ts
Executable 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];
|
||||
}
|
||||
14
cli/examples/u/admin/go_test/go_test.script.go
Executable file
14
cli/examples/u/admin/go_test/go_test.script.go
Executable 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
|
||||
}
|
||||
38
cli/examples/u/admin/go_test/go_test.script.json
Executable file
38
cli/examples/u/admin/go_test/go_test.script.json
Executable 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
115
cli/flow.ts
Normal 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
65
cli/login.ts
Normal 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
88
cli/main.ts
Normal 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
138
cli/push.ts
Normal 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
112
cli/remote.ts
Normal 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
137
cli/resource.ts
Normal 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
203
cli/script.ts
Normal 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
100
cli/setup.ts
Normal 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
28
cli/store.ts
Normal 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
8
cli/types.ts
Normal 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
89
cli/user.ts
Normal 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
143
cli/variable.ts
Normal 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
100
cli/workspace.ts
Normal 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;
|
||||
Reference in New Issue
Block a user