Merge remote-tracking branch 'origin/main' into datatable-perms-3

# Conflicts:
#	backend/ee-repo-ref.txt
This commit is contained in:
Diego Imbert
2026-08-31 22:01:47 +02:00
247 changed files with 12191 additions and 3048 deletions
+8 -1
View File
@@ -191,7 +191,14 @@ function createSveltePlugin(appDir: string): any {
// Convert Svelte syntax to JavaScript
try {
const { js, warnings } = svelte.compile(source, { filename });
// The raw-app editor's in-browser bundler compiles with
// `css: "injected"`, so this must too, or the same app renders
// styled there and unstyled once the CLI builds it: Svelte's default
// ("external") hands the <style> back on a `css` field nothing emits.
const { js, warnings } = svelte.compile(source, {
filename,
css: "injected",
});
const contents = js.code + `//# sourceMappingURL=` + js.map.toUrl();
return { contents, warnings: warnings.map(convertMessage) };
} catch (e: any) {
+108 -44
View File
@@ -26,37 +26,33 @@ async function readDirRecursive(
return out;
}
/** The workspace's shared UI store, or undefined when it cannot be read. */
async function fetchSharedUi(
workspace: string,
): Promise<Record<string, string> | undefined> {
try {
const got = await wmill.getSharedUi({ workspace });
return got.files ?? {};
} catch {
return undefined;
}
}
export type SharedUiChange =
| { type: "added"; path: string }
| { type: "edited"; path: string; before: string; after: string }
| { type: "deleted"; path: string };
/**
* Diff the local <cwd>/ui/ folder against the workspace's shared UI store in
* the push direction (local -> remote), returning entries whose `path` is
* prefixed with `ui/`. This is the same comparison pushSharedUi applies, so the
* dry-run preview and the real push never diverge.
*
* Mirrors pushSharedUi's no-op: with no local ui/ folder there is nothing to
* push, so the apply is a no-op and the preview must be empty (even when the
* remote store is non-empty) to avoid phantom diffs the apply won't perform.
* The push-direction diff (local -> remote) of two already-read file maps.
* Split out so the preview and the push itself compare identically off one
* read of each side.
*/
export async function diffSharedUi(workspace: string): Promise<SharedUiChange[]> {
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
if (!fs.existsSync(localDir)) {
return [];
}
const files = await readDirRecursive(localDir);
let remote: Record<string, string> = {};
try {
const got = await wmill.getSharedUi({ workspace });
remote = got.files ?? {};
} catch {
// If endpoint missing or unauthorized, treat remote as empty (the push
// would attempt the PUT anyway).
}
function sharedUiChanges(
files: Record<string, string>,
remote: Record<string, string>,
keepDeleted?: boolean,
): SharedUiChange[] {
// Use Object.hasOwn, not `in`: a file named after an Object.prototype member
// (e.g. ui/toString) would otherwise register as always-present and be
// misdiffed.
@@ -69,34 +65,97 @@ export async function diffSharedUi(workspace: string): Promise<SharedUiChange[]>
changes.push({ type: "edited", path: p, before: remote[rel], after: content });
}
}
for (const rel of Object.keys(remote)) {
if (!Object.hasOwn(files, rel)) {
changes.push({ type: "deleted", path: `${SHARED_UI_DIR}/${rel}` });
if (!keepDeleted) {
for (const rel of Object.keys(remote)) {
if (!Object.hasOwn(files, rel)) {
changes.push({ type: "deleted", path: `${SHARED_UI_DIR}/${rel}` });
}
}
}
return changes;
}
/**
* Diff the local <cwd>/ui/ folder against the workspace's shared UI store in
* the push direction (local -> remote), returning entries whose `path` is
* prefixed with `ui/`. This is the same comparison pushSharedUi applies, so the
* dry-run preview and the real push never diverge.
*
* Mirrors pushSharedUi's no-ops: with no local ui/ folder, or under
* `keepDeleted` with an unreadable store, the apply does nothing, so the
* preview must be empty (even when the remote store is non-empty) to avoid
* phantom diffs the apply won't perform.
*/
export async function diffSharedUi(
workspace: string,
keepDeleted?: boolean,
): Promise<SharedUiChange[]> {
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
if (!fs.existsSync(localDir)) {
return [];
}
const files = await readDirRecursive(localDir);
const remote = await fetchSharedUi(workspace);
if (remote === undefined && keepDeleted) {
return [];
}
// If endpoint missing or unauthorized, treat remote as empty (the push
// would attempt the PUT anyway).
return sharedUiChanges(files, remote ?? {}, keepDeleted);
}
/**
* Push the local <cwd>/ui/ folder to the workspace's shared UI store.
* Returns true if a push was performed, false if the folder is missing or
* already matches the remote store. Note an empty-but-existing folder still
* pushes an empty map (clearing the remote store) if the remote is non-empty.
* pushes an empty map (clearing the remote store) if the remote is non-empty —
* unless `keepDeleted`, which folds remote-only files back into the map.
*/
export async function pushSharedUi(workspace: string): Promise<boolean> {
export async function pushSharedUi(
workspace: string,
keepDeleted?: boolean,
): Promise<boolean> {
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
if (!fs.existsSync(localDir)) {
return false;
}
// Skip if no change — reuse diffSharedUi so preview and push never diverge.
const diff = await diffSharedUi(workspace);
if (diff.length === 0) {
const files = await readDirRecursive(localDir);
const remote = await fetchSharedUi(workspace);
// The store is written whole, so a remote-only file is pruned by omission
// alone. An unreadable store leaves no way to carry those files over, so
// the shared UI is left untouched rather than cleared.
if (remote === undefined && keepDeleted) {
log.warn(
colors.yellow(
"Could not read the shared UI folder from the remote; skipping its push so --keep-deleted does not clear it.",
),
);
return false;
}
// Same comparison as diffSharedUi, off the same two maps, so preview and
// push never diverge.
if (sharedUiChanges(files, remote ?? {}, keepDeleted).length === 0) {
log.info(colors.gray("Shared UI folder up to date"));
return false;
}
const files = await readDirRecursive(localDir);
if (keepDeleted) {
for (const [rel, content] of Object.entries(remote!)) {
// defineProperty, not assignment: `files.__proto__ = "..."` runs the
// inherited setter and creates no own property, so a remote `ui/__proto__`
// would be dropped from the whole-map PUT — i.e. deleted despite the flag.
if (!Object.hasOwn(files, rel)) {
Object.defineProperty(files, rel, {
value: content,
writable: true,
enumerable: true,
configurable: true,
});
}
}
}
await wmill.updateSharedUi({
workspace,
requestBody: { files },
@@ -111,9 +170,12 @@ export async function pushSharedUi(workspace: string): Promise<boolean> {
/**
* Pull the workspace's shared UI store into <cwd>/ui/.
* Files removed remotely are also removed locally.
* Files removed remotely are also removed locally, unless `keepDeleted`.
*/
export async function pullSharedUi(workspace: string): Promise<boolean> {
export async function pullSharedUi(
workspace: string,
keepDeleted?: boolean,
): Promise<boolean> {
const localDir = path.join(process.cwd(), SHARED_UI_DIR);
let got;
try {
@@ -145,15 +207,17 @@ export async function pullSharedUi(workspace: string): Promise<boolean> {
}
// Delete locally-orphaned files
const known = new Set(Object.keys(files));
const local = await readDirRecursive(localDir);
for (const rel of Object.keys(local)) {
if (!known.has(rel)) {
const full = path.join(localDir, rel);
try {
fs.unlinkSync(full);
} catch {
// ignore
if (!keepDeleted) {
const known = new Set(Object.keys(files));
const local = await readDirRecursive(localDir);
for (const rel of Object.keys(local)) {
if (!known.has(rel)) {
const full = path.join(localDir, rel);
try {
fs.unlinkSync(full);
} catch {
// ignore
}
}
}
}
+79 -15
View File
@@ -3174,6 +3174,28 @@ export function untrackedDatatableMigrationDeletions<
);
}
/**
* `--keep-deleted`: strip every deletion from the changeset, in place, so the
* sync only adds and updates. A path missing on one side is not on its own
* evidence that it should go from the other — a partial clone, a scoped
* checkout or an item authored in the UI all read as deletions here.
*/
function dropDeletions(changes: Change[], keptOn: "local" | "remote"): void {
const deletions = changes.filter((c) => c.name === "deleted");
if (deletions.length === 0) return;
const kept = changes.filter((c) => c.name !== "deleted");
changes.length = 0;
changes.push(...kept);
log.info(
colors.yellow(
`--keep-deleted: keeping ${deletions.length} item(s) that exist only ` +
(keptOn === "local"
? `on disk instead of deleting them locally`
: `on the remote instead of deleting them from the workspace`),
),
);
}
interface ChangeTracker {
scripts: string[];
flows: string[];
@@ -3433,6 +3455,7 @@ export async function pull(
repository?: string;
promotion?: string;
branch?: string;
keepDeleted?: boolean;
useIndividualBranch?: boolean;
groupByFolder?: boolean;
gitDeployItems?: string;
@@ -3684,6 +3707,10 @@ export async function pull(
await isCaseInsensitiveFilesystem(process.cwd()),
);
if (opts.keepDeleted) {
dropDeletions(changes, "local");
}
log.info(
`remote (${workspace.name}) -> local: ${changes.length} changes to apply`,
);
@@ -4117,10 +4144,14 @@ export async function pull(
}
}
try {
await pullSharedUi(workspace.workspaceId);
} catch (e) {
log.warn(`Failed to pull shared UI folder: ${e}`);
// Skipped under --dry-run since pullSharedUi writes to the local ui/ folder.
// An empty changeset falls through the return above and reaches here.
if (!opts.dryRun) {
try {
await pullSharedUi(workspace.workspaceId, opts.keepDeleted);
} catch (e) {
log.warn(`Failed to pull shared UI folder: ${e}`);
}
}
// Datatable migrations are part of the workspace export now, so they flow
@@ -4425,12 +4456,19 @@ function removeSuffix(str: string, suffix: string) {
}
// Shown after a `wmill sync push --dry-run` preview that has changes. `sync push`
// deploys to the remote workspace and is destructive (it overwrites and prunes
// remote items that differ from or are absent locally), so the preview reminds
// the caller — especially an AI agent that ran the dry-run to inspect changes —
// to get explicit user confirmation before applying it for real.
const SYNC_PUSH_DESTRUCTIVE_WARNING =
"`wmill sync push` is destructive: applying it deploys these changes to the remote workspace and overwrites or deletes remote items that differ from or are absent locally — this is not automatically reversible. If you are an AI agent, do NOT run `wmill sync push` (without --dry-run) until the user has explicitly confirmed this deploy, unless your custom instructions explicitly allow bypassing that confirmation.";
// deploys to the remote workspace and is destructive (it overwrites remote items
// that differ from local, and prunes those absent locally unless --keep-deleted),
// so the preview reminds the caller — especially an AI agent that ran the dry-run
// to inspect changes — to get explicit user confirmation before applying it for real.
function syncPushDestructiveWarning(keepDeleted?: boolean): string {
return (
"`wmill sync push` is destructive: applying it deploys these changes to the remote workspace and overwrites " +
(keepDeleted
? "remote items that differ from local"
: "or deletes remote items that differ from or are absent locally") +
" — this is not automatically reversible. If you are an AI agent, do NOT run `wmill sync push` (without --dry-run) until the user has explicitly confirmed this deploy, unless your custom instructions explicitly allow bypassing that confirmation."
);
}
// A script pushed without a local lock queues a server-side dependency job; if
// that job fails the script deploys broken (no lock/assets) with no CLI signal.
@@ -4490,6 +4528,7 @@ export async function push(
SyncOptions & {
repository?: string;
branch?: string;
keepDeleted?: boolean;
acceptOverridingPermissionedAsWithSelf?: boolean;
},
) {
@@ -4791,6 +4830,13 @@ export async function push(
);
}
// After the shared-lock pass, which reads a shared lockfile's deletion as the
// signal that this checkout is not deduplicated — an advisory about the local
// tree that holds whether or not remote items are being kept.
if (opts.keepDeleted) {
dropDeletions(changes, "remote");
}
const autoRegenerate = !!(opts as any).autoMetadata;
const staleScripts: string[] = [];
const staleFlows: string[] = [];
@@ -5103,7 +5149,10 @@ export async function push(
// unchanged (pushSharedUi still runs) and the summary count includes ui/.
if (opts.dryRun) {
try {
for (const c of await diffSharedUi(workspace.workspaceId)) {
for (const c of await diffSharedUi(
workspace.workspaceId,
opts.keepDeleted,
)) {
if (c.type === "added") {
changes.push({ name: "added", path: c.path, content: "" });
} else if (c.type === "deleted") {
@@ -5275,7 +5324,9 @@ export async function push(
: {}),
})),
total: changes.length,
...(changes.length > 0 ? { warning: SYNC_PUSH_DESTRUCTIVE_WARNING } : {}),
...(changes.length > 0
? { warning: syncPushDestructiveWarning(opts.keepDeleted) }
: {}),
};
console.log(JSON.stringify(result, null, 2));
return;
@@ -5339,7 +5390,9 @@ export async function push(
if (opts.dryRun) {
log.info(colors.gray(`Dry run complete.`));
log.warn(colors.yellow(`\n⚠ ${SYNC_PUSH_DESTRUCTIVE_WARNING}`));
log.warn(
colors.yellow(`\n⚠ ${syncPushDestructiveWarning(opts.keepDeleted)}`),
);
return;
}
@@ -6313,7 +6366,7 @@ export async function push(
}
}
try {
await pushSharedUi(workspace.workspaceId);
await pushSharedUi(workspace.workspaceId, opts.keepDeleted);
} catch (e) {
log.warn(`Failed to push shared UI folder: ${e}`);
}
@@ -6415,7 +6468,10 @@ export async function push(
let sharedUiPushed = false;
if (!opts.dryRun) {
try {
sharedUiPushed = await pushSharedUi(workspace.workspaceId);
sharedUiPushed = await pushSharedUi(
workspace.workspaceId,
opts.keepDeleted,
);
} catch (e) {
log.warn(`Failed to push shared UI folder: ${e}`);
}
@@ -6480,6 +6536,10 @@ const command = new Command()
.option("--include-groups", "Include syncing groups")
.option("--include-settings", "Include syncing workspace settings")
.option("--include-key", "Include workspace encryption key")
.option(
"--keep-deleted",
"Do not delete local files for items that no longer exist on the remote workspace. Only adds and updates.",
)
.option("--skip-branch-validation", "Skip git branch validation and prompts")
.option("--json-output", "Output results in JSON format")
.option(
@@ -6543,6 +6603,10 @@ const command = new Command()
"--skip-reencrypt-on-key-change",
"When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.",
)
.option(
"--keep-deleted",
"Do not delete remote items that no longer exist locally. Only adds and updates.",
)
.option("--skip-branch-validation", "Skip git branch validation and prompts")
.option("--json-output", "Output results in JSON format")
.option(
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.796.0";
export const VERSION = "1.800.0";
+26 -8
View File
@@ -7533,6 +7533,7 @@ sync local with a remote workspaces or the opposite (push or pull)
- \`--include-groups\` - Include syncing groups
- \`--include-settings\` - Include syncing workspace settings
- \`--include-key\` - Include workspace encryption key
- \`--keep-deleted\` - Do not delete local files for items that no longer exist on the remote workspace. Only adds and updates.
- \`--skip-branch-validation\` - Skip git branch validation and prompts
- \`--json-output\` - Output results in JSON format
- \`-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). Overrides wmill.yaml includes
@@ -7564,6 +7565,7 @@ sync local with a remote workspaces or the opposite (push or pull)
- \`--include-settings\` - Include syncing workspace settings
- \`--include-key\` - Include workspace encryption key
- \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.
- \`--keep-deleted\` - Do not delete remote items that no longer exist locally. Only adds and updates.
- \`--skip-branch-validation\` - Skip git branch validation and prompts
- \`--json-output\` - Output results in JSON format
- \`-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)
@@ -7949,7 +7951,9 @@ properties:
at once (1-65535)
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -8399,7 +8403,9 @@ properties:
as JSON
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -8566,7 +8572,9 @@ properties:
endpoint.
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -8690,7 +8698,9 @@ properties:
- v5
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -8786,7 +8796,9 @@ properties:
description: Array of NATS subjects to subscribe to
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -8876,7 +8888,9 @@ properties:
description: Name of the PostgreSQL logical replication slot to use
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -9123,7 +9137,9 @@ properties:
description: Array of SQS message attribute names to include with each message
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow
@@ -9322,7 +9338,9 @@ properties:
The extracted value replaces {{state}} in the heartbeat message.
error_handler_path:
type: string
description: Path to a script or flow to run when the triggered job fails
description: Path to a script to run when the triggered job fails. A bare path,
without the script/ or flow/ prefix a schedule error handler takes; it cannot
be a flow.
error_handler_args:
type: object
description: The arguments to pass to the script or flow