my pkgs monorepo

feat(pkgs): add Popfeed card, migrate NoiseImage to @ewanc26/ui, add release script

- Add PopfeedReview types and fetchRecentPopfeedReviews to @ewanc26/atproto
- Add NoiseImage component to @ewanc26/ui (was website-only)
- Add PopfeedCard to @ewanc26/ui, styled after PostCard/DocumentCard
- Update MusicStatusCard to use NoiseImage instead of manual img/fallback
- Swap TangledRepoCard icon from GitBranch to Code
- Add release.sh: dynamic build/version/publish script with retry logic
- Bump @ewanc26/atproto to 0.2.7, @ewanc26/ui to 0.3.8

ewancroft.uk 4519bdf5 432369dd

verified
+487 -18
+1 -1
packages/atproto/package.json
··· 1 1 { 2 2 "name": "@ewanc26/atproto", 3 - "version": "0.2.5", 3 + "version": "0.2.7", 4 4 "description": "AT Protocol service layer extracted from ewancroft.uk", 5 5 "author": "Ewan Croft", 6 6 "license": "AGPL-3.0-only",
+58 -1
packages/atproto/src/fetch.ts
··· 9 9 MusicStatusData, 10 10 KibunStatusData, 11 11 TangledRepo, 12 - TangledReposData 12 + TangledReposData, 13 + PopfeedReview, 14 + PopfeedCreativeWorkType, 15 + PopfeedMainCreditRole 13 16 } from './types.js'; 14 17 15 18 export async function fetchProfile(did: string, fetchFn?: typeof fetch): Promise<ProfileData> { ··· 303 306 return null; 304 307 } catch { 305 308 return null; 309 + } 310 + } 311 + 312 + export async function fetchRecentPopfeedReviews( 313 + did: string, 314 + limit = 5, 315 + fetchFn?: typeof fetch 316 + ): Promise<PopfeedReview[]> { 317 + const cacheKey = `popfeed-reviews:${did}:${limit}`; 318 + const cached = cache.get<PopfeedReview[]>(cacheKey); 319 + if (cached) return cached; 320 + 321 + try { 322 + const records = await withFallback( 323 + did, 324 + async (agent) => { 325 + const response = await agent.com.atproto.repo.listRecords({ 326 + repo: did, 327 + collection: 'social.popfeed.feed.review', 328 + limit 329 + }); 330 + return response.data.records; 331 + }, 332 + true, 333 + fetchFn 334 + ); 335 + 336 + if (!records?.length) return []; 337 + 338 + const data: PopfeedReview[] = records.map((record) => { 339 + const value = record.value as any; 340 + const rkey = record.uri.split('/').pop() ?? record.uri; 341 + return { 342 + rkey, 343 + uri: record.uri, 344 + title: value.title, 345 + creativeWorkType: value.creativeWorkType as PopfeedCreativeWorkType, 346 + rating: value.rating, 347 + text: value.text, 348 + posterUrl: value.posterUrl, 349 + mainCredit: value.mainCredit, 350 + mainCreditRole: value.mainCreditRole as PopfeedMainCreditRole | undefined, 351 + genres: value.genres, 352 + tags: value.tags, 353 + createdAt: value.createdAt, 354 + containsSpoilers: value.containsSpoilers, 355 + isRevisit: value.isRevisit 356 + }; 357 + }); 358 + 359 + cache.set(cacheKey, data); 360 + return data; 361 + } catch { 362 + return []; 306 363 } 307 364 } 308 365
+5 -1
packages/atproto/src/index.ts
··· 4 4 // now accept `did: string` as their first argument. 5 5 6 6 export type { 7 + PopfeedReview, 8 + PopfeedCreativeWorkType, 9 + PopfeedMainCreditRole, 7 10 ProfileData, 8 11 StatusData, 9 12 SiteInfoData, ··· 43 46 fetchLinks, 44 47 fetchMusicStatus, 45 48 fetchKibunStatus, 46 - fetchTangledRepos 49 + fetchTangledRepos, 50 + fetchRecentPopfeedReviews 47 51 } from './fetch.js'; 48 52 49 53 export {
+43
packages/atproto/src/types.ts
··· 279 279 export interface StandardSiteDocumentsData { 280 280 documents: StandardSiteDocument[]; 281 281 } 282 + 283 + export type PopfeedCreativeWorkType = 284 + | 'movie' 285 + | 'tv_show' 286 + | 'video_game' 287 + | 'album' 288 + | 'book' 289 + | 'book_series' 290 + | 'episode' 291 + | 'ep' 292 + | 'tv_season' 293 + | 'tv_episode' 294 + | 'track'; 295 + 296 + export type PopfeedMainCreditRole = 297 + | 'director' 298 + | 'author' 299 + | 'artist' 300 + | 'showrunner' 301 + | 'lead_actor' 302 + | 'creator' 303 + | 'studio' 304 + | 'publisher' 305 + | 'developer' 306 + | 'performer' 307 + | 'network'; 308 + 309 + export interface PopfeedReview { 310 + rkey: string; 311 + uri: string; 312 + title?: string; 313 + creativeWorkType: PopfeedCreativeWorkType; 314 + rating: number; 315 + text?: string; 316 + posterUrl?: string; 317 + mainCredit?: string; 318 + mainCreditRole?: PopfeedMainCreditRole; 319 + genres?: string[]; 320 + tags?: string[]; 321 + createdAt: string; 322 + containsSpoilers?: boolean; 323 + isRevisit?: boolean; 324 + }
+2 -1
packages/ui/package.json
··· 1 1 { 2 2 "name": "@ewanc26/ui", 3 - "version": "0.2.1", 3 + "version": "0.3.8", 4 4 "description": "Svelte UI component library extracted from ewancroft.uk — pluggable layout, UI primitives, stores, and SEO components.", 5 5 "author": "Ewan Croft", 6 6 "license": "AGPL-3.0-only", ··· 48 48 "check": "svelte-check --tsconfig ./tsconfig.json" 49 49 }, 50 50 "dependencies": { 51 + "@ewanc26/noise-avatar": "workspace:*", 51 52 "@lucide/svelte": "^0.577.0" 52 53 }, 53 54 "optionalDependencies": {
+8 -10
packages/ui/src/lib/components/layout/main/card/MusicStatusCard.svelte
··· 1 1 <script lang="ts"> 2 2 import Card from '../../../ui/Card.svelte'; 3 + import NoiseImage from '../../../ui/NoiseImage.svelte'; 3 4 import type { MusicStatusData } from '@ewanc26/atproto'; 4 5 import { formatRelativeTime } from '../../../../utils/locale.js'; 5 - import { Music, Disc3, Users, Album, Clock, Radio } from '@lucide/svelte'; 6 + import { Music, Users, Album, Clock, Radio } from '@lucide/svelte'; 6 7 7 8 interface Props { musicStatus?: MusicStatusData | null; } 8 9 let { musicStatus = null }: Props = $props(); 9 - 10 - let artworkError = $state(false); 11 10 12 11 function formatArtists(artists: { artistName: string }[]): string { 13 12 if (!artists?.length) return 'Unknown Artist'; ··· 58 57 </div> 59 58 <div class="flex items-start gap-3"> 60 59 <div class="shrink-0"> 61 - {#if s.artworkUrl && !artworkError} 62 - <img src={s.artworkUrl} alt="Album artwork for {s.releaseName || s.trackName}" class="h-20 w-20 rounded-lg object-cover shadow-md" loading="lazy" onerror={() => (artworkError = true)} /> 63 - {:else} 64 - <div class="flex h-20 w-20 items-center justify-center rounded-lg bg-canvas-200 shadow-md dark:bg-canvas-700"> 65 - <Disc3 class="h-10 w-10 text-ink-500 dark:text-ink-400" aria-hidden="true" /> 66 - </div> 67 - {/if} 60 + <NoiseImage 61 + src={s.artworkUrl} 62 + seed={`${s.trackName}|${s.artists?.[0]?.artistName ?? ''}`} 63 + alt="Album artwork for {s.releaseName || s.trackName}" 64 + class="h-20 w-20 rounded-lg object-cover shadow-md" 65 + /> 68 66 </div> 69 67 <div class="min-w-0 flex-1"> 70 68 <div class="mb-4">
+122
packages/ui/src/lib/components/layout/main/card/PopfeedCard.svelte
··· 1 + <script lang="ts"> 2 + import Card from '../../../ui/Card.svelte'; 3 + import { ExternalLink, Star } from '@lucide/svelte'; 4 + import type { PopfeedReview, PopfeedCreativeWorkType } from '@ewanc26/atproto'; 5 + import InternalCard from '../../../ui/InternalCard.svelte'; 6 + import NoiseImage from '../../../ui/NoiseImage.svelte'; 7 + import { formatRelativeTime } from '../../../../utils/locale.js'; 8 + 9 + interface Props { 10 + reviews?: PopfeedReview[] | null; 11 + /** The handle of the account, used to build Popfeed review URLs. */ 12 + handle?: string | null; 13 + } 14 + 15 + let { reviews = null, handle = null }: Props = $props(); 16 + 17 + const TYPE_LABELS: Record<PopfeedCreativeWorkType, string> = { 18 + movie: 'Film', 19 + tv_show: 'TV Show', 20 + tv_season: 'TV Season', 21 + tv_episode: 'Episode', 22 + episode: 'Episode', 23 + video_game: 'Game', 24 + album: 'Album', 25 + ep: 'EP', 26 + track: 'Track', 27 + book: 'Book', 28 + book_series: 'Book Series' 29 + }; 30 + 31 + function popfeedUrl(h: string | null | undefined, rkey: string): string { 32 + if (h) return `https://popfeed.app/u/${h}/review/${rkey}`; 33 + return 'https://popfeed.app'; 34 + } 35 + 36 + function formatRating(rating: number): string { 37 + return `${rating % 1 === 0 ? rating : rating.toFixed(1)}/10`; 38 + } 39 + </script> 40 + 41 + <div class="mx-auto w-full max-w-2xl"> 42 + {#if !reviews} 43 + <Card loading={true} variant="elevated" padding="md"> 44 + {#snippet skeleton()} 45 + <div class="mb-4 h-6 w-40 rounded bg-canvas-300 dark:bg-canvas-700"></div> 46 + <div class="space-y-3"> 47 + {#each Array(3) as _} 48 + <div class="rounded-lg bg-canvas-200 p-4 dark:bg-canvas-800"> 49 + <div class="flex items-start gap-3"> 50 + <div class="h-12 w-9 shrink-0 rounded bg-canvas-300 dark:bg-canvas-700"></div> 51 + <div class="flex-1 space-y-2"> 52 + <div class="h-3 w-16 rounded bg-canvas-300 dark:bg-canvas-700"></div> 53 + <div class="h-4 w-2/3 rounded bg-canvas-300 dark:bg-canvas-700"></div> 54 + <div class="h-3 w-24 rounded bg-canvas-300 dark:bg-canvas-700"></div> 55 + </div> 56 + <div class="h-4 w-12 shrink-0 rounded bg-canvas-300 dark:bg-canvas-700"></div> 57 + </div> 58 + </div> 59 + {/each} 60 + </div> 61 + {/snippet} 62 + </Card> 63 + {:else if reviews.length > 0} 64 + <Card variant="elevated" padding="md"> 65 + {#snippet children()} 66 + <h2 class="mb-4 text-2xl font-bold text-ink-900 dark:text-ink-50">Recent Reviews</h2> 67 + <div class="space-y-3"> 68 + {#each reviews as review (review.rkey)} 69 + <InternalCard href={popfeedUrl(handle, review.rkey)}> 70 + {#snippet children()} 71 + <!-- Poster thumbnail --> 72 + <div class="shrink-0"> 73 + <NoiseImage 74 + src={review.posterUrl} 75 + seed={review.title ?? review.rkey} 76 + alt={review.title ? `Poster for ${review.title}` : 'Review poster'} 77 + class="h-12 w-9 rounded object-cover object-top" 78 + /> 79 + </div> 80 + 81 + <!-- Metadata --> 82 + <div class="relative min-w-0 flex-1 space-y-1.5"> 83 + {#if review.creativeWorkType} 84 + <div class="flex flex-wrap items-center gap-2"> 85 + <span class="rounded bg-accent-500 px-2 py-0.5 text-xs font-semibold text-white uppercase dark:bg-accent-600"> 86 + {TYPE_LABELS[review.creativeWorkType] ?? review.creativeWorkType} 87 + </span> 88 + </div> 89 + {/if} 90 + {#if review.title} 91 + <h4 class="overflow-wrap-anywhere font-semibold wrap-break-word text-ink-900 dark:text-ink-50"> 92 + {review.title} 93 + </h4> 94 + {/if} 95 + {#if review.mainCredit} 96 + <p class="overflow-wrap-anywhere line-clamp-1 text-sm wrap-break-word text-ink-700 dark:text-ink-200"> 97 + {review.mainCredit} 98 + </p> 99 + {/if} 100 + <div class="pt-1"> 101 + <time datetime={review.createdAt} class="text-xs font-medium text-ink-800 dark:text-ink-100"> 102 + {formatRelativeTime(review.createdAt)} 103 + </time> 104 + </div> 105 + </div> 106 + 107 + <!-- Right: link icon + rating --> 108 + <div class="flex shrink-0 flex-col items-end justify-between gap-2 self-stretch"> 109 + <ExternalLink class="h-4 w-4 text-ink-700 transition-colors dark:text-ink-200" aria-hidden="true" /> 110 + <div class="flex items-center gap-1.5 rounded bg-ink-100 px-2 py-0.5 dark:bg-ink-800" aria-label="Rating: {formatRating(review.rating)}"> 111 + <Star class="h-3 w-3 text-ink-700 dark:text-ink-200" aria-hidden="true" /> 112 + <span class="text-xs font-medium text-ink-800 dark:text-ink-100">{formatRating(review.rating)}</span> 113 + </div> 114 + </div> 115 + {/snippet} 116 + </InternalCard> 117 + {/each} 118 + </div> 119 + {/snippet} 120 + </Card> 121 + {/if} 122 + </div>
+2 -2
packages/ui/src/lib/components/layout/main/card/TangledRepoCard.svelte
··· 1 1 <script lang="ts"> 2 - import { ExternalLink, GitBranch, Server, User } from '@lucide/svelte'; 2 + import { ExternalLink, Code, Server, User } from '@lucide/svelte'; 3 3 import Card from '../../../ui/Card.svelte'; 4 4 import InternalCard from '../../../ui/InternalCard.svelte'; 5 5 import type { TangledReposData, ProfileData } from '@ewanc26/atproto'; ··· 47 47 {#each repos.repos as repo} 48 48 <InternalCard href={buildRepoUrl(repo.name)}> 49 49 {#snippet children()} 50 - <GitBranch class="h-5 w-5 shrink-0 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 50 + <Code class="h-5 w-5 shrink-0 text-primary-600 dark:text-primary-400" aria-hidden="true" /> 51 51 <div class="min-w-0 flex-1 space-y-2"> 52 52 <h3 class="overflow-wrap-anywhere font-semibold wrap-break-word text-ink-900 dark:text-ink-50">{repo.name}</h3> 53 53 <div class="flex flex-wrap items-center gap-3 text-xs text-ink-700 dark:text-ink-200">
+2
packages/ui/src/lib/components/layout/main/card/index.ts
··· 1 + export { default as PopfeedCard } from './PopfeedCard.svelte'; 1 2 export { default as LinkCard } from './LinkCard.svelte'; 3 + 2 4 export { default as ProfileCard } from './ProfileCard.svelte'; 3 5 export { default as PostCard } from './PostCard.svelte'; 4 6 export { default as BlueskyPostCard } from './BlueskyPostCard.svelte';
+50
packages/ui/src/lib/components/ui/NoiseImage.svelte
··· 1 + <script lang="ts"> 2 + import { noiseAvatarAction } from '@ewanc26/noise-avatar'; 3 + 4 + /** 5 + * Renders an `<img>` when `src` is present and loads successfully, 6 + * otherwise falls back to a `<canvas>` filled with deterministic noise 7 + * keyed on `seed`. Both elements receive the same `class` string so 8 + * layout is identical regardless of which branch is active. 9 + * 10 + * Accessibility: 11 + * - Canvas with a non-empty `alt` → `aria-label={alt}` 12 + * - Canvas with no `alt` → `aria-hidden="true"` 13 + */ 14 + 15 + interface Props { 16 + /** Image URL. When absent the canvas is rendered immediately. */ 17 + src?: string | null | undefined; 18 + /** Deterministic seed passed to `noiseAvatarAction`. */ 19 + seed: string; 20 + /** Alt text for the `<img>`; also used as `aria-label` on the canvas. */ 21 + alt?: string; 22 + /** CSS classes applied to both `<img>` and `<canvas>`. */ 23 + class?: string; 24 + loading?: 'lazy' | 'eager'; 25 + role?: string; 26 + } 27 + 28 + let { src, seed, alt = '', class: className = '', loading = 'lazy', role }: Props = $props(); 29 + 30 + let failed = $state(false); 31 + </script> 32 + 33 + {#if src && !failed} 34 + <img 35 + {src} 36 + {alt} 37 + class={className} 38 + {loading} 39 + {role} 40 + onerror={() => (failed = true)} 41 + /> 42 + {:else} 43 + <canvas 44 + use:noiseAvatarAction={seed} 45 + class={className} 46 + {role} 47 + aria-label={alt || undefined} 48 + aria-hidden={alt ? undefined : 'true'} 49 + ></canvas> 50 + {/if}
+1
packages/ui/src/lib/components/ui/index.ts
··· 1 + export { default as NoiseImage } from './NoiseImage.svelte'; 1 2 export { default as Card } from './Card.svelte'; 2 3 export { default as InternalCard } from './InternalCard.svelte'; 3 4 export { default as Dropdown } from './Dropdown.svelte';
+2 -2
packages/ui/src/lib/index.ts
··· 24 24 export { DynamicLinks, ScrollToTop, TangledRepos } from './components/layout/main/index.js'; 25 25 26 26 // ─── Cards ──────────────────────────────────────────────────────────────────── 27 - export { LinkCard, ProfileCard, PostCard, BlueskyPostCard, TangledRepoCard, MusicStatusCard, KibunStatusCard, FeedCard } from './components/layout/main/card/index.js'; 27 + export { PopfeedCard, LinkCard, ProfileCard, PostCard, BlueskyPostCard, TangledRepoCard, MusicStatusCard, KibunStatusCard, FeedCard } from './components/layout/main/card/index.js'; 28 28 export type { FeedItem } from './components/layout/main/card/index.js'; 29 29 30 30 // ─── SEO ────────────────────────────────────────────────────────────────────── 31 31 export { MetaTags } from './components/seo/index.js'; 32 32 33 33 // ─── UI primitives ──────────────────────────────────────────────────────────── 34 - export { Card, InternalCard, Dropdown, Pagination, SearchBar, Tabs, PostsGroupedView, DocumentCard, BlogPostCard } from './components/ui/index.js'; 34 + export { NoiseImage, Card, InternalCard, Dropdown, Pagination, SearchBar, Tabs, PostsGroupedView, DocumentCard, BlogPostCard } from './components/ui/index.js';
+5
pnpm-lock.yaml
··· 377 377 378 378 packages/ui: 379 379 dependencies: 380 + '@ewanc26/noise-avatar': 381 + specifier: workspace:* 382 + version: link:../noise-avatar 380 383 '@lucide/svelte': 381 384 specifier: ^0.577.0 382 385 version: 0.577.0(svelte@5.53.11) ··· 412 415 typescript: 413 416 specifier: ^5.9.3 414 417 version: 5.9.3 418 + 419 + packages/wafrn-theme: {} 415 420 416 421 packages: 417 422
+186
release.sh
··· 1 + #!/usr/bin/env bash 2 + # release.sh — build, version-bump, publish one or more packages, then optionally 3 + # update downstream consumers. 4 + # 5 + # Usage: 6 + # ./release.sh [options] <package-name> [package-name ...] 7 + # 8 + # Options: 9 + # -b, --bump <patch|minor|major> Version bump type (default: patch) 10 + # -d, --downstream <dir> [dir...] Paths to run `pnpm install` in after publish 11 + # -w, --wait <seconds> Registry propagation wait (default: 10) 12 + # -n, --dry-run Print what would happen without doing it 13 + # -h, --help Show this help 14 + # 15 + # Package names can be short ("ui", "atproto") or scoped ("@ewanc26/ui"). 16 + # They are matched against the `name` field in each packages/*/package.json. 17 + # 18 + # Examples: 19 + # ./release.sh ui 20 + # ./release.sh --bump minor ui atproto 21 + # ./release.sh --bump patch ui --downstream ../website 22 + 23 + set -euo pipefail 24 + 25 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 26 + PACKAGES_DIR="$SCRIPT_DIR/packages" 27 + 28 + # ── Defaults ────────────────────────────────────────────────────────────────── 29 + BUMP="patch" 30 + DOWNSTREAM=() 31 + WAIT=10 32 + DRY_RUN=false 33 + TARGETS=() 34 + 35 + # ── Colours ─────────────────────────────────────────────────────────────────── 36 + bold="\033[1m" 37 + green="\033[32m" 38 + yellow="\033[33m" 39 + red="\033[31m" 40 + reset="\033[0m" 41 + 42 + info() { echo -e "${bold} →${reset} $*"; } 43 + success() { echo -e "${green} ✓${reset} $*"; } 44 + warn() { echo -e "${yellow} ⚠${reset} $*"; } 45 + die() { echo -e "${red} ✗${reset} $*" >&2; exit 1; } 46 + dryrun() { echo -e "${yellow} [dry-run]${reset} $*"; } 47 + 48 + # ── Argument parsing ────────────────────────────────────────────────────────── 49 + while [[ $# -gt 0 ]]; do 50 + case "$1" in 51 + -b|--bump) 52 + BUMP="$2"; shift 2 53 + [[ "$BUMP" =~ ^(patch|minor|major)$ ]] || die "Invalid bump type: $BUMP" 54 + ;; 55 + -d|--downstream) 56 + shift 57 + while [[ $# -gt 0 && ! "$1" =~ ^- ]]; do 58 + DOWNSTREAM+=("$1"); shift 59 + done 60 + ;; 61 + -w|--wait) 62 + WAIT="$2"; shift 2 63 + ;; 64 + -n|--dry-run) 65 + DRY_RUN=true; shift 66 + ;; 67 + -h|--help) 68 + sed -n '3,20p' "$0" | sed 's/^# \?//' 69 + exit 0 70 + ;; 71 + -*) 72 + die "Unknown option: $1" 73 + ;; 74 + *) 75 + TARGETS+=("$1"); shift 76 + ;; 77 + esac 78 + done 79 + 80 + [[ ${#TARGETS[@]} -eq 0 ]] && die "No packages specified. Run with --help for usage." 81 + 82 + # ── Resolve package directories ─────────────────────────────────────────────── 83 + resolve_package() { 84 + local target="$1" 85 + # Strip leading @scope/ if present, try both directory name and package.json name 86 + for pkg_dir in "$PACKAGES_DIR"/*/; do 87 + [[ -f "$pkg_dir/package.json" ]] || continue 88 + local name dir_name 89 + name="$(node -p "require('$pkg_dir/package.json').name" 2>/dev/null || true)" 90 + dir_name="$(basename "$pkg_dir")" 91 + if [[ "$name" == "$target" || "$dir_name" == "$target" || "$name" == "@ewanc26/$target" ]]; then 92 + echo "$pkg_dir" 93 + return 94 + fi 95 + done 96 + die "Could not find package matching: $target" 97 + } 98 + 99 + # ── Main loop ───────────────────────────────────────────────────────────────── 100 + RELEASED=() 101 + 102 + for target in "${TARGETS[@]}"; do 103 + pkg_dir="$(resolve_package "$target")" 104 + pkg_name="$(node -p "require('$pkg_dir/package.json').name")" 105 + current_ver="$(node -p "require('$pkg_dir/package.json').version")" 106 + 107 + echo "" 108 + info "Package: ${bold}$pkg_name${reset} (${pkg_dir##"$SCRIPT_DIR/"})" 109 + info "Current: $current_ver" 110 + info "Bump: $BUMP" 111 + 112 + if $DRY_RUN; then 113 + dryrun "Would run: cd $pkg_dir && npm version $BUMP --no-git-tag-version" 114 + dryrun "Would run: pnpm --filter $pkg_name build" 115 + dryrun "Would run: pnpm --filter $pkg_name publish --no-git-checks" 116 + RELEASED+=("$pkg_name") 117 + continue 118 + fi 119 + 120 + # Bump version 121 + new_ver="$(cd "$pkg_dir" && npm version "$BUMP" --no-git-tag-version | tr -d 'v')" 122 + success "Bumped to $new_ver" 123 + 124 + # Build 125 + info "Building..." 126 + pnpm --filter "$pkg_name" build 127 + success "Built" 128 + 129 + # Publish 130 + info "Publishing..." 131 + pnpm --filter "$pkg_name" publish --no-git-checks 132 + success "Published $pkg_name@$new_ver" 133 + 134 + RELEASED+=("$pkg_name@$new_ver") 135 + done 136 + 137 + # ── Downstream updates ──────────────────────────────────────────────────────── 138 + if [[ ${#DOWNSTREAM[@]} -gt 0 ]]; then 139 + if $DRY_RUN; then 140 + dryrun "Would wait ${WAIT}s for registry propagation" 141 + for dir in "${DOWNSTREAM[@]}"; do 142 + dryrun "Would run: cd $dir && pnpm install --registry https://registry.npmjs.org" 143 + done 144 + else 145 + echo "" 146 + for dir in "${DOWNSTREAM[@]}"; do 147 + abs_dir="$(cd "$SCRIPT_DIR" && cd "$dir" && pwd)" 148 + pkg_name_there="$(node -p "require('$abs_dir/package.json').name" 2>/dev/null || echo "$dir")" 149 + info "Updating downstream: ${bold}$pkg_name_there${reset}" 150 + 151 + # For each released package, update the constraint in package.json if pinned 152 + for released in "${RELEASED[@]}"; do 153 + pkg="${released%@*}" # e.g. @ewanc26/ui 154 + ver="${released##*@}" # e.g. 0.3.4 155 + escaped_pkg="$(echo "$pkg" | sed 's/[\/&]/\\&/g')" 156 + sed -i '' "s/\"$escaped_pkg\": \"\^[0-9]*\.[0-9]*\.[0-9]*\"/\"$escaped_pkg\": \"^$ver\"/" \ 157 + "$abs_dir/package.json" 2>/dev/null || true 158 + done 159 + 160 + # Retry install until the registry has propagated the new versions 161 + _attempt=0 162 + _max=10 163 + while true; do 164 + _attempt=$((_attempt + 1)) 165 + info "Install attempt $_attempt/$_max..." 166 + if (cd "$abs_dir" && pnpm install --registry https://registry.npmjs.org 2>&1); then 167 + success "Updated $pkg_name_there" 168 + break 169 + fi 170 + if [[ $_attempt -ge $_max ]]; then 171 + die "Failed to install after $_max attempts. Registry may still be propagating — try again in a moment." 172 + fi 173 + warn "Not yet available, waiting ${WAIT}s..." 174 + sleep "$WAIT" 175 + done 176 + done 177 + fi 178 + fi 179 + 180 + # ── Summary ─────────────────────────────────────────────────────────────────── 181 + echo "" 182 + echo -e "${bold}Released:${reset}" 183 + for r in "${RELEASED[@]}"; do 184 + echo -e " ${green}✓${reset} $r" 185 + done 186 + echo ""