@thorprovider/storefront 0.3.1 → 0.4.0-mvp.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.
Files changed (263) hide show
  1. package/README.md +321 -152
  2. package/VENDOR.md +69 -0
  3. package/assets/openspec/config.yaml +38 -0
  4. package/assets/templates/AGENTS.md +76 -0
  5. package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
  6. package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
  7. package/assets/templates/app/app/globals.css +5 -8
  8. package/assets/templates/app/app/layout.tsx +17 -7
  9. package/assets/templates/app/app/robots.ts +19 -0
  10. package/assets/templates/app/app/sitemap.ts +71 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  13. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
  14. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  16. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
  17. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  18. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  20. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
  21. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
  22. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  24. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  27. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
  30. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  31. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
  32. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  34. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  39. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
  40. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  41. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  42. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  43. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  44. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  45. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  46. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  47. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  48. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  49. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  50. package/assets/templates/features/checkout/handlers.ts +159 -0
  51. package/assets/templates/features/checkout/state.ts +61 -0
  52. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  53. package/assets/templates/features/customer-auth/state.ts +45 -0
  54. package/assets/templates/features/recommendations/state.ts +11 -0
  55. package/assets/templates/views/account/content.ts +35 -0
  56. package/assets/templates/views/account/index.tsx +114 -0
  57. package/assets/templates/views/account/registry.tsx +439 -0
  58. package/assets/templates/views/account/schema.ts +53 -0
  59. package/assets/templates/views/cart/content.ts +11 -0
  60. package/assets/templates/views/cart/data.ts +23 -0
  61. package/assets/templates/views/cart/index.tsx +53 -0
  62. package/assets/templates/views/cart/props.ts +12 -0
  63. package/assets/templates/views/cart/registry.tsx +81 -0
  64. package/assets/templates/views/cart/schema.ts +52 -0
  65. package/assets/templates/views/category/assembly/assembly.json +29 -0
  66. package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
  67. package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
  68. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  69. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  70. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  71. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  72. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  73. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  74. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  75. package/assets/templates/views/category/content.ts +30 -0
  76. package/assets/templates/views/category/data.ts +12 -0
  77. package/assets/templates/views/category/index.tsx +299 -0
  78. package/assets/templates/views/category/props.ts +23 -0
  79. package/assets/templates/views/category/registry.tsx +122 -0
  80. package/assets/templates/views/category/schema.ts +91 -0
  81. package/assets/templates/views/checkout/content.ts +105 -0
  82. package/assets/templates/views/checkout/data.ts +23 -0
  83. package/assets/templates/views/checkout/index.tsx +111 -0
  84. package/assets/templates/views/checkout/props.ts +49 -0
  85. package/assets/templates/views/checkout/registry.tsx +217 -0
  86. package/assets/templates/views/checkout/schema.ts +229 -0
  87. package/assets/templates/views/home/assembly/assembly.json +33 -0
  88. package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
  89. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  90. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  91. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  92. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  93. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  94. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  95. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  96. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  97. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  98. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  99. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  100. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  101. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  102. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  103. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  104. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  105. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  106. package/assets/templates/views/home/content.ts +70 -0
  107. package/assets/templates/views/home/data.ts +24 -0
  108. package/assets/templates/views/home/index.tsx +57 -85
  109. package/assets/templates/views/home/props.ts +22 -0
  110. package/assets/templates/views/home/registry.tsx +38 -22
  111. package/assets/templates/views/home/schema.ts +93 -0
  112. package/assets/templates/views/product/content.ts +25 -0
  113. package/assets/templates/views/product/data.ts +50 -0
  114. package/assets/templates/views/product/index.tsx +148 -0
  115. package/assets/templates/views/product/props.ts +27 -0
  116. package/assets/templates/views/product/registry.tsx +103 -0
  117. package/assets/templates/views/product/schema.ts +121 -0
  118. package/assets/templates/views/static/content.ts +99 -0
  119. package/assets/templates/views/static/index.tsx +60 -0
  120. package/assets/templates/views/static/registry.tsx +66 -0
  121. package/assets/templates/views/static/schema.ts +45 -0
  122. package/bin/storefront.js +2424 -148
  123. package/examples/merchandising/category-led.json +37 -0
  124. package/examples/merchandising/collection-led.json +37 -0
  125. package/package.json +15 -9
  126. package/recipes/archetypes.json +28 -14
  127. package/recipes/catalog.json +400 -0
  128. package/recipes/catalog.mjs +128 -0
  129. package/recipes/catalog.schema.json +64 -0
  130. package/recipes/feature.schema.json +89 -0
  131. package/recipes/features.json +162 -0
  132. package/recipes/manifest.schema.json +69 -0
  133. package/recipes/operations.schema.json +43 -0
  134. package/recipes/recipe.schema.json +11 -2
  135. package/recipes/recipes.json +76 -19
  136. package/recipes/sections.json +34 -5
  137. package/recipes/validate.mjs +838 -32
  138. package/scripts/candidate.mjs +236 -0
  139. package/scripts/composers.mjs +160 -0
  140. package/scripts/env-file.mjs +86 -0
  141. package/scripts/file-classes.mjs +121 -0
  142. package/scripts/logical-controls.mjs +61 -0
  143. package/scripts/manifest.mjs +298 -0
  144. package/scripts/migrate-english-routes.mjs +276 -0
  145. package/scripts/operation-registry.mjs +256 -0
  146. package/scripts/operations.mjs +720 -0
  147. package/scripts/ownership-conflict.mjs +356 -0
  148. package/scripts/pinning.mjs +573 -0
  149. package/scripts/plan-apply.mjs +987 -0
  150. package/scripts/preflight.mjs +51 -0
  151. package/scripts/reconcile.mjs +549 -0
  152. package/scripts/render-templates.mjs +338 -68
  153. package/scripts/schema-validate.mjs +132 -0
  154. package/scripts/section-compat.mjs +300 -0
  155. package/scripts/section-config.mjs +253 -0
  156. package/scripts/section-operations.mjs +508 -0
  157. package/scripts/starter.mjs +17 -0
  158. package/scripts/sync-assets.mjs +22 -15
  159. package/scripts/theme/color.mjs +185 -0
  160. package/scripts/theme/css-theme.mjs +233 -0
  161. package/scripts/theme/fonts.mjs +123 -0
  162. package/scripts/theme/import-export.mjs +125 -0
  163. package/scripts/theme/preset.mjs +93 -0
  164. package/scripts/theme/theme-plan.mjs +292 -0
  165. package/scripts/theme/tokens.mjs +137 -0
  166. package/scripts/view-assembly.mjs +337 -0
  167. package/scripts/view-selection.mjs +211 -0
  168. package/skills/sf-feature/SKILL.md +80 -0
  169. package/skills/sf-setup/SKILL.md +79 -0
  170. package/skills/sf-theme-gen/SKILL.md +100 -30
  171. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  172. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  173. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  174. package/skills/sf-view-gen/SKILL.md +146 -93
  175. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
  176. package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
  177. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  178. package/skills/sf-view-gen/assets/recipes/features.json +162 -0
  179. package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
  180. package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
  181. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  182. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  183. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  184. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  185. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  186. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  187. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  188. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  189. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  190. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  191. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  192. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  193. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  194. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  195. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  196. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  197. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  198. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  199. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  200. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  201. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  202. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  203. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  204. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  205. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  206. package/skills/sf-view-gen/references/views.md +58 -33
  207. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  208. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  209. package/skills/sf-view-gen/scripts/validate.mjs +838 -32
  210. package/skills/storefront-workflow/SKILL.md +179 -0
  211. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  212. package/CHANGELOG.md +0 -61
  213. package/commands/sf-add-view.md +0 -26
  214. package/commands/sf-theme.md +0 -20
  215. package/commands/sf-view.md +0 -25
  216. package/skills/building-storefronts/SKILL.md +0 -178
  217. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  218. package/skills/json-render-remotion/SKILL.md +0 -111
  219. package/skills/json-render-solid/SKILL.md +0 -204
  220. package/skills/nextjs-shadcn/SKILL.md +0 -367
  221. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  222. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  223. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  224. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  225. package/skills/nextjs-shadcn/references/styling.md +0 -555
  226. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  227. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  228. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  229. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  230. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  231. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
  232. package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
  233. package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
  234. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  235. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  236. package/skills/shadcn-theming/SKILL.md +0 -104
  237. package/skills/storefront-best-practices/SKILL.md +0 -421
  238. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  239. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  240. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  241. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  242. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  243. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  244. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  245. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  246. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  247. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  248. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  249. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  250. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  251. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  252. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  253. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  254. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  255. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  256. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  257. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  258. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  259. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  260. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  261. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  262. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  263. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -0,0 +1,573 @@
