diff --git a/claude-code-mods/wmill-sync/.claude-plugin/plugin.json b/claude-code-mods/wmill-sync/.claude-plugin/plugin.json new file mode 100644 index 0000000000..5fffa2a3bc --- /dev/null +++ b/claude-code-mods/wmill-sync/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "wmill-sync", + "version": "0.1.0", + "description": "Shows what differs between this wmill project and its Windmill workspace, with one-press push and pull", + "types": "./types/index.d.ts", + "userConfig": { + "wmillPath": { + "type": "string", + "title": "wmill executable", + "description": "Path to the wmill CLI when it is not on the PATH Claude Code runs with", + "default": "wmill" + } + } +} diff --git a/claude-code-mods/wmill-sync/README.md b/claude-code-mods/wmill-sync/README.md new file mode 100644 index 0000000000..ee68593554 --- /dev/null +++ b/claude-code-mods/wmill-sync/README.md @@ -0,0 +1,42 @@ +# wmill-sync + +A Claude Code mod that shows what differs between a wmill project folder and its Windmill workspace. + +Claude Code loads it on its own from the project's `.claude/skills/wmill-sync/` once the folder is trusted. It does nothing in a folder without a `wmill.yaml` in it or above it. + +## What it shows + +- **A band above the prompt** that appears only while something needs action: `wmill demo 1 to push · 1 to pull · 1 conflict [Push 1] [Pull 1] [Refresh] [Details]`. Details lists one row per item with its own Push, Pull, Diff or Resolve button. +- **Resolve** (on a conflict) opens the diff in a pane with three choices: Ask Claude to merge, Keep local (push), Keep workspace (pull). +- **`/wmill-sync`** runs a check now and prints the result, including "In sync". +- **Notes to Claude.** When Claude reads or edits a file (or names it in a shell command) whose item changed on the workspace, the tool result carries a note telling it to pull first. A `wmill … push` Claude runs goes through the permission prompt, named as a deploy. + +## How it decides the direction + +After every check, each item that matches the workspace is recorded with its local file hash and its workspace version (script hash, flow `edited_at`, app version). For an item that differs: + +- If its local hash moved since that record, it was edited locally, so it needs a push. +- If its workspace version moved, it changed on the workspace, so it needs a pull. +- If both moved, it's a conflict. + +Variables, resources, schedules and triggers have no version, so they never show as a conflict: a local change reads as a push, anything else as a pull. With no record yet, git decides: a file that differs from `HEAD` is a local edit. + +When the workspace has Git Sync configured, a git row shows how far the branch is ahead of and behind its upstream, with `git pull` and `git push` buttons. + +## Checks + +- At session start, and right after any `wmill` or `git` command Claude runs. +- About 2s after Claude edits a project file. +- Every 2 minutes. The timer runs the full check (the `wmill sync pull --dry-run` download) only when a workspace version or a local file changed, plus every fifth tick for the kinds without a version. + +## Requirements + +- The `wmill` CLI, logged in (`wmill workspace add`). +- If Claude Code can't find `wmill` on its PATH, set the plugin option `wmillPath` in `~/.claude/settings.json` under `pluginConfigs["wmill-sync@skills-dir"].options`. + +## Development + +``` +claude plugin validate . +claude plugin test . +``` diff --git a/claude-code-mods/wmill-sync/hooks/hooks.json b/claude-code-mods/wmill-sync/hooks/hooks.json new file mode 100644 index 0000000000..5a63f64959 --- /dev/null +++ b/claude-code-mods/wmill-sync/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.tsx"] } diff --git a/claude-code-mods/wmill-sync/hooks/model.ts b/claude-code-mods/wmill-sync/hooks/model.ts new file mode 100644 index 0000000000..480770eebc --- /dev/null +++ b/claude-code-mods/wmill-sync/hooks/model.ts @@ -0,0 +1,346 @@ +import type { ItemKind, SyncDirection, SyncItem } from '../types' + +export type ItemRef = { kind: ItemKind; path: string } + +export const itemKey = (item: ItemRef) => `${item.kind}:${item.path}` + +const FOLDER_ITEM = /^(.*?)(__flow|\.flow|__raw_app|\.raw_app|__app|\.app)\// +const TYPED_FILE = /^(.*)\.(variable|resource|resource-type|schedule|script)\.(yaml|json|lock)$/ +const TRIGGER_FILE = /^(.*)\.[a-z0-9]+_trigger\.(yaml|json)$/ +const RESOURCE_FILE = /^(.*)\.resource\.file\.[^/]+$/ +// Script code files whose language is spelled with two extensions. +const COMPOUND_CODE = /\.(pg|my|bq|sf|ms|odb|duckdb|oracle|fetch|bun|deno|native|nativets)\.[a-z0-9]+$/ +const ROOTS = ['f/', 'u/', 'g/'] + +const TYPED_KIND: Record = { + variable: 'variable', + resource: 'resource', + 'resource-type': 'resource_type', + schedule: 'schedule', + script: 'script', +} + +/** + * The workspace item a project file belongs to, from wmill's on-disk layout; + * undefined for files sync never takes (wmill.yaml, node_modules, docs). + */ +export const itemOfFile = (file: string): ItemRef | undefined => { + const rel = file.replace(/^\.\//, '') + if (!ROOTS.some(root => rel.startsWith(root))) return undefined + if (rel.split('/').some(part => part === 'node_modules' || part.startsWith('.'))) { + return undefined + } + + const folderItem = FOLDER_ITEM.exec(rel) + if (folderItem) { + const suffix = folderItem[2] ?? '' + const kind: ItemKind = suffix.includes('flow') ? 'flow' : 'app' + return { kind, path: folderItem[1] ?? '' } + } + if (rel.endsWith('/folder.meta.yaml') || rel.endsWith('/folder.meta.json')) { + return { kind: 'folder', path: rel.slice(0, rel.lastIndexOf('/')) } + } + const trigger = TRIGGER_FILE.exec(rel) + if (trigger) return { kind: 'trigger', path: trigger[1] ?? '' } + const resourceFile = RESOURCE_FILE.exec(rel) + if (resourceFile) return { kind: 'resource', path: resourceFile[1] ?? '' } + const typed = TYPED_FILE.exec(rel) + if (typed) { + const kind = TYPED_KIND[typed[2] ?? ''] + return kind ? { kind, path: typed[1] ?? '' } : undefined + } + if (/\.(yaml|yml|json|md|lock)$/.test(rel)) return undefined + const base = rel.slice(rel.lastIndexOf('/') + 1) + if (!base.includes('.')) return undefined + const path = COMPOUND_CODE.test(rel) ? rel.replace(COMPOUND_CODE, '') : rel.slice(0, rel.lastIndexOf('.')) + return { kind: 'script', path } +} + +export type WorkspaceBinding = { + name: string + baseUrl?: string + workspaceId?: string + gitBranch?: string +} + +/** + * The `workspaces:` map of a wmill.yaml, read line by line: the mod has no + * YAML library, and only the four scalar fields of each entry matter here. + */ +export const parseWorkspaceBindings = (yaml: string): WorkspaceBinding[] => { + const lines = yaml.split('\n') + const start = lines.findIndex(line => /^workspaces:/.test(line)) + if (start < 0) return [] + + const bindings: WorkspaceBinding[] = [] + let current: WorkspaceBinding | undefined + let entryIndent = -1 + for (const raw of lines.slice(start + 1)) { + const line = raw.replace(/\s+#.*$/, '') + if (line.trim() === '' || line.trim().startsWith('#')) continue + const indent = line.length - line.trimStart().length + if (indent === 0) break + const match = /^(\s*)([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line) + if (!match) continue + const value = (match[3] ?? '').replace(/^["']|["']$/g, '') + if (entryIndent < 0) entryIndent = indent + if (indent === entryIndent) { + current = { name: match[2] ?? '' } + bindings.push(current) + } else if (current && indent > entryIndent && value !== '') { + const field = match[2] + if (field === 'baseUrl' || field === 'workspaceId' || field === 'gitBranch') { + current[field] = value + } + } + } + return bindings.filter(binding => binding.name !== 'commonSpecificItems') +} + +export const normalizeRemote = (url: string) => url.replace(/\/+$/, '') + '/' + +export type BaseEntry = { localHash: string | null; version: string | null } + +export type Observed = { + /** Hash of the item's local files; null when it has none locally. */ + localHash: string | null + /** The workspace's version token (script hash, flow edited_at, app version). */ + version: string | null + /** Whether the item kind has a version token on the workspace at all. */ + hasVersion: boolean + /** Whether git sees any of the item's local files as changed or untracked. */ + gitDirty: boolean | null + existsLocally: boolean + existsRemotely: boolean +} + +/** + * Which way an item that differs needs to go, from the last-synced record + * when there is one and from git otherwise. + */ +export const classify = ( + base: BaseEntry | undefined, + seen: Observed, +): { direction: SyncDirection; why: string } => { + const local = seen.existsLocally ? 'edited locally' : 'deleted locally' + const remote = seen.existsRemotely ? 'changed on the workspace' : 'deleted on the workspace' + + // A record is only written while both sides match, so the item existed on + // both then: a change since is an edit or a deletion, never a creation. + if (base) { + const isLocalChanged = seen.localHash !== base.localHash + const isRemoteChanged = seen.hasVersion ? seen.version !== base.version : !isLocalChanged + if (isLocalChanged && isRemoteChanged) { + return { direction: 'conflict', why: `${local}, and ${remote}` } + } + if (isLocalChanged) return { direction: 'push', why: local } + if (isRemoteChanged) return { direction: 'pull', why: remote } + return { direction: 'differs', why: 'differs, no change recorded on either side' } + } + + if (seen.gitDirty === true) { + return { direction: 'push', why: seen.existsRemotely ? local : 'new locally' } + } + if (seen.gitDirty === false) { + return { direction: 'pull', why: seen.existsLocally ? remote : 'new on the workspace' } + } + return { direction: 'differs', why: 'differs, no sync recorded yet' } +} + +export const KIND_LABEL: Record = { + script: 'script', + flow: 'flow', + app: 'app', + variable: 'variable', + resource: 'resource', + resource_type: 'resource type', + schedule: 'schedule', + trigger: 'trigger', + folder: 'folder', +} + +export type DryRunChange = { type: string; path: string } + +export type LocalItem = { ref: ItemRef; files: string[]; hash: string } + +export const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, '') + +export const lastLine = (text: string) => + text + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .at(-1) ?? '' + +/** + * Groups the dry-run's file changes into items and decides each one's + * direction; returns the new last-synced record alongside, with every item + * that matches the workspace recorded at its current local hash and version. + */ +export const compare = (input: { + changes: DryRunChange[] + local: Map + versions: Map + base: Record + gitDirty: Set | null +}) => { + const differing = new Map; types: Set }>() + for (const change of input.changes) { + const ref = itemOfFile(change.path) + if (!ref) continue + const key = itemKey(ref) + const entry = differing.get(key) ?? { ref, files: new Set(), types: new Set() } + entry.files.add(change.path) + entry.types.add(change.type) + differing.set(key, entry) + } + + const nextBase: Record = {} + for (const [key, local] of input.local) { + if (differing.has(key)) continue + nextBase[key] = { localHash: local.hash, version: input.versions.get(key) ?? null } + } + + const items: SyncItem[] = [] + for (const [key, entry] of differing) { + const old = input.base[key] + if (old) nextBase[key] = old + const local = input.local.get(key) + const files = [...new Set([...entry.files, ...(local?.files ?? [])])].sort() + // Pull's view: "added" files exist only remotely, "deleted" only locally. + const isOnlyRemote = [...entry.types].every(type => type === 'added') + const isOnlyLocal = [...entry.types].every(type => type === 'deleted') + const verdict = classify(old, { + localHash: local?.hash ?? null, + version: input.versions.get(key) ?? null, + hasVersion: ['script', 'flow', 'app'].includes(entry.ref.kind), + gitDirty: input.gitDirty === null ? null : files.some(file => input.gitDirty?.has(file)), + existsLocally: local !== undefined && !isOnlyRemote, + existsRemotely: !isOnlyLocal, + }) + items.push({ key, kind: entry.ref.kind, path: entry.ref.path, files, ...verdict }) + } + + const order = { conflict: 0, push: 1, pull: 2, differs: 3 } + items.sort((a, b) => order[a.direction] - order[b.direction] || a.path.localeCompare(b.path)) + return { items, nextBase } +} + +export type FileDiff = { file: string; diff: string; isTruncated: boolean } + +const SGR = /\x1b\[([0-9;]*)m/g +const HEADER = /^[~+-] [a-z_-]+ (\S+)$/ +const CODE_LIMIT = 9_000 + +/** + * The per-file diffs `wmill sync pull --dry-run --show-diffs` prints, as + * unified hunks. The CLI marks lines by colour alone (red: the local text, + * green: the workspace's, white: both), so the colours are the parse. + */ +export const parseShowDiffs = (raw: string): FileDiff[] => { + const files: { file: string; lines: string[] }[] = [] + let color: 'local' | 'remote' | 'both' | null = null + for (const line of raw.split('\n')) { + let lineColor: typeof color = null + let isVisibleSeen = false + let last = 0 + for (const match of line.matchAll(SGR)) { + if (!isVisibleSeen && match.index > last) isVisibleSeen = true + last = match.index + match[0].length + const codes = (match[1] ?? '').split(';') + const next = codes.includes('31') + ? 'local' + : codes.includes('32') + ? 'remote' + : codes.includes('37') + ? 'both' + : codes.includes('0') || codes.includes('39') + ? null + : undefined + if (next === undefined) continue + if (!isVisibleSeen) lineColor = next + color = next + } + const text = line.replace(SGR, '') + const header = HEADER.exec(text) + if (header) { + files.push({ file: header[1] ?? '', lines: [] }) + continue + } + const current = files.at(-1) + if (!current || text.startsWith('Dry run complete')) continue + const kind = lineColor ?? color + if (kind === null) { + if (text.trim() === '') current.lines.push('') + continue + } + current.lines.push(`${kind === 'local' ? '-' : kind === 'remote' ? '+' : ' '}${text}`) + } + + return files.map(({ file, lines }) => { + while (lines.length > 0 && lines.at(-1) === '') lines.pop() + const body = lines.map(line => (line === '' ? ' ' : line)) + const removed = body.filter(line => !line.startsWith('+')).length + const added = body.filter(line => !line.startsWith('-')).length + const diff = [ + `--- local/${file}`, + `+++ workspace/${file}`, + `@@ -1,${removed} +1,${added} @@`, + ...body, + ].join('\n') + return diff.length > CODE_LIMIT + ? { file, diff: diff.slice(0, CODE_LIMIT), isTruncated: true } + : { file, diff, isTruncated: false } + }) +} + +/** The workspace items a tool call names: a file tool's path, or project paths in a shell command. */ +export const touchedItems = (root: string, input: { file_path?: unknown; command?: unknown }) => { + const paths: string[] = [] + if (typeof input.file_path === 'string' && input.file_path.startsWith(`${root}/`)) { + paths.push(input.file_path.slice(root.length + 1)) + } + if (typeof input.command === 'string') { + for (const match of input.command.matchAll(/(?:^|[\s'"=(])((?:\.\/)?[fug]\/[^\s'";|&)]+)/g)) { + paths.push((match[1] ?? '').replace(/^\.\//, '')) + } + for (const match of input.command.split(`${root}/`).slice(1)) { + paths.push(match.split(/[\s'";|&)]/)[0] ?? '') + } + } + const refs = new Map() + for (const path of paths) { + const ref = itemOfFile(path) + if (ref) refs.set(itemKey(ref), ref) + } + return [...refs.values()] +} + +/** + * The items a shell command syncs (a `wmill sync pull|push` that is not a + * dry-run, or a per-item push): `all` when it takes the whole project. + */ +export const syncedByCommand = (command: string): ItemRef[] | 'all' | undefined => { + const runs = command.split(/&&|\|\||;|\n/).map(part => part.trim()) + let synced: ItemRef[] | 'all' | undefined + for (const run of runs) { + if (/--dry-run/.test(run)) continue + const sync = /\bwmill\s+sync\s+(pull|push)\b(.*)$/.exec(run) + const single = /\bwmill\s+(script|flow|app)\s+push\s+(\S+)/.exec(run) + if (sync) { + const includes = /(?:-i|--includes)(?:\s+|=)("[^"]*"|'[^']*'|\S+)/.exec(sync[2] ?? '') + if (!includes) return 'all' + const paths = (includes[1] ?? '').replace(/^["']|["']$/g, '').split(',') + const refs = paths + .map(path => itemOfFile(path.trim())) + .filter((ref): ref is ItemRef => ref !== undefined) + synced = [...(synced === 'all' ? [] : (synced ?? [])), ...refs] + } else if (single) { + const path = (single[2] ?? '').replace(/^["']|["']$/g, '').replace(/^\.\//, '') + // A flow or app is pushed by its folder, which names no file in it. + const ref = itemOfFile(path) ?? itemOfFile(`${path.replace(/\/+$/, '')}/flow.yaml`) + if (ref) synced = [...(synced === 'all' ? [] : (synced ?? [])), ref] + } + } + return synced +} diff --git a/claude-code-mods/wmill-sync/hooks/register.tsx b/claude-code-mods/wmill-sync/hooks/register.tsx new file mode 100644 index 0000000000..39ecb3377f --- /dev/null +++ b/claude-code-mods/wmill-sync/hooks/register.tsx @@ -0,0 +1,911 @@ +import { atom, read, update } from 'claude-code' +import type { EngineInterface, Register } from 'claude-code' + +import type { GitSyncInfo, SyncItem, SyncStatus, SyncTarget } from '../types' +import { + type BaseEntry, + compare, + type DryRunChange, + itemKey, + itemOfFile, + type ItemRef, + KIND_LABEL, + lastLine, + type LocalItem, + normalizeRemote, + parseWorkspaceBindings, + syncedByCommand, + touchedItems, + parseShowDiffs, + stripAnsi, +} from './model' + +type $ = EngineInterface + +export type Target = SyncTarget & { token: string } + +export class SyncError extends Error {} + +const WMILL_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '~/.bun/bin', '~/.npm-global/bin', '~/.local/bin'] + +/** The folder holding wmill.yaml, walking up from the session's root. */ +export const findProjectRoot = async ($: $): Promise => { + let dir = (await $.session.root()).replace(/\/+$/, '') + while (dir !== '') { + if (await $.fs.exists(`${dir}/wmill.yaml`)) return dir + dir = dir.slice(0, dir.lastIndexOf('/')) + } + return null +} + +const home = async ($: $) => (await $.env.get('HOME')) ?? '' + +/** + * Desktop and IDE hosts start Claude Code without the login shell's PATH, so + * `wmill` (and the `node` its shebang asks for) are looked up in the usual + * install folders too. + */ +const childEnv = async ($: $, isColored: boolean) => { + const homeDir = await home($) + const extra = WMILL_DIRS.map(dir => dir.replace(/^~/, homeDir)) + const path = (await $.env.get('PATH')) ?? '/usr/bin:/bin' + return { PATH: [...extra, ...path.split(':')].join(':'), FORCE_COLOR: isColored ? '1' : '0' } +} + +export const runWmill = async ( + $: $, + wmill: string, + root: string, + args: string[], + timeoutMs = 120_000, + isColored = false, +) => { + const result = await $.process + .run([wmill, ...args], { cwd: root, env: await childEnv($, isColored), timeoutMs }) + .catch((error: unknown) => { + throw new SyncError( + `could not run ${wmill} (${String(error).slice(0, 120)}). Install the wmill CLI or set wmill-sync's "wmill executable" option.`, + ) + }) + if (isColored) return result + return { ...result, stdout: stripAnsi(result.stdout), stderr: stripAnsi(result.stderr) } +} + +const runGit = async ($: $, root: string, args: string[]) => { + const result = await $.process.run(['git', '-C', root, ...args]).catch(() => undefined) + return result?.exitCode === 0 ? result.stdout : undefined +} + +// ---------------------------------------------------------------- target + +const configDir = async ($: $) => { + const override = await $.env.get('WMILL_CONFIG_DIR') + if (override) return `${override.replace(/\/+$/, '')}/windmill` + const homeDir = await home($) + if (await $.fs.exists(`${homeDir}/Library/Preferences`)) { + return `${homeDir}/Library/Preferences/windmill` + } + const xdg = await $.env.get('XDG_CONFIG_HOME') + return `${xdg ?? `${homeDir}/.config`}/windmill` +} + +type Profile = { name: string; remote: string; workspaceId: string; token: string } + +const readProfiles = async ($: $, dir: string): Promise => { + const text = await $.fs.read(`${dir}/remotes.ndjson`).catch(() => '') + return text + .split('\n') + .filter(line => line.trim() !== '') + .flatMap(line => { + try { + return [JSON.parse(line) as Profile] + } catch { + return [] + } + }) +} + +/** + * The workspace this project syncs with, resolved the way the CLI does for + * the common setups: a wmill.yaml binding for the current git branch, else + * the active profile. The token is the CLI's own and never leaves memory. + */ +export const resolveTarget = async ($: $, root: string): Promise => { + const dir = await configDir($) + const profiles = await readProfiles($, dir) + const yaml = await $.fs.read(`${root}/wmill.yaml`).catch(() => '') + const bindings = parseWorkspaceBindings(yaml) + + if (bindings.length > 0) { + const branch = (await runGit($, root, ['rev-parse', '--abbrev-ref', 'HEAD']))?.trim() + const binding = + bindings.find(one => (one.gitBranch ?? one.name) === branch) ?? + (bindings.length === 1 ? bindings[0] : undefined) + if (binding) { + const workspaceId = binding.workspaceId ?? binding.name + const profile = + profiles.find( + one => + one.workspaceId === workspaceId && + (!binding.baseUrl || normalizeRemote(one.remote) === normalizeRemote(binding.baseUrl)), + ) ?? profiles.find(one => one.name === binding.name) + if (!profile) { + throw new SyncError( + `no wmill profile for workspace "${workspaceId}". Run \`wmill workspace add\` for it.`, + ) + } + return { + remote: normalizeRemote(binding.baseUrl ?? profile.remote), + workspaceId, + token: profile.token, + source: `wmill.yaml "${binding.name}"`, + } + } + } + + const active = (await $.fs.read(`${dir}/activeWorkspace`).catch(() => '')).trim() + const profile = profiles.find(one => one.name === active) + if (!profile) { + throw new SyncError('no active wmill workspace. Run `wmill workspace add` or `wmill workspace switch`.') + } + return { + remote: normalizeRemote(profile.remote), + workspaceId: profile.workspaceId, + token: profile.token, + source: `profile "${profile.name}"`, + } +} + +// ---------------------------------------------------------------- remote + +const api = async ($: $, target: Target, path: string) => { + const url = `${target.remote}api/w/${target.workspaceId}/${path}` + const response = await $.http + .fetch(url, { headers: { authorization: `Bearer ${target.token}` } }) + .catch(() => { + throw new SyncError(`cannot reach ${target.remote}; is the instance up?`) + }) + if (response.status === 401 || response.status === 403) { + throw new SyncError( + `the wmill token for ${target.workspaceId} was refused. Run \`wmill workspace add\` again.`, + ) + } + if (!response.ok) { + throw new SyncError(`${path} answered ${response.status}`) + } + return JSON.parse(response.text) as unknown +} + +const listAll = async ($: $, target: Target, endpoint: string) => { + const rows: Record[] = [] + for (let page = 1; page < 50; page += 1) { + const batch = (await api($, target, `${endpoint}?per_page=1000&page=${page}`)) as Record< + string, + unknown + >[] + rows.push(...batch) + if (batch.length < 1000) break + } + return rows +} + +/** Version tokens of the kinds that have one, keyed by item key. */ +export const remoteVersions = async ($: $, target: Target) => { + const [scripts, flows, apps] = await Promise.all([ + listAll($, target, 'scripts/list'), + listAll($, target, 'flows/list'), + listAll($, target, 'apps/list'), + ]) + const versions = new Map() + for (const row of scripts) + versions.set(itemKey({ kind: 'script', path: String(row.path) }), String(row.hash)) + for (const row of flows) + versions.set(itemKey({ kind: 'flow', path: String(row.path) }), String(row.edited_at)) + for (const row of apps) versions.set(itemKey({ kind: 'app', path: String(row.path) }), String(row.version)) + return versions +} + +export const VERSIONED = new Set(['script', 'flow', 'app']) + +export const gitSyncMode = async ($: $, target: Target) => { + const mode = (await api($, target, 'workspaces/git_sync_deploy_mode').catch(() => undefined)) as + { configured?: boolean; deploy_on_push?: boolean } | undefined + return mode?.configured ? { deployOnPush: mode.deploy_on_push === true } : null +} + +export const gitAheadBehind = async ( + $: $, + root: string, + deployOnPush: boolean, + isFetching: boolean, +): Promise => { + const branch = (await runGit($, root, ['rev-parse', '--abbrev-ref', 'HEAD']))?.trim() + if (!branch) return null + if (isFetching) await runGit($, root, ['fetch', '--quiet']) + const upstream = (await runGit($, root, ['rev-parse', '--abbrev-ref', '@{upstream}']))?.trim() ?? null + if (!upstream) return { deployOnPush, branch, upstream: null, ahead: 0, behind: 0 } + const counts = ( + await runGit($, root, ['rev-list', '--left-right', '--count', `HEAD...${upstream}`]) + )?.trim() + const [ahead = 0, behind = 0] = (counts ?? '0 0').split(/\s+/).map(Number) + return { deployOnPush, branch, upstream, ahead, behind } +} + +// ---------------------------------------------------------------- local + +type FileStamp = { mtimeMs: number; size: number; hash: string } + +const fileHashes = new Map() + +const sha256 = async (text: string) => { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)) + return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('') +} + +const walk = async ( + $: $, + root: string, + rel: string, + out: { rel: string; mtimeMs: number; size: number }[], +) => { + const entries = await $.fs.list(`${root}/${rel}`).catch(() => []) + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue + const child = `${rel}/${entry.name}` + if (entry.kind === 'dir') await walk($, root, child, out) + else if (entry.kind === 'file') out.push({ rel: child, mtimeMs: entry.mtimeMs, size: entry.size }) + } +} + +/** + * Every item with files in the project, hashed over its files' contents. + * A file's hash is reused while its mtime and size stay the same. + */ +export const scanLocal = async ($: $, root: string) => { + const files: { rel: string; mtimeMs: number; size: number }[] = [] + for (const top of ['f', 'u', 'g']) { + if (await $.fs.exists(`${root}/${top}`)) await walk($, root, top, files) + } + + const byItem = new Map() + for (const file of files) { + const ref = itemOfFile(file.rel) + if (!ref) continue + const key = itemKey(ref) + const entry = byItem.get(key) ?? { ref, files: [] } + entry.files.push(file.rel) + byItem.set(key, entry) + } + + const stamps = new Map(files.map(file => [file.rel, file])) + const items = new Map() + for (const [key, entry] of byItem) { + const parts: string[] = [] + for (const rel of entry.files.sort()) { + const stamp = stamps.get(rel) + const absolute = `${root}/${rel}` + const cached = fileHashes.get(absolute) + let hash = cached?.hash + if (!cached || !stamp || cached.mtimeMs !== stamp.mtimeMs || cached.size !== stamp.size) { + hash = await sha256(await $.fs.read(absolute).catch(() => '')) + fileHashes.set(absolute, { mtimeMs: stamp?.mtimeMs ?? 0, size: stamp?.size ?? 0, hash }) + } + parts.push(`${rel}:${hash}`) + } + items.set(key, { ref: entry.ref, files: entry.files, hash: await sha256(parts.join('\n')) }) + } + const fingerprint = files + .map(file => `${file.rel}:${file.mtimeMs}:${file.size}`) + .sort() + .join('\n') + return { items, fingerprint } +} + +/** Project-relative paths git reports as modified, deleted or untracked; null outside git. */ +export const gitDirtyFiles = async ($: $, root: string) => { + const prefix = await runGit($, root, ['rev-parse', '--show-prefix']) + if (prefix === undefined) return null + const status = await runGit($, root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', '.']) + if (status === undefined) return null + const dirty = new Set() + const records = status.split('\0') + for (let index = 0; index < records.length; index += 1) { + const record = records[index] ?? '' + if (record.length < 4) continue + const code = record.slice(0, 2) + const path = record.slice(3) + dirty.add(path.startsWith(prefix.trim()) ? path.slice(prefix.trim().length) : path) + // A rename's record is followed by its source path. + if (code.startsWith('R') || code.startsWith('C')) index += 1 + } + return dirty +} + +// ---------------------------------------------------------------- compare + +export const dryRunPull = async ($: $, wmill: string, root: string): Promise => { + const run = await runWmill($, wmill, root, ['sync', 'pull', '--dry-run', '--json-output']) + const start = run.stdout.search(/^\{/m) + if (run.exitCode !== 0 || start < 0) { + throw new SyncError(`wmill sync pull --dry-run failed: ${lastLine(run.stderr) || lastLine(run.stdout)}`) + } + const parsed = JSON.parse(run.stdout.slice(start)) as { changes?: DryRunChange[] } + return parsed.changes ?? [] +} + +const POLL_MS = 120_000 +const EDIT_SETTLE_MS = 2_000 +// A file tool touching an item without a version rechecks first past this age. +const STALE_MS = 60_000 +// Stands for "whatever the files held right after a sync command": never a real hash. +const SYNCED_BY_COMMAND = 'synced-by-command' +// Variables, resources and schedules carry no version, so only a dry-run +// sees them change remotely: every this-many polls one runs regardless. +const FULL_CHECK_EVERY = 5 + +const EMPTY: SyncStatus = { + phase: 'inactive', + root: null, + target: null, + items: [], + checkedAt: null, + error: null, + gitSync: null, +} + +const status = atom({ plugin: 'wmill-sync', key: 'status' } as const, EMPTY) +const isExpanded = atom({ plugin: 'wmill-sync', key: 'isExpanded' } as const, false) +const busy = atom({ plugin: 'wmill-sync', key: 'busy' } as const, null) +const diffView = atom({ plugin: 'wmill-sync', key: 'diff' } as const, null) +const DIFF_PANE = 'wmill-sync-diff' + +const MARK = { push: '↑', pull: '↓', conflict: '!', differs: '≠' } as const +const COLOR = { push: 'cyan', pull: 'yellow', conflict: 'red', differs: 'magenta' } as const + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}` + +export const summarize = (items: SyncItem[]) => { + const count = (direction: SyncItem['direction']) => + items.filter(item => item.direction === direction).length + return [ + count('push') > 0 && `${count('push')} to push`, + count('pull') > 0 && `${count('pull')} to pull`, + count('conflict') > 0 && plural(count('conflict'), 'conflict'), + count('differs') > 0 && `${count('differs')} differing`, + ] + .filter(Boolean) + .join(' · ') +} + +const fit = (text: string, width: number, cut: 'middle' | 'end') => { + if (text.length <= width) return text.padEnd(width) + if (width <= 1) return '…'.slice(0, width) + if (cut === 'end') return `${text.slice(0, width - 1)}…` + const head = Math.ceil((width - 1) / 2) + return `${text.slice(0, head)}…${text.slice(text.length - (width - 1 - head))}` +} + +const describe = (item: SyncItem) => `${KIND_LABEL[item.kind]} ${item.path}` + +const baseKey = (root: string, remote: string, workspaceId: string) => `base:${root}|${remote}|${workspaceId}` + +let wmill = 'wmill' +let isRefreshing = false +let isRefreshQueued = false +let pollCount = 0 +let lastSeen: { versions: Map; fingerprint: string } | null = null +let gitSync: { remote: string; mode: { deployOnPush: boolean } | null } | null = null +let editTimer: { cancel: () => void } | null = null +const noted = new Set() + +async function setStatus($: $, patch: Partial) { + await update($, status, current => ({ ...current, ...patch })) +} + +/** + * The full comparison: scan local files, read workspace versions, run the + * CLI's pull dry-run, classify each differing item and move the last-synced + * record forward for every item that matches. + */ +async function refresh($: $, options: { isFetchingGit?: boolean } = {}) { + if (isRefreshing) { + isRefreshQueued = true + return + } + isRefreshing = true + try { + const root = await findProjectRoot($) + if (!root) { + await setStatus($, { ...EMPTY }) + return + } + await setStatus($, { phase: 'checking', root }) + const target = await resolveTarget($, root) + const [{ items: local, fingerprint }, versions, changes, dirty] = await Promise.all([ + scanLocal($, root), + remoteVersions($, target), + dryRunPull($, wmill, root), + gitDirtyFiles($, root), + ]) + if (gitSync?.remote !== `${target.remote}|${target.workspaceId}`) { + gitSync = { remote: `${target.remote}|${target.workspaceId}`, mode: await gitSyncMode($, target) } + } + const key = baseKey(root, target.remote, target.workspaceId) + const base = ((await $.store.get(key)) ?? {}) as Record + const { items, nextBase } = compare({ changes, local, versions, base, gitDirty: dirty }) + await $.store.set(key, nextBase) + const git = gitSync.mode + ? await gitAheadBehind($, root, gitSync.mode.deployOnPush, options.isFetchingGit === true) + : null + lastSeen = { versions, fingerprint } + await setStatus($, { + phase: 'ready', + root, + target: { remote: target.remote, workspaceId: target.workspaceId, source: target.source }, + items, + checkedAt: await $.clock.now(), + error: null, + gitSync: git, + }) + } catch (error) { + const message = + error instanceof SyncError ? error.message : `check failed: ${String(error).slice(0, 200)}` + await setStatus($, { phase: 'error', error: message, checkedAt: await $.clock.now() }) + } finally { + isRefreshing = false + if (isRefreshQueued) { + isRefreshQueued = false + void refresh($) + } + } +} + +/** The cheap timer check: a dry-run only when something moved on either side. */ +async function poll($: $) { + pollCount += 1 + const current = await read($, status) + if (current.phase === 'inactive' || !current.root) return + if (pollCount % FULL_CHECK_EVERY === 0 || !lastSeen) { + await refresh($, { isFetchingGit: true }) + return + } + try { + const target = await resolveTarget($, current.root) + const [versions, { fingerprint }] = await Promise.all([ + remoteVersions($, target), + scanLocal($, current.root), + ]) + const hasMoved = + JSON.stringify([...versions]) !== JSON.stringify([...lastSeen.versions]) || + fingerprint !== lastSeen.fingerprint + if (hasMoved || current.phase === 'error') await refresh($, { isFetchingGit: true }) + } catch { + await refresh($) + } +} + +/** + * Rechecks once edits settle. A shell command may have written project files + * through any program, so after one the local files are rescanned (no network) + * and the full check runs only when they moved. + */ +function refreshSoon($: $, options: { isLocalOnly?: boolean } = {}) { + editTimer?.cancel() + editTimer = $.clock.after(EDIT_SETTLE_MS, () => void refreshIfMoved($, options.isLocalOnly === true)) +} + +async function refreshIfMoved($: $, isLocalOnly: boolean) { + const { root } = await read($, status) + if (isLocalOnly && root && lastSeen) { + const { fingerprint } = await scanLocal($, root) + if (fingerprint === lastSeen.fingerprint) return + } + await refresh($) +} + +/** + * Records items a shell command just pulled or pushed as matching the + * workspace at its current version. A command that goes on to edit them + * (`pull && sed …`) leaves them differing, and that reads as a local edit + * rather than both sides changing. + */ +async function markSynced($: $, synced: ItemRef[] | 'all') { + const current = await read($, status) + if (!current.root) return + try { + const target = await resolveTarget($, current.root) + const versions = await remoteVersions($, target) + const key = baseKey(current.root, target.remote, target.workspaceId) + const base = ((await $.store.get(key)) ?? {}) as Record + const keys = synced === 'all' ? current.items.map(item => item.key) : synced.map(itemKey) + for (const itemKey of keys) + base[itemKey] = { localHash: SYNCED_BY_COMMAND, version: versions.get(itemKey) ?? null } + await $.store.set(key, base) + } catch { + // The next full check falls back to comparing against the older record. + } +} + +/** Whether the workspace version of any of these items moved since the last full check: three list calls. */ +async function hasRemoteMoved($: $, keys: string[]) { + const { root } = await read($, status) + if (!root || !lastSeen) return true + try { + const versions = await remoteVersions($, await resolveTarget($, root)) + return keys.some(key => versions.get(key) !== lastSeen?.versions.get(key)) + } catch { + return false + } +} + +async function act($: $, label: string, argv: string[], timeoutMs = 600_000) { + if ((await read($, busy)) !== null) return + const { root } = await read($, status) + if (!root) return + await update($, busy, () => label) + try { + const run = await runWmill( + $, + argv[0] === 'git' ? 'git' : wmill, + root, + argv[0] === 'git' ? argv.slice(1) : argv, + timeoutMs, + ) + if (run.exitCode === 0) { + $.ui.toast(`wmill-sync: ${label} done`) + } else { + $.ui.toast(`wmill-sync: ${label} failed: ${lastLine(run.stderr) || lastLine(run.stdout)}`, { + timeoutMs: 10_000, + }) + } + } catch (error) { + $.ui.toast(`wmill-sync: ${label} failed: ${error instanceof Error ? error.message : String(error)}`) + } finally { + await update($, busy, () => null) + } + await refresh($) +} + +function push($: $, items: SyncItem[]) { + return act($, items.length === 1 ? `push ${items[0]?.path}` : `push ${plural(items.length, 'item')}`, [ + 'sync', + 'push', + '--yes', + '--message', + 'Pushed from Claude Code (wmill-sync)', + '-i', + items.flatMap(item => item.files).join(','), + ]) +} + +function pull($: $, items: SyncItem[]) { + return act($, items.length === 1 ? `pull ${items[0]?.path}` : `pull ${plural(items.length, 'item')}`, [ + 'sync', + 'pull', + '--yes', + '-i', + items.flatMap(item => item.files).join(','), + ]) +} + +async function showDiff($: $, item: SyncItem) { + const { root } = await read($, status) + if (!root) return + const title = `${describe(item)}: local (−) vs workspace (+)` + await update($, diffView, () => ({ item, title, files: [], error: 'Loading the workspace version…' })) + await $.ui.open({ id: DIFF_PANE, title: `diff ${item.path}`, focus: true, closeOnEscape: true }) + try { + const run = await runWmill( + $, + wmill, + root, + ['sync', 'pull', '--dry-run', '--show-diffs', '-i', item.files.join(',')], + 120_000, + true, + ) + const files = parseShowDiffs(run.stdout) + const error = + run.exitCode !== 0 + ? lastLine(stripAnsi(run.stderr)) || lastLine(stripAnsi(run.stdout)) + : files.length === 0 + ? 'No differences left: the item matches the workspace now.' + : null + await update($, diffView, () => ({ item, title, files, error })) + } catch (caught) { + await update($, diffView, () => ({ + item, + title, + files: [], + error: caught instanceof Error ? caught.message : String(caught), + })) + } +} + +async function closeDiff($: $) { + await $.ui.close({ id: DIFF_PANE }) +} + +async function askToMerge($: $, item: SyncItem) { + const { target } = await read($, status) + const files = item.files.join(', ') + await $.prompt.submit({ + text: [ + `The Windmill ${describe(item)} changed both in this project and on the workspace ${target?.workspaceId ?? ''}.`, + `Its files: ${files}.`, + `Show me what changed remotely without overwriting my files (\`wmill sync pull --dry-run --show-diffs -i ${item.files.join(',')}\`),`, + 'then merge the workspace changes into the local files, keeping my local edits.', + 'Do not push: once I have reviewed the merge I will press "Keep local" in the wmill-sync panel, which pushes it.', + ].join(' '), + }) +} + +export const register: Register = (on, options) => { + wmill = typeof options.wmillPath === 'string' && options.wmillPath !== '' ? options.wmillPath : 'wmill' + + on('session.start', async ($, e, next) => { + const started = await next(e) + $.ui.status(undefined) + await $.command.register({ + name: 'wmill-sync', + description: 'Check what differs between this wmill project and its Windmill workspace', + }) + void refresh($) + $.clock.every(POLL_MS, () => void poll($)) + return started + }) + + on('command.run', { command: 'wmill-sync' }, async $ => { + await refresh($) + const current = await read($, status) + if (current.phase === 'inactive') return { text: 'No wmill.yaml in this project or above it.' } + if (current.phase === 'error') return { text: current.error ?? 'The check failed.' } + await update($, isExpanded, () => true) + const where = `workspace ${current.target?.workspaceId} on ${current.target?.remote}` + if (current.items.length === 0) return { text: `In sync with ${where}.` } + const lines = current.items.map(item => `${MARK[item.direction]} ${describe(item)}: ${item.why}`) + return { text: [`${summarize(current.items)} against ${where}`, ...lines].join('\n') } + }) + + // Tell the model when a file it works on belongs to an item that is out of + // sync, and recheck once its edits settle. + on('tool.call', async ($, e, next) => { + const tool = String(e.tool) + const input = e as unknown as { file_path?: unknown; command?: unknown } + const before = await read($, status) + const refs = before.root ? touchedItems(before.root, input) : [] + if (refs.length > 0 && before.phase === 'ready') { + // Recheck before the model works on an item someone may have changed on + // the workspace: by its version where it has one, by age otherwise. + const isStale = (await $.clock.now()) - (before.checkedAt ?? 0) > STALE_MS + const versioned = refs.filter(ref => ref.kind === 'script' || ref.kind === 'flow' || ref.kind === 'app') + const hasMoved = + (versioned.length < refs.length && isStale) || + (versioned.length > 0 && (await hasRemoteMoved($, versioned.map(itemKey)))) + if (hasMoved) await refresh($) + } + + const result = await next(e) + if (tool === 'Bash') { + // A sync command moves the last-synced record: check at once, before a + // following edit makes the item differ again. + const isSyncCommand = typeof input.command === 'string' && /\b(wmill|git)\b/.test(input.command) + const synced = typeof input.command === 'string' ? syncedByCommand(input.command) : undefined + if (synced && !result.isError && result.deny === undefined) await markSynced($, synced) + if (isSyncCommand) await refresh($) + else refreshSoon($, { isLocalOnly: true }) + } else if (refs.length > 0 && ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(tool)) { + refreshSoon($) + } + if (refs.length === 0 || result.deny !== undefined) return result + + const current = await read($, status) + const notes = refs.flatMap(ref => { + const item = current.items.find(one => one.key === itemKey(ref)) + if (!item || item.direction === 'push') return [] + const noteKey = `${item.key}|${item.direction}|${item.why}` + if (noted.has(noteKey)) return [] + noted.add(noteKey) + const advice = { + pull: `Pull it before editing (\`wmill sync pull --yes -i ${item.files.join(',')}\`), or a later push overwrites the workspace version.`, + conflict: 'Both sides changed: do not push it. Show the user the remote diff and merge first.', + differs: + 'Which side changed is unknown: check `wmill sync pull --dry-run --show-diffs` before pushing or pulling.', + }[item.direction] + return [ + `wmill-sync: ${describe(item)} differs from workspace ${current.target?.workspaceId} (${item.why}). ${advice}`, + ] + }) + return notes.length === 0 ? result : { ...result, context: [...(result.context ?? []), ...notes] } + }) + + // Deploying is the person's call: a push the model starts goes to them. + on('tool.check', { tool: 'Bash' }, async ($, e, next) => { + const command = (e.input as { command?: unknown }).command + if ( + typeof command !== 'string' || + !/\bwmill\b[^|;&]*\bpush\b/.test(command) || + /--dry-run/.test(command) + ) { + return next(e) + } + const decision = await next(e) + if (decision.decision === 'deny') return decision + const { target } = await read($, status) + const where = target ? `${target.workspaceId} on ${target.remote}` : 'the Windmill workspace' + return { decision: 'ask', reason: `wmill-sync: this deploys to ${where}.` } + }) + + on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { + if (e.props.hasSurvey) return next(e) + const current = await read($, status) + const running = await read($, busy) + const git = current.gitSync + const hasGitWork = git !== null && (git.ahead > 0 || git.behind > 0) + const isVisible = current.phase === 'error' || current.items.length > 0 || running !== null || hasGitWork + if (current.phase === 'inactive' || !isVisible) return next(e) + + const { Box, Button, Text } = $.ui.resolve(e) + const expanded = await read($, isExpanded) + const where = current.target ? `${current.target.workspaceId}` : 'workspace' + const toPush = current.items.filter(item => item.direction === 'push') + const toPull = current.items.filter(item => item.direction === 'pull') + const canPushItems = !git?.deployOnPush + + const header = ( + + wmill + {where} + {current.phase === 'error' ? ( + + + {current.error} + + + ) : ( + {summarize(current.items)} + )} + {running !== null && {`${running}…`}} + {running === null && current.phase === 'checking' && checking…} + {running === null && toPush.length > 0 && canPushItems && ( +