@thorprovider/storefront 0.3.1 → 0.4.0-mvp.0

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 (179) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +248 -99
  3. package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
  4. package/assets/templates/app/app/layout.tsx +18 -4
  5. package/assets/templates/app/app/robots.ts +13 -0
  6. package/assets/templates/app/app/sitemap.ts +12 -0
  7. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
  8. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  9. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
  10. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  13. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  15. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
  16. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
  17. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
  22. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  27. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  30. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  31. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  32. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  34. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  39. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  40. package/assets/templates/features/checkout/handlers.ts +153 -0
  41. package/assets/templates/features/checkout/state.ts +61 -0
  42. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  43. package/assets/templates/features/customer-auth/state.ts +36 -0
  44. package/assets/templates/features/recommendations/state.ts +11 -0
  45. package/assets/templates/views/account/content.ts +35 -0
  46. package/assets/templates/views/account/index.tsx +114 -0
  47. package/assets/templates/views/account/registry.tsx +378 -0
  48. package/assets/templates/views/account/schema.ts +52 -0
  49. package/assets/templates/views/category/assembly/assembly.json +28 -0
  50. package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
  51. package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
  52. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  53. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  54. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  55. package/assets/templates/views/category/content.ts +30 -0
  56. package/assets/templates/views/category/data.ts +12 -0
  57. package/assets/templates/views/category/index.tsx +230 -0
  58. package/assets/templates/views/category/props.ts +16 -0
  59. package/assets/templates/views/category/registry.tsx +103 -0
  60. package/assets/templates/views/category/schema.ts +78 -0
  61. package/assets/templates/views/checkout/content.ts +105 -0
  62. package/assets/templates/views/checkout/data.ts +23 -0
  63. package/assets/templates/views/checkout/index.tsx +111 -0
  64. package/assets/templates/views/checkout/props.ts +49 -0
  65. package/assets/templates/views/checkout/registry.tsx +217 -0
  66. package/assets/templates/views/checkout/schema.ts +229 -0
  67. package/assets/templates/views/home/assembly/assembly.json +24 -0
  68. package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
  69. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  70. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  71. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  72. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  73. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  74. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  75. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  76. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  77. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  78. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  79. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  80. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  81. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  82. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  83. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  84. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  85. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  86. package/assets/templates/views/home/content.ts +70 -0
  87. package/assets/templates/views/home/data.ts +15 -0
  88. package/assets/templates/views/home/index.tsx +38 -84
  89. package/assets/templates/views/home/props.ts +22 -0
  90. package/assets/templates/views/home/registry.tsx +38 -22
  91. package/assets/templates/views/home/schema.ts +93 -0
  92. package/assets/templates/views/product/content.ts +25 -0
  93. package/assets/templates/views/product/data.ts +48 -0
  94. package/assets/templates/views/product/index.tsx +104 -0
  95. package/assets/templates/views/product/props.ts +20 -0
  96. package/assets/templates/views/product/registry.tsx +78 -0
  97. package/assets/templates/views/product/schema.ts +108 -0
  98. package/assets/templates/views/static/content.ts +99 -0
  99. package/assets/templates/views/static/index.tsx +60 -0
  100. package/assets/templates/views/static/registry.tsx +66 -0
  101. package/assets/templates/views/static/schema.ts +45 -0
  102. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
  103. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
  104. package/assets/templates/views/user-panel/schema.ts +79 -0
  105. package/bin/storefront.js +1278 -79
  106. package/commands/sf-add-view.md +44 -13
  107. package/commands/sf-feature.md +28 -0
  108. package/commands/sf-theme.md +14 -8
  109. package/commands/sf-view.md +54 -12
  110. package/package.json +6 -4
  111. package/recipes/archetypes.json +28 -1
  112. package/recipes/catalog.json +361 -0
  113. package/recipes/catalog.mjs +128 -0
  114. package/recipes/catalog.schema.json +64 -0
  115. package/recipes/feature.schema.json +89 -0
  116. package/recipes/features.json +139 -0
  117. package/recipes/manifest.schema.json +69 -0
  118. package/recipes/operations.schema.json +43 -0
  119. package/recipes/recipe.schema.json +11 -2
  120. package/recipes/recipes.json +60 -5
  121. package/recipes/sections.json +24 -5
  122. package/recipes/validate.mjs +784 -32
  123. package/scripts/candidate.mjs +236 -0
  124. package/scripts/composers.mjs +140 -0
  125. package/scripts/file-classes.mjs +116 -0
  126. package/scripts/logical-controls.mjs +61 -0
  127. package/scripts/manifest.mjs +298 -0
  128. package/scripts/operation-registry.mjs +256 -0
  129. package/scripts/operations.mjs +720 -0
  130. package/scripts/ownership-conflict.mjs +356 -0
  131. package/scripts/pinning.mjs +504 -0
  132. package/scripts/plan-apply.mjs +843 -0
  133. package/scripts/preflight.mjs +51 -0
  134. package/scripts/reconcile.mjs +511 -0
  135. package/scripts/render-templates.mjs +245 -28
  136. package/scripts/schema-validate.mjs +132 -0
  137. package/scripts/section-compat.mjs +300 -0
  138. package/scripts/section-operations.mjs +508 -0
  139. package/scripts/sync-assets.mjs +22 -15
  140. package/scripts/view-assembly.mjs +291 -0
  141. package/scripts/view-selection.mjs +211 -0
  142. package/skills/sf-feature/SKILL.md +67 -0
  143. package/skills/sf-theme-gen/SKILL.md +12 -5
  144. package/skills/sf-view-gen/SKILL.md +123 -93
  145. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
  146. package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
  147. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  148. package/skills/sf-view-gen/assets/recipes/features.json +139 -0
  149. package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
  150. package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
  151. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  152. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  153. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  154. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  155. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  156. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  157. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  158. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  159. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  160. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  161. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  162. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  163. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  164. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  165. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  166. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  167. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  168. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  169. package/skills/sf-view-gen/references/views.md +44 -10
  170. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  171. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  172. package/skills/sf-view-gen/scripts/validate.mjs +784 -32
  173. package/skills/storefront-workflow/SKILL.md +116 -0
  174. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  175. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  176. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  177. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  178. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  179. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
