@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.
- package/CHANGELOG.md +23 -0
- package/README.md +248 -99
- package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
- package/assets/templates/app/app/layout.tsx +18 -4
- package/assets/templates/app/app/robots.ts +13 -0
- package/assets/templates/app/app/sitemap.ts +12 -0
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
- package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
- package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
- package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
- package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
- package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
- package/assets/templates/features/checkout/handlers.ts +153 -0
- package/assets/templates/features/checkout/state.ts +61 -0
- package/assets/templates/features/customer-auth/handlers.ts +59 -0
- package/assets/templates/features/customer-auth/state.ts +36 -0
- package/assets/templates/features/recommendations/state.ts +11 -0
- package/assets/templates/views/account/content.ts +35 -0
- package/assets/templates/views/account/index.tsx +114 -0
- package/assets/templates/views/account/registry.tsx +378 -0
- package/assets/templates/views/account/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +28 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
- package/assets/templates/views/category/content.ts +30 -0
- package/assets/templates/views/category/data.ts +12 -0
- package/assets/templates/views/category/index.tsx +230 -0
- package/assets/templates/views/category/props.ts +16 -0
- package/assets/templates/views/category/registry.tsx +103 -0
- package/assets/templates/views/category/schema.ts +78 -0
- package/assets/templates/views/checkout/content.ts +105 -0
- package/assets/templates/views/checkout/data.ts +23 -0
- package/assets/templates/views/checkout/index.tsx +111 -0
- package/assets/templates/views/checkout/props.ts +49 -0
- package/assets/templates/views/checkout/registry.tsx +217 -0
- package/assets/templates/views/checkout/schema.ts +229 -0
- package/assets/templates/views/home/assembly/assembly.json +24 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
- package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
- package/assets/templates/views/home/content.ts +70 -0
- package/assets/templates/views/home/data.ts +15 -0
- package/assets/templates/views/home/index.tsx +38 -84
- package/assets/templates/views/home/props.ts +22 -0
- package/assets/templates/views/home/registry.tsx +38 -22
- package/assets/templates/views/home/schema.ts +93 -0
- package/assets/templates/views/product/content.ts +25 -0
- package/assets/templates/views/product/data.ts +48 -0
- package/assets/templates/views/product/index.tsx +104 -0
- package/assets/templates/views/product/props.ts +20 -0
- package/assets/templates/views/product/registry.tsx +78 -0
- package/assets/templates/views/product/schema.ts +108 -0
- package/assets/templates/views/static/content.ts +99 -0
- package/assets/templates/views/static/index.tsx +60 -0
- package/assets/templates/views/static/registry.tsx +66 -0
- package/assets/templates/views/static/schema.ts +45 -0
- package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
- package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
- package/assets/templates/views/user-panel/schema.ts +79 -0
- package/bin/storefront.js +1278 -79
- package/commands/sf-add-view.md +44 -13
- package/commands/sf-feature.md +28 -0
- package/commands/sf-theme.md +14 -8
- package/commands/sf-view.md +54 -12
- package/package.json +6 -4
- package/recipes/archetypes.json +28 -1
- package/recipes/catalog.json +361 -0
- package/recipes/catalog.mjs +128 -0
- package/recipes/catalog.schema.json +64 -0
- package/recipes/feature.schema.json +89 -0
- package/recipes/features.json +139 -0
- package/recipes/manifest.schema.json +69 -0
- package/recipes/operations.schema.json +43 -0
- package/recipes/recipe.schema.json +11 -2
- package/recipes/recipes.json +60 -5
- package/recipes/sections.json +24 -5
- package/recipes/validate.mjs +784 -32
- package/scripts/candidate.mjs +236 -0
- package/scripts/composers.mjs +140 -0
- package/scripts/file-classes.mjs +116 -0
- package/scripts/logical-controls.mjs +61 -0
- package/scripts/manifest.mjs +298 -0
- package/scripts/operation-registry.mjs +256 -0
- package/scripts/operations.mjs +720 -0
- package/scripts/ownership-conflict.mjs +356 -0
- package/scripts/pinning.mjs +504 -0
- package/scripts/plan-apply.mjs +843 -0
- package/scripts/preflight.mjs +51 -0
- package/scripts/reconcile.mjs +511 -0
- package/scripts/render-templates.mjs +245 -28
- package/scripts/schema-validate.mjs +132 -0
- package/scripts/section-compat.mjs +300 -0
- package/scripts/section-operations.mjs +508 -0
- package/scripts/sync-assets.mjs +22 -15
- package/scripts/view-assembly.mjs +291 -0
- package/scripts/view-selection.mjs +211 -0
- package/skills/sf-feature/SKILL.md +67 -0
- package/skills/sf-theme-gen/SKILL.md +12 -5
- package/skills/sf-view-gen/SKILL.md +123 -93
- package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
- package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
- package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
- package/skills/sf-view-gen/assets/recipes/features.json +139 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
- package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
- package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
- package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
- package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
- package/skills/sf-view-gen/references/views.md +44 -10
- package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
- package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
- package/skills/sf-view-gen/scripts/validate.mjs +784 -32
- package/skills/storefront-workflow/SKILL.md +116 -0
- package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
- package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
- package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
- package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
- package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
- 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
|
+
}
|