chore: add proof-of-concept wmill-sync claude code mod

This commit is contained in:
Guilhem Lemouel
2026-10-07 01:47:29 -07:00
parent af1a29a3cb
commit 71718347b4
8 changed files with 1616 additions and 0 deletions
@@ -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"
}
}
}
+42
View File
@@ -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 .
```
@@ -0,0 +1 @@
{ "modules": ["./register.tsx"] }
+346
View File
@@ -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<string, ItemKind> = {
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<ItemKind, string> = {
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<string, LocalItem>
versions: Map<string, string>
base: Record<string, BaseEntry>
gitDirty: Set<string> | null
}) => {
const differing = new Map<string, { ref: ItemRef; files: Set<string>; types: Set<string> }>()
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<string>(), types: new Set<string>() }
entry.files.add(change.path)
entry.types.add(change.type)
differing.set(key, entry)
}
const nextBase: Record<string, BaseEntry> = {}
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<string, ItemRef>()
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
}
@@ -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<string | null> => {
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<Profile[]> => {
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<Target> => {
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<string, unknown>[] = []
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<string, string>()
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<GitSyncInfo | null> => {
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<string, FileStamp>()
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<string, { ref: ItemRef; files: string[] }>()
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<string, LocalItem>()
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<string>()
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<DryRunChange[]> => {
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<string, string>; fingerprint: string } | null = null
let gitSync: { remote: string; mode: { deployOnPush: boolean } | null } | null = null
let editTimer: { cancel: () => void } | null = null
const noted = new Set<string>()
async function setStatus($: $, patch: Partial<SyncStatus>) {
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<string, BaseEntry>
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<string, BaseEntry>
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 = (
<Box key="header" flexDirection="row" gap={1}>
<Text bold>wmill</Text>
<Text dimColor>{where}</Text>
{current.phase === 'error' ? (
<Box flexShrink={1}>
<Text color="red" wrap="truncate-end">
{current.error}
</Text>
</Box>
) : (
<Text>{summarize(current.items)}</Text>
)}
{running !== null && <Text dimColor>{`${running}…`}</Text>}
{running === null && current.phase === 'checking' && <Text dimColor>checking…</Text>}
{running === null && toPush.length > 0 && canPushItems && (
<Button
key="push-all"
variant="primary"
label={`Push ${toPush.length}`}
onPress={() => push($, toPush)}
/>
)}
{running === null && toPull.length > 0 && (
<Button key="pull-all" label={`Pull ${toPull.length}`} onPress={() => pull($, toPull)} />
)}
<Button key="refresh" label="Refresh" dimColor onPress={() => refresh($, { isFetchingGit: true })} />
{current.items.length > 0 && (
<Button
key="toggle"
label={expanded ? 'Hide' : 'Details'}
dimColor
onPress={() => update($, isExpanded, value => !value)}
/>
)}
</Box>
)
const gitRow = git && hasGitWork && (
<Box key="git" flexDirection="row" gap={1}>
<Text dimColor>{` git ${git.branch}`}</Text>
<Text>{`${git.ahead} ahead, ${git.behind} behind ${git.upstream ?? 'its upstream'}`}</Text>
<Text dimColor>{git.deployOnPush ? '(Git Sync deploys on push)' : '(Git Sync)'}</Text>
{running === null && git.behind > 0 && (
<Button
key="git-pull"
label="git pull"
onPress={() => act($, 'git pull', ['git', 'pull', '--ff-only'])}
/>
)}
{running === null && git.ahead > 0 && (
<Button key="git-push" label="git push" onPress={() => act($, 'git push', ['git', 'push'])} />
)}
</Box>
)
// Rows are laid out by hand: the band narrows when a pane docks beside
// it, and shrinking Text elements wrap mid-word.
const buttonsWidth = 20
const room = Math.max(20, e.props.bodyColumns - buttonsWidth - 14)
const pathWidth = Math.min(
Math.max(...current.items.map(item => item.path.length)),
Math.ceil(room * 0.55),
)
const whyWidth = Math.min(Math.max(...current.items.map(item => item.why.length)), room - pathWidth)
const rows =
expanded &&
current.items.map(item => (
<Box key={`row-${item.key}`} flexDirection="row" gap={1}>
<Text color={COLOR[item.direction]}>{` ${MARK[item.direction]}`}</Text>
<Text dimColor>{KIND_LABEL[item.kind].slice(0, 8).padEnd(8)}</Text>
<Text>{fit(item.path, pathWidth, 'middle')}</Text>
<Text dimColor>{fit(item.why, whyWidth, 'end')}</Text>
{running === null && item.direction !== 'pull' && item.direction !== 'conflict' && canPushItems && (
<Button key={`push-${item.key}`} label="Push" onPress={() => push($, [item])} />
)}
{running === null && item.direction !== 'push' && item.direction !== 'conflict' && (
<Button key={`pull-${item.key}`} label="Pull" onPress={() => pull($, [item])} />
)}
{item.direction === 'conflict' ? (
<Button key={`resolve-${item.key}`} label="Resolve" onPress={() => showDiff($, item)} />
) : (
<Button key={`diff-${item.key}`} label="Diff" dimColor onPress={() => showDiff($, item)} />
)}
</Box>
))
return (
<Box flexDirection="column">
{header}
{gitRow}
{rows}
</Box>
)
})
on('ui.render', { component: 'Pane', requestId: DIFF_PANE }, async ($, e) => {
const { Box, Button, Code, Text } = $.ui.resolve(e)
const view = await read($, diffView)
if (!view?.item) return <Text dimColor>No diff loaded.</Text>
const running = await read($, busy)
const { item } = view
const isConflict = item.direction === 'conflict'
const actions = running === null && (
<Box key="actions" flexDirection="row" gap={1} flexWrap="wrap">
{isConflict && (
<Button
key="merge"
variant="primary"
label="Ask Claude to merge"
onPress={async () => {
await closeDiff($)
await askToMerge($, item)
}}
/>
)}
{item.direction !== 'pull' && (
<Button
key="keep-local"
label={isConflict ? 'Keep local (push)' : 'Push'}
onPress={async () => {
await closeDiff($)
await push($, [item])
}}
/>
)}
{item.direction !== 'push' && (
<Button
key="keep-remote"
label={isConflict ? 'Keep workspace (pull)' : 'Pull'}
onPress={async () => {
await closeDiff($)
await pull($, [item])
}}
/>
)}
</Box>
)
return (
<Box flexDirection="column" gap={1}>
<Text bold>{view.title}</Text>
{isConflict && (
<Text dimColor>
Both sides changed. Merge the workspace changes in, then keep local to push the result.
</Text>
)}
{actions}
{view.error !== null && <Text dimColor>{view.error}</Text>}
{view.files.map(file => (
<Box key={`file-${file.file}`} flexDirection="column">
<Text dimColor>{file.isTruncated ? `${file.file} (cut to fit)` : file.file}</Text>
<Code source={file.diff} format={file.isTruncated ? 'source' : 'diff'} path={file.file} />
</Box>
))}
</Box>
)
})
}
@@ -0,0 +1,63 @@
import { expect, test } from 'claude-code/testing'
import type { SyncStatus } from '../types'
const STATUS: SyncStatus = {
phase: 'ready',
root: '/project',
target: { remote: 'http://localhost:8000/', workspaceId: 'demo', source: 'profile "demo"' },
checkedAt: 1,
error: null,
gitSync: null,
items: [
{
key: 'flow:f/demo/pipeline',
kind: 'flow',
path: 'f/demo/pipeline',
direction: 'conflict',
why: 'edited locally, and changed on the workspace',
files: ['f/demo/pipeline__flow/a.ts'],
},
{
key: 'script:f/demo/greet',
kind: 'script',
path: 'f/demo/greet',
direction: 'push',
why: 'edited locally',
files: ['f/demo/greet.ts'],
},
],
}
const BAND = {
component: 'AbovePrompt',
props: {
hasSurvey: false,
isWorking: false,
maxRows: 12,
bodyColumns: 90,
scroll: { offset: 0, bodyRows: 12 },
view: {},
},
} as const
test('the band lists out-of-sync items with their actions on every surface with buttons', async ($, on) => {
// The kit hands a state.get answer's `value` back as the read itself, so
// the read the plugin sees is nested one level down.
const held = (value: unknown) => ({ value: { value, version: 1 }, version: 1 }) as never
on('state.get', (_, e, next) => {
if (e.plugin !== 'wmill-sync') return next(e)
if (e.key === 'status') return held(STATUS)
if (e.key === 'isExpanded') return held(true)
return next(e)
})
for (const surface of ['terminal', 'desktop'] as const) {
const ui = await $.ui.mount({ plugin: 'wmill-sync', surface, ...BAND })
expect((await ui.find({ key: 'push-all' }))?.text).toBe('Push 1')
expect(await ui.find({ key: 'resolve-flow:f/demo/pipeline' })).toBeDefined()
expect(await ui.find({ key: 'push-flow:f/demo/pipeline' })).toBeUndefined()
expect(await ui.find({ key: 'push-script:f/demo/greet' })).toBeDefined()
expect(await ui.find({ type: 'Text', text: /1 to push · 1 conflict/ })).toBeDefined()
await ui.unmount()
}
})
@@ -0,0 +1,169 @@
import { expect, test } from 'claude-code/testing'
import {
compare,
itemOfFile,
parseShowDiffs,
parseWorkspaceBindings,
syncedByCommand,
touchedItems,
type LocalItem,
} from '../hooks/model'
const local = (kind: 'script' | 'flow' | 'variable', path: string, files: string[], hash: string) =>
[`${kind}:${path}`, { ref: { kind, path }, files, hash }] as [string, LocalItem]
test('maps wmill files to the item they belong to', async () => {
expect(itemOfFile('f/demo/greet.ts')).toEqual({ kind: 'script', path: 'f/demo/greet' })
expect(itemOfFile('f/demo/greet.script.yaml')).toEqual({ kind: 'script', path: 'f/demo/greet' })
expect(itemOfFile('f/demo/q.pg.sql')).toEqual({ kind: 'script', path: 'f/demo/q' })
expect(itemOfFile('f/demo/pipeline__flow/a.ts')).toEqual({ kind: 'flow', path: 'f/demo/pipeline' })
expect(itemOfFile('f/demo/dash.app/app.yaml')).toEqual({ kind: 'app', path: 'f/demo/dash' })
expect(itemOfFile('f/demo/greeting.variable.yaml')).toEqual({ kind: 'variable', path: 'f/demo/greeting' })
expect(itemOfFile('f/demo/hook.http_trigger.yaml')).toEqual({ kind: 'trigger', path: 'f/demo/hook' })
expect(itemOfFile('f/demo/folder.meta.yaml')).toEqual({ kind: 'folder', path: 'f/demo' })
expect(itemOfFile('wmill.yaml')).toBeUndefined()
expect(itemOfFile('f/demo/node_modules/x.ts')).toBeUndefined()
})
test('reads the workspace bindings of a wmill.yaml', async () => {
const yaml = [
'includes:',
' - "f/**"',
'workspaces:',
' main:',
' baseUrl: https://app.windmill.dev # prod',
' workspaceId: acme',
' staging:',
' gitBranch: develop',
' overrides:',
' skipSecrets: false',
'codebases: []',
].join('\n')
expect(parseWorkspaceBindings(yaml)).toEqual([
{ name: 'main', baseUrl: 'https://app.windmill.dev', workspaceId: 'acme' },
{ name: 'staging', gitBranch: 'develop' },
])
expect(parseWorkspaceBindings('workspaces: {}\n')).toEqual([])
})
test('tells push, pull and conflict apart from the last-synced record', async () => {
const base = {
'script:f/a': { localHash: 'a1', version: 'v1' },
'script:f/b': { localHash: 'b1', version: 'v1' },
'script:f/c': { localHash: 'c1', version: 'v1' },
'variable:f/d': { localHash: 'd1', version: null },
}
const { items, nextBase } = compare({
changes: [
{ type: 'edited', path: 'f/a.ts' },
{ type: 'edited', path: 'f/b.ts' },
{ type: 'edited', path: 'f/c.ts' },
{ type: 'edited', path: 'f/d.variable.yaml' },
{ type: 'added', path: 'f/e.ts' },
],
local: new Map([
local('script', 'f/a', ['f/a.ts'], 'a2'),
local('script', 'f/b', ['f/b.ts'], 'b1'),
local('script', 'f/c', ['f/c.ts'], 'c2'),
local('variable', 'f/d', ['f/d.variable.yaml'], 'd1'),
local('script', 'f/same', ['f/same.ts'], 's1'),
]),
versions: new Map([
['script:f/a', 'v1'],
['script:f/b', 'v2'],
['script:f/c', 'v2'],
['script:f/e', 'v1'],
['script:f/same', 'v9'],
]),
base,
gitDirty: new Set(),
})
const byPath = Object.fromEntries(items.map(item => [item.path, item.direction]))
expect(byPath).toEqual({ 'f/a': 'push', 'f/b': 'pull', 'f/c': 'conflict', 'f/d': 'pull', 'f/e': 'pull' })
// An item that matches is recorded as synced at its current hash and version.
expect(nextBase['script:f/same']).toEqual({ localHash: 's1', version: 'v9' })
// A differing item keeps its old record until it matches again.
expect(nextBase['script:f/a']).toEqual(base['script:f/a'])
})
test('falls back to git when nothing was recorded', async () => {
const { items } = compare({
changes: [
{ type: 'edited', path: 'f/mine.ts' },
{ type: 'edited', path: 'f/theirs.ts' },
{ type: 'deleted', path: 'f/new.ts' },
],
local: new Map([
local('script', 'f/mine', ['f/mine.ts'], 'm'),
local('script', 'f/theirs', ['f/theirs.ts'], 't'),
local('script', 'f/new', ['f/new.ts'], 'n'),
]),
versions: new Map(),
base: {},
gitDirty: new Set(['f/mine.ts', 'f/new.ts']),
})
const byPath = Object.fromEntries(items.map(item => [item.path, `${item.direction}: ${item.why}`]))
expect(byPath).toEqual({
'f/mine': 'push: edited locally',
'f/new': 'push: new locally',
'f/theirs': 'pull: changed on the workspace',
})
})
test("turns the CLI's coloured --show-diffs output into a unified hunk", async () => {
const raw =
'\u001b[34m\u001b[90mComputing the files to update locally to match remote\u001b[39m\u001b[39m\n' +
'\u001b[34m\u001b[33m~ flow f/demo/pipeline__flow/b.ts\u001b[90m\u001b[33m\u001b[39m\u001b[39m\n' +
'\u001b[34m\u001b[37mexport async function main(x: number) {\n' +
'\u001b[0m\u001b[31m return x + 2\n' +
'\u001b[0m\u001b[32m return x + 1\n' +
'\u001b[0m\u001b[37m}\n' +
'\u001b[0m\u001b[39m\n' +
'\u001b[34m\u001b[90mDry run complete.\u001b[39m\u001b[39m\n'
expect(parseShowDiffs(raw)).toEqual([
{
file: 'f/demo/pipeline__flow/b.ts',
isTruncated: false,
diff: [
'--- local/f/demo/pipeline__flow/b.ts',
'+++ workspace/f/demo/pipeline__flow/b.ts',
'@@ -1,3 +1,3 @@',
' export async function main(x: number) {',
'- return x + 2',
'+ return x + 1',
' }',
].join('\n'),
},
])
})
test('finds the items a tool call names, from a file path or a shell command', async () => {
const root = '/work/project'
expect(touchedItems(root, { file_path: '/work/project/f/demo/greet.ts' })).toEqual([
{ kind: 'script', path: 'f/demo/greet' },
])
expect(touchedItems(root, { file_path: '/elsewhere/f/demo/greet.ts' })).toEqual([])
expect(
touchedItems(root, {
command: `sed -i '' 's/a/b/' f/demo/greet.ts && cat "./f/demo/pipeline__flow/a.ts" /work/project/f/demo/sum.py`,
}),
).toEqual([
{ kind: 'script', path: 'f/demo/greet' },
{ kind: 'flow', path: 'f/demo/pipeline' },
{ kind: 'script', path: 'f/demo/sum' },
])
expect(touchedItems(root, { command: 'git status' })).toEqual([])
})
test('tells which items a shell command synced', async () => {
expect(
syncedByCommand('wmill sync pull --yes -i f/demo/greet.ts && sed -i "" s/a/b/ f/demo/greet.ts'),
).toEqual([{ kind: 'script', path: 'f/demo/greet' }])
expect(syncedByCommand('wmill sync push --yes')).toBe('all')
expect(syncedByCommand('wmill sync pull --dry-run --json-output')).toBeUndefined()
expect(syncedByCommand('wmill flow push f/demo/pipeline__flow f/demo/pipeline')).toEqual([
{ kind: 'flow', path: 'f/demo/pipeline' },
])
expect(syncedByCommand('git status')).toBeUndefined()
})
+70
View File
@@ -0,0 +1,70 @@
export type ItemKind =
| 'script'
| 'flow'
| 'app'
| 'variable'
| 'resource'
| 'resource_type'
| 'schedule'
| 'trigger'
| 'folder'
/**
* push: changed locally since the last sync. pull: changed on the workspace.
* conflict: both. differs: no last-sync record and no git history to tell.
*/
export type SyncDirection = 'push' | 'pull' | 'conflict' | 'differs'
export type SyncItem = {
key: string
kind: ItemKind
path: string
direction: SyncDirection
/** One short phrase: "edited locally", "deleted on the workspace", ... */
why: string
/** Project-relative files the item spans, locally or on the workspace. */
files: string[]
}
export type SyncTarget = {
remote: string
workspaceId: string
/** Where the binding came from: a wmill.yaml entry or the active profile. */
source: string
}
export type GitSyncInfo = {
deployOnPush: boolean
branch: string
upstream: string | null
ahead: number
behind: number
}
export type SyncStatus = {
phase: 'inactive' | 'checking' | 'ready' | 'error'
root: string | null
target: SyncTarget | null
items: SyncItem[]
checkedAt: number | null
error: string | null
gitSync: GitSyncInfo | null
}
export type DiffView = {
item: SyncItem
title: string
files: { file: string; diff: string; isTruncated: boolean }[]
error: string | null
}
declare module 'claude-code' {
interface PluginState {
'wmill-sync': {
status: SyncStatus
isExpanded: boolean
busy: string | null
diff: DiffView | null
}
}
}