mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
chore: add proof-of-concept wmill-sync claude code mod
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user