mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 08:07:03 +00:00
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:
co-authored by
Claude Opus 4.6
parent
b59d60378c
commit
835db5d290
+8
-32
@@ -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;
|
||||
|
||||
+40
-11
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',
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
+70
-10
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user