* 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>
332 lines
9.3 KiB
TypeScript
332 lines
9.3 KiB
TypeScript
import { expect, test, describe } from "bun:test";
|
|
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import * as path from "node:path";
|
|
import { checkMissingLocks, runLint } from "../src/commands/lint/lint.ts";
|
|
|
|
async function withTempDir(
|
|
fn: (tempDir: string) => Promise<void>,
|
|
): Promise<void> {
|
|
const tempDir = await mkdtemp(path.join(os.tmpdir(), "wmill_lint_locks_"));
|
|
const originalCwd = process.cwd();
|
|
try {
|
|
process.chdir(tempDir);
|
|
await fn(tempDir);
|
|
} finally {
|
|
process.chdir(originalCwd);
|
|
await rm(tempDir, { recursive: true });
|
|
}
|
|
}
|
|
|
|
// Helper to create a script with metadata and optional lock
|
|
async function createScript(
|
|
tempDir: string,
|
|
scriptBase: string,
|
|
ext: string,
|
|
opts: { lock?: string; lockFileContent?: string } = {},
|
|
) {
|
|
const dir = path.dirname(path.join(tempDir, scriptBase));
|
|
await mkdir(dir, { recursive: true });
|
|
|
|
// Script content file
|
|
await writeFile(path.join(tempDir, scriptBase + ext), "# placeholder", "utf-8");
|
|
|
|
// Metadata YAML
|
|
const lockLine = opts.lock !== undefined ? `lock: "${opts.lock}"` : "lock: ''";
|
|
await writeFile(
|
|
path.join(tempDir, scriptBase + ".script.yaml"),
|
|
`summary: test\n${lockLine}\nschema:\n properties: {}\n`,
|
|
"utf-8",
|
|
);
|
|
|
|
// Lock file (if inline reference)
|
|
if (opts.lockFileContent !== undefined) {
|
|
await writeFile(
|
|
path.join(tempDir, scriptBase + ".script.lock"),
|
|
opts.lockFileContent,
|
|
"utf-8",
|
|
);
|
|
}
|
|
}
|
|
|
|
// --- checkMissingLocks unit tests ---
|
|
|
|
describe("checkMissingLocks", () => {
|
|
test("reports missing lock for python script", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await createScript(tempDir, "f/my_script", ".py", { lock: "" });
|
|
|
|
const issues = await checkMissingLocks({} as any, tempDir);
|
|
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0].target).toBe("script");
|
|
expect(issues[0].errors[0]).toContain("Missing lock");
|
|
expect(issues[0].errors[0]).toContain("python3");
|
|
});
|
|
});
|
|
|
|
test("no issues for python script with inline lock file", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await createScript(tempDir, "f/my_script", ".py", {
|
|
lock: "!inline f/my_script.script.lock",
|
|
lockFileContent: "some-dep==1.0.0",
|
|
});
|
|
|
|
const issues = await checkMissingLocks({} as any, tempDir);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
test("reports missing lock when inline lock file is empty", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await createScript(tempDir, "f/my_script", ".py", {
|
|
lock: "!inline f/my_script.script.lock",
|
|
lockFileContent: "",
|
|
});
|
|
|
|
const issues = await checkMissingLocks({} as any, tempDir);
|
|
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0].errors[0]).toContain("Missing lock");
|
|
});
|
|
});
|
|
|
|
test("no issues for bash script without lock (lock not required)", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await createScript(tempDir, "f/my_bash", ".sh", { lock: "" });
|
|
|
|
const issues = await checkMissingLocks({} as any, tempDir);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
test("reports missing lock for bun script", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await createScript(tempDir, "f/my_ts", ".ts", { lock: "" });
|
|
|
|
const issues = await checkMissingLocks(
|
|
{ defaultTs: "bun" } as any,
|
|
tempDir,
|
|
);
|
|
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0].errors[0]).toContain("Missing lock");
|
|
expect(issues[0].errors[0]).toContain("bun");
|
|
});
|
|
});
|
|
|
|
test("reports missing lock for flow inline rawscript", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/my_flow.flow/flow.yaml`,
|
|
`summary: test flow
|
|
value:
|
|
modules:
|
|
- id: step1
|
|
value:
|
|
type: rawscript
|
|
language: python3
|
|
content: "print('hello')"
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
const issues = await checkMissingLocks({} as any, tempDir);
|
|
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0].target).toBe("flow_inline_script");
|
|
expect(issues[0].errors[0]).toContain("step1");
|
|
expect(issues[0].errors[0]).toContain("python3");
|
|
});
|
|
});
|
|
|
|
test("no issues for flow inline rawscript with lock", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/my_flow.flow/flow.yaml`,
|
|
`summary: test flow
|
|
value:
|
|
modules:
|
|
- id: step1
|
|
value:
|
|
type: rawscript
|
|
language: python3
|
|
content: "print('hello')"
|
|
lock: "some-dep==1.0.0"
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
const issues = await checkMissingLocks({} as any, tempDir);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
test("reports missing lock for nested flow modules (forloopflow)", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/my_flow.flow/flow.yaml`,
|
|
`summary: test flow
|
|
value:
|
|
modules:
|
|
- id: loop1
|
|
value:
|
|
type: forloopflow
|
|
modules:
|
|
- id: inner_step
|
|
value:
|
|
type: rawscript
|
|
language: python3
|
|
content: "print('inner')"
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
const issues = await checkMissingLocks({} as any, tempDir);
|
|
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0].errors[0]).toContain("inner_step");
|
|
});
|
|
});
|
|
|
|
test("reports missing lock for app inline script", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/my_app.app`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/my_app.app/app.yaml`,
|
|
`value:
|
|
grid:
|
|
- data:
|
|
inlineScript:
|
|
language: python3
|
|
content: "x = 1"
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
const issues = await checkMissingLocks({} as any, tempDir);
|
|
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0].target).toBe("app_inline_script");
|
|
expect(issues[0].errors[0]).toContain("python3");
|
|
});
|
|
});
|
|
|
|
test("no issues for app inline script with lock", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/my_app.app`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/my_app.app/app.yaml`,
|
|
`value:
|
|
grid:
|
|
- data:
|
|
inlineScript:
|
|
language: python3
|
|
content: "x = 1"
|
|
lock: "some-dep==1.0.0"
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
const issues = await checkMissingLocks({} as any, tempDir);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
test("no issues for flow with non-lock-requiring language (bash)", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/my_flow.flow`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/my_flow.flow/flow.yaml`,
|
|
`summary: test flow
|
|
value:
|
|
modules:
|
|
- id: step1
|
|
value:
|
|
type: rawscript
|
|
language: bash
|
|
content: "echo hello"
|
|
`,
|
|
"utf-8",
|
|
);
|
|
|
|
const issues = await checkMissingLocks({} as any, tempDir);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
test("skips raw app without backend folder", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await mkdir(`${tempDir}/f/my_rawapp.raw_app`, { recursive: true });
|
|
await writeFile(
|
|
`${tempDir}/f/my_rawapp.raw_app/raw_app.yaml`,
|
|
`summary: test raw app
|
|
`,
|
|
"utf-8",
|
|
);
|
|
// No backend/ folder created
|
|
|
|
const issues = await checkMissingLocks({} as any, tempDir);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
// --- runLint --locks-required integration tests ---
|
|
|
|
describe("runLint with --locks-required", () => {
|
|
test("reports lock issues when locksRequired is true", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await createScript(tempDir, "f/my_script", ".py", { lock: "" });
|
|
|
|
const report = await runLint({ locksRequired: true } as any, tempDir);
|
|
|
|
expect(report.success).toBe(false);
|
|
expect(report.exitCode).toBe(1);
|
|
expect(report.issues.length).toBeGreaterThanOrEqual(1);
|
|
expect(
|
|
report.issues.some((i) => i.errors.some((e) => e.includes("Missing lock"))),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
test("does not check locks when locksRequired is false", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await createScript(tempDir, "f/my_script", ".py", { lock: "" });
|
|
|
|
const report = await runLint({} as any, tempDir);
|
|
|
|
// Without locksRequired, no lock issues should appear
|
|
expect(
|
|
report.issues.some((i) => i.errors.some((e) => e.includes("Missing lock"))),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
test("passes when locksRequired is true and locks exist", async () => {
|
|
await withTempDir(async (tempDir) => {
|
|
await createScript(tempDir, "f/my_script", ".py", {
|
|
lock: "!inline f/my_script.script.lock",
|
|
lockFileContent: "some-dep==1.0.0",
|
|
});
|
|
|
|
const report = await runLint({ locksRequired: true } as any, tempDir);
|
|
|
|
expect(report.success).toBe(true);
|
|
expect(report.exitCode).toBe(0);
|
|
expect(
|
|
report.issues.some((i) => i.errors.some((e) => e.includes("Missing lock"))),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
});
|