Files
windmill/cli/test/list_get_new_commands.test.ts
Ruben Fiszel 4fedfdfd11 feat(cli): add consistent get/list/new subcommands for all item types (#8047)
* feat(cli): add consistent get/list/new subcommands for all item types

Make the CLI consistent so every item type (script, flow, app, resource,
resource-type, variable, schedule, folder, trigger) supports get/list/new
subcommands, enabling the CLI to be used as a full API client in bash
scripts with jq piping.

- Add --json flag to all list commands for machine-readable output
- Register explicit "list" subcommand alongside default action
- Add "get <path> [--json]" subcommand to fetch single items from API
- Rename "bootstrap" to "new" for script/flow, keep "bootstrap" as alias
- Add "new" subcommand for resource, resource-type, variable, schedule,
  folder, and trigger to create local template YAML files
- Update cli-commands skill documentation for wmill init
- Add integration tests for all new commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* all

* feat: install wmill CLI in Docker images and use it for bash variable/resource access

- Install windmill-cli via bun in all Dockerfiles that include bun
- DockerfileCli: switch from node:slim to oven/bun:slim
- CLI: auto-configure from WM_WORKSPACE/WM_TOKEN/BASE_INTERNAL_URL env vars
  as last-resort fallback when no workspace is configured
- Frontend: replace curl-based bash snippets with wmill variable/resource get
- Add backend integration tests for wmill CLI in bash scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): install windmill-cli in backend test workflow