@@ -0,0 +1,504 @@
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
+ export const MANAGED_SUITE_DIRS = ['commands', 'skills']
45
+
46
+ // The CLI pins itself as a direct dependency so a generated project can
47
+ // reproduce the exact toolchain that produced it.
48
+ export const CLI_PACKAGE = '@thorprovider/storefront'
49
+
50
+ function sha256(input) {
51
+ return createHash('sha256').update(input).digest('hex')
52
+ }
53
+
54
+ function hasOwn(obj, key) {
55
+ return Object.prototype.hasOwnProperty.call(obj, key)
56
+ }
57
+
58
+ function isPlainObject(value) {
59
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
60
+ }
61
+
62
+ /** True when `range` is a non-empty, valid semver range. */
63
+ export function isValidRange(range) {
64
+ return typeof range === 'string' && range.trim() !== '' && semver.validRange(range) !== null
65
+ }
66
+
67
+ /** True when `version` is a valid semver version. */
68
+ export function isValidVersion(version) {
69
+ return typeof version === 'string' && semver.valid(version) !== null
70
+ }
71
+
72
+ /**
73
+ * Semver range check. Prerelease semantics are the documented semver rules:
74
+ * a prerelease version only satisfies a range when a comparator with the same
75
+ * `[major, minor, patch]` tuple also carries a prerelease (so `0.4.0-mvp.0`
76
+ * does NOT satisfy `^0.4.0`).
77
+ */
78
+ export function satisfiesRange(version, range) {
79
+ if (!isValidVersion(version) || !isValidRange(range)) return false
80
+ return semver.satisfies(version, range)
81
+ }
82
+
83
+ /**
84
+ * The package-owned suite file set (sorted POSIX-relative paths) under the
85
+ * managed suite directories of `pkgRoot`. This is the *complete owned asset
86
+ * set*: only files shipped by the package are ever hashed or compared, so a
87
+ * user's own skill/command files under `.opencode/` are neither stamped nor
88
+ * treated as drift.
89
+ */
90
+ export function managedSuitePaths(pkgRoot, dirs = MANAGED_SUITE_DIRS) {
91
+ const paths = []
92
+ for (const dir of dirs) {
93
+ const base = join(pkgRoot, dir)
94
+ if (!existsSync(base)) continue
95
+ const walk = (current) => {
96
+ for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) =>
97
+ a.name.localeCompare(b.name)
98
+ )) {
99
+ const abs = join(current, entry.name)
100
+ if (entry.isDirectory()) walk(abs)
101
+ else if (entry.isFile()) paths.push(relative(pkgRoot, abs).split(sep).join('/'))
102
+ }
103
+ }
104
+ walk(base)
105
+ }
106
+ return paths.sort()
107
+ }
108
+
109
+ /**
110
+ * Content digest map (`relpath -> sha256`) for an explicit set of relative
111
+ * paths under `rootDir`. A path that is absent is recorded as `null` (never
112
+ * silently dropped), so a missing owned asset is reported by `validatePinning`.
113
+ */
114
+ export function hashPaths(rootDir, relPaths) {
115
+ const bom = {}
116
+ for (const rel of relPaths) {
117
+ const abs = join(rootDir, rel)
118
+ bom[rel] = existsSync(abs) ? sha256(readFileSync(abs)) : null
119
+ }
120
+ return bom
121
+ }
122
+
123
+ /**
124
+ * Content digest map (`relpath -> sha256`) of the managed suite directories
125
+ * under `rootDir`. Paths are POSIX-normalized so a record written on any
126
+ * platform compares equal. Only `MANAGED_SUITE_DIRS` are hashed.
127
+ */
128
+ export async function hashManagedSuite(rootDir, dirs = MANAGED_SUITE_DIRS) {
129
+ return hashPaths(rootDir, managedSuitePaths(rootDir, dirs))
130
+ }
131
+
132
+ /**
133
+ * Read the consumer `package.json`. Returns `{ ok, pkg }` or `{ ok:false,
134
+ * error }`; a missing or malformed file is an error, never an empty object.
135
+ */
136
+ export function readProjectPackage(projectDir) {
137
+ const pkgPath = join(projectDir, 'package.json')
138
+ if (!existsSync(pkgPath)) return { ok: false, error: 'package.json not found' }
139
+ let pkg
140
+ try {
141
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
142
+ } catch (err) {
143
+ return { ok: false, error: `package.json is not valid JSON: ${err.message}` }
144
+ }
145
+ if (!isPlainObject(pkg)) {
146
+ return { ok: false, error: 'package.json must contain a JSON object' }
147
+ }
148
+ return { ok: true, pkg }
149
+ }
150
+
151
+ /**
152
+ * Declared direct dependencies (name -> range) of the consumer project.
153
+ * Returns `{ ok, dependencies }` or `{ ok:false, error }`.
154
+ */
155
+ export function readDirectDependencies(projectDir) {
156
+ const parsed = readProjectPackage(projectDir)
157
+ if (!parsed.ok) return parsed
158
+ const deps = parsed.pkg.dependencies
159
+ if (deps === undefined) return { ok: true, dependencies: {} }
160
+ if (!isPlainObject(deps)) {
161
+ return { ok: false, error: 'package.json dependencies must be an object' }
162
+ }
163
+ return { ok: true, dependencies: { ...deps } }
164
+ }
165
+
166
+ /**
167
+ * Exact resolved versions for the named dependencies, read from the consumer's
168
+ * `node_modules`. A dependency that cannot be resolved is recorded as `null`
169
+ * (and is reported by `validatePinning`), never silently dropped.
170
+ */
171
+ export function readResolvedVersions(projectDir, names) {
172
+ const versions = {}
173
+ for (const name of [...names].sort()) {
174
+ const pkgPath = join(projectDir, 'node_modules', name, 'package.json')
175
+ if (!existsSync(pkgPath)) {
176
+ versions[name] = null
177
+ continue
178
+ }
179
+ try {
180
+ const parsed = JSON.parse(readFileSync(pkgPath, 'utf8'))
181
+ versions[name] = typeof parsed.version === 'string' ? parsed.version : null
182
+ } catch {
183
+ versions[name] = null
184
+ }
185
+ }
186
+ return versions
187
+ }
188
+
189
+ /** Real `yarn.lock` evidence: existence plus content hash. */
190
+ export function readLockfile(projectDir) {
191
+ const lockPath = join(projectDir, 'yarn.lock')
192
+ if (!existsSync(lockPath)) return { ok: false, error: 'yarn.lock not found' }
193
+ return { ok: true, lockfile: { path: 'yarn.lock', sha256: sha256(readFileSync(lockPath)) } }
194
+ }
195
+
196
+ /**
197
+ * Live toolchain evidence: the actual `.yarnrc.yml` linker, the running Node
198
+ * version, and the pinned Yarn version from `packageManager`. A wrong linker or
199
+ * a missing package-manager pin is the failure mode `doctor` used to miss, so
200
+ * both are required.
201
+ */
202
+ export function readToolchainEvidence(projectDir) {
203
+ const yarnrcPath = join(projectDir, '.yarnrc.yml')
204
+ if (!existsSync(yarnrcPath)) return { ok: false, error: '.yarnrc.yml not found' }
205
+ let linker = null
206
+ for (const line of readFileSync(yarnrcPath, 'utf8').split('\n')) {
207
+ const match = /^\s*nodeLinker\s*:\s*(.+?)\s*$/.exec(line)
208
+ if (match) {
209
+ linker = match[1].replace(/^['"]|['"]$/g, '')
210
+ break
211
+ }
212
+ }
213
+ if (!linker) return { ok: false, error: '.yarnrc.yml has no nodeLinker setting' }
214
+
215
+ const parsed = readProjectPackage(projectDir)
216
+ if (!parsed.ok) return parsed
217
+ const packageManager = parsed.pkg.packageManager
218
+ if (typeof packageManager !== 'string' || !packageManager.startsWith('yarn@')) {
219
+ return {
220
+ ok: false,
221
+ error: 'package.json must pin the package manager (packageManager: "yarn@<version>")',
222
+ }
223
+ }
224
+ const yarnVersion = packageManager.slice('yarn@'.length)
225
+ if (!isValidVersion(yarnVersion)) {
226
+ return { ok: false, error: `packageManager "${packageManager}" does not pin a valid Yarn version` }
227
+ }
228
+ return {
229
+ ok: true,
230
+ toolchain: { linker, nodeVersion: process.versions.node, yarnVersion },
231
+ }
232
+ }
233
+
234
+ /** Compare a live toolchain against the declared intent; collects every error. */
235
+ export function validateToolchainEvidence(declared = TOOLCHAIN, live) {
236
+ const errors = []
237
+ if (!isPlainObject(live)) return ['toolchain evidence is missing']
238
+ if (live.linker !== declared.linker) {
239
+ errors.push(`toolchain linker "${live.linker ?? 'missing'}" must be "${declared.linker}".`)
240
+ }
241
+ if (!isValidVersion(live.nodeVersion)) {
242
+ errors.push('toolchain nodeVersion is missing or not a valid semver version')
243
+ } else if (!semver.satisfies(live.nodeVersion, declared.node)) {
244
+ errors.push(`Node version "${live.nodeVersion}" does not satisfy "${declared.node}".`)
245
+ }
246
+ if (live.yarnVersion != null) {
247
+ if (!isValidVersion(live.yarnVersion)) {
248
+ errors.push(`toolchain yarnVersion "${live.yarnVersion}" is not a valid semver version.`)
249
+ } else if (!semver.satisfies(live.yarnVersion, declared.yarn)) {
250
+ errors.push(`Yarn version "${live.yarnVersion}" does not satisfy "${declared.yarn}".`)
251
+ }
252
+ }
253
+ return errors
254
+ }
255
+
256
+ /** Assemble the declared/resolved pinning block stored in the install record. */
257
+ export function buildPinningRecord({
258
+ cliVersion,
259
+ directDependencies,
260
+ resolvedVersions,
261
+ lockfile,
262
+ assets,
263
+ }) {
264
+ return {
265
+ pinningVersion: PINNING_VERSION,
266
+ declared: {
267
+ toolchain: { ...TOOLCHAIN },
268
+ directDependencies: { ...directDependencies },
269
+ cliVersion,
270
+ },
271
+ resolved: {
272
+ versions: { ...resolvedVersions },
273
+ lockfile,
274
+ },
275
+ assets: { ...assets },
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Validate the declared layer (compatibility intent) of an incoming contract
281
+ * before any write. Every declared range must be a real semver range; the CLI
282
+ * must be pinned to a range its version satisfies; `cliVersion` must be a valid
283
+ * version matching the distributed package; and, when `toolchain` evidence is
284
+ * supplied, the live linker/Node/Yarn must match the declared intent.
285
+ */
286
+ export function validateDeclaredContract({
287
+ directDependencies,
288
+ cliVersion,
289
+ distributedVersion,
290
+ toolchain,
291
+ } = {}) {
292
+ const errors = []
293
+ if (!isPlainObject(directDependencies)) {
294
+ errors.push('declared directDependencies is missing')
295
+ } else {
296
+ const entries = Object.entries(directDependencies)
297
+ if (entries.length === 0) errors.push('declared directDependencies is empty')
298
+ for (const [name, range] of entries) {
299
+ if (!isValidRange(range)) {
300
+ errors.push(`Dependency "${name}" has no valid compatibility range ("${range}").`)
301
+ }
302
+ }
303
+ if (!hasOwn(directDependencies, CLI_PACKAGE)) {
304
+ errors.push(`declared directDependencies must pin the CLI "${CLI_PACKAGE}".`)
305
+ } else if (
306
+ isValidVersion(cliVersion) &&
307
+ isValidRange(directDependencies[CLI_PACKAGE]) &&
308
+ !semver.satisfies(cliVersion, directDependencies[CLI_PACKAGE])
309
+ ) {
310
+ errors.push(
311
+ `declared CLI version "${cliVersion}" does not satisfy its range "${directDependencies[CLI_PACKAGE]}".`
312
+ )
313
+ }
314
+ }
315
+ if (!isValidVersion(cliVersion)) {
316
+ errors.push('declared cliVersion is missing or not a valid semver version')
317
+ } else if (distributedVersion && cliVersion !== distributedVersion) {
318
+ errors.push(
319
+ `declared cliVersion "${cliVersion}" does not match the distributed package version "${distributedVersion}".`
320
+ )
321
+ }
322
+ if (toolchain) errors.push(...validateToolchainEvidence(TOOLCHAIN, toolchain))
323
+ return { ok: errors.length === 0, errors }
324
+ }
325
+
326
+ function compareMaps(recorded, live, label, describe) {
327
+ const errors = []
328
+ for (const key of Object.keys(recorded)) {
329
+ if (!hasOwn(live, key)) errors.push(`${label} "${key}" is recorded but missing from the live project.`)
330
+ else if (live[key] !== recorded[key]) errors.push(describe(key))
331
+ }
332
+ for (const key of Object.keys(live)) {
333
+ if (!hasOwn(recorded, key)) errors.push(`${label} "${key}" is present in the live project but not recorded.`)
334
+ }
335
+ return errors
336
+ }
337
+
338
+ /**
339
+ * Validate a pinning block. Structural/declared/resolved/lockfile/toolchain
340
+ * coherence is always checked; when `live` (the actual project evidence) is
341
+ * supplied, the record is compared against it instead of only against itself.
342
+ * `includeAssets:false` skips the asset comparison so a caller can validate
343
+ * everything precomputable BEFORE structural writes and check assets after the
344
+ * copy. Returns `{ ok, errors }`.
345
+ */
346
+ export function validatePinning(pinning, { distributedVersion, live, includeAssets = true } = {}) {
347
+ const errors = []
348
+ if (!isPlainObject(pinning)) {
349
+ return { ok: false, errors: ['install record has no pinning contract'] }
350
+ }
351
+ if (pinning.pinningVersion !== PINNING_VERSION) {
352
+ errors.push(
353
+ `pinning.pinningVersion must be ${PINNING_VERSION} (got ${JSON.stringify(pinning.pinningVersion)}).`
354
+ )
355
+ }
356
+
357
+ const declared = pinning.declared
358
+ let declaredDeps = {}
359
+ let declaredCliRange = null
360
+ if (!isPlainObject(declared)) {
361
+ errors.push('pinning.declared is missing')
362
+ } else {
363
+ const toolchain = declared.toolchain
364
+ if (!isPlainObject(toolchain)) {
365
+ errors.push('pinning.declared.toolchain is missing')
366
+ } else {
367
+ if (!isValidRange(toolchain.node)) errors.push('pinning.declared.toolchain.node is not a valid range')
368
+ if (!isValidRange(toolchain.yarn)) errors.push('pinning.declared.toolchain.yarn is not a valid range')
369
+ if (toolchain.linker !== TOOLCHAIN.linker) {
370
+ errors.push(`pinning.declared.toolchain.linker must be "${TOOLCHAIN.linker}".`)
371
+ }
372
+ }
373
+
374
+ const deps = declared.directDependencies
375
+ if (!isPlainObject(deps) || Object.keys(deps).length === 0) {
376
+ errors.push('pinning.declared.directDependencies is empty')
377
+ } else {
378
+ declaredDeps = deps
379
+ for (const [name, range] of Object.entries(deps)) {
380
+ if (!isValidRange(range)) {
381
+ errors.push(`Dependency "${name}" has no valid compatibility range ("${range}").`)
382
+ }
383
+ }
384
+ if (!hasOwn(deps, CLI_PACKAGE)) {
385
+ errors.push(`pinning.declared.directDependencies must pin the CLI "${CLI_PACKAGE}".`)
386
+ } else {
387
+ declaredCliRange = deps[CLI_PACKAGE]
388
+ }
389
+ }
390
+
391
+ if (!isValidVersion(declared.cliVersion)) {
392
+ errors.push('pinning.declared.cliVersion is missing or not a valid semver version')
393
+ } else {
394
+ if (distributedVersion && declared.cliVersion !== distributedVersion) {
395
+ errors.push(
396
+ `pinning.declared.cliVersion "${declared.cliVersion}" does not match the distributed package version "${distributedVersion}".`
397
+ )
398
+ }
399
+ if (
400
+ isValidRange(declaredCliRange) &&
401
+ !semver.satisfies(declared.cliVersion, declaredCliRange)
402
+ ) {
403
+ errors.push(
404
+ `declared CLI version "${declared.cliVersion}" does not satisfy its range "${declaredCliRange}".`
405
+ )
406
+ }
407
+ }
408
+ }
409
+
410
+ const resolved = pinning.resolved
411
+ if (!isPlainObject(resolved)) {
412
+ errors.push('pinning.resolved is missing')
413
+ } else {
414
+ const versions = resolved.versions
415
+ if (!isPlainObject(versions)) {
416
+ errors.push('pinning.resolved.versions is missing')
417
+ } else {
418
+ for (const [name, range] of Object.entries(declaredDeps)) {
419
+ if (!hasOwn(versions, name)) {
420
+ errors.push(`Resolved version for "${name}" is missing.`)
421
+ continue
422
+ }
423
+ const version = versions[name]
424
+ if (!isValidVersion(version)) {
425
+ errors.push(`Resolved version for "${name}" is missing or not a valid semver version.`)
426
+ continue
427
+ }
428
+ if (isValidRange(range) && !semver.satisfies(version, range)) {
429
+ errors.push(`Resolved version "${version}" for "${name}" does not satisfy its range "${range}".`)
430
+ }
431
+ }
432
+ if (isValidVersion(declared?.cliVersion)) {
433
+ const cliResolved = hasOwn(versions, CLI_PACKAGE) ? versions[CLI_PACKAGE] : null
434
+ if (cliResolved !== declared.cliVersion) {
435
+ errors.push(
436
+ `Resolved version for the CLI "${cliResolved ?? 'null'}" does not match cliVersion "${declared.cliVersion}".`
437
+ )
438
+ }
439
+ }
440
+ }
441
+ const lockfile = resolved.lockfile
442
+ if (!isPlainObject(lockfile)) {
443
+ errors.push('pinning.resolved.lockfile is missing')
444
+ } else if (typeof lockfile.sha256 !== 'string' || lockfile.sha256.trim() === '') {
445
+ errors.push('pinning.resolved.lockfile has no content hash.')
446
+ }
447
+ }
448
+
449
+ if (includeAssets) {
450
+ const assets = pinning.assets
451
+ if (!isPlainObject(assets) || Object.keys(assets).length === 0) {
452
+ errors.push('pinning.assets is empty')
453
+ } else {
454
+ for (const [rel, digest] of Object.entries(assets)) {
455
+ if (typeof digest !== 'string' || digest.trim() === '') {
456
+ errors.push(`Asset "${rel}" has no digest.`)
457
+ }
458
+ }
459
+ }
460
+ }
461
+
462
+ if (live) {
463
+ if (isPlainObject(live.directDependencies)) {
464
+ errors.push(
465
+ ...compareMaps(
466
+ declaredDeps,
467
+ live.directDependencies,
468
+ 'Declared dependency',
469
+ (key) =>
470
+ `Declared dependency "${key}" range changed from "${declaredDeps[key]}" to "${live.directDependencies[key]}".`
471
+ )
472
+ )
473
+ }
474
+ if (isPlainObject(live.resolvedVersions) && isPlainObject(resolved?.versions)) {
475
+ errors.push(
476
+ ...compareMaps(
477
+ resolved.versions,
478
+ live.resolvedVersions,
479
+ 'Resolved version',
480
+ (key) =>
481
+ `Resolved version for "${key}" changed from "${resolved.versions[key]}" to "${live.resolvedVersions[key]}".`
482
+ )
483
+ )
484
+ }
485
+ if (isPlainObject(live.lockfile) && resolved?.lockfile?.sha256 !== live.lockfile.sha256) {
486
+ errors.push('yarn.lock content hash does not match the recorded hash.')
487
+ }
488
+ if (isPlainObject(live.toolchain)) {
489
+ errors.push(...validateToolchainEvidence(TOOLCHAIN, live.toolchain))
490
+ }
491
+ if (includeAssets && isPlainObject(live.assets)) {
492
+ errors.push(
493
+ ...compareMaps(
494
+ pinning.assets ?? {},
495
+ live.assets,
496
+ 'Asset',
497
+ (key) => `Asset "${key}" digest does not match the recorded set.`
498
+ )
499
+ )
500
+ }
501
+ }
502
+
503
+ return { ok: errors.length === 0, errors }
504
+ }