@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,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* locate-handler.mjs — request-level logic for C7/C8 click-to-source:
|
|
3
|
+
* POST /__tot/locate {domPath, path} → {ok, path, line, col} | {status}
|
|
4
|
+
* POST /__tot/open-in-editor {path, line, col} → {ok} | {status}
|
|
5
|
+
*
|
|
6
|
+
* Keeps locate.mjs a PURE parse5 walk (already integrated + tested 5/5); this module
|
|
7
|
+
* adds the env/path-resolution/refusal concerns and is itself unit-testable under
|
|
8
|
+
* `node --test` with no running dev server (the scripts/dev delegation pattern — see
|
|
9
|
+
* file-browser.mjs). The thin dev-plugins/dev-locate.mjs wrapper maps req/res onto it.
|
|
10
|
+
*
|
|
11
|
+
* HONORS decision `click-to-source-anchors-at-content-region`: the served page wraps
|
|
12
|
+
* the raw content region (<main>, the whole of content/home.html) in the storefront's
|
|
13
|
+
* own chrome (skip-link, header, nav, footer) that is NOT in the tenant source. So we
|
|
14
|
+
* resolve the clicked URL to its raw content source and walk THAT from <body>:
|
|
15
|
+
* · a content-region click resolves to the exact source line;
|
|
16
|
+
* · a click on a page with no raw source (templated/commerce) → {status:"not_raw_html"};
|
|
17
|
+
* · a click on the chrome of a raw page (header/footer/etc — absent from the source,
|
|
18
|
+
* so the walk misses) → an EXPLICIT {status:"not_in_content"} refusal, never a
|
|
19
|
+
* silent/coincidental miss and never a wrong location.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { resolveSafePath, matchesWriteAllowlist } from "./safe-path.mjs";
|
|
24
|
+
import { resolveTenantSourceRoot } from "./tenant-source-root.mjs";
|
|
25
|
+
import { locate } from "./locate.mjs";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Map a served page pathname to its raw-HTML content source, root-relative.
|
|
29
|
+
* `/` or `/<tenant>/` → content/home.html; `/<tenant>/<slug…>` → content/pages-html/<slug…>.html.
|
|
30
|
+
* The tenant path-prefix (appDomain, present under `tot dev`'s localhost routing) is
|
|
31
|
+
* stripped when it leads. Returns null if the result isn't a raw-HTML content path.
|
|
32
|
+
* @returns {string|null}
|
|
33
|
+
*/
|
|
34
|
+
export function resolvePageSource(pathname, tenantId) {
|
|
35
|
+
if (typeof pathname !== "string") return null;
|
|
36
|
+
let clean;
|
|
37
|
+
try {
|
|
38
|
+
clean = decodeURIComponent(pathname.split("?")[0].split("#")[0]);
|
|
39
|
+
} catch {
|
|
40
|
+
return null; // malformed %-encoding
|
|
41
|
+
}
|
|
42
|
+
const segments = clean.split("/").filter(Boolean);
|
|
43
|
+
if (segments.length && tenantId && segments[0] === tenantId) segments.shift();
|
|
44
|
+
// No `..` games — resolveSafePath is the real guard, but reject early so a slug can't
|
|
45
|
+
// even be shaped into a traversal.
|
|
46
|
+
if (segments.some((s) => s === "..")) return null;
|
|
47
|
+
|
|
48
|
+
const rel = segments.length === 0 ? "content/home.html" : `content/pages-html/${segments.join("/")}.html`;
|
|
49
|
+
return matchesWriteAllowlist(rel) ? rel : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True iff `domPath` is a non-empty array of {tag, index} steps. */
|
|
53
|
+
function isDomPath(domPath) {
|
|
54
|
+
return (
|
|
55
|
+
Array.isArray(domPath) &&
|
|
56
|
+
domPath.length > 0 &&
|
|
57
|
+
domPath.every((s) => s && typeof s.tag === "string" && Number.isFinite(Number(s.index)))
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* POST /__tot/locate — {domPath, path} → source line:col.
|
|
63
|
+
* @returns {{status:number, json:any}}
|
|
64
|
+
*/
|
|
65
|
+
export function handleLocate(env = process.env, body = {}) {
|
|
66
|
+
const root = resolveTenantSourceRoot(env);
|
|
67
|
+
if (!root.ok) return { status: root.status === "no_tenant" ? 403 : 500, json: root };
|
|
68
|
+
|
|
69
|
+
if (!isDomPath(body.domPath)) {
|
|
70
|
+
return { status: 400, json: { ok: false, status: "bad_dom_path", message: "domPath must be a non-empty array of {tag,index}" } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const rel = resolvePageSource(body.path, root.tenantId);
|
|
74
|
+
// No raw-HTML content file backs this URL → click-to-source doesn't apply here.
|
|
75
|
+
if (!rel) {
|
|
76
|
+
return { status: 200, json: { ok: false, status: "not_raw_html", message: "This page isn’t a raw-HTML content page — click-to-source is for content/*.html tenants." } };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let abs;
|
|
80
|
+
try {
|
|
81
|
+
abs = resolveSafePath(root.sourceRoot, rel);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { status: 400, json: { ok: false, status: "bad_path", message: e.message } };
|
|
84
|
+
}
|
|
85
|
+
if (!existsSync(abs)) {
|
|
86
|
+
// The URL shape is a raw content page, but no such source file exists (e.g. a
|
|
87
|
+
// templated route that merely looks like one) — refuse cleanly, never guess.
|
|
88
|
+
return { status: 200, json: { ok: false, status: "not_raw_html", message: "No raw-HTML source backs this page." } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const html = readFileSync(abs, "utf8");
|
|
92
|
+
const found = locate(html, body.domPath);
|
|
93
|
+
if (typeof found.line === "number") {
|
|
94
|
+
return { status: 200, json: { ok: true, path: rel, line: found.line, col: found.col } };
|
|
95
|
+
}
|
|
96
|
+
// The walk missed: the element is outside the raw content region — i.e. the storefront
|
|
97
|
+
// chrome (header, nav, footer) that wraps <main> and isn’t in the source. Explicit
|
|
98
|
+
// refusal per the content-region decision, not a bare not_found.
|
|
99
|
+
return {
|
|
100
|
+
status: 200,
|
|
101
|
+
json: {
|
|
102
|
+
ok: false,
|
|
103
|
+
status: "not_in_content",
|
|
104
|
+
message: "That element is part of the storefront frame (header, nav, or footer), not your content source. Click something inside your page content.",
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Clamp a 1-based line/col to a safe positive integer (defaults to 1). */
|
|
110
|
+
function toPos(n) {
|
|
111
|
+
const v = Math.floor(Number(n));
|
|
112
|
+
return Number.isFinite(v) && v > 0 ? v : 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* POST /__tot/open-in-editor — best-effort open {path} at {line, col} in the developer's
|
|
117
|
+
* $EDITOR. Degrades gracefully (never throws to the client) when no editor is configured.
|
|
118
|
+
* @returns {{status:number, json:any}}
|
|
119
|
+
*/
|
|
120
|
+
export function handleOpenInEditor(env = process.env, body = {}, spawnFn = spawn) {
|
|
121
|
+
const root = resolveTenantSourceRoot(env);
|
|
122
|
+
if (!root.ok) return { status: root.status === "no_tenant" ? 403 : 500, json: root };
|
|
123
|
+
|
|
124
|
+
const rel = typeof body.path === "string" ? body.path : "";
|
|
125
|
+
if (!matchesWriteAllowlist(rel)) {
|
|
126
|
+
return { status: 400, json: { ok: false, status: "bad_path", message: "path must be a tenant content file" } };
|
|
127
|
+
}
|
|
128
|
+
let abs;
|
|
129
|
+
try {
|
|
130
|
+
abs = resolveSafePath(root.sourceRoot, rel);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { status: 400, json: { ok: false, status: "bad_path", message: e.message } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const editorCmd = env.TOT_DEV_EDITOR || env.VISUAL || env.EDITOR || "";
|
|
136
|
+
if (!editorCmd.trim()) {
|
|
137
|
+
return { status: 200, json: { ok: false, status: "no_editor", message: "Set $EDITOR or TOT_DEV_EDITOR to open files from here." } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const line = toPos(body.line);
|
|
141
|
+
const col = toPos(body.col);
|
|
142
|
+
// Shell-free: split the editor command into a binary + preset flags, then append the
|
|
143
|
+
// file target in the binary's expected form. No user string ever reaches a shell.
|
|
144
|
+
const tokens = editorCmd.trim().split(/\s+/);
|
|
145
|
+
const bin = tokens[0];
|
|
146
|
+
const preArgs = tokens.slice(1);
|
|
147
|
+
let target;
|
|
148
|
+
if (/(^|\/)(code|codium|cursor|vscode)$/i.test(bin) || preArgs.includes("-g")) {
|
|
149
|
+
target = preArgs.includes("-g") ? [`${abs}:${line}:${col}`] : ["-g", `${abs}:${line}:${col}`];
|
|
150
|
+
} else if (/(^|\/)(vi|vim|nvim|nano|emacs|emacsclient)$/i.test(bin)) {
|
|
151
|
+
target = [`+${line}`, abs];
|
|
152
|
+
} else {
|
|
153
|
+
target = [abs]; // unknown editor — just open the file
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const child = spawnFn(bin, [...preArgs, ...target], { detached: true, stdio: "ignore" });
|
|
158
|
+
if (child && typeof child.unref === "function") child.unref();
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return { status: 200, json: { ok: false, status: "editor_failed", message: `Couldn’t launch ${bin}: ${e.message}` } };
|
|
161
|
+
}
|
|
162
|
+
return { status: 200, json: { ok: true, path: rel, line, col } };
|
|
163
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// locate.mjs — map a clicked element back to its source line:col in a raw-HTML
|
|
2
|
+
// tenant file (content/home.html, content/pages-html/**). Pure logic: given the
|
|
3
|
+
// file's HTML text + a DOM path captured in the browser, walk the parse5 tree the
|
|
4
|
+
// SAME way the browser walked its live DOM and read the target node's source
|
|
5
|
+
// location. Dependency-free except parse5 (server-side only), so it's unit-testable
|
|
6
|
+
// with node:test and carries no framework/build coupling.
|
|
7
|
+
//
|
|
8
|
+
// THE WALK (must stay byte-identical in intent to captureDomPath() in
|
|
9
|
+
// apps/storefront/src/lib/dev/ai-widget-client.js):
|
|
10
|
+
// A DOM path is an array of { tag, index } steps, ordered from the <body>'s
|
|
11
|
+
// direct child DOWN to the target. Each step names a lowercased tag and the
|
|
12
|
+
// 0-based position of that element among its same-tag ELEMENT siblings (text and
|
|
13
|
+
// comment nodes ignored) — i.e. an nth-of-type index. The walk is anchored at
|
|
14
|
+
// <body> because both the browser and parse5 run the HTML tree-construction spec
|
|
15
|
+
// (injecting html/head/body around a fragment identically), so body's element
|
|
16
|
+
// subtree corresponds one-to-one between the two. `locate.test.mjs` proves the
|
|
17
|
+
// round-trip element → domPath → locate === the element's own source location.
|
|
18
|
+
import { parse } from "parse5";
|
|
19
|
+
|
|
20
|
+
/** Element children only (parse5 tags elements with `.tagName`; skip #text/#comment). */
|
|
21
|
+
function elementChildren(node) {
|
|
22
|
+
return (node.childNodes ?? []).filter((c) => typeof c.tagName === "string");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Find the <body> element in a parsed parse5 document, or null. */
|
|
26
|
+
export function findBody(document) {
|
|
27
|
+
const html = elementChildren(document).find((n) => n.tagName === "html");
|
|
28
|
+
const scope = html ?? document;
|
|
29
|
+
return elementChildren(scope).find((n) => n.tagName === "body") ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Walk a DOM path from <body> to the target node in a parse5 tree.
|
|
34
|
+
* Returns the parse5 node, or null if any step doesn't resolve.
|
|
35
|
+
*/
|
|
36
|
+
export function walk(bodyNode, domPath) {
|
|
37
|
+
let node = bodyNode;
|
|
38
|
+
for (const step of domPath) {
|
|
39
|
+
const tag = String(step.tag).toLowerCase();
|
|
40
|
+
const index = Number(step.index) || 0;
|
|
41
|
+
let seen = -1;
|
|
42
|
+
let next = null;
|
|
43
|
+
for (const child of elementChildren(node)) {
|
|
44
|
+
if (child.tagName.toLowerCase() === tag) {
|
|
45
|
+
seen += 1;
|
|
46
|
+
if (seen === index) {
|
|
47
|
+
next = child;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!next) return null;
|
|
53
|
+
node = next;
|
|
54
|
+
}
|
|
55
|
+
return node;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reference implementation of the BROWSER-side capture (captureDomPath in
|
|
60
|
+
* ai-widget-client.js), operating on a parse5 node + its ancestry map. Exported so
|
|
61
|
+
* the test can assert the client walk and the server walk are mutually inverse over
|
|
62
|
+
* the same tree — the guarantee the card demands ("must EXACTLY match the client
|
|
63
|
+
* walk"). Not used by the request path.
|
|
64
|
+
*/
|
|
65
|
+
export function domPathOf(target, parentOf) {
|
|
66
|
+
const path = [];
|
|
67
|
+
let node = target;
|
|
68
|
+
while (node) {
|
|
69
|
+
const parent = parentOf.get(node);
|
|
70
|
+
if (!parent || parent.tagName === "body") {
|
|
71
|
+
if (parent && parent.tagName === "body") {
|
|
72
|
+
path.unshift(stepFor(node, parent));
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
path.unshift(stepFor(node, parent));
|
|
77
|
+
node = parent;
|
|
78
|
+
}
|
|
79
|
+
return path;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stepFor(node, parent) {
|
|
83
|
+
const tag = node.tagName.toLowerCase();
|
|
84
|
+
let index = 0;
|
|
85
|
+
for (const sib of elementChildren(parent)) {
|
|
86
|
+
if (sib === node) break;
|
|
87
|
+
if (sib.tagName.toLowerCase() === tag) index += 1;
|
|
88
|
+
}
|
|
89
|
+
return { tag, index };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a DOM path to a source location within `html`.
|
|
94
|
+
* @param {string} html raw source of the tenant file
|
|
95
|
+
* @param {Array<{tag:string,index:number}>} domPath from the browser capture
|
|
96
|
+
* @returns {{line:number,col:number}|{status:string}}
|
|
97
|
+
* { line, col } (1-based, from parse5 startLine/startCol) on success, or a
|
|
98
|
+
* { status } discriminant: "empty_path" | "no_body" | "not_found" | "no_location".
|
|
99
|
+
*/
|
|
100
|
+
export function locate(html, domPath) {
|
|
101
|
+
if (!Array.isArray(domPath) || domPath.length === 0) return { status: "empty_path" };
|
|
102
|
+
const document = parse(html, { sourceCodeLocationInfo: true });
|
|
103
|
+
const body = findBody(document);
|
|
104
|
+
if (!body) return { status: "no_body" };
|
|
105
|
+
const node = walk(body, domPath);
|
|
106
|
+
if (!node) return { status: "not_found" };
|
|
107
|
+
const loc = node.sourceCodeLocation;
|
|
108
|
+
// Synthesized/adjusted nodes (rare in raw HTML) carry no location — degrade
|
|
109
|
+
// rather than point at the wrong place.
|
|
110
|
+
if (!loc || typeof loc.startLine !== "number") return { status: "no_location" };
|
|
111
|
+
return { line: loc.startLine, col: loc.startCol };
|
|
112
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Is `port` free to bind right now? Resolves false if anything already holds it
|
|
6
|
+
* (EADDRINUSE, or any bind error) — we listen with the host OMITTED so Node binds
|
|
7
|
+
* dual-stack (::/0.0.0.0), matching how `astro dev --host` grabs the port, so this
|
|
8
|
+
* predicts astro's own bind on either IP family.
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS EXISTS: Vite's `strictPort` defaults OFF, so when the requested port is
|
|
11
|
+
* taken astro SILENTLY moves to the next free port. On 2026-07-14 that stranded the
|
|
12
|
+
* CLI's liveness poll on the ORIGINAL port (answered by a stale foreign server) →
|
|
13
|
+
* a false "✨ You're live" pointing at the wrong server. Pre-flighting the port and
|
|
14
|
+
* failing clearly removes that whole class of silent failure.
|
|
15
|
+
*
|
|
16
|
+
* Dependency-free (node:net only) so it copies into the standalone runner and is
|
|
17
|
+
* testable under `node --test`.
|
|
18
|
+
* @param {number} port
|
|
19
|
+
* @returns {Promise<boolean>}
|
|
20
|
+
*/
|
|
21
|
+
export function isPortFree(port) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const srv = net.createServer();
|
|
24
|
+
srv.once("error", () => resolve(false));
|
|
25
|
+
srv.once("listening", () => srv.close(() => resolve(true)));
|
|
26
|
+
try {
|
|
27
|
+
srv.listen(port);
|
|
28
|
+
} catch {
|
|
29
|
+
resolve(false);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The first free port at or after `preferred` (scanning upward). Lets `tot dev`
|
|
36
|
+
* boot on a busy default port by moving to the next open one and printing it —
|
|
37
|
+
* a better first-run experience than failing. Falls back to `preferred` after
|
|
38
|
+
* `maxTries` so the caller still gets a definite bind attempt (and a real error).
|
|
39
|
+
* @param {number} preferred
|
|
40
|
+
* @param {number} [maxTries]
|
|
41
|
+
* @returns {Promise<number>}
|
|
42
|
+
*/
|
|
43
|
+
export async function firstFreePort(preferred, maxTries = 64) {
|
|
44
|
+
for (let p = preferred; p < preferred + maxTries; p++) {
|
|
45
|
+
if (await isPortFree(p)) return p;
|
|
46
|
+
}
|
|
47
|
+
return preferred;
|
|
48
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safe-path.mjs — the shared path-traversal guard for every `/__tot/*` dev
|
|
3
|
+
* endpoint (C3 tree/file, C4 diff, C5 write, C7/C8 locate).
|
|
4
|
+
*
|
|
5
|
+
* `tot dev` binds `--host` (LAN-visible) and the later chunks add file WRITE +
|
|
6
|
+
* `$EDITOR` spawn, so an unguarded `?path=` is a real directory-traversal /
|
|
7
|
+
* symlink-escape surface. This module is the ONE guard they all route through:
|
|
8
|
+
* resolve the caller-supplied relative path against an absolute root, collapse
|
|
9
|
+
* `..`, and prove the result stays inside the root both LEXICALLY and after
|
|
10
|
+
* realpath (so a symlink inside the root can't point out of it).
|
|
11
|
+
*
|
|
12
|
+
* Dependency-free (node: builtins only) so it copies cleanly into the standalone
|
|
13
|
+
* `tot dev` runner (see scripts/build/build-runner.mjs) and is testable under
|
|
14
|
+
* `node --test` with no build step (the scripts/dev convention).
|
|
15
|
+
*/
|
|
16
|
+
import { realpathSync } from "node:fs";
|
|
17
|
+
import { resolve, sep, isAbsolute, relative } from "node:path";
|
|
18
|
+
|
|
19
|
+
export class SafePathError extends Error {
|
|
20
|
+
constructor(message, code) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "SafePathError";
|
|
23
|
+
this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* realpath the deepest EXISTING ancestor of `p`, then re-append the missing
|
|
29
|
+
* tail. Lets us symlink-check a path whose leaf doesn't exist yet (the C5 write
|
|
30
|
+
* case: writing a brand-new file), without following a non-existent leaf.
|
|
31
|
+
*/
|
|
32
|
+
function realpathOfNearest(p) {
|
|
33
|
+
let cur = p;
|
|
34
|
+
const tail = []; // deepest-first
|
|
35
|
+
for (;;) {
|
|
36
|
+
try {
|
|
37
|
+
const real = realpathSync(cur);
|
|
38
|
+
return tail.length ? resolve(real, ...tail.reverse()) : real;
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (e.code !== "ENOENT") throw e;
|
|
41
|
+
const parent = resolve(cur, "..");
|
|
42
|
+
if (parent === cur) return cur; // reached the filesystem root
|
|
43
|
+
tail.push(relative(parent, cur));
|
|
44
|
+
cur = parent;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a caller-supplied relative path against an absolute `root`,
|
|
51
|
+
* guaranteeing the result stays inside `root`. Returns the absolute path.
|
|
52
|
+
* Throws SafePathError (with a `.code`) on any escape / bad input.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveSafePath(root, relPath) {
|
|
55
|
+
if (typeof relPath !== "string" || relPath.length === 0) {
|
|
56
|
+
throw new SafePathError("path is required", "empty");
|
|
57
|
+
}
|
|
58
|
+
if (relPath.includes("\0")) {
|
|
59
|
+
throw new SafePathError("path contains a NUL byte", "nul");
|
|
60
|
+
}
|
|
61
|
+
if (isAbsolute(relPath)) {
|
|
62
|
+
throw new SafePathError(`absolute paths are not allowed: ${relPath}`, "absolute");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const realRoot = realpathSync(root); // root always exists
|
|
66
|
+
const candidate = resolve(realRoot, relPath); // resolve() collapses ..
|
|
67
|
+
|
|
68
|
+
// Lexical containment — covers `../../etc/passwd` and also non-existent leaves.
|
|
69
|
+
if (candidate !== realRoot && !candidate.startsWith(realRoot + sep)) {
|
|
70
|
+
throw new SafePathError(`path escapes the root: ${relPath}`, "escape");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Symlink containment — a symlink inside root pointing outside it.
|
|
74
|
+
const realResolved = realpathOfNearest(candidate);
|
|
75
|
+
if (realResolved !== realRoot && !realResolved.startsWith(realRoot + sep)) {
|
|
76
|
+
throw new SafePathError(`path escapes the root via a symlink: ${relPath}`, "escape");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return candidate;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Boolean convenience — true iff `relPath` resolves safely inside `root`. */
|
|
83
|
+
export function isSafePath(root, relPath) {
|
|
84
|
+
try {
|
|
85
|
+
resolveSafePath(root, relPath);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Strict WRITE allowlist for C5's `POST /__tot/write` — only the raw-HTML
|
|
94
|
+
* content surface is writable. Read/tree endpoints (C3) do NOT use this; they
|
|
95
|
+
* rely on `resolveSafePath` containment alone so the tree can show everything.
|
|
96
|
+
* Kept here so the write path reuses the one guard rather than reinventing it.
|
|
97
|
+
*/
|
|
98
|
+
const WRITE_ALLOWLIST = [
|
|
99
|
+
/^content\/home\.html$/,
|
|
100
|
+
/^content\/pages-html\/[^\0]+\.html$/,
|
|
101
|
+
/^theme\.json$/,
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/** True iff `relPath` (root-relative, any separator) is writable per the allowlist. */
|
|
105
|
+
export function matchesWriteAllowlist(relPath) {
|
|
106
|
+
if (typeof relPath !== "string" || !relPath) return false;
|
|
107
|
+
const norm = relPath.split(sep).join("/").replace(/^\.\//, "");
|
|
108
|
+
return WRITE_ALLOWLIST.some((re) => re.test(norm));
|
|
109
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// shot.mjs — headless screenshot of a URL to a file, in ONE command. Replaces the
|
|
3
|
+
// multi-step MCP navigate→screenshot→read loop and gives timeout control for heavy
|
|
4
|
+
// pages (e.g. a live Shopify site that stalls the browser MCP). Then just Read the
|
|
5
|
+
// output file. Uses the repo's playwright-core + a system Chrome (same resolution
|
|
6
|
+
// as scripts/site-migration/validate-page-browser.mjs).
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// node scripts/dev/shot.mjs <url> [outfile=/tmp/shot.jpeg] [--full] [--wait 4000] [--width 1200] [--scroll 900]
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
const repoRequire = createRequire(new URL("../../packages/migration/package.json", import.meta.url));
|
|
14
|
+
const { chromium } = repoRequire("playwright-core");
|
|
15
|
+
const CHROME_PATHS = [
|
|
16
|
+
process.env.CHROME_PATH,
|
|
17
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
18
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
19
|
+
"/usr/bin/google-chrome",
|
|
20
|
+
"/usr/bin/chromium-browser",
|
|
21
|
+
"/usr/bin/chromium",
|
|
22
|
+
].filter(Boolean);
|
|
23
|
+
const executablePath = CHROME_PATHS.find((p) => existsSync(p));
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const url = args[0];
|
|
27
|
+
if (!url) { console.error("usage: shot.mjs <url> [out] [--full] [--wait ms] [--width px] [--scroll y]"); process.exit(2); }
|
|
28
|
+
const out = args[1] && !args[1].startsWith("--") ? args[1] : "/tmp/shot.jpeg";
|
|
29
|
+
const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
|
|
30
|
+
const full = args.includes("--full");
|
|
31
|
+
const wait = parseInt(opt("--wait", "3500"), 10);
|
|
32
|
+
const width = parseInt(opt("--width", "1200"), 10);
|
|
33
|
+
const scrollY = opt("--scroll", null);
|
|
34
|
+
|
|
35
|
+
const browser = await chromium.launch(executablePath ? { executablePath } : {});
|
|
36
|
+
try {
|
|
37
|
+
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
38
|
+
try { await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); }
|
|
39
|
+
catch (e) { console.error(`nav warning: ${e.message}`); } // heavy page — screenshot what loaded
|
|
40
|
+
await page.waitForTimeout(wait);
|
|
41
|
+
if (scrollY != null) { await page.evaluate((y) => window.scrollTo(0, y), Number(scrollY)); await page.waitForTimeout(600); }
|
|
42
|
+
await page.screenshot({ path: out, type: "jpeg", quality: 88, fullPage: full && scrollY == null });
|
|
43
|
+
console.log(`shot: ${out} (${url})`);
|
|
44
|
+
} finally {
|
|
45
|
+
await browser.close();
|
|
46
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tenant-source-root.mjs — resolve WHERE the developer's editable tenant source
|
|
3
|
+
* lives, as threaded by `tot dev` (scripts/tot-dev.mjs) via env.
|
|
4
|
+
*
|
|
5
|
+
* CRITICAL (standalone write-through): in `--workspace` mode `tot dev` COPIES the
|
|
6
|
+
* developer's checkout into an in-tree graft and re-syncs on save. The file tree,
|
|
7
|
+
* viewer, diff and (C5) writer must operate on the developer's REAL CHECKOUT —
|
|
8
|
+
* NOT the in-tree graft — or edits made in the browser are silently discarded on
|
|
9
|
+
* the next resync. `tot dev` therefore sets TOT_DEV_SOURCE_ROOT to the checkout
|
|
10
|
+
* in standalone mode and to `tenants/<id>/` in monorepo mode; this module is the
|
|
11
|
+
* single reader of that contract.
|
|
12
|
+
*
|
|
13
|
+
* Env-only, never SSR: the storefront's SSR frontmatter runs in workerd (even
|
|
14
|
+
* under `astro dev`) and can't see TOT_DEV_* — so the panel derives tenant +
|
|
15
|
+
* root from these endpoints, never from an SSR prop.
|
|
16
|
+
*
|
|
17
|
+
* Dependency-free (node: builtins) — copies into the standalone runner, testable
|
|
18
|
+
* under `node --test`.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { resolve } from "node:path";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @returns {{ok:true, tenantId:string, sourceRoot:string, gitCwd:string}
|
|
25
|
+
* | {ok:false, status:string, message:string}}
|
|
26
|
+
* `gitCwd` is the working dir for C4's `git show HEAD:<pathspec>` — the source
|
|
27
|
+
* root is its own git working tree in standalone mode, and lies within the
|
|
28
|
+
* monorepo's tree in monorepo mode, so the source root serves as gitCwd in both.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveTenantSourceRoot(env = process.env) {
|
|
31
|
+
const tenantId = env.TOT_DEV_TENANT || null;
|
|
32
|
+
const sourceRoot = env.TOT_DEV_SOURCE_ROOT || null;
|
|
33
|
+
|
|
34
|
+
if (!tenantId) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
status: "no_tenant",
|
|
38
|
+
message: "TOT_DEV_TENANT is not set — the /dev file tools run only under `tot dev`.",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (!sourceRoot) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
status: "no_source_root",
|
|
45
|
+
message:
|
|
46
|
+
"TOT_DEV_SOURCE_ROOT is not set — update `tot dev` (scripts/tot-dev.mjs) to thread it onto the astro dev child.",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const abs = resolve(sourceRoot);
|
|
51
|
+
if (!existsSync(abs)) {
|
|
52
|
+
return { ok: false, status: "missing_source_root", message: `TOT_DEV_SOURCE_ROOT does not exist: ${abs}` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { ok: true, tenantId, sourceRoot: abs, gitCwd: abs };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The WRITER side of the same contract: decide the absolute TOT_DEV_SOURCE_ROOT
|
|
60
|
+
* `tot dev` (scripts/tot-dev.mjs) threads onto the astro dev child.
|
|
61
|
+
*
|
|
62
|
+
* STANDALONE (`--workspace <path>`): the developer's REAL checkout — the /dev
|
|
63
|
+
* file tools MUST read+write it, NOT the in-tree graft, or browser edits are
|
|
64
|
+
* silently discarded on the next resync (see the module header). MONOREPO (no
|
|
65
|
+
* workspace): the in-tree `tenants/<id>/` dir the app globs from.
|
|
66
|
+
*
|
|
67
|
+
* @param {string|null|undefined} workspacePath the `--workspace` checkout path, if any
|
|
68
|
+
* @param {string} repoRoot the storefront repo root
|
|
69
|
+
* @param {string} tenant the tenant id
|
|
70
|
+
* @returns {string} absolute source root
|
|
71
|
+
*/
|
|
72
|
+
export function resolveDevSourceRoot(workspacePath, repoRoot, tenant) {
|
|
73
|
+
return workspacePath
|
|
74
|
+
? resolve(workspacePath)
|
|
75
|
+
: resolve(repoRoot, "tenants", tenant);
|
|
76
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal unified-diff applier for the inline-AI edit loop (WS5 G1).
|
|
3
|
+
*
|
|
4
|
+
* The MCP `content_edit` tool returns a per-file unified diff (`--- a/<path>` /
|
|
5
|
+
* `+++ b/<path>` header, `@@ -l,c +l,c @@` hunks) rather than a full-file
|
|
6
|
+
* rewrite — this is what turns that diff back into the file's new content, so
|
|
7
|
+
* `tot dev` can apply it, hot-reload, and undo it cleanly. Dependency-free
|
|
8
|
+
* (matches the rest of the tenant tooling — see packages/cli/src/mcp.mjs).
|
|
9
|
+
*
|
|
10
|
+
* Deliberately narrow: standard unified-diff hunks only (context/added/removed
|
|
11
|
+
* lines prefixed ` `/`+`/`-`), no fuzzy matching, no rename/binary support —
|
|
12
|
+
* that's exactly the shape content_edit's system prompt asks the model for.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** One `@@ -l,c +l,c @@` hunk: the old/new starting line + its body lines. */
|
|
16
|
+
function parseHunks(diff) {
|
|
17
|
+
const lines = diff.split(/\r?\n/);
|
|
18
|
+
const hunks = [];
|
|
19
|
+
let current = null;
|
|
20
|
+
const hunkHeader = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
21
|
+
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const m = hunkHeader.exec(line);
|
|
24
|
+
if (m) {
|
|
25
|
+
current = { oldStart: Number(m[1]), body: [] };
|
|
26
|
+
hunks.push(current);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (!current) continue; // header lines (---/+++) or preamble — skip
|
|
30
|
+
if (line.startsWith(" ") || line.startsWith("+") || line.startsWith("-")) {
|
|
31
|
+
current.body.push(line);
|
|
32
|
+
}
|
|
33
|
+
// A line with no +/-/space prefix inside a hunk (e.g. "\ No newline at
|
|
34
|
+
// end of file") is ignored — we don't model trailing-newline markers.
|
|
35
|
+
}
|
|
36
|
+
return hunks;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Apply a unified diff to `content`, returning the new content.
|
|
41
|
+
* Throws with a descriptive message if a hunk's context/removed lines don't
|
|
42
|
+
* match the given content at the expected position (the diff is stale or the
|
|
43
|
+
* file changed underneath it) — callers should surface this, never guess.
|
|
44
|
+
*/
|
|
45
|
+
export function applyUnifiedDiff(content, diff) {
|
|
46
|
+
const original = content.split(/\r?\n/);
|
|
47
|
+
const hunks = parseHunks(diff);
|
|
48
|
+
if (hunks.length === 0) return content; // no-op diff
|
|
49
|
+
|
|
50
|
+
const result = [];
|
|
51
|
+
let cursor = 0; // 0-indexed position in `original` we've copied through
|
|
52
|
+
|
|
53
|
+
for (const hunk of hunks) {
|
|
54
|
+
const start = hunk.oldStart - 1; // hunk headers are 1-indexed
|
|
55
|
+
if (start < cursor) {
|
|
56
|
+
throw new Error(`overlapping or out-of-order hunk at line ${hunk.oldStart}`);
|
|
57
|
+
}
|
|
58
|
+
// Copy untouched context up to the hunk.
|
|
59
|
+
result.push(...original.slice(cursor, start));
|
|
60
|
+
let pos = start;
|
|
61
|
+
for (const line of hunk.body) {
|
|
62
|
+
const tag = line[0];
|
|
63
|
+
const text = line.slice(1);
|
|
64
|
+
if (tag === " " || tag === "-") {
|
|
65
|
+
if (original[pos] !== text) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`diff does not match file content at line ${pos + 1}: `
|
|
68
|
+
+ `expected ${JSON.stringify(text)}, found ${JSON.stringify(original[pos])}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (tag === " ") result.push(text);
|
|
72
|
+
pos += 1;
|
|
73
|
+
} else if (tag === "+") {
|
|
74
|
+
result.push(text);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
cursor = pos;
|
|
78
|
+
}
|
|
79
|
+
result.push(...original.slice(cursor));
|
|
80
|
+
return result.join("\n");
|
|
81
|
+
}
|