fix: stale update banner + skill sync dirty worktree crash #358

Merged
jason.woltje merged 1 commits from fix/cli-sync-issues into main 2026-04-03 02:04:06 +00:00
4 changed files with 62 additions and 29 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaic/cli",
"version": "0.0.4",
"version": "0.0.5",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -56,25 +56,39 @@ if [[ $fetch -eq 1 ]]; then
if [[ -d "$SKILLS_REPO_DIR/.git" ]]; then
echo "[mosaic-skills] Updating skills source: $SKILLS_REPO_DIR"
# Stash any local changes (dirty index or worktree) before pulling
local_changes=0
if ! git -C "$SKILLS_REPO_DIR" diff --quiet 2>/dev/null || \
! git -C "$SKILLS_REPO_DIR" diff --cached --quiet 2>/dev/null; then
local_changes=1
echo "[mosaic-skills] Stashing local changes..."
git -C "$SKILLS_REPO_DIR" stash push -q -m "mosaic-sync-skills auto-stash"
# Detect ANY dirty state: modified, staged, or untracked files.
# Use git status --porcelain which catches everything pull --rebase cares about.
dirty=""
dirty="$(git -C "$SKILLS_REPO_DIR" status --porcelain 2>/dev/null || true)"
if [[ -n "$dirty" ]]; then
echo "[mosaic-skills] Stashing local changes (including untracked)..."
git -C "$SKILLS_REPO_DIR" stash push -q --include-untracked -m "mosaic-sync-skills auto-stash" 2>/dev/null || {
echo "[mosaic-skills] WARN: stash failed — skipping pull, using existing checkout" >&2
dirty="skip-pull"
}
fi
if ! git -C "$SKILLS_REPO_DIR" pull --rebase; then
echo "[mosaic-skills] WARN: pull failed — continuing with existing checkout" >&2
if [[ "$dirty" != "skip-pull" ]]; then
if ! git -C "$SKILLS_REPO_DIR" pull --rebase 2>/dev/null; then
echo "[mosaic-skills] WARN: pull failed — continuing with existing checkout" >&2
# Abort any in-progress rebase so the repo isn't left in a broken state
git -C "$SKILLS_REPO_DIR" rebase --abort 2>/dev/null || true
fi
fi
# Restore stashed changes
if [[ $local_changes -eq 1 ]]; then
# Restore stashed changes if we stashed anything
if [[ -n "$dirty" && "$dirty" != "skip-pull" ]]; then
echo "[mosaic-skills] Restoring local changes..."
git -C "$SKILLS_REPO_DIR" stash pop -q 2>/dev/null || \
echo "[mosaic-skills] WARN: stash pop had conflicts — check $SKILLS_REPO_DIR" >&2
fi
# Hint: customized skills belong in skills-local/, not in the canonical repo
if [[ -n "$dirty" ]]; then
echo "[mosaic-skills] TIP: Put customized skills in $MOSAIC_LOCAL_SKILLS_DIR/ instead"
echo "[mosaic-skills] Files there are never overwritten and take precedence."
fi
else
echo "[mosaic-skills] Cloning skills source to: $SKILLS_REPO_DIR"
mkdir -p "$(dirname "$SKILLS_REPO_DIR")"

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaic/mosaic",
"version": "0.0.4",
"version": "0.0.5",
"description": "Mosaic agent framework — installation wizard and meta package",
"type": "module",
"main": "dist/index.js",

View File

@@ -122,10 +122,18 @@ export function semverLt(a: string, b: string): boolean {
// ─── Cache ──────────────────────────────────────────────────────────────────
function readCache(): UpdateCheckResult | null {
/** Cache stores only the latest registry version (the expensive network call).
* The installed version is always checked fresh — it's a local `npm ls`. */
interface RegistryCache {
latest: string;
checkedAt: string;
registry: string;
}
function readCache(): RegistryCache | null {
try {
if (!existsSync(CACHE_FILE)) return null;
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as UpdateCheckResult;
const raw = JSON.parse(readFileSync(CACHE_FILE, 'utf-8')) as RegistryCache;
const age = Date.now() - new Date(raw.checkedAt).getTime();
if (age > CACHE_TTL_MS) return null;
return raw;
@@ -134,10 +142,10 @@ function readCache(): UpdateCheckResult | null {
}
}
function writeCache(result: UpdateCheckResult): void {
function writeCache(entry: RegistryCache): void {
try {
mkdirSync(CACHE_DIR, { recursive: true });
writeFileSync(CACHE_FILE, JSON.stringify(result, null, 2) + '\n', 'utf-8');
writeFileSync(CACHE_FILE, JSON.stringify(entry, null, 2) + '\n', 'utf-8');
} catch {
// Best-effort — cache is not critical
}
@@ -174,29 +182,40 @@ export function getLatestVersion(): string {
}
/**
* Perform an update check — uses cache when fresh, otherwise hits registry.
* Perform an update check — uses registry cache when fresh, always checks
* installed version fresh (local npm ls is cheap, caching it causes stale
* "update available" banners after an upgrade).
* Never throws.
*/
export function checkForUpdate(options?: { skipCache?: boolean }): UpdateCheckResult {
const current = getInstalledVersion();
let latest: string;
let checkedAt: string;
if (!options?.skipCache) {
const cached = readCache();
if (cached) return cached;
if (cached) {
latest = cached.latest;
checkedAt = cached.checkedAt;
} else {
latest = getLatestVersion();
checkedAt = new Date().toISOString();
writeCache({ latest, checkedAt, registry: REGISTRY });
}
} else {
latest = getLatestVersion();
checkedAt = new Date().toISOString();
writeCache({ latest, checkedAt, registry: REGISTRY });
}
const current = getInstalledVersion();
const latest = getLatestVersion();
const updateAvailable = !!(current && latest && semverLt(current, latest));
const result: UpdateCheckResult = {
return {
current,
latest,
updateAvailable,
checkedAt: new Date().toISOString(),
updateAvailable: !!(current && latest && semverLt(current, latest)),
checkedAt,
registry: REGISTRY,
};
writeCache(result);
return result;
}
/**