Deploy fixes (macOS rsync, web Dockerfile), PeeringDB disabled pending approval, offline country lookup
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
12 changed files +273 −4
added
apps/web/src/components/entity/breadcrumbs.tsx
+45 −0
@@ -0,0 +1,45 @@ | ||
| 1 | +import { ChevronRight } from "lucide-react"; | |
| 2 | +import Link from "next/link"; | |
| 3 | +import { cn } from "@/components/ui/cn"; | |
| 4 | +import { routes } from "@/lib/routes"; | |
| 5 | +import { breadcrumbJsonLd } from "@/lib/seo"; | |
| 6 | +import { JsonLd } from "./json-ld"; | |
| 7 | + | |
| 8 | +export interface Crumb { | |
| 9 | + name: string; | |
| 10 | + href: string; | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Breadcrumb trail (Home is implicit). Last item is the current page (not a link). Emits BreadcrumbList JSON-LD. | |
| 15 | + * On mobile the trail scrolls horizontally instead of wrapping so the title stays first in view. | |
| 16 | + */ | |
| 17 | +export function Breadcrumbs({ items, className }: { items: Crumb[]; className?: string }) { | |
| 18 | + const all: Crumb[] = [{ name: "Home", href: routes.home() }, ...items]; | |
| 19 | + return ( | |
| 20 | + <> | |
| 21 | + <nav aria-label="Breadcrumb" className={cn("-mx-3 overflow-x-auto px-3 no-scrollbar sm:mx-0 sm:px-0", className)}> | |
| 22 | + <ol className="flex min-w-max items-center gap-1 text-[12px] text-ink-3"> | |
| 23 | + {all.map((c, i) => { | |
| 24 | + const last = i === all.length - 1; | |
| 25 | + return ( | |
| 26 | + <li key={c.href} className="inline-flex items-center gap-1"> | |
| 27 | + {i > 0 && <ChevronRight className="size-3 text-ink-4" aria-hidden />} | |
| 28 | + {last ? ( | |
| 29 | + <span aria-current="page" className="max-w-[60vw] truncate text-ink-2 sm:max-w-none"> | |
| 30 | + {c.name} | |
| 31 | + </span> | |
| 32 | + ) : ( | |
| 33 | + <Link href={c.href} className="hover:text-ink hover:underline"> | |
| 34 | + {c.name} | |
| 35 | + </Link> | |
| 36 | + )} | |
| 37 | + </li> | |
| 38 | + ); | |
| 39 | + })} | |
| 40 | + </ol> | |
| 41 | + </nav> | |
| 42 | + <JsonLd data={breadcrumbJsonLd(all)} /> | |
| 43 | + </> | |
| 44 | + ); | |
| 45 | +} | |
added
apps/web/src/components/entity/json-ld.tsx
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +/** Inline JSON-LD script (server component). Pass one object or an array of objects. */ | |
| 2 | +export function JsonLd({ data }: { data: Record<string, unknown> | Array<Record<string, unknown>> }) { | |
| 3 | + const json = JSON.stringify(data).replace(/</g, "\\u003c"); | |
| 4 | + return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: json }} />; | |
| 5 | +} | |
added
apps/web/src/components/entity/section-nav.tsx
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +import { cn } from "@/components/ui/cn"; | |
| 2 | + | |
| 3 | +export interface SectionLink { | |
| 4 | + id: string; | |
| 5 | + label: string; | |
| 6 | + count?: number | null; | |
| 7 | +} | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * In-page anchor navigation for stacked detail sections (sticky under the header, horizontal scroll on mobile). | |
| 11 | + * Server component — no active-state tracking, sections are all on one page. | |
| 12 | + */ | |
| 13 | +export function SectionNav({ items, className }: { items: SectionLink[]; className?: string }) { | |
| 14 | + const list = items.filter((it) => it.count === undefined || it.count === null || it.count > 0); | |
| 15 | + if (list.length < 2) return null; | |
| 16 | + return ( | |
| 17 | + <nav aria-label="Sections" className={cn("sticky top-12 z-[15] -mx-3 border-b border-hair bg-paper/95 px-3 backdrop-blur sm:top-14 sm:mx-0 sm:px-0", className)}> | |
| 18 | + <ul className="flex min-w-max gap-0 overflow-x-auto no-scrollbar"> | |
| 19 | + {list.map((it) => ( | |
| 20 | + <li key={it.id} className="first:[&>a]:pl-0"> | |
| 21 | + <a href={`#${it.id}`} className="inline-flex h-10 items-center gap-1.5 whitespace-nowrap border-b-2 border-transparent px-3 text-[12.5px] font-medium text-ink-3 hover:border-hair-2 hover:text-ink"> | |
| 22 | + {it.label} | |
| 23 | + {typeof it.count === "number" && <span className="figure text-[11px] text-ink-4">{it.count}</span>} | |
| 24 | + </a> | |
| 25 | + </li> | |
| 26 | + ))} | |
| 27 | + </ul> | |
| 28 | + </nav> | |
| 29 | + ); | |
| 30 | +} | |
modified
apps/web/src/lib/api.ts
+20 −0
@@ -1,6 +1,7 @@ | ||
| 1 | 1 | import type { |
| 2 | 2 | ApiEnvelope, |
| 3 | 3 | CloudRegionSummary, |
| 4 | + CountryDetail, | |
| 4 | 5 | CountrySummary, |
| 5 | 6 | Dashboard, |
| 6 | 7 | EventDTO, |
@@ -9,12 +10,16 @@ import type { | ||
| 9 | 10 | FacilitySummary, |
| 10 | 11 | IxpSummary, |
| 11 | 12 | MapResponse, |
| 13 | + MetroDetail, | |
| 12 | 14 | MetroSummary, |
| 15 | + OperatorDetail, | |
| 13 | 16 | OperatorSummary, |
| 17 | + ProjectDetail, | |
| 14 | 18 | ProjectSummary, |
| 15 | 19 | Ranking, |
| 16 | 20 | SearchResponse, |
| 17 | 21 | } from "@dci/core"; |
| 22 | +import type { CloudRegionDetail, IxpDetail, PipelineAggregates, SitemapEntry, SitemapKind, SourceDetail, SourceWithCounts } from "@/lib/dto"; | |
| 18 | 23 | |
| 19 | 24 | /** |
| 20 | 25 | * Typed API client over the v1 contract (packages/core/src/api-types.ts). |
@@ -157,6 +162,21 @@ export const api = { | ||
| 157 | 162 | search: (q: string, extra?: Query) => apiGet<SearchResponse>("/search", { q, ...extra }, { revalidate: REVALIDATE.list }), |
| 158 | 163 | |
| 159 | 164 | map: (q: Query) => apiGet<MapResponse>("/map", q, { revalidate: REVALIDATE.map }), |
| 165 | + | |
| 166 | + /* ---- entity details (facilities paginated with ?fPage on operator / country / metro) */ | |
| 167 | + operator: (slug: string, q?: Query) => apiGet<OperatorDetail>(`/operators/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }), | |
| 168 | + country: (slug: string, q?: Query) => apiGet<CountryDetail>(`/countries/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }), | |
| 169 | + metro: (slug: string, q?: Query) => apiGet<MetroDetail>(`/metros/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }), | |
| 170 | + project: (slug: string) => apiGet<ProjectDetail>(`/projects/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }), | |
| 171 | + projectPipeline: () => apiGet<PipelineAggregates>("/projects/pipeline", undefined, { revalidate: REVALIDATE.list }), | |
| 172 | + cloudRegion: (slug: string) => apiGet<CloudRegionDetail>(`/cloud-regions/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }), | |
| 173 | + ixp: (slug: string) => apiGet<IxpDetail>(`/ixps/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }), | |
| 174 | + sources: async (q?: Query) => { | |
| 175 | + const r = await apiGet<SourceWithCounts[] | { items: SourceWithCounts[]; total?: number }>("/sources", q, { revalidate: REVALIDATE.list }); | |
| 176 | + return paged<SourceWithCounts>(r); | |
| 177 | + }, | |
| 178 | + source: (id: string) => apiGet<SourceDetail>(`/sources/${encodeURIComponent(id)}`, undefined, { revalidate: REVALIDATE.detail }), | |
| 179 | + sitemap: (kind: SitemapKind, page = 1) => apiGet<SitemapEntry[]>(`/sitemap/${kind}`, { page }, { revalidate: 600 }), | |
| 160 | 180 | }; |
| 161 | 181 | |
| 162 | 182 | /** Event-list query for /live and the home module. */ |
added
apps/web/src/lib/dto.ts
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +import type { CloudRegionSummary, FacilityStatus, FacilitySummary, IxpSummary, SourceRef } from "@dci/core"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Response shapes implemented by @dci/api that are not (yet) part of the shared contract in | |
| 5 | + * packages/core/src/api-types.ts (see apps/api/README.md and apps/api/src/repositories/*). | |
| 6 | + */ | |
| 7 | + | |
| 8 | +export interface CloudRegionDetail extends CloudRegionSummary { | |
| 9 | + regionName: string | null; | |
| 10 | + metro: { id: string; slug: string; name: string } | null; | |
| 11 | + countryName: string | null; | |
| 12 | + siblingRegions: CloudRegionSummary[]; | |
| 13 | + facilitiesInMetro: FacilitySummary[]; | |
| 14 | + externalIds: Record<string, string | number>; | |
| 15 | + updatedAt: string; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export interface IxpDetail extends IxpSummary { | |
| 19 | + countryName: string | null; | |
| 20 | + metro: { id: string; slug: string; name: string } | null; | |
| 21 | + regionContinent: string | null; | |
| 22 | + facilities: FacilitySummary[]; | |
| 23 | + externalIds: Record<string, string | number>; | |
| 24 | + updatedAt: string; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export interface SourceWithCounts extends SourceRef { | |
| 28 | + connectorId: string; | |
| 29 | + priority: number; | |
| 30 | + robotsAllowed: boolean | null; | |
| 31 | + notes: string | null; | |
| 32 | + documents: number; | |
| 33 | + provenanceRows: number; | |
| 34 | + facilities: number; | |
| 35 | + lastDocumentAt: string | null; | |
| 36 | + updatedAt: string; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export interface SourceDetail extends SourceWithCounts { | |
| 40 | + byPageType: Array<{ pageType: string; count: number }>; | |
| 41 | + byEntityType: Array<{ entityType: string; count: number }>; | |
| 42 | +} | |
| 43 | + | |
| 44 | +export interface PipelineAggregates { | |
| 45 | + byStatus: Array<{ status: FacilityStatus; count: number; mw: number | null; investmentUsd: number | null }>; | |
| 46 | + byYear: Array<{ year: string; count: number; mw: number | null }>; | |
| 47 | + byCountry: Array<{ countryIso2: string; name: string | null; slug: string | null; count: number; mw: number | null }>; | |
| 48 | + totals: { projects: number; plannedMw: number | null; investmentUsd: number | null; ai: number }; | |
| 49 | +} | |
| 50 | + | |
| 51 | +export interface SitemapEntry { | |
| 52 | + slug: string; | |
| 53 | + updatedAt: string; | |
| 54 | +} | |
| 55 | + | |
| 56 | +export const SITEMAP_KINDS = ["facilities", "operators", "countries", "metros", "projects", "cloud-regions"] as const; | |
| 57 | +export type SitemapKind = (typeof SITEMAP_KINDS)[number]; | |
| 58 | + | |
| 59 | +/** `meta` returned by the operator / country / metro detail endpoints (facilities paginated with ?fPage). */ | |
| 60 | +export interface DetailFacilitiesMeta { | |
| 61 | + facilitiesPage?: number; | |
| 62 | + facilitiesPerPage?: number; | |
| 63 | + facilitiesTotal?: number; | |
| 64 | + methodology?: string; | |
| 65 | +} | |
modified
apps/web/src/lib/routes.ts
+2 −0
@@ -18,6 +18,8 @@ export const routes = { | ||
| 18 | 18 | projects: (qs?: string) => (qs ? `/projects?${qs}` : "/projects"), |
| 19 | 19 | project: (slug: string) => `/projects/${slug}`, |
| 20 | 20 | rankings: (key?: string) => (key ? `/rankings#${key}` : "/rankings"), |
| 21 | + ranking: (key: string) => `/rankings/${key}`, | |
| 22 | + bot: () => "/bot", | |
| 21 | 23 | cloudRegions: () => "/cloud-regions", |
| 22 | 24 | cloudRegion: (slug: string) => `/cloud-regions/${slug}`, |
| 23 | 25 | ixps: () => "/ixps", |
added
apps/web/src/lib/seo.ts
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 2 | +import { SITE_URL } from "@/lib/routes"; | |
| 3 | + | |
| 4 | +/** Metadata for an entity / index page: title, description, canonical + OG/Twitter mirror (OG image comes from the segment's opengraph-image.tsx). */ | |
| 5 | +export function pageMetadata({ title, description, path, absoluteTitle }: { title: string; description: string; path: string; absoluteTitle?: boolean }): Metadata { | |
| 6 | + const desc = description.length > 300 ? `${description.slice(0, 297)}…` : description; | |
| 7 | + return { | |
| 8 | + title: absoluteTitle ? { absolute: title } : title, | |
| 9 | + description: desc, | |
| 10 | + alternates: { canonical: path }, | |
| 11 | + openGraph: { title, description: desc, url: `${SITE_URL}${path}`, type: "website" }, | |
| 12 | + twitter: { card: "summary_large_image", title, description: desc }, | |
| 13 | + }; | |
| 14 | +} | |
| 15 | + | |
| 16 | +/** Metadata for an entity that does not exist (still renders 404 body via notFound()). */ | |
| 17 | +export const NOT_FOUND_METADATA: Metadata = { title: "Not found", robots: { index: false, follow: false } }; | |
| 18 | + | |
| 19 | +export function absoluteUrl(path: string): string { | |
| 20 | + return `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** Drop null / undefined / empty values from a JSON-LD object (Google ignores empty props but it keeps the payload tidy). */ | |
| 24 | +export function compactJsonLd<T extends Record<string, unknown>>(obj: T): Partial<T> { | |
| 25 | + const out: Record<string, unknown> = {}; | |
| 26 | + for (const [k, v] of Object.entries(obj)) { | |
| 27 | + if (v === null || v === undefined || v === "") continue; | |
| 28 | + if (Array.isArray(v) && v.length === 0) continue; | |
| 29 | + out[k] = v; | |
| 30 | + } | |
| 31 | + return out as Partial<T>; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export function breadcrumbJsonLd(items: Array<{ name: string; href: string }>) { | |
| 35 | + return { | |
| 36 | + "@context": "https://schema.org", | |
| 37 | + "@type": "BreadcrumbList", | |
| 38 | + itemListElement: items.map((it, i) => ({ "@type": "ListItem", position: i + 1, name: it.name, item: absoluteUrl(it.href) })), | |
| 39 | + }; | |
| 40 | +} | |
added
apps/worker/src/ingest/country-lookup.ts
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +import { readFileSync, existsSync } from "node:fs"; | |
| 2 | +import { dirname, join } from "node:path"; | |
| 3 | +import { fileURLToPath } from "node:url"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Offline point-in-country lookup (Natural Earth 1:50m admin-0, public domain) — used when a source publishes | |
| 7 | + * coordinates but no country (OpenStreetMap features rarely carry addr:country). Deterministic, no network. | |
| 8 | + */ | |
| 9 | +type Ring = number[][]; | |
| 10 | +interface Feat { iso2: string; bbox: [number, number, number, number]; polys: Ring[][] } | |
| 11 | +let feats: Feat[] | null = null; | |
| 12 | + | |
| 13 | +function load(): Feat[] { | |
| 14 | + if (feats) return feats; | |
| 15 | + const here = dirname(fileURLToPath(import.meta.url)); | |
| 16 | + const candidates = [join(here, "..", "..", "data", "ne_50m_admin_0_countries.geojson"), join(process.cwd(), "apps", "worker", "data", "ne_50m_admin_0_countries.geojson"), join(process.cwd(), "data", "ne_50m_admin_0_countries.geojson")]; | |
| 17 | + const file = candidates.find((f) => existsSync(f)); | |
| 18 | + if (!file) { feats = []; return feats; } | |
| 19 | + const gj = JSON.parse(readFileSync(file, "utf8")) as { features: Array<{ properties: Record<string, unknown>; geometry: { type: string; coordinates: unknown } }> }; | |
| 20 | + const out: Feat[] = []; | |
| 21 | + for (const f of gj.features) { | |
| 22 | + const p = f.properties; | |
| 23 | + let iso2 = String(p.ISO_A2_EH ?? p.ISO_A2 ?? "-99"); | |
| 24 | + if (iso2 === "-99") { const a = String(p.ADM0_A3 ?? ""); iso2 = ({ FRA: "FR", NOR: "NO", KOS: "XK", SDS: "SS", SOL: "SO" } as Record<string, string>)[a] ?? "-99"; } | |
| 25 | + if (!/^[A-Z]{2}$/.test(iso2)) continue; | |
| 26 | + const polys: Ring[][] = f.geometry.type === "Polygon" ? [f.geometry.coordinates as Ring[]] : f.geometry.type === "MultiPolygon" ? (f.geometry.coordinates as Ring[][]) : []; | |
| 27 | + let minX = 180, minY = 90, maxX = -180, maxY = -90; | |
| 28 | + for (const poly of polys) for (const [x, y] of poly[0] ?? []) { if (x! < minX) minX = x!; if (x! > maxX) maxX = x!; if (y! < minY) minY = y!; if (y! > maxY) maxY = y!; } | |
| 29 | + out.push({ iso2, bbox: [minX, minY, maxX, maxY], polys }); | |
| 30 | + } | |
| 31 | + feats = out; | |
| 32 | + return feats; | |
| 33 | +} | |
| 34 | + | |
| 35 | +function inRing(x: number, y: number, ring: Ring): boolean { | |
| 36 | + let inside = false; | |
| 37 | + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { | |
| 38 | + const xi = ring[i]![0]!, yi = ring[i]![1]!, xj = ring[j]![0]!, yj = ring[j]![1]!; | |
| 39 | + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; | |
| 40 | + } | |
| 41 | + return inside; | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** ISO-2 of the country containing the point, or null (ocean / unknown). Handles holes. */ | |
| 45 | +export function countryFromPoint(lat: number, lng: number): string | null { | |
| 46 | + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; | |
| 47 | + for (const f of load()) { | |
| 48 | + const [minX, minY, maxX, maxY] = f.bbox; | |
| 49 | + if (lng < minX || lng > maxX || lat < minY || lat > maxY) continue; | |
| 50 | + for (const poly of f.polys) { | |
| 51 | + if (!poly[0] || !inRing(lng, lat, poly[0])) continue; | |
| 52 | + let hole = false; | |
| 53 | + for (let k = 1; k < poly.length; k++) if (inRing(lng, lat, poly[k]!)) { hole = true; break; } | |
| 54 | + if (!hole) return f.iso2; | |
| 55 | + } | |
| 56 | + } | |
| 57 | + return null; | |
| 58 | +} | |
modified
apps/worker/src/ingest/facilities.ts
+2 −0
@@ -14,6 +14,7 @@ import { isHyperscalerName } from "./canonical-operators.js"; | ||
| 14 | 14 | import { resolveCampus } from "./campuses.js"; |
| 15 | 15 | import { discoverySignificance, emitDiffEvents, recordEvent, TRACKED_FACILITY_FIELDS } from "./events.js"; |
| 16 | 16 | import { bboxAround, validGeo } from "./geo.js"; |
| 17 | +import { countryFromPoint } from "./country-lookup.js"; | |
| 17 | 18 | import { facilityIdForKey, upsertKey } from "./keys.js"; |
| 18 | 19 | import { attachIxpsByName } from "./ixps.js"; |
| 19 | 20 | import { bestMatch, bestMw, completenessScore, decide, facilityConfidence, isPipelineStatus, shouldReplace, shouldReplaceGeo, shouldReplaceMw, type FacilityCandidate, type FieldObservation, type MatchDecision, type MatchScore } from "./match.js"; |
@@ -191,6 +192,7 @@ export async function ingestFacility(tx: Tx, ctx: IngestContext, nf: NormalizedF | ||
| 191 | 192 | const owner = nf.ownerName && normalizeName(nf.ownerName) !== normalizeName(nf.operatorName ?? "") ? await resolveOperator(tx, ctx, { name: nf.ownerName }) : null; |
| 192 | 193 | const geo = validGeo(nf.geo) ? nf.geo : null; |
| 193 | 194 | let country = await safeCountry(tx, ctx, nf.countryIso2); |
| 195 | + if (!country && geo) country = await safeCountry(tx, ctx, countryFromPoint(geo.lat, geo.lng)); // offline Natural Earth polygons (OSM features rarely carry addr:country) | |
| 194 | 196 | const metro = await assignMetro(tx, { lat: geo?.lat, lng: geo?.lng, city: nf.city, countryIso2: country }); |
| 195 | 197 | country = country ?? metro.countryIso2; |
| 196 | 198 | |
modified
config/connectors/peeringdb.yaml
+3 −0
@@ -4,6 +4,9 @@ domain: www.peeringdb.com | ||
| 4 | 4 | kind: dataset |
| 5 | 5 | mode: api |
| 6 | 6 | priority: 2 |
| 7 | +# DISABLED 2026-09-11 pending written approval from support@peeringdb.com: the AUP excludes commercial application | |
| 8 | +# and requires permission for bulk reproduction. Re-enable (enabled: true) once approval + PEERINGDB_API_KEY are in place. | |
| 9 | +enabled: false | |
| 7 | 10 | # Verbatim from https://www.peeringdb.com/aup (retrieved 2026-09-11). PeeringDB publishes no CC/ODbL license; the AUP governs use. |
| 8 | 11 | license: >- |
| 9 | 12 | PeeringDB Acceptable Use Policy (https://www.peeringdb.com/aup), © 2004-2026 PeeringDB All Rights Reserved: |
modified
deploy/bin/lib.sh
+2 −2
@@ -81,12 +81,12 @@ ensure_remote_layout() { # ensure_remote_layout <target> | ||
| 81 | 81 | sync_repo() { # sync_repo <target> |
| 82 | 82 | local t="$1" host; host="$(host_for "$t")" |
| 83 | 83 | log "rsync repo → $host:$REMOTE_DIR" |
| 84 | − rsync -az --delete --info=stats1 \ | |
| 84 | + rsync -az --delete \ | |
| 85 | 85 | --exclude-from="$REPO_ROOT/deploy/rsync-exclude.txt" \ |
| 86 | 86 | -e "ssh ${SSH_OPTS[*]}" \ |
| 87 | 87 | "$REPO_ROOT/" "$host:$REMOTE_DIR/" |
| 88 | 88 | if [[ -f "$REPO_ROOT/deploy/.env.$t" ]]; then |
| 89 | − rsync -az --chmod=F600 -e "ssh ${SSH_OPTS[*]}" "$REPO_ROOT/deploy/.env.$t" "$host:$REMOTE_DIR/deploy/.env.$t" | |
| 89 | + scp -q "${SSH_OPTS[@]}" "$REPO_ROOT/deploy/.env.$t" "$host:$REMOTE_DIR/deploy/.env.$t" && remote "$t" "chmod 600 $REMOTE_DIR/deploy/.env.$t" | |
| 90 | 90 | fi |
| 91 | 91 | remote "$t" "chmod +x $REMOTE_DIR/deploy/bin/*.sh $REMOTE_DIR/deploy/bin/remote/*.sh $REMOTE_DIR/deploy/docker/entrypoint.sh $REMOTE_DIR/deploy/minio/init.sh" |
| 92 | 92 | ok "synced" |
modified
deploy/docker/Dockerfile.web
+1 −2
@@ -32,8 +32,7 @@ COPY . . | ||
| 32 | 32 | RUN --mount=type=cache,id=dci-pnpm-store,target=/pnpm/store \ |
| 33 | 33 | pnpm install --offline --frozen-lockfile --config.dangerously-allow-all-builds=true --config.confirm-modules-purge=false --filter "@dci/web..." |
| 34 | 34 | RUN --mount=type=cache,id=dci-next-cache,target=/app/apps/web/.next/cache \ |
| 35 | − pnpm --filter @dci/web run build \ | |
| 36 | − && rm -rf apps/web/.next/cache | |
| 35 | + pnpm --filter @dci/web run build | |
| 37 | 36 | |
| 38 | 37 | # ---------------------------------------------------------------- prod-deps: runtime node_modules only |
| 39 | 38 | FROM fetch AS prod-deps |
| 40 | 39 | |