@tokenoftrust/storefront-runner 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +58 -0
- package/README.md +42 -0
- package/apps/storefront/astro.config.mjs +66 -0
- package/apps/storefront/dev-plugins/dev-loop-state.mjs +147 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +238 -0
- package/apps/storefront/env.d.ts +162 -0
- package/apps/storefront/migrations/0000_baseline.sql +54 -0
- package/apps/storefront/migrations/0001_products_fts.sql +9 -0
- package/apps/storefront/migrations/README.md +54 -0
- package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/_journal.json +20 -0
- package/apps/storefront/package.json +42 -0
- package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
- package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
- package/apps/storefront/public/brand/northwind-og.svg +13 -0
- package/apps/storefront/public/favicon.svg +5 -0
- package/apps/storefront/public/js/dashboard-team.js +147 -0
- package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
- package/apps/storefront/public/shared/commerce-chrome.css +369 -0
- package/apps/storefront/src/components/Badge.astro +34 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
- package/apps/storefront/src/components/CollectionCard.astro +56 -0
- package/apps/storefront/src/components/EmptyState.astro +55 -0
- package/apps/storefront/src/components/Img.astro +68 -0
- package/apps/storefront/src/components/ProductCard.astro +228 -0
- package/apps/storefront/src/components/Section.astro +39 -0
- package/apps/storefront/src/components/Seo.astro +70 -0
- package/apps/storefront/src/components/WidgetFrame.astro +34 -0
- package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +531 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +597 -0
- package/apps/storefront/src/components/admin/AdminFoxyCartSettingsPanel.astro +227 -0
- package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +465 -0
- package/apps/storefront/src/components/admin/AdminProductsTab.astro +901 -0
- package/apps/storefront/src/components/admin/AdminReportingTab.astro +435 -0
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +782 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +1223 -0
- package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
- package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
- package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +234 -0
- package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
- package/apps/storefront/src/components/commerce/ArticleCard.astro +242 -0
- package/apps/storefront/src/components/commerce/BrandGrid.astro +175 -0
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
- package/apps/storefront/src/components/commerce/FoxySidecartStyles.astro +61 -0
- package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
- package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
- package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
- package/apps/storefront/src/components/commerce/ReviewList.astro +54 -0
- package/apps/storefront/src/components/commerce/ReviewSummary.astro +44 -0
- package/apps/storefront/src/components/commerce/TagFilterCloud.astro +149 -0
- package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +37 -0
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +99 -0
- package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +45 -0
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
- package/apps/storefront/src/components/compliance/PactActNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/Prop65Warning.astro +80 -0
- package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +38 -0
- package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +53 -0
- package/apps/storefront/src/components/home/Hero.astro +164 -0
- package/apps/storefront/src/components/islands/AgeGate.tsx +109 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
- package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
- package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
- package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
- package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +375 -0
- package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
- package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
- package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
- package/apps/storefront/src/components/marketing/Faq.astro +37 -0
- package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
- package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
- package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
- package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
- package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
- package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
- package/apps/storefront/src/components/marketing/Steps.astro +36 -0
- package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
- package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
- package/apps/storefront/src/components/membership/IllustrativeLabel.astro +56 -0
- package/apps/storefront/src/components/membership/TierCards.astro +164 -0
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +148 -0
- package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
- package/apps/storefront/src/components/plp/FacetSidebar.astro +199 -0
- package/apps/storefront/src/components/plp/Pagination.astro +99 -0
- package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
- package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +95 -0
- package/apps/storefront/src/components/subscription/HowItWorksSteps.astro +67 -0
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +67 -0
- package/apps/storefront/src/components/subscription/SavingsExplainer.astro +101 -0
- package/apps/storefront/src/config/adminTargetTenant.ts +28 -0
- package/apps/storefront/src/config/devTenantSeed.ts +78 -0
- package/apps/storefront/src/config/devTenants.ts +16 -0
- package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
- package/apps/storefront/src/config/resolver.ts +102 -0
- package/apps/storefront/src/config/tenants.ts +87 -0
- package/apps/storefront/src/layouts/Layout.astro +368 -0
- package/apps/storefront/src/lib/admin/README.md +122 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +21 -0
- package/apps/storefront/src/lib/admin/ai/askEnvelope.ts +48 -0
- package/apps/storefront/src/lib/admin/ai/audit.ts +14 -0
- package/apps/storefront/src/lib/admin/ai/index.ts +6 -0
- package/apps/storefront/src/lib/admin/ai/operationalRuns.ts +155 -0
- package/apps/storefront/src/lib/admin/ai/recommendation.ts +20 -0
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +172 -0
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +65 -0
- package/apps/storefront/src/lib/admin/envelope.ts +10 -0
- package/apps/storefront/src/lib/admin/evidence/evidenceReference.ts +32 -0
- package/apps/storefront/src/lib/admin/memoizeByEnvelope.ts +27 -0
- package/apps/storefront/src/lib/admin/orderClient.ts +146 -0
- package/apps/storefront/src/lib/admin/seed.ts +54 -0
- package/apps/storefront/src/lib/admin/services/mock/aiWorkflows.ts +59 -0
- package/apps/storefront/src/lib/admin/services/mock/customers.ts +86 -0
- package/apps/storefront/src/lib/admin/services/mock/evidence.ts +99 -0
- package/apps/storefront/src/lib/admin/services/mock/foxyCartSettings.ts +78 -0
- package/apps/storefront/src/lib/admin/services/mock/fulfillment.ts +62 -0
- package/apps/storefront/src/lib/admin/services/mock/index.ts +12 -0
- package/apps/storefront/src/lib/admin/services/mock/orders.ts +184 -0
- package/apps/storefront/src/lib/admin/services/mock/products.ts +105 -0
- package/apps/storefront/src/lib/admin/services/mock/reporting.ts +51 -0
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +70 -0
- package/apps/storefront/src/lib/admin/services/mock/tenants.ts +29 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +300 -0
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +67 -0
- package/apps/storefront/src/lib/admin/viewmodels/customers.ts +56 -0
- package/apps/storefront/src/lib/admin/viewmodels/fulfillment.ts +49 -0
- package/apps/storefront/src/lib/admin/viewmodels/orders.ts +92 -0
- package/apps/storefront/src/lib/admin/viewmodels/products.ts +74 -0
- package/apps/storefront/src/lib/admin/viewmodels/reporting.ts +34 -0
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +64 -0
- package/apps/storefront/src/lib/adminTenantEnvelope.ts +90 -0
- package/apps/storefront/src/lib/analytics/index.ts +47 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
- package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
- package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +119 -0
- package/apps/storefront/src/lib/auth/route.ts +146 -0
- package/apps/storefront/src/lib/auth/session.ts +203 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
- package/apps/storefront/src/lib/basePath.ts +41 -0
- package/apps/storefront/src/lib/cfImage.ts +55 -0
- package/apps/storefront/src/lib/chrome/model.ts +244 -0
- package/apps/storefront/src/lib/chrome/parseConfig.ts +34 -0
- package/apps/storefront/src/lib/colors.ts +97 -0
- package/apps/storefront/src/lib/commerce/foxyCartSettings.ts +253 -0
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +91 -0
- package/apps/storefront/src/lib/content-edit/client.ts +230 -0
- package/apps/storefront/src/lib/content-edit/index.ts +14 -0
- package/apps/storefront/src/lib/content-edit/route.ts +25 -0
- package/apps/storefront/src/lib/content-edit/types.ts +19 -0
- package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
- package/apps/storefront/src/lib/d1/catalog.ts +366 -0
- package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
- package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
- package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
- package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
- package/apps/storefront/src/lib/demoGate.ts +113 -0
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +78 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +72 -0
- package/apps/storefront/src/lib/dev/ai-widget-client.js +308 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +14 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +155 -0
- package/apps/storefront/src/lib/dev/devDraft.ts +133 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +169 -0
- package/apps/storefront/src/lib/dev/livePreview.ts +77 -0
- package/apps/storefront/src/lib/dev/runtimeStore.ts +120 -0
- package/apps/storefront/src/lib/dev/storeStatus.ts +142 -0
- package/apps/storefront/src/lib/diag/redact.ts +20 -0
- package/apps/storefront/src/lib/edgeCache.ts +128 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
- package/apps/storefront/src/lib/env.ts +60 -0
- package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
- package/apps/storefront/src/lib/format.ts +38 -0
- package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
- package/apps/storefront/src/lib/imageAttrs.ts +55 -0
- package/apps/storefront/src/lib/jsonld.ts +166 -0
- package/apps/storefront/src/lib/maintenance.ts +71 -0
- package/apps/storefront/src/lib/membership/model.ts +97 -0
- package/apps/storefront/src/lib/memoryKv.ts +32 -0
- package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
- package/apps/storefront/src/lib/placeholder.ts +99 -0
- package/apps/storefront/src/lib/previewReload.ts +125 -0
- package/apps/storefront/src/lib/priceBuckets.ts +33 -0
- package/apps/storefront/src/lib/rawChrome.ts +505 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +136 -0
- package/apps/storefront/src/lib/search/index.ts +74 -0
- package/apps/storefront/src/lib/sections.ts +129 -0
- package/apps/storefront/src/lib/securityHeaders.ts +31 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +607 -0
- package/apps/storefront/src/lib/subscription/model.ts +68 -0
- package/apps/storefront/src/lib/tenantRouting.ts +91 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
- package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
- package/apps/storefront/src/lib/tot/query.ts +249 -0
- package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
- package/apps/storefront/src/lib/url.ts +142 -0
- package/apps/storefront/src/lib/wishlist.ts +63 -0
- package/apps/storefront/src/middleware/index.ts +455 -0
- package/apps/storefront/src/pages/404.astro +61 -0
- package/apps/storefront/src/pages/[...slug].astro +90 -0
- package/apps/storefront/src/pages/admin.astro +1675 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +53 -0
- package/apps/storefront/src/pages/api/admin/orders.ts +71 -0
- package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
- package/apps/storefront/src/pages/api/auth/send.ts +85 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
- package/apps/storefront/src/pages/api/checkout-style.css.ts +43 -0
- package/apps/storefront/src/pages/api/cli/policy.ts +70 -0
- package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +164 -0
- package/apps/storefront/src/pages/api/csp-report.ts +25 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
- package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
- package/apps/storefront/src/pages/api/dev/activity.ts +124 -0
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +73 -0
- package/apps/storefront/src/pages/api/dev/diff.ts +68 -0
- package/apps/storefront/src/pages/api/dev/file.ts +57 -0
- package/apps/storefront/src/pages/api/dev/locate.ts +125 -0
- package/apps/storefront/src/pages/api/dev/publish.ts +128 -0
- package/apps/storefront/src/pages/api/dev/tree.ts +70 -0
- package/apps/storefront/src/pages/api/dev/write.ts +116 -0
- package/apps/storefront/src/pages/api/newsletter.ts +104 -0
- package/apps/storefront/src/pages/api/request-access.ts +172 -0
- package/apps/storefront/src/pages/auth/login.astro +149 -0
- package/apps/storefront/src/pages/auth/magic.astro +105 -0
- package/apps/storefront/src/pages/capabilities.astro +292 -0
- package/apps/storefront/src/pages/collections/[handle].astro +177 -0
- package/apps/storefront/src/pages/collections/index.astro +59 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
- package/apps/storefront/src/pages/dashboard/index.astro +160 -0
- package/apps/storefront/src/pages/index.astro +290 -0
- package/apps/storefront/src/pages/llms.txt.ts +92 -0
- package/apps/storefront/src/pages/products/[handle].astro +402 -0
- package/apps/storefront/src/pages/robots.txt.ts +24 -0
- package/apps/storefront/src/pages/saved.astro +35 -0
- package/apps/storefront/src/pages/search.astro +150 -0
- package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +1280 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +229 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +144 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +116 -0
- package/apps/storefront/src/pages/style-guide/index.astro +98 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
- package/apps/storefront/src/styles/global.css +292 -0
- package/apps/storefront/src/themes/reference.ts +68 -0
- package/apps/storefront/src/themes/schema.ts +111 -0
- package/apps/storefront/src/themes/tenants/index.ts +51 -0
- package/apps/storefront/tsconfig.json +16 -0
- package/apps/storefront/vitest.config.ts +22 -0
- package/package.json +26 -0
- package/packages/public-runtime/LICENSE +58 -0
- package/packages/public-runtime/package.json +22 -0
- package/packages/public-runtime/src/binary-path.ts +68 -0
- package/packages/public-runtime/src/capabilities.ts +89 -0
- package/packages/public-runtime/src/catalog-api.ts +81 -0
- package/packages/public-runtime/src/catalog-d1.ts +192 -0
- package/packages/public-runtime/src/checkout-style-publish.ts +200 -0
- package/packages/public-runtime/src/checkout-style.ts +375 -0
- package/packages/public-runtime/src/csp.ts +109 -0
- package/packages/public-runtime/src/customization-preview.ts +197 -0
- package/packages/public-runtime/src/customization-reconcile.ts +63 -0
- package/packages/public-runtime/src/customization-runtime.ts +157 -0
- package/packages/public-runtime/src/customization-scripts.ts +64 -0
- package/packages/public-runtime/src/customization-versioning.ts +95 -0
- package/packages/public-runtime/src/foxy-orders.ts +136 -0
- package/packages/public-runtime/src/foxy.ts +196 -0
- package/packages/public-runtime/src/hot-standby-readiness.ts +140 -0
- package/packages/public-runtime/src/index.ts +34 -0
- package/packages/public-runtime/src/product.ts +159 -0
- package/packages/public-runtime/src/search.ts +45 -0
- package/packages/public-runtime/src/tenant-assets.ts +444 -0
- package/packages/public-runtime/src/tenant.ts +384 -0
- package/packages/public-runtime/tsconfig.json +8 -0
- package/packages/public-runtime/vitest.config.ts +8 -0
- package/pnpm-workspace.yaml +7 -0
- package/scripts/build/copy-tenant-assets.mjs +69 -0
- package/scripts/dev/ai-edit.mjs +183 -0
- package/scripts/dev/checkout-watch.mjs +116 -0
- package/scripts/dev/file-browser.mjs +159 -0
- package/scripts/dev/file-writer.mjs +141 -0
- package/scripts/dev/git-status.mjs +171 -0
- package/scripts/dev/locate-handler.mjs +163 -0
- package/scripts/dev/locate.mjs +112 -0
- package/scripts/dev/port-check.mjs +48 -0
- package/scripts/dev/safe-path.mjs +109 -0
- package/scripts/dev/shot.mjs +46 -0
- package/scripts/dev/tenant-source-root.mjs +76 -0
- package/scripts/dev/unified-diff.mjs +81 -0
- package/scripts/tot-dev.mjs +376 -0
- package/tenants/README.md +14 -0
- package/tenants/home/content/chrome.json +7 -0
- package/tenants/home/content/home.html +58 -0
- package/tenants/home/content/home.json +11 -0
- package/tenants/home/public/js/storefront.js +210 -0
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +212 -0
- package/tenants/home/theme.json +6 -0
- package/tsconfig.base.json +20 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { watch, readdirSync, statSync, existsSync, cpSync, rmSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Standalone (`tot dev --workspace`) re-sync: the developer's checkout is COPIED
|
|
6
|
+
// into the storefront's in-tree tenants/<id>/ (Vite's server.fs.allow refuses files
|
|
7
|
+
// outside its root), and each save is mirrored back so Vite's own watcher fires the
|
|
8
|
+
// browser reload. This module owns detecting the save + doing the copy.
|
|
9
|
+
|
|
10
|
+
/** Re-sync one changed checkout path into its in-tree copy (copy on save, remove on delete). */
|
|
11
|
+
export function resync(sourceRoot, targetRoot, rel) {
|
|
12
|
+
const from = rel ? join(sourceRoot, rel) : sourceRoot;
|
|
13
|
+
const to = rel ? join(targetRoot, rel) : targetRoot;
|
|
14
|
+
try {
|
|
15
|
+
if (existsSync(from)) {
|
|
16
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
17
|
+
cpSync(from, to, { recursive: true });
|
|
18
|
+
} else if (existsSync(to)) {
|
|
19
|
+
rmSync(to, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.warn(` ⚠ re-sync failed for ${rel || "(root)"}: ${e?.message}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Watch a checkout path (dir or single file) and invoke `onChange(rel)` on every
|
|
28
|
+
* add/change/delete, where `rel` is the source-relative path ("" for a file source).
|
|
29
|
+
*
|
|
30
|
+
* RELIABILITY-FIRST: defaults to an mtime POLL, not native `fs.watch`. Native watch
|
|
31
|
+
* is event-driven but LOSSY on macOS — editors that save via write-temp-then-rename
|
|
32
|
+
* (VS Code, vim, JetBrains, …) routinely deliver NO change event on the original
|
|
33
|
+
* path, so the save silently fails to hot-reload (the 2026-07-14 "didn't reload"
|
|
34
|
+
* report). The poll stat()s the tree on a short interval and never misses an edit.
|
|
35
|
+
*
|
|
36
|
+
* Opt into event-driven native watch with TOT_DEV_NATIVE_WATCH=1 (it still falls
|
|
37
|
+
* back to polling where recursive watch is unavailable, e.g. Linux). Dependency-free
|
|
38
|
+
* on purpose (matches the rest of the tenant tooling); returns `{ close() }`.
|
|
39
|
+
* @param {string} source @param {boolean} isDir @param {(rel: string) => void} onChange
|
|
40
|
+
*/
|
|
41
|
+
export function startWatch(source, isDir, onChange) {
|
|
42
|
+
const useNative = /^(1|true|yes|on)$/i.test(process.env.TOT_DEV_NATIVE_WATCH || "");
|
|
43
|
+
if (useNative) {
|
|
44
|
+
try {
|
|
45
|
+
return watch(source, { recursive: isDir }, (_event, filename) =>
|
|
46
|
+
onChange(isDir ? (filename ? String(filename) : "") : ""),
|
|
47
|
+
);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// Linux: recursive fs.watch is ERR_FEATURE_UNAVAILABLE_ON_PLATFORM → poll.
|
|
50
|
+
console.warn(` ⚠ native watch unavailable (${e?.code || e?.message}); falling back to polling.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Poll frequently for a snappy save→reload (responsiveness > CPU here). Tune with
|
|
54
|
+
// CHOKIDAR_INTERVAL (ms) for very large trees or constrained hosts.
|
|
55
|
+
const interval = Number(process.env.CHOKIDAR_INTERVAL) || 150;
|
|
56
|
+
const seenMtimes = new Map(); // relPath -> mtimeMs
|
|
57
|
+
let primed = false;
|
|
58
|
+
const scan = () => {
|
|
59
|
+
const present = new Set();
|
|
60
|
+
const visit = (absDir, relDir) => {
|
|
61
|
+
let entries;
|
|
62
|
+
try {
|
|
63
|
+
entries = readdirSync(absDir, { withFileTypes: true });
|
|
64
|
+
} catch {
|
|
65
|
+
return; // dir vanished mid-scan — a later scan reconciles it
|
|
66
|
+
}
|
|
67
|
+
for (const e of entries) {
|
|
68
|
+
const rel = relDir ? join(relDir, e.name) : e.name;
|
|
69
|
+
const abs = join(absDir, e.name);
|
|
70
|
+
if (e.isDirectory()) {
|
|
71
|
+
visit(abs, rel);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
present.add(rel);
|
|
75
|
+
let m;
|
|
76
|
+
try {
|
|
77
|
+
m = statSync(abs).mtimeMs;
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const prev = seenMtimes.get(rel);
|
|
82
|
+
if (prev === undefined || prev !== m) {
|
|
83
|
+
seenMtimes.set(rel, m);
|
|
84
|
+
if (primed) onChange(rel);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
if (isDir) {
|
|
89
|
+
visit(source, "");
|
|
90
|
+
} else {
|
|
91
|
+
present.add("");
|
|
92
|
+
let m;
|
|
93
|
+
try {
|
|
94
|
+
m = statSync(source).mtimeMs;
|
|
95
|
+
} catch {
|
|
96
|
+
m = undefined;
|
|
97
|
+
}
|
|
98
|
+
const prev = seenMtimes.get("");
|
|
99
|
+
if (m !== undefined && prev !== m) {
|
|
100
|
+
seenMtimes.set("", m);
|
|
101
|
+
if (primed) onChange("");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const rel of [...seenMtimes.keys()]) {
|
|
105
|
+
if (!present.has(rel)) {
|
|
106
|
+
seenMtimes.delete(rel);
|
|
107
|
+
if (primed) onChange(rel);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
primed = true;
|
|
111
|
+
};
|
|
112
|
+
scan(); // prime the snapshot without emitting spurious initial events
|
|
113
|
+
const id = setInterval(scan, interval);
|
|
114
|
+
if (typeof id.unref === "function") id.unref();
|
|
115
|
+
return { close: () => clearInterval(id) };
|
|
116
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-browser.mjs — server-side logic for the C3 Files tab: a nested source
|
|
3
|
+
* tree (`GET /__tot/tree`) and a single-file read for the read-only viewer
|
|
4
|
+
* (`GET /__tot/file?path=`).
|
|
5
|
+
*
|
|
6
|
+
* Follows the scripts/dev delegation pattern (see ai-edit.mjs): all logic lives
|
|
7
|
+
* here, dependency-free and testable under `node --test`; a thin dev-only Vite
|
|
8
|
+
* plugin (dev-plugins/dev-file-browser.mjs — added when C0/C1 integrate) maps
|
|
9
|
+
* req/res onto `handleTree` / `handleFile`. Every path is routed through
|
|
10
|
+
* safe-path.mjs; the source root comes from tenant-source-root.mjs (never SSR).
|
|
11
|
+
*
|
|
12
|
+
* Git-status badges (C3 acceptance) layer on when C4's git-status.mjs lands —
|
|
13
|
+
* the tree here carries structure only, so this module owns no git surface and
|
|
14
|
+
* doesn't collide with C4.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
17
|
+
import { join, extname, sep } from "node:path";
|
|
18
|
+
import { resolveSafePath, matchesWriteAllowlist } from "./safe-path.mjs";
|
|
19
|
+
import { resolveTenantSourceRoot } from "./tenant-source-root.mjs";
|
|
20
|
+
|
|
21
|
+
/** Above this, the viewer degrades to "not shown" rather than shipping a huge string. */
|
|
22
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
23
|
+
|
|
24
|
+
/** Extension → CodeMirror language name (null = plain text, still viewable). */
|
|
25
|
+
const LANG_BY_EXT = {
|
|
26
|
+
".html": "html",
|
|
27
|
+
".htm": "html",
|
|
28
|
+
".svg": "html",
|
|
29
|
+
".json": "json",
|
|
30
|
+
".css": "css",
|
|
31
|
+
".js": "javascript",
|
|
32
|
+
".mjs": "javascript",
|
|
33
|
+
".cjs": "javascript",
|
|
34
|
+
".ts": "javascript",
|
|
35
|
+
".md": "markdown",
|
|
36
|
+
".txt": "text",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Extensions we never try to render as text (binary assets under public/). */
|
|
40
|
+
const BINARY_EXT = new Set([
|
|
41
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico", ".bmp",
|
|
42
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
43
|
+
".pdf", ".zip", ".gz", ".mp4", ".webm", ".mov", ".mp3", ".wav",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Dirs never worth showing in the tenant source tree. */
|
|
47
|
+
const IGNORE_NAMES = new Set([".git", "node_modules", ".DS_Store"]);
|
|
48
|
+
|
|
49
|
+
/** @returns CodeMirror language name for a path, or null for plain text. */
|
|
50
|
+
export function languageForPath(relPath) {
|
|
51
|
+
return LANG_BY_EXT[extname(relPath).toLowerCase()] ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a nested tree of the tenant source root:
|
|
56
|
+
* { name, path, type:"dir", children:[…] } | { name, path, type:"file", language }
|
|
57
|
+
* `path` is always root-relative with forward slashes. Dirs sort before files,
|
|
58
|
+
* each alphabetically. Unreadable dirs degrade to empty children (never throw).
|
|
59
|
+
*/
|
|
60
|
+
export function buildTree(sourceRoot, { ignore = IGNORE_NAMES } = {}) {
|
|
61
|
+
const walk = (absDir, relDir) => {
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = readdirSync(absDir, { withFileTypes: true });
|
|
65
|
+
} catch {
|
|
66
|
+
return []; // vanished / unreadable mid-walk — reconcile on next fetch
|
|
67
|
+
}
|
|
68
|
+
const nodes = [];
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
if (ignore.has(e.name)) continue;
|
|
71
|
+
const rel = relDir ? `${relDir}/${e.name}` : e.name;
|
|
72
|
+
if (e.isDirectory()) {
|
|
73
|
+
nodes.push({ name: e.name, path: rel, type: "dir", children: walk(join(absDir, e.name), rel) });
|
|
74
|
+
} else if (e.isFile()) {
|
|
75
|
+
nodes.push({ name: e.name, path: rel, type: "file", language: languageForPath(rel) });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
nodes.sort((a, b) =>
|
|
79
|
+
a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1,
|
|
80
|
+
);
|
|
81
|
+
return nodes;
|
|
82
|
+
};
|
|
83
|
+
return walk(sourceRoot, "");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read a single file for the viewer. Traversal-guarded via safe-path. Binary and
|
|
88
|
+
* oversized files degrade to `{content:null}` with a flag rather than shipping
|
|
89
|
+
* bytes the viewer can't render.
|
|
90
|
+
*/
|
|
91
|
+
export function readFile(sourceRoot, relPath) {
|
|
92
|
+
let abs;
|
|
93
|
+
try {
|
|
94
|
+
abs = resolveSafePath(sourceRoot, relPath);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { ok: false, status: "bad_path", message: e.message };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let st;
|
|
100
|
+
try {
|
|
101
|
+
st = statSync(abs);
|
|
102
|
+
} catch {
|
|
103
|
+
return { ok: false, status: "not_found", message: `No such file: ${relPath}` };
|
|
104
|
+
}
|
|
105
|
+
if (st.isDirectory()) {
|
|
106
|
+
return { ok: false, status: "is_dir", message: `${relPath} is a directory` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const norm = relPath.split(sep).join("/");
|
|
110
|
+
// The viewer needs the on-disk version (C6 baseVersion) + whether the file is in
|
|
111
|
+
// the write allowlist (C5 editable vs read-only) — surfaced on every ok read.
|
|
112
|
+
const mtimeMs = st.mtimeMs;
|
|
113
|
+
const writable = matchesWriteAllowlist(norm);
|
|
114
|
+
if (BINARY_EXT.has(extname(norm).toLowerCase())) {
|
|
115
|
+
return { ok: true, status: "ok", path: norm, binary: true, size: st.size, language: null, content: null, mtimeMs, writable };
|
|
116
|
+
}
|
|
117
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
118
|
+
return { ok: true, status: "ok", path: norm, tooBig: true, size: st.size, language: languageForPath(norm), content: null, mtimeMs, writable };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const buf = readFileSync(abs);
|
|
122
|
+
if (buf.includes(0)) {
|
|
123
|
+
// NUL byte → treat as binary even without a known extension.
|
|
124
|
+
return { ok: true, status: "ok", path: norm, binary: true, size: st.size, language: null, content: null, mtimeMs, writable };
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, status: "ok", path: norm, size: st.size, language: languageForPath(norm), content: buf.toString("utf8"), mtimeMs, writable };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- request-level handlers (env → {status, json}) -------------------------
|
|
130
|
+
// The deferred dev-plugins/dev-file-browser.mjs wrapper maps HTTP req/res onto
|
|
131
|
+
// these; keeping them here means the whole endpoint contract is unit-testable
|
|
132
|
+
// without a running dev server.
|
|
133
|
+
|
|
134
|
+
/** GET /__tot/tree */
|
|
135
|
+
export function handleTree(env = process.env) {
|
|
136
|
+
const root = resolveTenantSourceRoot(env);
|
|
137
|
+
if (!root.ok) return { status: root.status === "no_tenant" ? 403 : 500, json: root };
|
|
138
|
+
return {
|
|
139
|
+
status: 200,
|
|
140
|
+
json: { ok: true, tenantId: root.tenantId, root: root.sourceRoot, tree: buildTree(root.sourceRoot) },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** GET /__tot/file?path= */
|
|
145
|
+
export function handleFile(env = process.env, relPath) {
|
|
146
|
+
const root = resolveTenantSourceRoot(env);
|
|
147
|
+
if (!root.ok) return { status: root.status === "no_tenant" ? 403 : 500, json: root };
|
|
148
|
+
if (!relPath) return { status: 400, json: { ok: false, status: "no_path", message: "path query param is required" } };
|
|
149
|
+
|
|
150
|
+
const result = readFile(root.sourceRoot, relPath);
|
|
151
|
+
const status = result.ok
|
|
152
|
+
? 200
|
|
153
|
+
: result.status === "bad_path"
|
|
154
|
+
? 400
|
|
155
|
+
: result.status === "not_found"
|
|
156
|
+
? 404
|
|
157
|
+
: 400;
|
|
158
|
+
return { status, json: result };
|
|
159
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file-writer.mjs — server-side logic for C5's in-browser edit: write a single
|
|
3
|
+
* tenant source file (`POST /__tot/write {path, content}`).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the file-browser.mjs pattern (see also ai-edit.mjs): all logic lives
|
|
6
|
+
* here, dependency-free and testable under `node --test`; a thin dev-only Vite
|
|
7
|
+
* plugin (dev-plugins/dev-write.mjs — added when C0/C1 integrate) maps req/res
|
|
8
|
+
* onto `handleWrite`. Every write is double-guarded — the strict WRITE allowlist
|
|
9
|
+
* (matchesWriteAllowlist) AND the traversal/symlink guard (resolveSafePath) — and
|
|
10
|
+
* the target is resolved from tenant-source-root.mjs, which points at the
|
|
11
|
+
* developer's REAL CHECKOUT in standalone mode (never the in-tree graft, or the
|
|
12
|
+
* edit is silently discarded on the next resync).
|
|
13
|
+
*
|
|
14
|
+
* The existing tenant-hot-reload watcher carries the browser reload once the file
|
|
15
|
+
* lands on disk (and C1's Monitor timestamps the save→reload) — this module never
|
|
16
|
+
* sends its own reload.
|
|
17
|
+
*
|
|
18
|
+
* Stale-write guard (C6): writeSourceFile/handleWrite accept an optional
|
|
19
|
+
* baseVersion — the mtimeMs the client captured when it opened or last saved the
|
|
20
|
+
* file. If the file on disk has since changed (someone edited it via $EDITOR, git,
|
|
21
|
+
* or the AI ask box while autosave was armed), the write is refused with
|
|
22
|
+
* `{status:"conflict", current}` (HTTP 409) carrying the current mtimeMs instead
|
|
23
|
+
* of silently clobbering. Omit baseVersion to force-save (the UI's explicit
|
|
24
|
+
* override). A brand-new file with a baseVersion is likewise a conflict — someone
|
|
25
|
+
* else created it first.
|
|
26
|
+
*/
|
|
27
|
+
import { mkdirSync, writeFileSync, statSync } from "node:fs";
|
|
28
|
+
import { dirname, sep } from "node:path";
|
|
29
|
+
import { resolveSafePath, matchesWriteAllowlist } from "./safe-path.mjs";
|
|
30
|
+
import { resolveTenantSourceRoot } from "./tenant-source-root.mjs";
|
|
31
|
+
|
|
32
|
+
/** Reject absurd payloads outright (the editable surface is small HTML/JSON). */
|
|
33
|
+
const MAX_WRITE_BYTES = 2 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write `content` to `relPath` under `sourceRoot`, double-guarded. Creates parent
|
|
37
|
+
* dirs for a new file (e.g. content/pages-html/<new>.html). Never throws — every
|
|
38
|
+
* failure is a `{ok:false, status, message}` result.
|
|
39
|
+
* @param {string} sourceRoot
|
|
40
|
+
* @param {string} relPath
|
|
41
|
+
* @param {string} content
|
|
42
|
+
* @param {{baseVersion?:number}} [opts] baseVersion = mtimeMs the client last saw;
|
|
43
|
+
* a mismatch (or a file that now exists when the client expected none) → conflict.
|
|
44
|
+
* @returns {{ok:true,status:"ok",path:string,bytes:number,mtimeMs:number}
|
|
45
|
+
* | {ok:false,status:"conflict",current:number|null,message:string}
|
|
46
|
+
* | {ok:false,status:string,message:string}}
|
|
47
|
+
*/
|
|
48
|
+
export function writeSourceFile(sourceRoot, relPath, content, opts = {}) {
|
|
49
|
+
if (typeof content !== "string") {
|
|
50
|
+
return { ok: false, status: "bad_content", message: "content must be a string" };
|
|
51
|
+
}
|
|
52
|
+
if (Buffer.byteLength(content, "utf8") > MAX_WRITE_BYTES) {
|
|
53
|
+
return { ok: false, status: "too_large", message: "content exceeds the write size limit" };
|
|
54
|
+
}
|
|
55
|
+
// Allowlist first (only the raw-HTML content surface + theme.json are writable),
|
|
56
|
+
// then the traversal/symlink guard — defence in depth.
|
|
57
|
+
if (!matchesWriteAllowlist(relPath)) {
|
|
58
|
+
return { ok: false, status: "not_writable", message: `not in the write allowlist: ${relPath}` };
|
|
59
|
+
}
|
|
60
|
+
let abs;
|
|
61
|
+
try {
|
|
62
|
+
abs = resolveSafePath(sourceRoot, relPath);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return { ok: false, status: "bad_path", message: e.message };
|
|
65
|
+
}
|
|
66
|
+
// Current on-disk state (mtime + dir check) in one stat.
|
|
67
|
+
let current = null;
|
|
68
|
+
try {
|
|
69
|
+
const cur = statSync(abs);
|
|
70
|
+
if (cur.isDirectory()) {
|
|
71
|
+
return { ok: false, status: "is_dir", message: `${relPath} is a directory` };
|
|
72
|
+
}
|
|
73
|
+
current = cur.mtimeMs;
|
|
74
|
+
} catch {
|
|
75
|
+
/* leaf doesn't exist yet — a new file, which is fine */
|
|
76
|
+
}
|
|
77
|
+
// Stale-write guard (C6): if the client supplied the version it last saw and the
|
|
78
|
+
// file has since changed on disk (or now exists when it expected none), refuse
|
|
79
|
+
// rather than clobber. Omit baseVersion to force-save.
|
|
80
|
+
const baseVersion = opts && opts.baseVersion;
|
|
81
|
+
if (typeof baseVersion === "number" && current !== baseVersion) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
status: "conflict",
|
|
85
|
+
current,
|
|
86
|
+
message: current === null
|
|
87
|
+
? `${relPath} was deleted since you opened it`
|
|
88
|
+
: `${relPath} changed on disk since you opened it`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
93
|
+
writeFileSync(abs, content, "utf8");
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return { ok: false, status: "write_failed", message: e.message };
|
|
96
|
+
}
|
|
97
|
+
const st = statSync(abs);
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
status: "ok",
|
|
101
|
+
path: relPath.split(sep).join("/"),
|
|
102
|
+
bytes: Buffer.byteLength(content, "utf8"),
|
|
103
|
+
mtimeMs: st.mtimeMs,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- request-level handler (env + body → {status, json}) -------------------
|
|
108
|
+
// The deferred dev-plugins/dev-write.mjs wrapper maps the HTTP req/res + JSON
|
|
109
|
+
// body onto this; keeping it here makes the whole endpoint contract unit-testable
|
|
110
|
+
// without a running dev server.
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* POST /__tot/write — body {path, content, baseVersion?}.
|
|
114
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
115
|
+
* @param {{path?:string, content?:unknown, baseVersion?:number}} [body]
|
|
116
|
+
*/
|
|
117
|
+
export function handleWrite(env = process.env, body = {}) {
|
|
118
|
+
const root = resolveTenantSourceRoot(env);
|
|
119
|
+
if (!root.ok) return { status: root.status === "no_tenant" ? 403 : 500, json: root };
|
|
120
|
+
|
|
121
|
+
const relPath = body && body.path;
|
|
122
|
+
if (!relPath) {
|
|
123
|
+
return { status: 400, json: { ok: false, status: "no_path", message: "path is required" } };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const result = writeSourceFile(root.sourceRoot, relPath, body.content, {
|
|
127
|
+
baseVersion: body ? body.baseVersion : undefined,
|
|
128
|
+
});
|
|
129
|
+
const status = result.ok
|
|
130
|
+
? 200
|
|
131
|
+
: result.status === "conflict"
|
|
132
|
+
? 409
|
|
133
|
+
: result.status === "not_writable"
|
|
134
|
+
? 403
|
|
135
|
+
: result.status === "too_large"
|
|
136
|
+
? 413
|
|
137
|
+
: result.status === "write_failed"
|
|
138
|
+
? 500
|
|
139
|
+
: 400; // bad_content | bad_path | is_dir
|
|
140
|
+
return { status, json: result };
|
|
141
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-status.mjs — working-tree status + HEAD diff for C4's /__tot/diff endpoint
|
|
3
|
+
* (and reusable by C3's tree git-status badges).
|
|
4
|
+
*
|
|
5
|
+
* Two jobs, both dependency-free (node: builtins + the `git` CLI) so they copy
|
|
6
|
+
* into the standalone `tot dev` runner and test under `node --test`:
|
|
7
|
+
*
|
|
8
|
+
* 1. parseStatus / fileStatus — classify a path from `git status --porcelain`
|
|
9
|
+
* (untracked / modified / added / unchanged), the authoritative "is this file
|
|
10
|
+
* new?" check the handoff calls out (a new file shows NOTHING under a plain
|
|
11
|
+
* `git diff`, so porcelain is what distinguishes all-added from a real diff).
|
|
12
|
+
*
|
|
13
|
+
* 2. computeDiff / handleDiff — return {before, after} strings for the client's
|
|
14
|
+
* @codemirror/merge view (which diffs the two strings itself — no jsdiff dep).
|
|
15
|
+
* `before` = the file's content at HEAD via `git show HEAD:./<path>` ("" for a
|
|
16
|
+
* new/untracked file → renders as all-added); `after` = the working file.
|
|
17
|
+
* Binary and oversized files degrade to `{shown:false}` rather than shipping
|
|
18
|
+
* bytes the merge view can't render.
|
|
19
|
+
*
|
|
20
|
+
* Git is run with cwd = gitCwd (from tenant-source-root.mjs) and the pathspec as
|
|
21
|
+
* `HEAD:./<relPath>` — the `./` makes it relative to gitCwd, correct in BOTH the
|
|
22
|
+
* standalone mode (gitCwd is its own repo root) and monorepo mode (gitCwd is a
|
|
23
|
+
* subdir of the monorepo tree).
|
|
24
|
+
*/
|
|
25
|
+
import { execFileSync } from "node:child_process";
|
|
26
|
+
import { readFileSync, statSync } from "node:fs";
|
|
27
|
+
import { extname, sep } from "node:path";
|
|
28
|
+
import { resolveSafePath } from "./safe-path.mjs";
|
|
29
|
+
import { resolveTenantSourceRoot } from "./tenant-source-root.mjs";
|
|
30
|
+
|
|
31
|
+
/** Above this the diff degrades to "not shown" rather than shipping a huge string. */
|
|
32
|
+
const MAX_DIFF_BYTES = 512 * 1024;
|
|
33
|
+
|
|
34
|
+
const BINARY_EXT = new Set([
|
|
35
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico", ".bmp",
|
|
36
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
37
|
+
".pdf", ".zip", ".gz", ".mp4", ".webm", ".mov", ".mp3", ".wav",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** Run a git command in `cwd`; return {ok, stdout} — never throws. */
|
|
41
|
+
function git(cwd, args) {
|
|
42
|
+
try {
|
|
43
|
+
const stdout = execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
|
|
44
|
+
return { ok: true, stdout };
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return { ok: false, stdout: "", code: e && e.status, stderr: e && e.stderr };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse `git status --porcelain` output into a Map of posix-relative-path →
|
|
52
|
+
* two-char XY status code (e.g. " M", "??", "A "). Handles the rename form
|
|
53
|
+
* "R old -> new" by keying the new path.
|
|
54
|
+
*/
|
|
55
|
+
export function parseStatus(porcelain) {
|
|
56
|
+
const map = new Map();
|
|
57
|
+
for (const line of porcelain.split("\n")) {
|
|
58
|
+
if (line.length < 4) continue;
|
|
59
|
+
const xy = line.slice(0, 2);
|
|
60
|
+
let path = line.slice(3);
|
|
61
|
+
const arrow = path.indexOf(" -> ");
|
|
62
|
+
if (arrow >= 0) path = path.slice(arrow + 4);
|
|
63
|
+
// porcelain quotes paths with special chars; strip surrounding quotes.
|
|
64
|
+
if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1);
|
|
65
|
+
map.set(path, xy);
|
|
66
|
+
}
|
|
67
|
+
return map;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Classify one path from a parsed status map → "untracked"|"modified"|"added"|"unchanged". */
|
|
71
|
+
export function classify(statusMap, relPathPosix) {
|
|
72
|
+
const xy = statusMap.get(relPathPosix);
|
|
73
|
+
if (!xy) return "unchanged";
|
|
74
|
+
if (xy === "??") return "untracked";
|
|
75
|
+
if (xy.includes("A")) return "added";
|
|
76
|
+
return "modified";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* fileStatus(gitCwd, relPath) → status string (single-file porcelain query).
|
|
81
|
+
* We filter with `-- <path>`, so any returned line pertains to THIS file — read
|
|
82
|
+
* its XY code directly rather than matching the path key. This is essential in
|
|
83
|
+
* monorepo mode: porcelain output paths are repo-root-relative while the query is
|
|
84
|
+
* cwd-relative, so a key lookup would always miss and report "unchanged".
|
|
85
|
+
*/
|
|
86
|
+
export function fileStatus(gitCwd, relPath) {
|
|
87
|
+
const posix = relPath.split(sep).join("/");
|
|
88
|
+
const res = git(gitCwd, ["status", "--porcelain", "--", posix]);
|
|
89
|
+
if (!res.ok) return "unknown";
|
|
90
|
+
const line = res.stdout.split("\n").find((l) => l.length >= 2);
|
|
91
|
+
if (!line) return "unchanged";
|
|
92
|
+
const xy = line.slice(0, 2);
|
|
93
|
+
if (xy === "??") return "untracked";
|
|
94
|
+
if (xy.includes("A")) return "added";
|
|
95
|
+
return "modified";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the before/after diff payload for one file.
|
|
100
|
+
* @returns {{ok:true, path, status, shown:boolean, before?:string, after?:string, reason?:string}
|
|
101
|
+
* | {ok:false, status:string, message:string}}
|
|
102
|
+
*/
|
|
103
|
+
export function computeDiff(gitCwd, sourceRoot, relPath) {
|
|
104
|
+
let abs;
|
|
105
|
+
try {
|
|
106
|
+
abs = resolveSafePath(sourceRoot, relPath);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return { ok: false, status: "bad_path", message: e.message };
|
|
109
|
+
}
|
|
110
|
+
const posix = relPath.split(sep).join("/");
|
|
111
|
+
|
|
112
|
+
// Working ("after") side — may be absent (a deleted file).
|
|
113
|
+
let after = null;
|
|
114
|
+
let workingExists = true;
|
|
115
|
+
let st;
|
|
116
|
+
try {
|
|
117
|
+
st = statSync(abs);
|
|
118
|
+
} catch {
|
|
119
|
+
workingExists = false;
|
|
120
|
+
}
|
|
121
|
+
if (workingExists && st.isDirectory()) {
|
|
122
|
+
return { ok: false, status: "is_dir", message: `${relPath} is a directory` };
|
|
123
|
+
}
|
|
124
|
+
if (workingExists) {
|
|
125
|
+
if (BINARY_EXT.has(extname(posix).toLowerCase())) {
|
|
126
|
+
return { ok: true, path: posix, status: fileStatus(gitCwd, relPath), shown: false, reason: "binary" };
|
|
127
|
+
}
|
|
128
|
+
if (st.size > MAX_DIFF_BYTES) {
|
|
129
|
+
return { ok: true, path: posix, status: fileStatus(gitCwd, relPath), shown: false, reason: "too_big" };
|
|
130
|
+
}
|
|
131
|
+
const buf = readFileSync(abs);
|
|
132
|
+
if (buf.includes(0)) {
|
|
133
|
+
return { ok: true, path: posix, status: fileStatus(gitCwd, relPath), shown: false, reason: "binary" };
|
|
134
|
+
}
|
|
135
|
+
after = buf.toString("utf8");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const status = fileStatus(gitCwd, relPath);
|
|
139
|
+
|
|
140
|
+
// HEAD ("before") side — "" when the file isn't in HEAD (new/untracked →
|
|
141
|
+
// all-added in the merge view). git show fails for a path not in HEAD.
|
|
142
|
+
let before = "";
|
|
143
|
+
if (status !== "untracked" && status !== "added") {
|
|
144
|
+
const shown = git(gitCwd, ["show", `HEAD:./${posix}`]);
|
|
145
|
+
if (shown.ok) before = shown.stdout;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
path: posix,
|
|
151
|
+
status: workingExists ? status : "deleted",
|
|
152
|
+
shown: true,
|
|
153
|
+
before,
|
|
154
|
+
after: after ?? "",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- request-level handler (env → {status, json}) --------------------------
|
|
159
|
+
// The deferred dev-plugins wrapper maps GET /__tot/diff?path= onto this; keeping
|
|
160
|
+
// it here makes the endpoint contract unit-testable without a running dev server.
|
|
161
|
+
|
|
162
|
+
/** GET /__tot/diff?path= */
|
|
163
|
+
export function handleDiff(env = process.env, relPath) {
|
|
164
|
+
const root = resolveTenantSourceRoot(env);
|
|
165
|
+
if (!root.ok) return { status: root.status === "no_tenant" ? 403 : 500, json: root };
|
|
166
|
+
if (!relPath) return { status: 400, json: { ok: false, status: "no_path", message: "path query param is required" } };
|
|
167
|
+
|
|
168
|
+
const result = computeDiff(root.gitCwd, root.sourceRoot, relPath);
|
|
169
|
+
const status = result.ok ? 200 : result.status === "bad_path" ? 400 : 400;
|
|
170
|
+
return { status, json: result };
|
|
171
|
+
}
|