Ensures wmill is available on PATH for bash integration tests
that use `wmill variable get` and `wmill resource get`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(cli): replace @std/* Deno dependencies with Node.js equivalents

Replace @std/log with a lightweight custom logger (core/log.ts),
@std/path with node:path, and @std/yaml with the yaml npm package.
Also fix process hang on exit, add --node option to install_dev.sh,
and add missing hasRequiredPermissions to NpmProvider.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* all

* all

* all

* refactor(cli): replace @ayonli/jsext and @std/encoding with lightweight alternatives

Replace @ayonli/jsext (8.4MB) with tar-stream (32kB) for tar creation,
replace @std/encoding with Node.js Buffer.toString("hex"), and fix
@windmill-labs/shared-utils to use direct npm instead of JSR mirror.
Also resolve merge conflicts in sync.ts and fix pre-existing type errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(cli): use singleQuote YAML output and pass yamlOptions in gitsync pull

The yaml library defaults to double quotes, but the codebase (and tests)
expect single-quoted strings. Add singleQuote: true to yamlOptions and
pass yamlOptions to gitsync-settings pull writeFile calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* all

* all

* fix(cli): address code review feedback

- Install CLI from source in backend tests instead of npm
- Fix script bootstrap catch block to re-throw "File already exists"
- Add type-safe local variable after trigger kind validation
- Use created_by instead of policy.on_behalf_of for app get output
- Note --kind is recommended for faster trigger lookup in help text
- Document node symlink purpose in Dockerfiles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): use /usr/bin for wmill wrapper to ensure it's in PATH

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): install wmill to ~/.local/bin to avoid permission issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci(backend): switch to Blacksmith runner and add cargo caching

- Switch from ubicloud-standard-16 to blacksmith-16vcpu-ubuntu-2404 for faster NVMe-backed builds
- Add stickydisk for cargo target directory (persistent NVMe cache across runs)
- Add cache for cargo registry and git dependencies
- Upgrade DuckDB FFI cache from actions/cache@v3 to useblacksmith/cache@v1
- Enable CARGO_INCREMENTAL=1 to benefit from persistent target cache

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix ci

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:53:28 +00:00

640 lines
19 KiB
TypeScript

/**
* Integration tests for the new list/get/new CLI commands.
*
* Tests:
* - `list --json` for all item types
* - `get <path>` and `get <path> --json` for all item types
* - `new` (bootstrap) for script, flow, resource, resource-type, variable, schedule, folder, trigger
* - `bootstrap` alias for script and flow
*/
import { expect, test, describe } from "bun:test";
import { writeFile, mkdir, stat, readFile } from "node:fs/promises";
import { join } from "node:path";
import { withTestBackend, type TestBackend } from "./test_backend.ts";
import { addWorkspace } from "../workspace.ts";
async function setupWorkspaceProfile(backend: TestBackend): Promise<void> {
await addWorkspace(
{
remote: backend.baseUrl,
workspaceId: backend.workspace,
name: "localhost_test",
token: backend.token!,
},
{ force: true, configDir: backend.testConfigDir }
);
}
async function createRemoteScript(
backend: TestBackend,
scriptPath: string,
content: string = 'export async function main() { return "hello"; }'
): Promise<void> {
const resp = await backend.apiRequest!(
`/api/w/${backend.workspace}/scripts/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: scriptPath,
content,
language: "bun",
summary: "Test script summary",
description: "Test script description",
schema: {
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
properties: {},
required: [],
},
}),
}
);
expect(resp.status).toBeLessThan(300);
await resp.text();
}
// =============================================================================
// list --json
// =============================================================================
describe("list --json flag", () => {
test("script list --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
const scriptPath = `f/test/list_json_script_${uniqueId}`;
await createRemoteScript(backend, scriptPath);
const result = await backend.runCLICommand(
["script", "list", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.some((s: any) => s.path === scriptPath)).toBe(true);
});
});
test("flow list --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["flow", "list", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(Array.isArray(parsed)).toBe(true);
});
});
test("resource list --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["resource", "list", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(Array.isArray(parsed)).toBe(true);
// seedTestData creates f/test/my_resource
expect(parsed.some((r: any) => r.path === "f/test/my_resource")).toBe(
true
);
});
});
test("variable list --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["variable", "list", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(Array.isArray(parsed)).toBe(true);
});
});
test("folder list --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["folder", "list", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.some((f: any) => f.name === "test")).toBe(true);
});
});
test("schedule list --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["schedule", "list", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(Array.isArray(parsed)).toBe(true);
});
});
test("resource-type list --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["resource-type", "list", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(Array.isArray(parsed)).toBe(true);
});
});
test("trigger list --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["trigger", "list", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(Array.isArray(parsed)).toBe(true);
});
});
test("app list --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["app", "list", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(Array.isArray(parsed)).toBe(true);
});
});
test("default action with --json works (e.g. wmill script --json)", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["script", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(Array.isArray(parsed)).toBe(true);
});
});
});
// =============================================================================
// get <path> and get <path> --json
// =============================================================================
describe("get command", () => {
test("script get pretty-prints details", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
const scriptPath = `f/test/get_script_${uniqueId}`;
await createRemoteScript(backend, scriptPath);
const result = await backend.runCLICommand(
["script", "get", scriptPath],
tempDir
);
expect(result.code).toEqual(0);
const output = result.stdout;
expect(output).toContain("Path:");
expect(output).toContain(scriptPath);
expect(output).toContain("Summary:");
expect(output).toContain("Language:");
expect(output).toContain("bun");
});
});
test("script get --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const uniqueId = Date.now();
const scriptPath = `f/test/get_json_script_${uniqueId}`;
await createRemoteScript(backend, scriptPath);
const result = await backend.runCLICommand(
["script", "get", scriptPath, "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.path).toBe(scriptPath);
expect(parsed.language).toBe("bun");
expect(parsed.summary).toBe("Test script summary");
});
});
test("resource get --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["resource", "get", "f/test/my_resource", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.path).toBe("f/test/my_resource");
expect(parsed.resource_type).toBe("any");
});
});
test("resource get pretty-prints details", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["resource", "get", "f/test/my_resource"],
tempDir
);
expect(result.code).toEqual(0);
const output = result.stdout;
expect(output).toContain("Path:");
expect(output).toContain("f/test/my_resource");
expect(output).toContain("Resource Type:");
});
});
test("variable get --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["variable", "get", "f/test/my_variable", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.path).toBe("f/test/my_variable");
});
});
test("folder get --json outputs valid JSON", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["folder", "get", "test", "--json"],
tempDir
);
expect(result.code).toEqual(0);
const parsed = JSON.parse(result.stdout);
expect(parsed.name).toBe("test");
});
});
test("folder get pretty-prints details", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["folder", "get", "test"],
tempDir
);
expect(result.code).toEqual(0);
const output = result.stdout;
expect(output).toContain("Name:");
expect(output).toContain("test");
});
});
});
// =============================================================================
// new command
// =============================================================================
describe("new command", () => {
test("script new creates files (same as bootstrap)", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await writeFile(
join(tempDir, "wmill.yaml"),
`defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`,
"utf-8"
);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["script", "new", "f/test/new_cmd_script", "bun", "--summary", "Test new"],
tempDir
);
expect(result.code).toEqual(0);
const codeStat = await stat(join(tempDir, "f/test/new_cmd_script.ts"));
expect(codeStat.isFile()).toBe(true);
const metaStat = await stat(
join(tempDir, "f/test/new_cmd_script.script.yaml")
);
expect(metaStat.isFile()).toBe(true);
const metaContent = await readFile(
join(tempDir, "f/test/new_cmd_script.script.yaml"),
"utf-8"
);
expect(metaContent).toContain("Test new");
});
});
test("script bootstrap still works as alias", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await writeFile(
join(tempDir, "wmill.yaml"),
`defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`,
"utf-8"
);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["script", "bootstrap", "f/test/alias_script", "bun"],
tempDir
);
expect(result.code).toEqual(0);
const codeStat = await stat(join(tempDir, "f/test/alias_script.ts"));
expect(codeStat.isFile()).toBe(true);
});
});
test("flow new creates flow directory and flow.yaml", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["flow", "new", "f/test/new_flow", "--summary", "My flow"],
tempDir
);
expect(result.code).toEqual(0);
const flowYamlStat = await stat(
join(tempDir, "f/test/new_flow.flow/flow.yaml")
);
expect(flowYamlStat.isFile()).toBe(true);
const flowContent = await readFile(
join(tempDir, "f/test/new_flow.flow/flow.yaml"),
"utf-8"
);
expect(flowContent).toContain("My flow");
});
});
test("flow bootstrap still works as alias", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["flow", "bootstrap", "f/test/alias_flow"],
tempDir
);
expect(result.code).toEqual(0);
const flowYamlStat = await stat(
join(tempDir, "f/test/alias_flow.flow/flow.yaml")
);
expect(flowYamlStat.isFile()).toBe(true);
});
});
test("resource new creates resource yaml template", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["resource", "new", "f/test/new_resource"],
tempDir
);
expect(result.code).toEqual(0);
const filePath = join(tempDir, "f/test/new_resource.resource.yaml");
const fileStat = await stat(filePath);
expect(fileStat.isFile()).toBe(true);
const content = await readFile(filePath, "utf-8");
expect(content).toContain("resource_type");
expect(content).toContain("value");
});
});
test("resource-type new creates resource-type yaml template", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["resource-type", "new", "my_custom_type"],
tempDir
);
expect(result.code).toEqual(0);
const filePath = join(tempDir, "my_custom_type.resource-type.yaml");
const fileStat = await stat(filePath);
expect(fileStat.isFile()).toBe(true);
const content = await readFile(filePath, "utf-8");
expect(content).toContain("schema");
expect(content).toContain("description");
});
});
test("variable new creates variable yaml template", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["variable", "new", "f/test/new_var"],
tempDir
);
expect(result.code).toEqual(0);
const filePath = join(tempDir, "f/test/new_var.variable.yaml");
const fileStat = await stat(filePath);
expect(fileStat.isFile()).toBe(true);
const content = await readFile(filePath, "utf-8");
expect(content).toContain("is_secret");
expect(content).toContain("value");
});
});
test("schedule new creates schedule yaml template", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["schedule", "new", "f/test/new_sched"],
tempDir
);
expect(result.code).toEqual(0);
const filePath = join(tempDir, "f/test/new_sched.schedule.yaml");
const fileStat = await stat(filePath);
expect(fileStat.isFile()).toBe(true);
const content = await readFile(filePath, "utf-8");
expect(content).toContain("schedule");
expect(content).toContain("script_path");
expect(content).toContain("timezone");
});
});
test("folder new creates folder.meta.yaml in f/<name>/", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
const result = await backend.runCLICommand(
["folder", "new", "new_folder"],
tempDir
);
expect(result.code).toEqual(0);
const filePath = join(tempDir, "f/new_folder/folder.meta.yaml");
const fileStat = await stat(filePath);
expect(fileStat.isFile()).toBe(true);
const content = await readFile(filePath, "utf-8");
expect(content).toContain("owners");
expect(content).toContain("extra_perms");
});
});
test("trigger new --kind http creates http trigger yaml template", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["trigger", "new", "f/test/new_trigger", "--kind", "http"],
tempDir
);
expect(result.code).toEqual(0);
const filePath = join(
tempDir,
"f/test/new_trigger.http_trigger.yaml"
);
const fileStat = await stat(filePath);
expect(fileStat.isFile()).toBe(true);
const content = await readFile(filePath, "utf-8");
expect(content).toContain("script_path");
expect(content).toContain("route_path");
});
});
test("trigger new without --kind fails with error", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["trigger", "new", "f/test/fail_trigger"],
tempDir
);
expect(result.code).not.toEqual(0);
});
});
test("trigger new --kind kafka creates kafka trigger yaml template", async () => {
await withTestBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend);
await mkdir(join(tempDir, "f", "test"), { recursive: true });
const result = await backend.runCLICommand(
["trigger", "new", "f/test/kafka_trigger", "--kind", "kafka"],
tempDir
);
expect(result.code).toEqual(0);
const filePath = join(
tempDir,
"f/test/kafka_trigger.kafka_trigger.yaml"
);
const fileStat = await stat(filePath);
expect(fileStat.isFile()).toBe(true);
const content = await readFile(filePath, "utf-8");
expect(content).toContain("kafka_resource_path");
expect(content).toContain("topics");
});
});
});