1
+ // Candidate compatibility/pinning contract (design Decision 13).
2
+ //
3
+ // The install record separates two layers:
4
+ // - declared: compatibility *intent* — supported toolchain ranges (including
5
+ // the fixed `node-modules` linker), a name-to-range map for every direct
6
+ // dependency (including the CLI itself), the local CLI version (which must
7
+ // equal the distributed package version), and the asset BOM.
8
+ // - resolved: the exact versions those ranges resolve to, the real
9
+ // `yarn.lock` content hash, and the recorded asset digests, so an M1
10
+ // artifact is reproducible.
11
+ //
12
+ // Every rule is reported, never short-circuited, and no missing/malformed data
13
+ // is swallowed. Validation rejects an unpinned dependency, an invalid range, a
14
+ // declared/resolved incoherence, a CLI/distributed version mismatch, a missing
15
+ // lockfile, a wrong toolchain/linker, or an asset-digest mismatch. The concrete
16
+ // versions and packed-artifact proof are M1 evidence; this module owns the shape
17
+ // and the rules.
18
+ //
19
+ // Version/range matching uses the well-documented `semver` package (npm) rather
20
+ // than a hand-rolled parser: spec-compliant prerelease semantics, x-ranges,
21
+ // hyphen ranges and comparator sets are all required by the contract and are
22
+ // exactly what a fragile regex drops. This is a deliberate canonical extension
23
+ // of the storefront package's dependencies (justified at this decision site).
24
+
25
+ import { createHash } from 'node:crypto'
26
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
27
+ import { join, relative, sep } from 'node:path'
28
+ import semver from 'semver'
29
+
30
+ export const PINNING_VERSION = 2
31
+
32
+ // Declared toolchain intent. Node >=20.9.0 is the Next.js 16 floor (Node 18 was
33
+ // dropped); the linker is fixed to `node-modules` because PnP breaks the
34
+ // Next.js 16 Turbopack build the candidate targets.
35
+ export const TOOLCHAIN = {
36
+ node: '>=20.9.0',
37
+ yarn: '>=4.0.0 <5.0.0',
38
+ linker: 'node-modules',
39
+ }
40
+
41
+ // The managed suite is the package-owned surface installed into `.opencode/`.
42
+ // Its BOM is what `doctor` and `update` compare; user-owned storefront files
43
+ // live outside it. Only these directories are ever treated as package-owned.
44
+ // The suite ships skills only — the root `AGENTS.md` is a rendered project file
45
+ // covered by derived drift (reconcile dry-run), not by this BOM.
46
+ export const MANAGED_SUITE_DIRS = ['skills']
47
+
48
+ // The CLI pins itself as a direct dependency so a generated project can
49
+ // reproduce the exact toolchain that produced it.
50
+ export const CLI_PACKAGE = '@thorprovider/storefront'
51
+
52
+ function sha256(input) {
53
+ return createHash('sha256').update(input).digest('hex')
54
+ }
55
+
56
+ function hasOwn(obj, key) {
57
+ return Object.prototype.hasOwnProperty.call(obj, key)
58
+ }
59
+
60
+ function isPlainObject(value) {
61
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
62
+ }
63
+
64
+ /** True when `range` is a non-empty, valid semver range. */
65
+ export function isValidRange(range) {
66
+ return typeof range === 'string' && range.trim() !== '' && semver.validRange(range) !== null
67
+ }
68
+
69
+ /** True when `version` is a valid semver version. */
70
+ export function isValidVersion(version) {
71
+ return typeof version === 'string' && semver.valid(version) !== null
72
+ }
73
+
74
+ /**
75
+ * Semver range check. Prerelease semantics are the documented semver rules:
76
+ * a prerelease version only satisfies a range when a comparator with the same
77
+ * `[major, minor, patch]` tuple also carries a prerelease (so `0.4.0-mvp.0`
78
+ * does NOT satisfy `^0.4.0`).
79
+ */
80
+ export function satisfiesRange(version, range) {
81
+ if (!isValidVersion(version) || !isValidRange(range)) return false
82
+ return semver.satisfies(version, range)
83
+ }
84
+
85
+ /**
86
+ * The package's operative skill set, read from the single declared source: the
87
+ * package skill layout. Under each managed suite directory, a directory that
88
+ * directly contains a `SKILL.md` is an operative skill; any other file under a
89
+ * skill is a scoped reference, never a skill. Returns sorted `<dir>/<name>`
90
+ * POSIX-relative paths. Installation, migration/refresh, and validation all
91
+ * read this one source — there is no separate registry or manifest that
92
+ * restates the set.
93
+ */
94
+ export function declaredOperativeSkills(pkgRoot, dirs = MANAGED_SUITE_DIRS) {
95
+ const skills = []
96
+ for (const dir of dirs) {
97
+ const base = join(pkgRoot, dir)
98
+ if (!existsSync(base)) continue
99
+ for (const entry of readdirSync(base, { withFileTypes: true }).sort((a, b) =>
100
+ a.name.localeCompare(b.name)
101
+ )) {
102
+ if (!entry.isDirectory()) continue
103
+ if (existsSync(join(base, entry.name, 'SKILL.md'))) skills.push(`${dir}/${entry.name}`)
104
+ }
105
+ }
106
+ return skills.sort()
107
+ }
108
+
109
+ /**
110
+ * The scoped reference files shipped under the operative skills: every managed
111
+ * suite file that is not a skill's own root `SKILL.md`. References travel with
112
+ * their owning skill (so the installed skill can load them) but are never
113
+ * counted or installed as skills.
114
+ */
115
+ export function declaredSuiteReferences(pkgRoot, dirs = MANAGED_SUITE_DIRS) {
116
+ const skillRoots = new Set(declaredOperativeSkills(pkgRoot, dirs).map((skill) => `${skill}/SKILL.md`))
117
+ return managedSuitePaths(pkgRoot, dirs).filter((rel) => !skillRoots.has(rel))
118
+ }
119
+
120
+ /**
121
+ * The package-owned suite file set (sorted POSIX-relative paths) under the
122
+ * managed suite directories of `pkgRoot`. This is the *complete owned asset
123
+ * set*: only files shipped by the package are ever hashed or compared, so a
124
+ * user's own skill/command files under `.opencode/` are neither stamped nor
125
+ * treated as drift. The set is derived from the declared skill layout
126
+ * (`declaredOperativeSkills`), so a directory without a `SKILL.md` is not part
127
+ * of the suite.
128
+ */
129
+ export function managedSuitePaths(pkgRoot, dirs = MANAGED_SUITE_DIRS) {
130
+ const paths = []
131
+ for (const skillRel of declaredOperativeSkills(pkgRoot, dirs)) {
132
+ const base = join(pkgRoot, skillRel)
133
+ const walk = (current) => {
134
+ for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) =>
135
+ a.name.localeCompare(b.name)
136
+ )) {
137
+ const abs = join(current, entry.name)
138
+ if (entry.isDirectory()) walk(abs)
139
+ else if (entry.isFile()) paths.push(relative(pkgRoot, abs).split(sep).join('/'))
140
+ }
141
+ }
142
+ walk(base)
143
+ }
144
+ return paths.sort()
145
+ }
146
+
147
+ /**
148
+ * Content digest map (`relpath -> sha256`) for an explicit set of relative
149
+ * paths under `rootDir`. A path that is absent is recorded as `null` (never
150
+ * silently dropped), so a missing owned asset is reported by `validatePinning`.
151
+ */
152
+ export function hashPaths(rootDir, relPaths) {
153
+ const bom = {}
154
+ for (const rel of relPaths) {
155
+ const abs = join(rootDir, rel)
156
+ bom[rel] = existsSync(abs) ? sha256(readFileSync(abs)) : null
157
+ }
158
+ return bom
159
+ }
160
+
161
+ /**
162
+ * Content digest map (`relpath -> sha256`) of the managed suite directories
163
+ * under `rootDir`. Paths are POSIX-normalized so a record written on any
164
+ * platform compares equal. Only `MANAGED_SUITE_DIRS` are hashed.
165
+ */
166
+ export async function hashManagedSuite(rootDir, dirs = MANAGED_SUITE_DIRS) {
167
+ return hashPaths(rootDir, managedSuitePaths(rootDir, dirs))
168
+ }
169
+
170
+ /**
171
+ * The owned-file set recorded in an install record, or `null` when the record
172
+ * predates the BOM (a legacy record) and the set cannot be derived. Callers
173
+ * MUST treat `null` conservatively: no file is retired from an undetermined
174
+ * set. Returns the `relpath -> digest` map itself (never a copy silently
175
+ * dropped), so callers can compare a recorded digest against the live file.
176
+ */
177
+ export function recordedOwnedSet(record) {
178
+ if (!isPlainObject(record)) return null
179
+ if (isPlainObject(record.pinning) && isPlainObject(record.pinning.assets)) {
180
+ return record.pinning.assets
181
+ }
182
+ // An intermediate flattened shape that carried the BOM at the record root.
183
+ if (isPlainObject(record.assets)) return record.assets
184
+ return null
185
+ }
186
+
187
+ /**
188
+ * Retirement candidates: every path in the previously recorded owned-file set
189
+ * (the install-record BOM) that the incoming managed set no longer ships. A
190
+ * `null` or malformed recorded set yields no candidates, so retirement is never
191
+ * derived from an unknown set (the conservative legacy-record path).
192
+ */
193
+ export function retirementCandidates(recordedAssets, incomingPaths) {
194
+ if (!isPlainObject(recordedAssets)) return []
195
+ const incoming = new Set(incomingPaths)
196
+ return Object.keys(recordedAssets)
197
+ .filter((rel) => !incoming.has(rel))
198
+ .sort()
199
+ }
200
+
201
+ /**
202
+ * Read the consumer `package.json`. Returns `{ ok, pkg }` or `{ ok:false,
203
+ * error }`; a missing or malformed file is an error, never an empty object.
204
+ */
205
+ export function readProjectPackage(projectDir) {
206
+ const pkgPath = join(projectDir, 'package.json')
207
+ if (!existsSync(pkgPath)) return { ok: false, error: 'package.json not found' }
208
+ let pkg
209
+ try {
210
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
211
+ } catch (err) {
212
+ return { ok: false, error: `package.json is not valid JSON: ${err.message}` }
213
+ }
214
+ if (!isPlainObject(pkg)) {
215
+ return { ok: false, error: 'package.json must contain a JSON object' }
216
+ }
217
+ return { ok: true, pkg }
218
+ }
219
+
220
+ /**
221
+ * Declared direct dependencies (name -> range) of the consumer project.
222
+ * Returns `{ ok, dependencies }` or `{ ok:false, error }`.
223
+ */
224
+ export function readDirectDependencies(projectDir) {
225
+ const parsed = readProjectPackage(projectDir)
226
+ if (!parsed.ok) return parsed
227
+ const deps = parsed.pkg.dependencies
228
+ if (deps === undefined) return { ok: true, dependencies: {} }
229
+ if (!isPlainObject(deps)) {
230
+ return { ok: false, error: 'package.json dependencies must be an object' }
231
+ }
232
+ return { ok: true, dependencies: { ...deps } }
233
+ }
234
+
235
+ /**
236
+ * Exact resolved versions for the named dependencies, read from the consumer's
237
+ * `node_modules`. A dependency that cannot be resolved is recorded as `null`
238
+ * (and is reported by `validatePinning`), never silently dropped.
239
+ */
240
+ export function readResolvedVersions(projectDir, names) {
241
+ const versions = {}
242
+ for (const name of [...names].sort()) {
243
+ const pkgPath = join(projectDir, 'node_modules', name, 'package.json')
244
+ if (!existsSync(pkgPath)) {
245
+ versions[name] = null
246
+ continue
247
+ }
248
+ try {
249
+ const parsed = JSON.parse(readFileSync(pkgPath, 'utf8'))
250
+ versions[name] = typeof parsed.version === 'string' ? parsed.version : null
251
+ } catch {
252
+ versions[name] = null
253
+ }
254
+ }
255
+ return versions
256
+ }
257
+
258
+ /** Real `yarn.lock` evidence: existence plus content hash. */
259
+ export function readLockfile(projectDir) {
260
+ const lockPath = join(projectDir, 'yarn.lock')
261
+ if (!existsSync(lockPath)) return { ok: false, error: 'yarn.lock not found' }
262
+ return { ok: true, lockfile: { path: 'yarn.lock', sha256: sha256(readFileSync(lockPath)) } }
263
+ }
264
+
265
+ /**
266
+ * Live toolchain evidence: the actual `.yarnrc.yml` linker, the running Node
267
+ * version, and the pinned Yarn version from `packageManager`. A wrong linker or
268
+ * a missing package-manager pin is the failure mode `doctor` used to miss, so
269
+ * both are required.
270
+ */
271
+ export function readToolchainEvidence(projectDir) {
272
+ const yarnrcPath = join(projectDir, '.yarnrc.yml')
273
+ if (!existsSync(yarnrcPath)) return { ok: false, error: '.yarnrc.yml not found' }
274
+ let linker = null
275
+ for (const line of readFileSync(yarnrcPath, 'utf8').split('\n')) {
276
+ const match = /^\s*nodeLinker\s*:\s*(.+?)\s*$/.exec(line)
277
+ if (match) {
278
+ linker = match[1].replace(/^['"]|['"]$/g, '')
279
+ break
280
+ }
281
+ }
282
+ if (!linker) return { ok: false, error: '.yarnrc.yml has no nodeLinker setting' }
283
+
284
+ const parsed = readProjectPackage(projectDir)
285
+ if (!parsed.ok) return parsed
286
+ const packageManager = parsed.pkg.packageManager
287
+ if (typeof packageManager !== 'string' || !packageManager.startsWith('yarn@')) {
288
+ return {
289
+ ok: false,
290
+ error: 'package.json must pin the package manager (packageManager: "yarn@<version>")',
291
+ }
292
+ }
293
+ const yarnVersion = packageManager.slice('yarn@'.length)
294
+ if (!isValidVersion(yarnVersion)) {
295
+ return { ok: false, error: `packageManager "${packageManager}" does not pin a valid Yarn version` }
296
+ }
297
+ return {
298
+ ok: true,
299
+ toolchain: { linker, nodeVersion: process.versions.node, yarnVersion },
300
+ }
301
+ }
302
+
303
+ /** Compare a live toolchain against the declared intent; collects every error. */
304
+ export function validateToolchainEvidence(declared = TOOLCHAIN, live) {
305
+ const errors = []
306
+ if (!isPlainObject(live)) return ['toolchain evidence is missing']
307
+ if (live.linker !== declared.linker) {
308
+ errors.push(`toolchain linker "${live.linker ?? 'missing'}" must be "${declared.linker}".`)
309
+ }
310
+ if (!isValidVersion(live.nodeVersion)) {
311
+ errors.push('toolchain nodeVersion is missing or not a valid semver version')
312
+ } else if (!semver.satisfies(live.nodeVersion, declared.node)) {
313
+ errors.push(`Node version "${live.nodeVersion}" does not satisfy "${declared.node}".`)
314
+ }
315
+ if (live.yarnVersion != null) {
316
+ if (!isValidVersion(live.yarnVersion)) {
317
+ errors.push(`toolchain yarnVersion "${live.yarnVersion}" is not a valid semver version.`)
318
+ } else if (!semver.satisfies(live.yarnVersion, declared.yarn)) {
319
+ errors.push(`Yarn version "${live.yarnVersion}" does not satisfy "${declared.yarn}".`)
320
+ }
321
+ }
322
+ return errors
323
+ }
324
+
325
+ /** Assemble the declared/resolved pinning block stored in the install record. */
326
+ export function buildPinningRecord({
327
+ cliVersion,
328
+ directDependencies,
329
+ resolvedVersions,
330
+ lockfile,
331
+ assets,
332
+ }) {
333
+ return {
334
+ pinningVersion: PINNING_VERSION,
335
+ declared: {
336
+ toolchain: { ...TOOLCHAIN },
337
+ directDependencies: { ...directDependencies },
338
+ cliVersion,
339
+ },
340
+ resolved: {
341
+ versions: { ...resolvedVersions },
342
+ lockfile,
343
+ },
344
+ assets: { ...assets },
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Validate the declared layer (compatibility intent) of an incoming contract
350
+ * before any write. Every declared range must be a real semver range; the CLI
351
+ * must be pinned to a range its version satisfies; `cliVersion` must be a valid
352
+ * version matching the distributed package; and, when `toolchain` evidence is
353
+ * supplied, the live linker/Node/Yarn must match the declared intent.
354
+ */
355
+ export function validateDeclaredContract({
356
+ directDependencies,
357
+ cliVersion,
358
+ distributedVersion,
359
+ toolchain,
360
+ } = {}) {
361
+ const errors = []
362
+ if (!isPlainObject(directDependencies)) {
363
+ errors.push('declared directDependencies is missing')
364
+ } else {
365
+ const entries = Object.entries(directDependencies)
366
+ if (entries.length === 0) errors.push('declared directDependencies is empty')
367
+ for (const [name, range] of entries) {
368
+ if (!isValidRange(range)) {
369
+ errors.push(`Dependency "${name}" has no valid compatibility range ("${range}").`)
370
+ }
371
+ }
372
+ if (!hasOwn(directDependencies, CLI_PACKAGE)) {
373
+ errors.push(`declared directDependencies must pin the CLI "${CLI_PACKAGE}".`)
374
+ } else if (
375
+ isValidVersion(cliVersion) &&
376
+ isValidRange(directDependencies[CLI_PACKAGE]) &&
377
+ !semver.satisfies(cliVersion, directDependencies[CLI_PACKAGE])
378
+ ) {
379
+ errors.push(
380
+ `declared CLI version "${cliVersion}" does not satisfy its range "${directDependencies[CLI_PACKAGE]}".`
381
+ )
382
+ }
383
+ }
384
+ if (!isValidVersion(cliVersion)) {
385
+ errors.push('declared cliVersion is missing or not a valid semver version')
386
+ } else if (distributedVersion && cliVersion !== distributedVersion) {
387
+ errors.push(
388
+ `declared cliVersion "${cliVersion}" does not match the distributed package version "${distributedVersion}".`
389
+ )
390
+ }
391
+ if (toolchain) errors.push(...validateToolchainEvidence(TOOLCHAIN, toolchain))
392
+ return { ok: errors.length === 0, errors }
393
+ }
394
+
395
+ function compareMaps(recorded, live, label, describe) {
396
+ const errors = []
397
+ for (const key of Object.keys(recorded)) {
398
+ if (!hasOwn(live, key)) errors.push(`${label} "${key}" is recorded but missing from the live project.`)
399
+ else if (live[key] !== recorded[key]) errors.push(describe(key))
400
+ }
401
+ for (const key of Object.keys(live)) {
402
+ if (!hasOwn(recorded, key)) errors.push(`${label} "${key}" is present in the live project but not recorded.`)
403
+ }
404
+ return errors
405
+ }
406
+
407
+ /**
408
+ * Validate a pinning block. Structural/declared/resolved/lockfile/toolchain
409
+ * coherence is always checked; when `live` (the actual project evidence) is
410
+ * supplied, the record is compared against it instead of only against itself.
411
+ * `includeAssets:false` skips the asset comparison so a caller can validate
412
+ * everything precomputable BEFORE structural writes and check assets after the
413
+ * copy. Returns `{ ok, errors }`.
414
+ */
415
+ export function validatePinning(pinning, { distributedVersion, live, includeAssets = true } = {}) {
416
+ const errors = []
417
+ if (!isPlainObject(pinning)) {
418
+ return { ok: false, errors: ['install record has no pinning contract'] }
419
+ }
420
+ if (pinning.pinningVersion !== PINNING_VERSION) {
421
+ errors.push(
422
+ `pinning.pinningVersion must be ${PINNING_VERSION} (got ${JSON.stringify(pinning.pinningVersion)}).`
423
+ )
424
+ }
425
+
426
+ const declared = pinning.declared
427
+ let declaredDeps = {}
428
+ let declaredCliRange = null
429
+ if (!isPlainObject(declared)) {
430
+ errors.push('pinning.declared is missing')
431
+ } else {
432
+ const toolchain = declared.toolchain
433
+ if (!isPlainObject(toolchain)) {
434
+ errors.push('pinning.declared.toolchain is missing')
435
+ } else {
436
+ if (!isValidRange(toolchain.node)) errors.push('pinning.declared.toolchain.node is not a valid range')
437
+ if (!isValidRange(toolchain.yarn)) errors.push('pinning.declared.toolchain.yarn is not a valid range')
438
+ if (toolchain.linker !== TOOLCHAIN.linker) {
439
+ errors.push(`pinning.declared.toolchain.linker must be "${TOOLCHAIN.linker}".`)
440
+ }
441
+ }
442
+
443
+ const deps = declared.directDependencies
444
+ if (!isPlainObject(deps) || Object.keys(deps).length === 0) {
445
+ errors.push('pinning.declared.directDependencies is empty')
446
+ } else {
447
+ declaredDeps = deps
448
+ for (const [name, range] of Object.entries(deps)) {
449
+ if (!isValidRange(range)) {
450
+ errors.push(`Dependency "${name}" has no valid compatibility range ("${range}").`)
451
+ }
452
+ }
453
+ if (!hasOwn(deps, CLI_PACKAGE)) {
454
+ errors.push(`pinning.declared.directDependencies must pin the CLI "${CLI_PACKAGE}".`)
455
+ } else {
456
+ declaredCliRange = deps[CLI_PACKAGE]
457
+ }
458
+ }
459
+
460
+ if (!isValidVersion(declared.cliVersion)) {
461
+ errors.push('pinning.declared.cliVersion is missing or not a valid semver version')
462
+ } else {
463
+ if (distributedVersion && declared.cliVersion !== distributedVersion) {
464
+ errors.push(
465
+ `pinning.declared.cliVersion "${declared.cliVersion}" does not match the distributed package version "${distributedVersion}".`
466
+ )
467
+ }
468
+ if (
469
+ isValidRange(declaredCliRange) &&
470
+ !semver.satisfies(declared.cliVersion, declaredCliRange)
471
+ ) {
472
+ errors.push(
473
+ `declared CLI version "${declared.cliVersion}" does not satisfy its range "${declaredCliRange}".`
474
+ )
475
+ }
476
+ }
477
+ }
478
+
479
+ const resolved = pinning.resolved
480
+ if (!isPlainObject(resolved)) {
481
+ errors.push('pinning.resolved is missing')
482
+ } else {
483
+ const versions = resolved.versions
484
+ if (!isPlainObject(versions)) {
485
+ errors.push('pinning.resolved.versions is missing')
486
+ } else {
487
+ for (const [name, range] of Object.entries(declaredDeps)) {
488
+ if (!hasOwn(versions, name)) {
489
+ errors.push(`Resolved version for "${name}" is missing.`)
490
+ continue
491
+ }
492
+ const version = versions[name]
493
+ if (!isValidVersion(version)) {
494
+ errors.push(`Resolved version for "${name}" is missing or not a valid semver version.`)
495
+ continue
496
+ }
497
+ if (isValidRange(range) && !semver.satisfies(version, range)) {
498
+ errors.push(`Resolved version "${version}" for "${name}" does not satisfy its range "${range}".`)
499
+ }
500
+ }
501
+ if (isValidVersion(declared?.cliVersion)) {
502
+ const cliResolved = hasOwn(versions, CLI_PACKAGE) ? versions[CLI_PACKAGE] : null
503
+ if (cliResolved !== declared.cliVersion) {
504
+ errors.push(
505
+ `Resolved version for the CLI "${cliResolved ?? 'null'}" does not match cliVersion "${declared.cliVersion}".`
506
+ )
507
+ }
508
+ }
509
+ }
510
+ const lockfile = resolved.lockfile
511
+ if (!isPlainObject(lockfile)) {
512
+ errors.push('pinning.resolved.lockfile is missing')
513
+ } else if (typeof lockfile.sha256 !== 'string' || lockfile.sha256.trim() === '') {
514
+ errors.push('pinning.resolved.lockfile has no content hash.')
515
+ }
516
+ }
517
+
518
+ if (includeAssets) {
519
+ const assets = pinning.assets
520
+ if (!isPlainObject(assets) || Object.keys(assets).length === 0) {
521
+ errors.push('pinning.assets is empty')
522
+ } else {
523
+ for (const [rel, digest] of Object.entries(assets)) {
524
+ if (typeof digest !== 'string' || digest.trim() === '') {
525
+ errors.push(`Asset "${rel}" has no digest.`)
526
+ }
527
+ }
528
+ }
529
+ }
530
+
531
+ if (live) {
532
+ if (isPlainObject(live.directDependencies)) {
533
+ errors.push(
534
+ ...compareMaps(
535
+ declaredDeps,
536
+ live.directDependencies,
537
+ 'Declared dependency',
538
+ (key) =>
539
+ `Declared dependency "${key}" range changed from "${declaredDeps[key]}" to "${live.directDependencies[key]}".`
540
+ )
541
+ )
542
+ }
543
+ if (isPlainObject(live.resolvedVersions) && isPlainObject(resolved?.versions)) {
544
+ errors.push(
545
+ ...compareMaps(
546
+ resolved.versions,
547
+ live.resolvedVersions,
548
+ 'Resolved version',
549
+ (key) =>
550
+ `Resolved version for "${key}" changed from "${resolved.versions[key]}" to "${live.resolvedVersions[key]}".`
551
+ )
552
+ )
553
+ }
554
+ if (isPlainObject(live.lockfile) && resolved?.lockfile?.sha256 !== live.lockfile.sha256) {
555
+ errors.push('yarn.lock content hash does not match the recorded hash.')
556
+ }
557
+ if (isPlainObject(live.toolchain)) {
558
+ errors.push(...validateToolchainEvidence(TOOLCHAIN, live.toolchain))
559
+ }
560
+ if (includeAssets && isPlainObject(live.assets)) {
561
+ errors.push(
562
+ ...compareMaps(
563
+ pinning.assets ?? {},
564
+ live.assets,
565
+ 'Asset',
566
+ (key) => `Asset "${key}" digest does not match the recorded set.`
567
+ )
568
+ )
569
+ }
570
+ }
571
+
572
+ return { ok: errors.length === 0, errors }
573
+ }