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:
@@ -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 |
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
400
cli/test/folder_missing_meta.test.ts
Normal file
400
cli/test/folder_missing_meta.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user