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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* update skills

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

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

* cleaning

* cleaning

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

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

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

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

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

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

* own workspace

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

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-02-24 08:38:17 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent b59d60378c
commit 835db5d290
14 changed files with 860 additions and 186 deletions
+8 -32
View File
@@ -112,39 +112,15 @@ source <(wmill completions zsh)
### Testing with a local `windmill-yaml-validator`
The CLI imports `windmill-yaml-validator` from npm (`npm:windmill-yaml-validator@1.1.0`).
To test local changes to the validator before publishing, use the Deno compatibility
script and import map override:
1. Make the validator sources Deno-compatible:
To test local changes to the validator before publishing, use `npm link`:
```bash
cd ../windmill-yaml-validator
./deno-compat.sh
```
# In windmill-yaml-validator/
npm run build
npm link
2. Add the following entries to `cli/deno.json` imports:
```json
"npm:windmill-yaml-validator@1.1.0": "../windmill-yaml-validator/src/index.ts",
"ajv": "npm:ajv@^8.17.1",
"@stoplight/yaml": "npm:@stoplight/yaml@^4.3.0"
```
3. Run the CLI directly with Deno:
```bash
deno run -A src/main.ts lint
```
4. When done, restore everything:
```bash
# Restore validator sources
cd ../windmill-yaml-validator
./deno-compat.sh -r
# Remove the 3 import map lines from cli/deno.json
# In cli/
npm link windmill-yaml-validator
```
### Running Tests
@@ -156,13 +132,13 @@ cd ../windmill-yaml-validator
**Run tests locally (full features):**
```bash
deno test --allow-all --no-check
bun test test/
```
**Run tests in CI mode (minimal features, skips EE tests):**
```bash
CI_MINIMAL_FEATURES=true deno test --allow-all --no-check
CI_MINIMAL_FEATURES=true bun test test/
```
| Variable | Description |
+5
View File
@@ -34,6 +34,7 @@
"yaml": "^2.7.0",
},
"devDependencies": {
"@types/bun": "^1.3.9",
"@types/diff": "^5.2.3",
"@types/node": "^22.0.0",
"@types/tar-stream": "^3.1.4",
@@ -151,6 +152,8 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/diff": ["@types/diff@5.2.3", "", {}, "sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -183,6 +186,8 @@
"brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+1
View File
@@ -42,6 +42,7 @@
"yaml": "^2.7.0"
},
"devDependencies": {
"@types/bun": "^1.3.9",
"@types/diff": "^5.2.3",
"@types/node": "^22.0.0",
"@types/tar-stream": "^3.1.4",
+71 -18
View File
@@ -1,4 +1,4 @@
import { stat, writeFile, mkdir } from "node:fs/promises";
import { stat, readdir, writeFile, mkdir } from "node:fs/promises";
import { stringify as yamlStringify } from "yaml";
import { colors } from "@cliffy/ansi/colors";
@@ -6,17 +6,19 @@ import { Command } from "@cliffy/command";
import { Table } from "@cliffy/table";
import * as log from "../../core/log.ts";
import { sep as SEP } from "node:path";
import { Confirm } from "@cliffy/prompt/confirm";
import * as wmill from "../../../gen/services.gen.ts";
import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace, validatePath } from "../../core/context.ts";
import { resolveWorkspace } from "../../core/context.ts";
import { GlobalOptions, isSuperset, parseFromFile } from "../../types.ts";
import { Folder } from "../../../gen/types.gen.ts";
export interface FolderFile {
summary: string | undefined;
display_name: string | undefined;
owners: Array<string> | undefined;
extra_perms: { [record: string]: boolean } | undefined;
display_name: string | undefined;
}
async function list(opts: GlobalOptions & { json?: boolean }) {
@@ -45,7 +47,7 @@ async function list(opts: GlobalOptions & { json?: boolean }) {
}
}
async function newFolder(opts: GlobalOptions, name: string) {
async function newFolder(opts: GlobalOptions & { summary?: string }, name: string) {
const dirPath = `f${SEP}${name}`;
const filePath = `${dirPath}${SEP}folder.meta.yaml`;
try {
@@ -54,7 +56,9 @@ async function newFolder(opts: GlobalOptions, name: string) {
} catch (e: any) {
if (e.message?.startsWith("File already exists")) throw e;
}
const template: Omit<FolderFile, "display_name"> = {
const template: FolderFile = {
summary: opts.summary ?? "",
display_name: name,
owners: [],
extra_perms: {},
};
@@ -143,30 +147,72 @@ export async function pushFolder(
}
}
async function push(opts: GlobalOptions, filePath: string, remotePath: string) {
async function push(opts: GlobalOptions, name: string) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
if (!validatePath(remotePath)) {
return;
}
const fstat = await stat(filePath);
if (!fstat.isFile()) {
throw new Error("file path must refer to a file.");
const metaPath = `f${SEP}${name}${SEP}folder.meta.yaml`;
try {
await stat(metaPath);
} catch {
throw new Error(`Could not find ${metaPath}. Does the folder exist locally?`);
}
console.log(colors.bold.yellow("Pushing folder..."));
await pushFolder(
workspace.workspaceId,
remotePath,
name,
undefined,
parseFromFile(filePath)
parseFromFile(metaPath)
);
console.log(colors.bold.underline.green("Folder pushed"));
}
async function addMissing(opts: GlobalOptions & { yes?: boolean }) {
const fDir = `f`;
try {
await stat(fDir);
} catch {
log.info("No 'f/' directory found. Nothing to do.");
return;
}
const entries = await readdir(fDir, { withFileTypes: true });
const missing: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const metaPath = `${fDir}${SEP}${entry.name}${SEP}folder.meta.yaml`;
try {
await stat(metaPath);
} catch {
missing.push(entry.name);
}
}
if (missing.length === 0) {
log.info("All folders already have a folder.meta.yaml. Nothing to do.");
return;
}
log.info(`Missing folder.meta.yaml for:`);
for (const name of missing) {
log.info(` - ${name}`);
}
if (
!opts.yes &&
!(await Confirm.prompt({
message: `Create ${missing.length} folder.meta.yaml file(s)?`,
default: true,
}))
) {
return;
}
for (const name of missing) {
await newFolder(opts, name);
}
log.info(
`\nCreated ${missing.length} folder.meta.yaml file(s). You can now run 'wmill sync push' to push them.`,
);
}
const command = new Command()
.description("folder related commands")
.option("--json", "Output as JSON (for piping to jq)")
@@ -180,12 +226,19 @@ const command = new Command()
.action(get as any)
.command("new", "create a new folder locally")
.arguments("<name:string>")
.option("--summary <summary:string>", "folder summary")
.action(newFolder as any)
.command(
"push",
"push a local folder spec. This overrides any remote versions."
"push a local folder to the remote by name. This overrides any remote versions."
)
.arguments("<file_path:string> <remote_path:string>")
.action(push as any);
.arguments("<name:string>")
.action(push as any)
.command(
"add-missing",
"create default folder.meta.yaml for all subdirectories of f/ that are missing one"
)
.option("-y, --yes", "skip confirmation prompt")
.action(addMissing as any);
export default command;
+40
View File
@@ -2480,6 +2480,45 @@ export async function push(
log.info(
`remote (${workspace.name}) <- local: ${changes.length} changes to apply`,
);
// Check that every folder referenced in the changeset has a local folder.meta.yaml
const missingFolders: string[] = [];
if (changes.length > 0) {
const folderNames = new Set<string>();
for (const change of changes) {
const parts = change.path.split(SEP);
if (parts.length >= 3 && parts[0] === "f" && change.name !== "deleted") {
folderNames.add(parts[1]);
}
}
for (const folderName of folderNames) {
try {
await stat(path.join("f", folderName, "folder.meta.yaml"));
} catch {
missingFolders.push(folderName);
}
}
}
if (missingFolders.length > 0) {
const folderList = missingFolders.map((f) => ` - ${f}`).join("\n");
const user = await wmill.whoami({ workspace: workspace.workspaceId });
const userIsAdmin = user.is_admin;
const msg =
`${userIsAdmin ? "Warning: " : ""}Missing folder.meta.yaml for:\n${folderList}\n` +
`Run 'wmill folder add-missing' to create them locally, then push again.`;
if (!userIsAdmin) {
if (opts.jsonOutput) {
console.log(JSON.stringify({ success: false, error: "missing_folders", missing_folders: missingFolders, message: msg }, null, 2));
} else {
log.error(msg);
}
process.exit(1);
}
if (!opts.jsonOutput) {
log.warn(msg);
}
}
// Handle JSON output for dry-run
if (opts.dryRun && opts.jsonOutput) {
const result = {
@@ -2511,6 +2550,7 @@ export async function push(
if (!opts.jsonOutput) {
prettyChanges(changes, specificItems, opts.branch);
}
if (opts.dryRun) {
log.info(colors.gray(`Dry run complete.`));
return;
File diff suppressed because one or more lines are too long
+7 -6
View File
@@ -232,8 +232,9 @@ export class CargoBackend {
*/
private getBasePostgresUrl(): string {
const url = new URL(this.config.postgresUrl);
// Remove any existing database path
// Remove any existing database path and query params (e.g. ?sslmode=disable)
url.pathname = "";
url.search = "";
return url.toString().replace(/\/$/, ""); // Remove trailing slash
}
@@ -629,13 +630,13 @@ export class CargoBackend {
/**
* Create CLI command with proper authentication
*/
createCLICommand(args: string[], workingDir: string, workspaceName?: string): { command: string, args: string[], cwd: string, env: Record<string, string> } {
const workspace = workspaceName || this.config.workspace;
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): { command: string, args: string[], cwd: string, env: Record<string, string> } {
const workspace = opts?.workspace || this.config.workspace;
const cliDir = join(dirname(fileURLToPath(import.meta.url)), "..");
const fullArgs = [
"--base-url", this.baseUrl,
"--workspace", workspace,
"--token", this.token,
"--token", opts?.token || this.token,
"--config-dir", this.config.testConfigDir,
...args,
];
@@ -660,12 +661,12 @@ export class CargoBackend {
/**
* Run CLI command and return result
*/
async runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
stdout: string;
stderr: string;
code: number;
}> {
const cmd = this.createCLICommand(args, workingDir, workspaceName);
const cmd = this.createCLICommand(args, workingDir, opts);
const proc = Bun.spawn([cmd.command, ...cmd.args], {
cwd: cmd.cwd,
env: cmd.env,
+5 -5
View File
@@ -1020,12 +1020,12 @@ export async function main(
/**
* Create CLI command with proper authentication
*/
createCLICommand(args: string[], workingDir: string, workspaceName?: string): { cmd: string[], cwd: string } {
const workspace = workspaceName || this.config.workspace;
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): { cmd: string[], cwd: string } {
const workspace = opts?.workspace || this.config.workspace;
const fullArgs = [
'--base-url', this.config.baseUrl,
'--workspace', workspace,
'--token', this.config.token,
'--token', opts?.token || this.config.token,
'--config-dir', this.config.testConfigDir,
...args
];
@@ -1049,12 +1049,12 @@ export async function main(
/**
* Run CLI command and return result
*/
async runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
stdout: string;
stderr: string;
code: number;
}> {
const { cmd, cwd } = this.createCLICommand(args, workingDir, workspaceName);
const { cmd, cwd } = this.createCLICommand(args, workingDir, opts);
const proc = Bun.spawn(cmd, {
stdout: 'pipe',
stderr: 'pipe',
+400
View File
@@ -0,0 +1,400 @@
/**
* Tests for missing folder.meta.yaml detection during sync push,
* the `folder add-missing` command, and the simplified `folder push` command.
*/
import { expect, test, describe } from "bun:test";
import { writeFile, mkdir, readFile, rm, mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { getTestBackend, createNonAdminUser } from "./test_backend.ts";
type IsolatedWorkspaceTestContext = {
backend: any;
tempDir: string;
workspaceId: string;
runCLICommand: (
args: string[],
opts?: { token?: string }
) => Promise<{ stdout: string; stderr: string; code: number }>;
apiRequest: (path: string, options?: RequestInit) => Promise<Response>;
};
async function createWorkspace(backend: any, workspaceId: string): Promise<void> {
const response = await backend.apiRequest!("/api/workspaces/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: workspaceId,
// Workspace name has a 50-char DB limit; keep it identical to the short ID.
name: workspaceId,
}),
});
if (!response.ok) {
const error = await response.text();
if (!error.includes("already exists") && !error.includes("duplicate")) {
throw new Error(`Failed to create workspace ${workspaceId}: ${error}`);
}
return;
}
await response.text();
}
async function withIsolatedWorkspace(
testFn: (ctx: IsolatedWorkspaceTestContext) => Promise<void>
): Promise<void> {
const backend = await getTestBackend();
const tempDir = await mkdtemp(join(tmpdir(), "windmill_cli_test_"));
const workspaceId = `fmeta_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
let workspaceCreated = false;
try {
await createWorkspace(backend, workspaceId);
workspaceCreated = true;
await testFn({
backend,
tempDir,
workspaceId,
runCLICommand: (args: string[], opts?: { token?: string }) =>
backend.runCLICommand(args, tempDir, {
workspace: workspaceId,
token: opts?.token,
}),
apiRequest: (path: string, options?: RequestInit) =>
backend.apiRequest!(`/api/w/${workspaceId}${path}`, options),
});
} finally {
if (workspaceCreated) {
try {
const archiveResponse = await backend.apiRequest!(
`/api/w/${workspaceId}/workspaces/archive`,
{ method: "POST" }
);
await archiveResponse.text();
} catch {
// Best-effort cleanup to avoid exceeding non-enterprise workspace limits.
}
}
await rm(tempDir, { recursive: true, force: true });
}
}
function wmillYaml(): string {
return `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`;
}
// =============================================================================
// folder new — creates folder.meta.yaml with summary and display_name
// =============================================================================
describe("folder new", () => {
test("creates folder.meta.yaml with summary and display_name", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
const folderName = `newfolder${Date.now()}`;
const result = await runCLICommand(
["folder", "new", folderName, "--summary", "My summary"],
);
expect(result.code).toEqual(0);
const metaPath = join(tempDir, "f", folderName, "folder.meta.yaml");
const content = await readFile(metaPath, "utf-8");
expect(content).toContain("summary: My summary");
expect(content).toContain(`display_name: ${folderName}`);
expect(content).toContain("owners:");
expect(content).toContain("extra_perms:");
});
});
test("creates folder.meta.yaml with empty summary when none provided", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
const folderName = `nosummary${Date.now()}`;
const result = await runCLICommand(
["folder", "new", folderName],
);
expect(result.code).toEqual(0);
const content = await readFile(
join(tempDir, "f", folderName, "folder.meta.yaml"),
"utf-8"
);
expect(content).toContain('summary: ""');
expect(content).toContain(`display_name: ${folderName}`);
});
});
test("fails if folder.meta.yaml already exists", async () => {
await withIsolatedWorkspace(async ({ runCLICommand }) => {
const folderName = `dupfolder${Date.now()}`;
// Create first
await runCLICommand(["folder", "new", folderName]);
// Try again — should fail
const result = await runCLICommand(
["folder", "new", folderName],
);
expect(result.code).not.toEqual(0);
});
});
});
// =============================================================================
// folder add-missing — scaffolds missing folder.meta.yaml files
// =============================================================================
describe("folder add-missing", () => {
test("creates folder.meta.yaml for directories missing one", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
// Create two folders: one with meta, one without
const withMeta = `withmeta${Date.now()}`;
const withoutMeta = `withoutmeta${Date.now()}`;
await mkdir(join(tempDir, "f", withMeta), { recursive: true });
await writeFile(
join(tempDir, "f", withMeta, "folder.meta.yaml"),
'summary: ""\ndisplay_name: existing\nowners: []\nextra_perms: {}\n',
"utf-8"
);
await mkdir(join(tempDir, "f", withoutMeta), { recursive: true });
// No folder.meta.yaml for withoutMeta
const result = await runCLICommand(
["folder", "add-missing", "-y"],
);
expect(result.code).toEqual(0);
// withoutMeta should now have a folder.meta.yaml
const createdMeta = await readFile(
join(tempDir, "f", withoutMeta, "folder.meta.yaml"),
"utf-8"
);
expect(createdMeta).toContain(`display_name: ${withoutMeta}`);
expect(createdMeta).toContain("owners:");
// withMeta should be unchanged
const existingMeta = await readFile(
join(tempDir, "f", withMeta, "folder.meta.yaml"),
"utf-8"
);
expect(existingMeta).toContain("display_name: existing");
});
});
test("reports nothing to do when all folders have meta", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
const folderName = `alldone${Date.now()}`;
await mkdir(join(tempDir, "f", folderName), { recursive: true });
await writeFile(
join(tempDir, "f", folderName, "folder.meta.yaml"),
'summary: ""\ndisplay_name: done\nowners: []\nextra_perms: {}\n',
"utf-8"
);
const result = await runCLICommand(
["folder", "add-missing", "-y"],
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("Nothing to do");
});
});
test("reports nothing to do when no f/ directory exists", async () => {
await withIsolatedWorkspace(async ({ runCLICommand }) => {
const result = await runCLICommand(
["folder", "add-missing", "-y"],
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("Nothing to do");
});
});
});
// =============================================================================
// folder push — simplified single-arg signature
// =============================================================================
describe("folder push", () => {
test("pushes a folder by name", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand, apiRequest }) => {
const folderName = `pushbyname${Date.now()}`;
// Create local folder meta
await mkdir(join(tempDir, "f", folderName), { recursive: true });
await writeFile(
join(tempDir, "f", folderName, "folder.meta.yaml"),
`summary: "pushed"\ndisplay_name: "${folderName}"\nowners:\n - "admin@windmill.dev"\nextra_perms: {}\n`,
"utf-8"
);
const result = await runCLICommand(
["folder", "push", folderName],
);
expect(result.code).toEqual(0);
expect(result.stdout + result.stderr).toContain("Folder pushed");
// Verify via API
const apiResp = await apiRequest(`/folders/get/${folderName}`);
expect(apiResp.status).toEqual(200);
});
});
test("fails when folder does not exist locally", async () => {
await withIsolatedWorkspace(async ({ runCLICommand }) => {
const result = await runCLICommand(
["folder", "push", "nonexistent"],
);
expect(result.code).not.toEqual(0);
});
});
});
// =============================================================================
// sync push — missing folder.meta.yaml detection
// =============================================================================
describe("sync push missing folder detection", () => {
test("admin user gets warning but push succeeds", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
const uniqueId = Date.now();
const folderName = `nometaadmin${uniqueId}`;
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
// Create a script inside a folder WITHOUT folder.meta.yaml
await mkdir(join(tempDir, "f", folderName), { recursive: true });
await writeFile(
join(tempDir, "f", folderName, "test_script.ts"),
'export async function main() { return "hello"; }',
"utf-8"
);
const result = await runCLICommand(
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
);
// Admin should get a warning but push succeeds (exit 0)
expect(result.code).toEqual(0);
const output = result.stdout + result.stderr;
expect(output).toContain("Missing folder.meta.yaml");
expect(output).toContain(folderName);
expect(output).toContain("wmill folder add-missing");
});
});
test("no warning when folder.meta.yaml exists", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand }) => {
const uniqueId = Date.now();
const folderName = `withmeta${uniqueId}`;
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
// Create folder WITH folder.meta.yaml
await mkdir(join(tempDir, "f", folderName), { recursive: true });
await writeFile(
join(tempDir, "f", folderName, "folder.meta.yaml"),
`summary: ""\ndisplay_name: "${folderName}"\nowners: []\nextra_perms: {}\n`,
"utf-8"
);
await writeFile(
join(tempDir, "f", folderName, "test_script.ts"),
'export async function main() { return "hello"; }',
"utf-8"
);
const result = await runCLICommand(
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
);
expect(result.code).toEqual(0);
const output = result.stdout + result.stderr;
expect(output).not.toContain("Missing folder.meta.yaml");
});
});
test.skipIf(!process.env["EE_LICENSE_KEY"])("non-admin user gets error and exit code 1", async () => {
await withIsolatedWorkspace(async ({ backend, tempDir, workspaceId, runCLICommand, apiRequest }) => {
const nonAdminToken = await createNonAdminUser(backend, workspaceId);
const uniqueId = Date.now();
const folderName = `nometanonadmin${uniqueId}`;
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
// Create a script inside a folder WITHOUT folder.meta.yaml
// First create the folder on remote so the non-admin has somewhere to push
await apiRequest(
"/folders/create",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: folderName,
extra_perms: { "g/all": true },
}),
}
);
await mkdir(join(tempDir, "f", folderName), { recursive: true });
await writeFile(
join(tempDir, "f", folderName, "test_script.ts"),
'export async function main() { return "hello"; }',
"utf-8"
);
const result = await runCLICommand(
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
{ token: nonAdminToken }
);
// Non-admin should get exit code 1
expect(result.code).toEqual(1);
const output = result.stdout + result.stderr;
expect(output).toContain("Missing folder.meta.yaml");
expect(output).toContain("wmill folder add-missing");
});
});
test("no warning for deleted changes without folder.meta.yaml", async () => {
await withIsolatedWorkspace(async ({ tempDir, runCLICommand, apiRequest }) => {
const uniqueId = Date.now();
const folderName = `delfolder${uniqueId}`;
// Create folder and script on remote via API
await apiRequest(
"/folders/create",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: folderName }),
}
);
await writeFile(join(tempDir, "wmill.yaml"), wmillYaml(), "utf-8");
// Pull to get remote state, then delete the folder locally
await runCLICommand(["sync", "pull", "--yes"]);
// Remove the folder locally to trigger a "deleted" change
await rm(join(tempDir, "f", folderName), { recursive: true, force: true });
const result = await runCLICommand(
["sync", "push", "--yes", "--includes", `f/${folderName}/**`],
);
// Should not warn about missing meta for deleted items
const output = result.stdout + result.stderr;
expect(output).not.toContain("Missing folder.meta.yaml");
});
});
});
+70 -10
View File
@@ -40,8 +40,8 @@ export interface TestBackend {
stop(): Promise<void>;
reset(): Promise<void>;
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any;
runCLICommand(args: string[], workingDir: string, workspaceName?: string): Promise<{
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any;
runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): Promise<{
stdout: string;
stderr: string;
code: number;
@@ -97,12 +97,12 @@ class CargoBackendAdapter implements TestBackend {
await this.backend.reset();
}
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any {
return this.backend.createCLICommand(args, workingDir, workspaceName);
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any {
return this.backend.createCLICommand(args, workingDir, opts);
}
async runCLICommand(args: string[], workingDir: string, workspaceName?: string) {
return this.backend.runCLICommand(args, workingDir, workspaceName);
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }) {
return this.backend.runCLICommand(args, workingDir, opts);
}
async apiRequest(path: string, options?: RequestInit): Promise<Response> {
@@ -369,12 +369,12 @@ class ContainerizedBackendAdapter implements TestBackend {
await this.backend.reset();
}
createCLICommand(args: string[], workingDir: string, workspaceName?: string): any {
return this.backend.createCLICommand(args, workingDir, workspaceName);
createCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }): any {
return this.backend.createCLICommand(args, workingDir, opts);
}
async runCLICommand(args: string[], workingDir: string, workspaceName?: string) {
return this.backend.runCLICommand(args, workingDir, workspaceName);
async runCLICommand(args: string[], workingDir: string, opts?: { workspace?: string; token?: string }) {
return this.backend.runCLICommand(args, workingDir, opts);
}
async seedTestData(): Promise<void> {
@@ -507,6 +507,66 @@ function registerCleanup() {
}
}
/**
* Create a non-admin user, add them to the workspace, and return their token.
*/
export async function createNonAdminUser(
backend: TestBackend,
workspaceId: string = backend.workspace
): Promise<string> {
if (!backend.apiRequest) {
throw new Error("Backend does not support apiRequest");
}
const email = `nonadmin_${Date.now()}@test.dev`;
const password = "testpass123";
// Create user globally (as admin)
const createResp = await backend.apiRequest("/api/users/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
password,
super_admin: false,
name: "Non-Admin Test User",
}),
});
if (!createResp.ok) {
throw new Error(`Failed to create user: ${await createResp.text()}`);
}
await createResp.text();
// Add user to workspace as non-admin
const addResp = await backend.apiRequest(
`/api/w/${workspaceId}/workspaces/add_user`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
is_admin: false,
operator: false,
}),
}
);
if (!addResp.ok) {
throw new Error(`Failed to add user to workspace: ${await addResp.text()}`);
}
await addResp.text();
// Login as the non-admin user to get a token
const loginResp = await fetch(`${backend.baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!loginResp.ok) {
throw new Error(`Failed to login as non-admin: ${await loginResp.text()}`);
}
return await loginResp.text();
}
// Re-export for convenience
export type { CargoBackendConfig } from "./cargo_backend.ts";
export type { ContainerConfig } from "./containerized_backend.ts";