mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 16:01:42 +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:
@@ -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
Reference in New Issue
Block a user