feat(cli): detect missing folders on sync push and add 'wmill folder add-missing' (#8011)

* fix: auto-create missing folders during sync push for non-admin users

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

* fix: show missing folders in sync push summary before confirmation

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

* fix: improve sync push folder auto-creation error handling and json output

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

* fix: only treat 404 as missing folder in getFolder check

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

* chore: remove obsolete Deno compatibility layer from yaml-validator

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

* chore(cli): add @types/bun dev dependency

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

* feat(cli): replace auto-create folders with `wmill folder add-missing` command

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

* fix(cli): improve folder commands with summary field and simpler push API

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

* feat(cli): add confirmation prompt to folder add-missing command

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

* refactor(cli): simplify missing folder check to use local stat instead of remote API

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

* update skills

* feat(cli): warn admins but block non-admins on missing folder.meta.yaml

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

* cleaning

* cleaning

* test(cli): add tests for missing folder detection and folder commands

- Add tests for `folder new`, `folder push`, `folder add-missing` commands
- Add tests for sync push missing folder.meta.yaml detection (admin warning, non-admin block)
- Fix getBasePostgresUrl to strip query params (e.g. ?sslmode=disable) from DATABASE_URL
- Add createNonAdminUser and runCLIWithToken test utilities to test_backend.ts

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

* refactor(cli): unify runCLICommand with optional token parameter

Replace separate runCLIWithToken utility with an optional { workspace?, token? }
options object on the existing runCLICommand across all backends.

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

* own workspace

* test(cli): isolate folder_missing_meta tests with per-test workspace

* test(cli): shorten isolated workspace id/name for workspace limits

* test(cli): archive temp isolated workspaces after each folder test

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-02-24 09:38:17 +01:00
committed by GitHub
parent b59d60378c
commit 835db5d290
14 changed files with 860 additions and 186 deletions

View File

@@ -112,39 +112,15 @@ source <(wmill completions zsh)
### Testing with a local `windmill-yaml-validator`
The CLI imports `windmill-yaml-validator` from npm (`npm:windmill-yaml-validator@1.1.0`).
To test local changes to the validator before publishing, use the Deno compatibility
script and import map override:
1. Make the validator sources Deno-compatible:
To test local changes to the validator before publishing, use `npm link`:
```bash
cd ../windmill-yaml-validator
./deno-compat.sh
```
# In windmill-yaml-validator/
npm run build
npm link
2. Add the following entries to `cli/deno.json` imports:
```json
"npm:windmill-yaml-validator@1.1.0": "../windmill-yaml-validator/src/index.ts",
"ajv": "npm:ajv@^8.17.1",
"@stoplight/yaml": "npm:@stoplight/yaml@^4.3.0"
```
3. Run the CLI directly with Deno:
```bash
deno run -A src/main.ts lint
```
4. When done, restore everything:
```bash
# Restore validator sources
cd ../windmill-yaml-validator
./deno-compat.sh -r
# Remove the 3 import map lines from cli/deno.json
# In cli/
npm link windmill-yaml-validator
```
### Running Tests
@@ -156,13 +132,13 @@ cd ../windmill-yaml-validator
**Run tests locally (full features):**
```bash
deno test --allow-all --no-check
bun test test/
```
**Run tests in CI mode (minimal features, skips EE tests):**
```bash
CI_MINIMAL_FEATURES=true deno test --allow-all --no-check
CI_MINIMAL_FEATURES=true bun test test/
```
| Variable | Description |

View File

@@ -34,6 +34,7 @@
"yaml": "^2.7.0",
},
"devDependencies": {
"@types/bun": "^1.3.9",
"@types/diff": "^5.2.3",
"@types/node": "^22.0.0",
"@types/tar-stream": "^3.1.4",
@@ -151,6 +152,8 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/diff": ["@types/diff@5.2.3", "", {}, "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -183,6 +186,8 @@
"brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],

View File

@@ -42,6 +42,7 @@
"yaml": "^2.7.0"
},
"devDependencies": {
"@types/bun": "^1.3.9",
"@types/diff": "^5.2.3",
"@types/node": "^22.0.0",
"@types/tar-stream": "^3.1.4",

View File

@@ -1,4 +1,4 @@
import { stat, writeFile, mkdir } from "node:fs/promises";
import { stat, readdir, writeFile, mkdir } from "node:fs/promises";
import { stringify as yamlStringify } from "yaml";
import { colors } from "@cliffy/ansi/colors";
@@ -6,17 +6,19 @@ import { Command } from "@cliffy/command";
import { Table } from "@cliffy/table";
import * as log from "../../core/log.ts";
import { sep as SEP } from "node:path";
import { Confirm } from "@cliffy/prompt/confirm";
import * as wmill from "../../../gen/services.gen.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace, validatePath } from "../../core/context.ts";
import { resolveWorkspace } from "../../core/context.ts";
import { GlobalOptions, isSuperset, parseFromFile } from "../../types.ts";
import { Folder } from "../../../gen/types.gen.ts";
export interface FolderFile {
summary: string | undefined;
display_name: string | undefined;
owners: Array<string> | undefined;
extra_perms: { [record: string]: boolean } | undefined;
display_name: string | undefined;
}
async function list(opts: GlobalOptions & { json?: boolean }) {
@@ -45,7 +47,7 @@ async function list(opts: GlobalOptions & { json?: boolean }) {
}
}
async function newFolder(opts: GlobalOptions, name: string) {
async function newFolder(opts: GlobalOptions & { summary?: string }, name: string) {
const dirPath = `f${SEP}${name}`;
const filePath = `${dirPath}${SEP}folder.meta.yaml`;
try {
@@ -54,7 +56,9 @@ async function newFolder(opts: GlobalOptions, name: string) {
} catch (e: any) {
if (e.message?.startsWith("File already exists")) throw e;
}
const template: Omit<FolderFile, "display_name"> = {
const template: FolderFile = {
summary: opts.summary ?? "",
display_name: name,
owners: [],
extra_perms: {},
};
@@ -143,30 +147,72 @@ export async function pushFolder(
}
}
async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
async function push(opts: GlobalOptions, name: string) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
if (!validatePath(remotePath)) {
return;
}
const fstat = await stat(filePath);
if (!fstat.isFile()) {
throw new Error("file path must refer to a file.");
const metaPath = `f${SEP}${name}${SEP}folder.meta.yaml`;
try {
await stat(metaPath);
} catch {
throw new Error(`Could not find ${metaPath}. Does the folder exist locally?`);
}
console.log(colors.bold.yellow("Pushing folder..."));
await pushFolder(
workspace.workspaceId,
remotePath,
name,
undefined,
parseFromFile(filePath)
parseFromFile(metaPath)
);
console.log(colors.bold.underline.green("Folder pushed"));
}
async function addMissing(opts: GlobalOptions & { yes?: boolean }) {
const fDir = `f`;
try {
await stat(fDir);
} catch {
log.info("No 'f/' directory found. Nothing to do.");
return;
}
const entries = await readdir(fDir, { withFileTypes: true });
const missing: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const metaPath = `${fDir}${SEP}${entry.name}${SEP}folder.meta.yaml`;
try {
await stat(metaPath);
} catch {
missing.push(entry.name);
}
}
if (missing.length === 0) {
log.info("All folders already have a folder.meta.yaml. Nothing to do.");
return;
}
log.info(`Missing folder.meta.yaml for:`);
for (const name of missing) {
log.info(` - ${name}`);
}
if (
!opts.yes &&
!(await Confirm.prompt({
message: `Create ${missing.length} folder.meta.yaml file(s)?`,
default: true,
}))
) {
return;
}
for (const name of missing) {
await newFolder(opts, name);
}
log.info(
`\nCreated ${missing.length} folder.meta.yaml file(s). You can now run 'wmill sync push' to push them.`,
);
}
const command = new Command()
.description("folder related commands")
.option("--json", "Output as JSON (for piping to jq)")
@@ -180,12 +226,19 @@ const command = new Command()
.action(get as any)
.command("new", "create a new folder locally")
.arguments("<name:string>")
.option("--summary <summary:string>", "folder summary")
.action(newFolder as any)
.command(
"push",
"push a local folder spec. This overrides any remote versions."
"push a local folder to the remote by name. This overrides any remote versions."
)
.arguments("<file_path:string> <remote_path:string>")
.action(push as any);
.arguments("<name:string>")
.action(push as any)
.command(
"add-missing",
"create default folder.meta.yaml for all subdirectories of f/ that are missing one"
)
.option("-y, --yes", "skip confirmation prompt")
.action(addMissing as any);
export default command;

View File

@@ -2480,6 +2480,45 @@ export async function push(
log.info(
`remote (${workspace.name}) <- local: ${changes.length} changes to apply`,
);
// Check that every folder referenced in the changeset has a local folder.meta.yaml
const missingFolders: string[] = [];
if (changes.length > 0) {
const folderNames = new Set<string>();
for (const change of changes) {
const parts = change.path.split(SEP);
if (parts.length >= 3 && parts[0] === "f" && change.name !== "deleted") {
folderNames.add(parts[1]);
}
}
for (const folderName of folderNames) {
try {
await stat(path.join("f", folderName, "folder.meta.yaml"));
} catch {
missingFolders.push(folderName);
}
}
}
if (missingFolders.length > 0) {
const folderList = missingFolders.map((f) => ` - ${f}`).join("\n");
const user = await wmill.whoami({ workspace: workspace.workspaceId });
const userIsAdmin = user.is_admin;
const msg =
`${userIsAdmin ? "Warning: " : ""}Missing folder.meta.yaml for:\n${folderList}\n` +
`Run 'wmill folder add-missing' to create them locally, then push again.`;
if (!userIsAdmin) {
if (opts.jsonOutput) {
console.log(JSON.stringify({ success: false, error: "missing_folders", missing_folders: missingFolders, message: msg }, null, 2));
} else {
log.error(msg);
}
process.exit(1);
}
if (!opts.jsonOutput) {
log.warn(msg);
}
}
// Handle JSON output for dry-run
if (opts.dryRun && opts.jsonOutput) {
const result = {
@@ -2511,6 +2550,7 @@ export async function push(
if (!opts.jsonOutput) {
prettyChanges(changes, specificItems, opts.branch);
}
if (opts.dryRun) {
log.info(colors.gray(`Dry run complete.`));
return;

File diff suppressed because one or more lines are too long

View File

@@ -232,8 +232,9 @@ export class CargoBackend {
*/
private getBasePostgresUrl(): string {
const url = new URL(this.config.postgresUrl);
// Remove any existing database path
// Remove any existing database path and query params (e.g. ?sslmode=disable)
url.pathname = "";
url.search = "";
return url.toString().replace(/\/$/, ""); // Remove trailing slash
}
@@ -629,13 +630,13 @@ export class CargoBackend {
/**
* Create CLI command with proper authentication
*/
createCLICommand(args: string[], workingDir: string, workspaceName?: string): { command: string, args: string[], cwd: string, env: Record<string, string> } {
const workspace = workspaceName || this.config.workspace;
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): { command: string, args: string[], cwd: string, env: Record<string, string> } {
const workspace = opts?.workspace || this.config.workspace;
const cliDir = join(dirname(fileURLToPath(import.meta.url)), "..");
const fullArgs = [
"--base-url", this.baseUrl,
"--workspace", workspace,
"--token", this.token,
"--token", opts?.token || this.token,
"--config-dir", this.config.testConfigDir,
...args,
];
@@ -660,12 +661,12 @@ export class CargoBackend {
/**
* Run CLI command and return result
*/
async runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
stdout: string;
stderr: string;
code: number;
}> {
const cmd = this.createCLICommand(args, workingDir, workspaceName);
const cmd = this.createCLICommand(args, workingDir, opts);
const proc = Bun.spawn([cmd.command, ...cmd.args], {
cwd: cmd.cwd,
env: cmd.env,

View File

@@ -1020,12 +1020,12 @@ export async function main(
/**
* Create CLI command with proper authentication
*/
createCLICommand(args: string[], workingDir: string, workspaceName?: string): { cmd: string[], cwd: string } {
const workspace = workspaceName || this.config.workspace;
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): { cmd: string[], cwd: string } {
const workspace = opts?.workspace || this.config.workspace;
const fullArgs = [
'--base-url', this.config.baseUrl,
'--workspace', workspace,
'--token', this.config.token,
'--token', opts?.token || this.config.token,
'--config-dir', this.config.testConfigDir,
...args
];
@@ -1049,12 +1049,12 @@ export async function main(
/**
* Run CLI command and return result
*/
async runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
stdout: string;
stderr: string;
code: number;
}> {
const { cmd, cwd } = this.createCLICommand(args, workingDir, workspaceName);
const { cmd, cwd } = this.createCLICommand(args, workingDir, opts);
const proc = Bun.spawn(cmd, {
stdout: 'pipe',
stderr: 'pipe',

View File

@@ -0,0 +1,400 @@
/**
* Tests for missing folder.meta.yaml detection during sync push,
* the `folder add-missing` command, and the simplified `folder push` command.
*/
import { expect, test, describe } from "bun:test";
import { writeFile, mkdir, readFile, rm, mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { getTestBackend, createNonAdminUser } from "./test_backend.ts";
type IsolatedWorkspaceTestContext = {
backend: any;
tempDir: string;
workspaceId: string;
runCLICommand: (
args: string[],
opts?: { token?: string }
) => Promise<{ stdout: string; stderr: string; code: number }>;
apiRequest: (path: string, options?: RequestInit) => Promise<Response>;
};
async function createWorkspace(backend: any, workspaceId: string): Promise<void> {
const response = await backend.apiRequest!("/api/workspaces/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: workspaceId,
// Workspace name has a 50-char DB limit; keep it identical to the short ID.
name: workspaceId,
}),
});
if (!response.ok) {
const error = await response.text();
if (!error.includes("already exists") && !error.includes("duplicate")) {
throw new Error(`Failed to create workspace ${workspaceId}: ${error}`);
}
return;
}
await response.text();
}
async function withIsolatedWorkspace(
testFn: (ctx: IsolatedWorkspaceTestContext) => Promise<void>
): Promise<void> {
const backend = await getTestBackend();
const tempDir = await mkdtemp(join(tmpdir(), "windmill_cli_test_"));
const workspaceId = `fmeta_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
let workspaceCreated = false;
try {
await createWorkspace(backend, workspaceId);
workspaceCreated = true;
await testFn({
backend,
tempDir,
workspaceId,
runCLICommand: (args: string[], opts?: { token?: string }) =>
backend.runCLICommand(args, tempDir, {
workspace: workspaceId,
token: opts?.token,
}),
apiRequest: (path: string, options?: RequestInit) =>
backend.apiRequest!(`/api/w/${workspaceId}${path}`, options),
});
} finally {
if (workspaceCreated) {
try {
const archiveResponse = await backend.apiRequest!(
`/api/w/${workspaceId}/workspaces/archive`,
{ method: "POST" }
);
await archiveResponse.text();
} catch {
// Best-effort cleanup to avoid exceeding non-enterprise workspace limits.
}
}
await rm(tempDir, { recursive: true, force: true });
}
}
function wmillYaml(): string {
return `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`;
}
// =============================================================================
// folder new — creates folder.meta.yaml with summary and display_name
// =============================================================================
describe("folder new", () => {
test("creates folder.meta.yaml with summary and display_name", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
const folderName = `newfolder${Date.now()}`;
const result = await runCLICommand(
["folder", "new", folderName, "--summary", "My summary"],
);
expect(result.code).toEqual(0);
const metaPath = join(tempDir, "f", folderName, "folder.meta.yaml");
const content = await readFile(metaPath, "utf-8");
expect(content).toContain("summary: My summary");
expect(content).toContain(`display_name: ${folderName}`);
expect(content).toContain("owners:");
expect(content).toContain("extra_perms:");
});
});
test("creates folder.meta.yaml with empty summary when none provided", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
const folderName = `nosummary${Date.now()}`;
const result = await runCLICommand(
["folder", "new", folderName],
);
expect(result.code).toEqual(0);
const content = await readFile(
join(tempDir, "f", folderName, "folder.meta.yaml"),
"utf-8"
);
expect(content).toContain('summary: ""');
expect(content).toContain(`display_name: ${folderName}`);
});
});
test("fails if folder.meta.yaml already exists", async () => {
await withIsolatedWorkspace(async ({ runCLICommand }) => {
const folderName = `dupfolder${Date.now()}`;
// Create first
await runCLICommand(["folder", "new", folderName]);
// Try again — should fail
const result = await runCLICommand(
["folder", "new", folderName],
);
expect(result.code).not.toEqual(0);
});
});
});
// =============================================================================
// folder add-missing — scaffolds missing folder.meta.yaml files
// =============================================================================
describe("folder add-missing", () => {
test("creates folder.meta.yaml for directories missing one", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
// Create two folders: one with meta, one without
const withMeta = `withmeta${Date.now()}`;
const withoutMeta = `withoutmeta${Date.now()}`;
await mkdir(join(tempDir, "f", withMeta), { recursive: true });
await writeFile(
join(tempDir, "f", withMeta, "folder.meta.yaml"),
'summary: ""\ndisplay_name: existing\nowners: []\nextra_perms: {}\n',
"utf-8"
);
await mkdir(join(tempDir, "f", withoutMeta), { recursive: true });
// No folder.meta.yaml for withoutMeta
const result = await runCLICommand(
["folder", "add-missing", "-y"],
);
expect(result.code).toEqual(0);
// withoutMeta should now have a folder.meta.yaml
const createdMeta = await readFile(
join(tempDir, "f", withoutMeta, "folder.meta.yaml"),
"utf-8"
);
expect(createdMeta).toContain(`display_name: ${withoutMeta}`);
expect(createdMeta).toContain("owners:");
// withMeta should be unchanged
const existingMeta = await readFile(
join(tempDir, "f", withMeta, "folder.meta.yaml"),
"utf-8"
);
expect(existingMeta).toContain("display_name: existing");
});
});
test("reports nothing to do when all folders have meta", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
const folderName = `alldone${Date.now()}`;
await mkdir(join(tempDir, "f", folderName), { recursive: true });
await writeFile(
join(tempDir, "f", folderName, "folder.meta.yaml"),
'summary: ""\ndisplay_name: done\nowners: []\nextra_perms: {}\n',
"utf-8"
);
const result = await runCLICommand(
["folder", "add-missing", "-y"],
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("Nothing to do");
});
});
test("reports nothing to do when no f/ directory exists", async () => {
await withIsolatedWorkspace(async ({ runCLICommand }) => {
const result = await runCLICommand(
["folder", "add-missing", "-y"],
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("Nothing to do");
});
});
});
// =============================================================================
// folder push — simplified single-arg signature
// =============================================================================
describe("folder push", () => {
test("pushes a folder by name", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand, apiRequest }) => {
const folderName = `pushbyname${Date.now()}`;
// Create local folder meta
await mkdir(join(tempDir, "f", folderName), { recursive: true });
await writeFile(
join(tempDir, "f", folderName, "folder.meta.yaml"),
`summary: "pushed"\ndisplay_name: "${folderName}"\nowners:\n - "admin@windmill.dev"\nextra_perms: {}\n`,
"utf-8"
);
const result = await runCLICommand(
["folder", "push", folderName],
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("Folder pushed");
// Verify via API
const apiResp = await apiRequest(`/folders/get/${folderName}`);
expect(apiResp.status).toEqual(200);
});
});
test("fails when folder does not exist locally", async () => {
await withIsolatedWorkspace(async ({ runCLICommand }) => {
const result = await runCLICommand(
["folder", "push", "nonexistent"],
);
expect(result.code).not.toEqual(0);
});
});
});
// =============================================================================
// sync push — missing folder.meta.yaml detection
// =============================================================================
describe("sync push missing folder detection", () => {
test("admin user gets warning but push succeeds", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
const uniqueId = Date.now();
const folderName = `nometaadmin${uniqueId}`;
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
// Create a script inside a folder WITHOUT folder.meta.yaml
await mkdir(join(tempDir, "f", folderName), { recursive: true });
await writeFile(
join(tempDir, "f", folderName, "test_script.ts"),
'export async function main() { return "hello"; }',
"utf-8"
);
const result = await runCLICommand(
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
);
// Admin should get a warning but push succeeds (exit 0)
expect(result.code).toEqual(0);
const output = result.stdout + result.stderr;
expect(output).toContain("Missing folder.meta.yaml");
expect(output).toContain(folderName);
expect(output).toContain("wmill folder add-missing");
});
});
test("no warning when folder.meta.yaml exists", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
const uniqueId = Date.now();
const folderName = `withmeta${uniqueId}`;
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
// Create folder WITH folder.meta.yaml
await mkdir(join(tempDir, "f", folderName), { recursive: true });
await writeFile(
join(tempDir, "f", folderName, "folder.meta.yaml"),
`summary: ""\ndisplay_name: "${folderName}"\nowners: []\nextra_perms: {}\n`,
"utf-8"
);
await writeFile(
join(tempDir, "f", folderName, "test_script.ts"),
'export async function main() { return "hello"; }',
"utf-8"
);
const result = await runCLICommand(
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
);
expect(result.code).toEqual(0);
const output = result.stdout + result.stderr;
expect(output).not.toContain("Missing folder.meta.yaml");
});
});
test.skipIf(!process.env["EE_LICENSE_KEY"])("non-admin user gets error and exit code 1", async () => {
await withIsolatedWorkspace(async ({ backend, tempDir, workspaceId, runCLICommand, apiRequest }) => {
const nonAdminToken = await createNonAdminUser(backend, workspaceId);
const uniqueId = Date.now();
const folderName = `nometanonadmin${uniqueId}`;
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
// Create a script inside a folder WITHOUT folder.meta.yaml
// First create the folder on remote so the non-admin has somewhere to push
await apiRequest(
"/folders/create",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: folderName,
extra_perms: { "g/all": true },
}),
}
);
await mkdir(join(tempDir, "f", folderName), { recursive: true });
await writeFile(
join(tempDir, "f", folderName, "test_script.ts"),
'export async function main() { return "hello"; }',
"utf-8"
);
const result = await runCLICommand(
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
{ token: nonAdminToken }
);
// Non-admin should get exit code 1
expect(result.code).toEqual(1);
const output = result.stdout + result.stderr;
expect(output).toContain("Missing folder.meta.yaml");
expect(output).toContain("wmill folder add-missing");
});
});
test("no warning for deleted changes without folder.meta.yaml", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand, apiRequest }) => {
const uniqueId = Date.now();
const folderName = `delfolder${uniqueId}`;
// Create folder and script on remote via API
await apiRequest(
"/folders/create",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: folderName }),
}
);
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
// Pull to get remote state, then delete the folder locally
await runCLICommand(["sync", "pull", "--yes"]);
// Remove the folder locally to trigger a "deleted" change
await rm(join(tempDir, "f", folderName), { recursive: true, force: true });
const result = await runCLICommand(
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
);
// Should not warn about missing meta for deleted items
const output = result.stdout + result.stderr;
expect(output).not.toContain("Missing folder.meta.yaml");
});
});
});

View File

@@ -40,8 +40,8 @@ export interface TestBackend {
stop(): Promise<void>;
reset(): Promise<void>;
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any;
runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any;
runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
stdout: string;
stderr: string;
code: number;
@@ -97,12 +97,12 @@ class CargoBackendAdapter implements TestBackend {
await this.backend.reset();
}
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any {
return this.backend.createCLICommand(args, workingDir, workspaceName);
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any {
return this.backend.createCLICommand(args, workingDir, opts);
}
async runCLICommand(args: string[], workingDir: string, workspaceName?: string) {
return this.backend.runCLICommand(args, workingDir, workspaceName);
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }) {
return this.backend.runCLICommand(args, workingDir, opts);
}
async apiRequest(path: string, options?: RequestInit): Promise<Response> {
@@ -369,12 +369,12 @@ class ContainerizedBackendAdapter implements TestBackend {
await this.backend.reset();
}
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any {
return this.backend.createCLICommand(args, workingDir, workspaceName);
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any {
return this.backend.createCLICommand(args, workingDir, opts);
}
async runCLICommand(args: string[], workingDir: string, workspaceName?: string) {
return this.backend.runCLICommand(args, workingDir, workspaceName);
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }) {
return this.backend.runCLICommand(args, workingDir, opts);
}
async seedTestData(): Promise<void> {
@@ -507,6 +507,66 @@ function registerCleanup() {
}
}
/**
* Create a non-admin user, add them to the workspace, and return their token.
*/
export async function createNonAdminUser(
backend: TestBackend,
workspaceId: string = backend.workspace
): Promise<string> {
if (!backend.apiRequest) {
throw new Error("Backend does not support apiRequest");
}
const email = `nonadmin_${Date.now()}@test.dev`;
const password = "testpass123";
// Create user globally (as admin)
const createResp = await backend.apiRequest("/api/users/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
password,
super_admin: false,
name: "Non-Admin Test User",
}),
});
if (!createResp.ok) {
throw new Error(`Failed to create user: ${await createResp.text()}`);
}
await createResp.text();
// Add user to workspace as non-admin
const addResp = await backend.apiRequest(
`/api/w/${workspaceId}/workspaces/add_user`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
is_admin: false,
operator: false,
}),
}
);
if (!addResp.ok) {
throw new Error(`Failed to add user to workspace: ${await addResp.text()}`);
}
await addResp.text();
// Login as the non-admin user to get a token
const loginResp = await fetch(`${backend.baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!loginResp.ok) {
throw new Error(`Failed to login as non-admin: ${await loginResp.text()}`);
}
return await loginResp.text();
}
// Re-export for convenience
export type { CargoBackendConfig } from "./cargo_backend.ts";
export type { ContainerConfig } from "./containerized_backend.ts";

View File

@@ -2,7 +2,7 @@
The Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.
Current version: 1.624.0
Current version: 1.642.0
## Global Options
@@ -19,8 +19,15 @@ Current version: 1.624.0
app related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `app list` - list all apps
- `--json` - Output as JSON (for piping to jq)
- `app get <path:string>` - get an app's details
- `--json` - Output as JSON (for piping to jq)
- `app push <file_path:string> <remote_path:string>` - push a local app
- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement
- `--port <port:number>` - Port to run the dev server on (will find next available port if occupied)
@@ -58,10 +65,16 @@ Launch a dev server that will spawn a webserver with HMR
flow related commands
**Options:**
- `--show-archived` - Enable archived scripts in output
- `--show-archived` - Enable archived flows in output
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `flow list` - list all flows
- `--show-archived` - Enable archived flows in output
- `--json` - Output as JSON (for piping to jq)
- `flow get <path:string>` - get a flow's details
- `--json` - Output as JSON (for piping to jq)
- `flow push <file_path:string> <remote_path:string>` - push a local flow spec. This overrides any remote versions.
- `flow run <path:string>` - run a flow by path.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
@@ -73,17 +86,31 @@ flow related commands
- `--yes` - Skip confirmation prompt
- `-i --includes <patterns:file[]>` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)
- `-e --excludes <patterns:file[]>` - Comma separated patterns to specify which file to NOT take into account.
- `flow bootstrap <flow_path:string>` - create a new empty flow
- `--summary <summary:string>` - script summary
- `--description <description:string>` - script description
- `flow new <flow_path:string>` - create a new empty flow
- `--summary <summary:string>` - flow summary
- `--description <description:string>` - flow description
- `flow bootstrap <flow_path:string>` - create a new empty flow (alias for new
- `--summary <summary:string>` - flow summary
- `--description <description:string>` - flow description
### folder
folder related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `folder push <file_path:string> <remote_path:string>` - push a local folder spec. This overrides any remote versions.
- `folder list` - list all folders
- `--json` - Output as JSON (for piping to jq)
- `folder get <name:string>` - get a folder's details
- `--json` - Output as JSON (for piping to jq)
- `folder new <name:string>` - create a new folder locally
- `--summary <summary:string>` - folder summary
- `folder push <name:string>` - push a local folder to the remote by name. This overrides any remote versions.
- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one
- `-y, --yes` - skip confirmation prompt
### gitsync-settings
@@ -162,6 +189,9 @@ sync local with a remote instance or the opposite (push or pull)
- `--prefix <prefix:string>` - Prefix of the local workspaces folders to push
- `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance
- `instance whoami` - Display information about the currently logged-in user
- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML
- `-o, --output-file <file:string>` - Write YAML to a file instead of stdout
- `--instance <instance:string>` - Name of the instance, override the active instance
### jobs
@@ -179,6 +209,17 @@ Pull completed and queued jobs from workspace
- `jobs pull`
- `jobs push`
### lint
Validate Windmill flow, schedule, and trigger YAML files in a directory
**Arguments:** `[directory:string]`
**Options:**
- `--json` - Output results in JSON format
- `--fail-on-warn` - Exit with code 1 when warnings are emitted
- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks
### queues
List all queues with their metrics
@@ -193,18 +234,33 @@ List all queues with their metrics
resource related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `resource list` - list all resources
- `--json` - Output as JSON (for piping to jq)
- `resource get <path:string>` - get a resource's details
- `--json` - Output as JSON (for piping to jq)
- `resource new <path:string>` - create a new resource locally
- `resource push <file_path:string> <remote_path:string>` - push a local resource spec. This overrides any remote versions.
### resource-type
resource type related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `resource-type list` - list all resource types
- `--schema` - Show schema in the output
- `--json` - Output as JSON (for piping to jq)
- `resource-type get <path:string>` - get a resource type's details
- `--json` - Output as JSON (for piping to jq)
- `resource-type new <name:string>` - create a new resource type locally
- `resource-type push <file_path:string> <name:string>` - push a local resource spec. This overrides any remote versions.
- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types
@@ -212,8 +268,16 @@ resource type related commands
schedule related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `schedule list` - list all schedules
- `--json` - Output as JSON (for piping to jq)
- `schedule get <path:string>` - get a schedule's details
- `--json` - Output as JSON (for piping to jq)
- `schedule new <path:string>` - create a new schedule locally
- `schedule push <file_path:string> <remote_path:string>` - push a local schedule spec. This overrides any remote versions.
### script
@@ -222,18 +286,27 @@ script related commands
**Options:**
- `--show-archived` - Enable archived scripts in output
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `script list` - list all scripts
- `--show-archived` - Enable archived scripts in output
- `--json` - Output as JSON (for piping to jq)
- `script push <path:file>` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh
- `script show <path:file>` - show a scripts content
- `script get <path:file>` - get a script's details
- `--json` - Output as JSON (for piping to jq)
- `script show <path:file>` - show a script's content (alias for get
- `script run <path:file>` - run a script by path
- `-d --data <data:file>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not output anything other then the final output. Useful for scripting.
- `script preview <path:file>` - preview a local script without deploying it. Supports both regular and codebase scripts.
- `-d --data <data:file>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not output anything other than the final output. Useful for scripting.
- `script bootstrap <path:file> <language:string>` - create a new script
- `script new <path:file> <language:string>` - create a new script
- `--summary <summary:string>` - script summary
- `--description <description:string>` - script description
- `script bootstrap <path:file> <language:string>` - create a new script (alias for new
- `--summary <summary:string>` - script summary
- `--description <description:string>` - script description
- `script generate-metadata [script:file]` - re-generate the metadata file updating the lock and the script schema (for flows, use `wmill flow generate-locks`
@@ -309,13 +382,25 @@ sync local with a remote workspaces or the opposite (push or pull)
- `--parallel <number>` - Number of changes to process in parallel
- `--repository <repo:string>` - Specify repository path (e.g., u/user/repo) when multiple repositories exist
- `--branch <branch:string>` - Override the current git branch (works even outside a git repository)
- `--lint` - Run lint validation before pushing
- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks
### trigger
trigger related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `trigger list` - list all triggers
- `--json` - Output as JSON (for piping to jq)
- `trigger get <path:string>` - get a trigger's details
- `--json` - Output as JSON (for piping to jq)
- `--kind <kind:string>` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email). Recommended for faster lookup
- `trigger new <path:string>` - create a new trigger locally
- `--kind <kind:string>` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)
- `trigger push <file_path:string> <remote_path:string>` - push a local trigger spec. This overrides any remote versions.
### user
@@ -337,8 +422,16 @@ user related commands
variable related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `variable list` - list all variables
- `--json` - Output as JSON (for piping to jq)
- `variable get <path:string>` - get a variable's details
- `--json` - Output as JSON (for piping to jq)
- `variable new <path:string>` - create a new variable locally
- `variable push <file_path:string> <remote_path:string>` - Push a local variable spec. This overrides any remote versions.
- `--plain-secrets` - Push secrets as plain text
- `variable add <value:string> <remote_path:string>` - Create a new variable on the remote. This will update the variable if it already exists.
@@ -387,7 +480,8 @@ workspace related commands
- `--create-username <username:string>` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.
- `workspace remove <workspace_name:string>` - Remove a workspace
- `workspace whoami` - Show the currently active user
- `workspace list` - List workspaces on the remote server that you have access to
- `workspace list` - List local workspace profiles
- `workspace list-remote` - List workspaces on the remote server that you have access to
- `workspace bind` - Bind the current Git branch to the active workspace
- `--branch <branch:string>` - Specify branch (defaults to current)
- `workspace unbind` - Remove workspace binding from the current Git branch

View File

@@ -7,7 +7,7 @@ description: MUST use when using the CLI.
The Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.
Current version: 1.624.0
Current version: 1.642.0
## Global Options
@@ -24,8 +24,15 @@ Current version: 1.624.0
app related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `app list` - list all apps
- `--json` - Output as JSON (for piping to jq)
- `app get <path:string>` - get an app's details
- `--json` - Output as JSON (for piping to jq)
- `app push <file_path:string> <remote_path:string>` - push a local app
- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement
- `--port <port:number>` - Port to run the dev server on (will find next available port if occupied)
@@ -63,10 +70,16 @@ Launch a dev server that will spawn a webserver with HMR
flow related commands
**Options:**
- `--show-archived` - Enable archived scripts in output
- `--show-archived` - Enable archived flows in output
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `flow list` - list all flows
- `--show-archived` - Enable archived flows in output
- `--json` - Output as JSON (for piping to jq)
- `flow get <path:string>` - get a flow's details
- `--json` - Output as JSON (for piping to jq)
- `flow push <file_path:string> <remote_path:string>` - push a local flow spec. This overrides any remote versions.
- `flow run <path:string>` - run a flow by path.
- `-d --data <data:string>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
@@ -78,17 +91,31 @@ flow related commands
- `--yes` - Skip confirmation prompt
- `-i --includes <patterns:file[]>` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)
- `-e --excludes <patterns:file[]>` - Comma separated patterns to specify which file to NOT take into account.
- `flow bootstrap <flow_path:string>` - create a new empty flow
- `--summary <summary:string>` - script summary
- `--description <description:string>` - script description
- `flow new <flow_path:string>` - create a new empty flow
- `--summary <summary:string>` - flow summary
- `--description <description:string>` - flow description
- `flow bootstrap <flow_path:string>` - create a new empty flow (alias for new
- `--summary <summary:string>` - flow summary
- `--description <description:string>` - flow description
### folder
folder related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `folder push <file_path:string> <remote_path:string>` - push a local folder spec. This overrides any remote versions.
- `folder list` - list all folders
- `--json` - Output as JSON (for piping to jq)
- `folder get <name:string>` - get a folder's details
- `--json` - Output as JSON (for piping to jq)
- `folder new <name:string>` - create a new folder locally
- `--summary <summary:string>` - folder summary
- `folder push <name:string>` - push a local folder to the remote by name. This overrides any remote versions.
- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one
- `-y, --yes` - skip confirmation prompt
### gitsync-settings
@@ -167,6 +194,9 @@ sync local with a remote instance or the opposite (push or pull)
- `--prefix <prefix:string>` - Prefix of the local workspaces folders to push
- `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance
- `instance whoami` - Display information about the currently logged-in user
- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML
- `-o, --output-file <file:string>` - Write YAML to a file instead of stdout
- `--instance <instance:string>` - Name of the instance, override the active instance
### jobs
@@ -184,6 +214,17 @@ Pull completed and queued jobs from workspace
- `jobs pull`
- `jobs push`
### lint
Validate Windmill flow, schedule, and trigger YAML files in a directory
**Arguments:** `[directory:string]`
**Options:**
- `--json` - Output results in JSON format
- `--fail-on-warn` - Exit with code 1 when warnings are emitted
- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks
### queues
List all queues with their metrics
@@ -198,18 +239,33 @@ List all queues with their metrics
resource related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `resource list` - list all resources
- `--json` - Output as JSON (for piping to jq)
- `resource get <path:string>` - get a resource's details
- `--json` - Output as JSON (for piping to jq)
- `resource new <path:string>` - create a new resource locally
- `resource push <file_path:string> <remote_path:string>` - push a local resource spec. This overrides any remote versions.
### resource-type
resource type related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `resource-type list` - list all resource types
- `--schema` - Show schema in the output
- `--json` - Output as JSON (for piping to jq)
- `resource-type get <path:string>` - get a resource type's details
- `--json` - Output as JSON (for piping to jq)
- `resource-type new <name:string>` - create a new resource type locally
- `resource-type push <file_path:string> <name:string>` - push a local resource spec. This overrides any remote versions.
- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types
@@ -217,8 +273,16 @@ resource type related commands
schedule related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `schedule list` - list all schedules
- `--json` - Output as JSON (for piping to jq)
- `schedule get <path:string>` - get a schedule's details
- `--json` - Output as JSON (for piping to jq)
- `schedule new <path:string>` - create a new schedule locally
- `schedule push <file_path:string> <remote_path:string>` - push a local schedule spec. This overrides any remote versions.
### script
@@ -227,18 +291,27 @@ script related commands
**Options:**
- `--show-archived` - Enable archived scripts in output
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `script list` - list all scripts
- `--show-archived` - Enable archived scripts in output
- `--json` - Output as JSON (for piping to jq)
- `script push <path:file>` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh
- `script show <path:file>` - show a scripts content
- `script get <path:file>` - get a script's details
- `--json` - Output as JSON (for piping to jq)
- `script show <path:file>` - show a script's content (alias for get
- `script run <path:file>` - run a script by path
- `-d --data <data:file>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not output anything other then the final output. Useful for scripting.
- `script preview <path:file>` - preview a local script without deploying it. Supports both regular and codebase scripts.
- `-d --data <data:file>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-.
- `-s --silent` - Do not output anything other than the final output. Useful for scripting.
- `script bootstrap <path:file> <language:string>` - create a new script
- `script new <path:file> <language:string>` - create a new script
- `--summary <summary:string>` - script summary
- `--description <description:string>` - script description
- `script bootstrap <path:file> <language:string>` - create a new script (alias for new
- `--summary <summary:string>` - script summary
- `--description <description:string>` - script description
- `script generate-metadata [script:file]` - re-generate the metadata file updating the lock and the script schema (for flows, use `wmill flow generate-locks`
@@ -314,13 +387,25 @@ sync local with a remote workspaces or the opposite (push or pull)
- `--parallel <number>` - Number of changes to process in parallel
- `--repository <repo:string>` - Specify repository path (e.g., u/user/repo) when multiple repositories exist
- `--branch <branch:string>` - Override the current git branch (works even outside a git repository)
- `--lint` - Run lint validation before pushing
- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks
### trigger
trigger related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `trigger list` - list all triggers
- `--json` - Output as JSON (for piping to jq)
- `trigger get <path:string>` - get a trigger's details
- `--json` - Output as JSON (for piping to jq)
- `--kind <kind:string>` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email). Recommended for faster lookup
- `trigger new <path:string>` - create a new trigger locally
- `--kind <kind:string>` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, email)
- `trigger push <file_path:string> <remote_path:string>` - push a local trigger spec. This overrides any remote versions.
### user
@@ -342,8 +427,16 @@ user related commands
variable related commands
**Options:**
- `--json` - Output as JSON (for piping to jq)
**Subcommands:**
- `variable list` - list all variables
- `--json` - Output as JSON (for piping to jq)
- `variable get <path:string>` - get a variable's details
- `--json` - Output as JSON (for piping to jq)
- `variable new <path:string>` - create a new variable locally
- `variable push <file_path:string> <remote_path:string>` - Push a local variable spec. This overrides any remote versions.
- `--plain-secrets` - Push secrets as plain text
- `variable add <value:string> <remote_path:string>` - Create a new variable on the remote. This will update the variable if it already exists.
@@ -392,7 +485,8 @@ workspace related commands
- `--create-username <username:string>` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.
- `workspace remove <workspace_name:string>` - Remove a workspace
- `workspace whoami` - Show the currently active user
- `workspace list` - List workspaces on the remote server that you have access to
- `workspace list` - List local workspace profiles
- `workspace list-remote` - List workspaces on the remote server that you have access to
- `workspace bind` - Bind the current Git branch to the active workspace
- `--branch <branch:string>` - Specify branch (defaults to current)
- `workspace unbind` - Remove workspace binding from the current Git branch

View File

@@ -188,40 +188,16 @@ npm test:watch
### Testing locally with the CLI
The Windmill CLI (`cli/`) is Deno-based and imports this package via `npm:windmill-yaml-validator@1.1.0`. Since Deno's `npm:` specifier always resolves from the npm registry, local testing requires a compatibility script that makes the TypeScript sources directly importable by Deno.
The `deno-compat.sh` script handles two Deno requirements:
- Adding `.ts` extensions to relative imports
- Adding `with { type: "json" }` assertions to JSON imports
**Steps:**
1. Apply Deno compatibility:
To test local changes before publishing, use `npm link`:
```bash
./deno-compat.sh
```
# In windmill-yaml-validator/
npm run build
npm link
2. Add the following entries to `cli/deno.json` imports:
```json
"npm:windmill-yaml-validator@1.1.0": "../windmill-yaml-validator/src/index.ts",
"ajv": "npm:ajv@^8.17.1",
"@stoplight/yaml": "npm:@stoplight/yaml@^4.3.0"
```
3. Run the CLI directly with Deno:
```bash
cd ../cli
deno run -A src/main.ts lint
```
4. When done, restore everything:
```bash
./deno-compat.sh -r # restore original imports
# Remove the 3 import map lines from cli/deno.json
# In cli/
npm link windmill-yaml-validator
bun run src/main.ts lint
```
### Schema Generation

View File

@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# Makes windmill-yaml-validator source files Deno-compatible by:
# 1. Adding .ts extensions to relative imports
# 2. Adding `with { type: "json" }` to JSON imports
# Use -r to restore (undo changes).
set -e
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RESTORE_MODE=false
while [[ $# -gt 0 ]]; do
case $1 in
-r)
RESTORE_MODE=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [-r]"
echo " -r Restore original imports"
exit 1
;;
esac
done
if [[ "$OSTYPE" == "darwin"* ]]; then
SED=gsed
if ! command -v gsed &> /dev/null; then
echo "Error: gsed not found. Run: brew install gnu-sed"
exit 1
fi
else
SED=sed
fi
if [[ "$RESTORE_MODE" == true ]]; then
echo "Restoring original imports..."
find "$script_dirpath"/src -name "*.ts" -type f ! -path '*__tests__*' | while read -r file; do
# Remove .ts from relative imports: from "./foo.ts" -> from "./foo"
$SED -E -i 's|(from "\.\.?/[^"]*)\.ts(")|\1\2|g' "$file"
# Remove ` with { type: "json" }` from JSON imports
$SED -E -i 's/ with \{ type: "json" \}//' "$file"
done
echo "✓ Restored original imports"
else
echo "Making sources Deno-compatible..."
find "$script_dirpath"/src -name "*.ts" -type f ! -path '*__tests__*' | while read -r file; do
# Add .ts to relative imports that don't already end in .ts or .json
$SED -E -i '/\.json"/! { /\.ts"/! s|(from "(\.\.?/[^"]*[^/]))"(;?)$|\1.ts"\3|; }' "$file"
# Add `with { type: "json" }` to .json imports that don't already have it
$SED -E -i '/with \{ type: "json" \}/! s/(from "[^"]*\.json")(;?)$/\1 with { type: "json" }\2/' "$file"
done
echo "✓ Sources are now Deno-compatible"
fi