@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,843 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Inspect / plan / apply boundary for the composition engine (E1 tasks 4.1 and
|
|
3
|
+
// 4.3; the remaining 4.x tasks harden each branch).
|
|
4
|
+
//
|
|
5
|
+
// `plan` is read-only against the project: it reduces the operation envelope
|
|
6
|
+
// through `operations.mjs`, records before/after identity fingerprints, and (when
|
|
7
|
+
// `--out` is given) writes only the plan artifact. `apply` resolves the current
|
|
8
|
+
// state against the recorded before/after fingerprints under a local lock, then
|
|
9
|
+
// writes derived files and the manifest last, rolling back every touched file on
|
|
10
|
+
// a handled failure. The legacy `view`/`feature` aliases reduce and commit
|
|
11
|
+
// through the same code (`executeOperations`), so alias and plan/apply agree.
|
|
12
|
+
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import {
|
|
15
|
+
closeSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
mkdtempSync,
|
|
19
|
+
openSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
realpathSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
statSync,
|
|
26
|
+
unlinkSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from 'node:fs'
|
|
29
|
+
import { cp, mkdir, readFile, rm } from 'node:fs/promises'
|
|
30
|
+
import { tmpdir } from 'node:os'
|
|
31
|
+
import path from 'node:path'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
import { getFeature } from '../recipes/validate.mjs'
|
|
34
|
+
import { classifyFile } from './file-classes.mjs'
|
|
35
|
+
import { manifestPath, readManifest, writeManifest } from './manifest.mjs'
|
|
36
|
+
import { parseOperationsEnvelope, reduceManifest } from './operations.mjs'
|
|
37
|
+
import { detectOwnershipConflicts } from './ownership-conflict.mjs'
|
|
38
|
+
import { customizedViewFiles, reconcile, removeFeatureFiles, removeViewFiles } from './reconcile.mjs'
|
|
39
|
+
|
|
40
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
41
|
+
const PKG_ROOT = path.resolve(here, '..')
|
|
42
|
+
|
|
43
|
+
export const PLAN_FORMAT_VERSION = 1
|
|
44
|
+
|
|
45
|
+
const SKIP_DIRS = new Set(['node_modules', '.next', '.git', '.turbo', '.yarn', 'dist', 'build'])
|
|
46
|
+
const SECRET_BASENAME = [/^\.env($|\.)/, /\.pem$/i, /\.key$/i]
|
|
47
|
+
|
|
48
|
+
function isPlainObject(value) {
|
|
49
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasOwn(object, key) {
|
|
53
|
+
return Object.prototype.hasOwnProperty.call(object, key)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function diagnostic(code, message, extra = {}) {
|
|
57
|
+
return { code, severity: 'error', message, ...extra }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function preflightDiagnostics(errors) {
|
|
61
|
+
return errors.map((message) => diagnostic('E_INVALID_COMPOSITION', message))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stableStringify(value) {
|
|
65
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
66
|
+
if (isPlainObject(value)) {
|
|
67
|
+
const keys = Object.keys(value).sort()
|
|
68
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`
|
|
69
|
+
}
|
|
70
|
+
return JSON.stringify(value)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sha256(text) {
|
|
74
|
+
return createHash('sha256').update(text).digest('hex')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sha256File(abs) {
|
|
78
|
+
return createHash('sha256').update(readFileSync(abs)).digest('hex')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isSecret(rel) {
|
|
82
|
+
const base = path.basename(rel)
|
|
83
|
+
return SECRET_BASENAME.some((re) => re.test(base))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Copy a project tree (e.g. `app/` or `lib/`) into a staging directory while
|
|
88
|
+
* excluding secret-bearing files (`.env`, `.env.*`, `.env.local`, `*.pem`,
|
|
89
|
+
* `*.key`). `fs.cp`'s `filter` runs before a file is read, so a credential is
|
|
90
|
+
* never copied — and therefore never written to the OS temp dir — even though
|
|
91
|
+
* the fingerprint domain already skips it (E1 task 4.13). Exported for the
|
|
92
|
+
* regression test; production callers use it through `withStagedProject`.
|
|
93
|
+
*/
|
|
94
|
+
export async function copyTreeExcludingSecrets(from, to) {
|
|
95
|
+
await cp(from, to, { recursive: true, filter: (src) => !isSecret(src) })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function walkFiles(dir, base = dir, out = []) {
|
|
99
|
+
if (!existsSync(dir)) return out
|
|
100
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
101
|
+
if (entry.isDirectory()) {
|
|
102
|
+
if (SKIP_DIRS.has(entry.name)) continue
|
|
103
|
+
walkFiles(path.join(dir, entry.name), base, out)
|
|
104
|
+
} else {
|
|
105
|
+
out.push(path.relative(base, path.join(dir, entry.name)).split(path.sep).join('/'))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Classify a project-relative file into the identity domain's derived vs
|
|
112
|
+
// user-owned buckets, reusing the single file-classification rules.
|
|
113
|
+
function classifyProjectRel(rel, name) {
|
|
114
|
+
if (rel.startsWith('lib/')) {
|
|
115
|
+
const viewsPrefix = `lib/${name}/views/`
|
|
116
|
+
const featuresPrefix = `lib/${name}/features/`
|
|
117
|
+
if (rel.startsWith(viewsPrefix)) return classifyFile(`views/${rel.slice(viewsPrefix.length)}`)
|
|
118
|
+
if (rel.startsWith(featuresPrefix)) {
|
|
119
|
+
return classifyFile(`features/${rel.slice(featuresPrefix.length)}`)
|
|
120
|
+
}
|
|
121
|
+
return classifyFile(`app/${rel}`)
|
|
122
|
+
}
|
|
123
|
+
if (rel.startsWith('app/')) return classifyFile(`app/app/${rel.slice('app/'.length)}`)
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The generator's meaningful inputs: the CLI entry, the generation scripts, the
|
|
128
|
+
// recipe/config registry (archetypes, features, sections, catalog, schemas), and
|
|
129
|
+
// the authoring templates/assemblies. Hashing the whole set — not only
|
|
130
|
+
// `scripts/` — keeps a template, registry, or archetype change from
|
|
131
|
+
// masquerading as an unchanged generator (E1 task 4.4).
|
|
132
|
+
const GENERATOR_ROOTS = ['bin', 'scripts', 'recipes', 'assets/templates']
|
|
133
|
+
|
|
134
|
+
// Consumer-side installed-toolset evidence. The install record is normalized to
|
|
135
|
+
// drop its per-install `installedAt` timestamp (nonsemantic: it changes on every
|
|
136
|
+
// install) while keeping `pinning` (the semantic declared/resolved/asset BOM).
|
|
137
|
+
const INSTALL_RECORD = path.join('.opencode', 'storefront.json')
|
|
138
|
+
|
|
139
|
+
function sha256FileOrNull(abs) {
|
|
140
|
+
return existsSync(abs) && statSync(abs).isFile() ? sha256File(abs) : null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hashRoots(rootDir, relRoots) {
|
|
144
|
+
const entries = []
|
|
145
|
+
for (const relRoot of relRoots) {
|
|
146
|
+
const abs = path.join(rootDir, relRoot)
|
|
147
|
+
if (!existsSync(abs)) continue
|
|
148
|
+
if (statSync(abs).isFile()) {
|
|
149
|
+
entries.push(`${relRoot}\0${sha256File(abs)}`)
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
for (const rel of walkFiles(abs)) {
|
|
153
|
+
entries.push(`${relRoot}/${rel}\0${sha256File(path.join(abs, rel))}`)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
entries.sort()
|
|
157
|
+
return sha256(entries.join('\n'))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readInstallRecord(projectDir) {
|
|
161
|
+
const abs = path.join(projectDir, INSTALL_RECORD)
|
|
162
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) return null
|
|
163
|
+
let parsed
|
|
164
|
+
try {
|
|
165
|
+
parsed = JSON.parse(readFileSync(abs, 'utf8'))
|
|
166
|
+
} catch {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
if (!isPlainObject(parsed)) return null
|
|
170
|
+
const normalized = { ...parsed }
|
|
171
|
+
delete normalized.installedAt
|
|
172
|
+
return normalized
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The pinned toolkit/toolset identity (Decision 14). `catalog` and `generator`
|
|
177
|
+
* are the package's semantic catalog and generation-authoring sources; `toolset`
|
|
178
|
+
* is the consumer's actual installed-toolset evidence (package manifest,
|
|
179
|
+
* lockfile, and the `installedAt`-normalized install record). An absent consumer
|
|
180
|
+
* file is recorded as `null`, so no phantom pin is emitted.
|
|
181
|
+
*/
|
|
182
|
+
export function computePins({ pkgRoot = PKG_ROOT, projectDir } = {}) {
|
|
183
|
+
return {
|
|
184
|
+
catalog: sha256File(path.join(pkgRoot, 'recipes', 'catalog.json')),
|
|
185
|
+
generator: hashRoots(pkgRoot, GENERATOR_ROOTS),
|
|
186
|
+
toolset: sha256(
|
|
187
|
+
stableStringify({
|
|
188
|
+
packageJson: sha256FileOrNull(path.join(projectDir, 'package.json')),
|
|
189
|
+
lockfile: sha256FileOrNull(path.join(projectDir, 'yarn.lock')),
|
|
190
|
+
installRecord: readInstallRecord(projectDir),
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The canonical identity domain (Decision 14): manifest, pins, derived and
|
|
198
|
+
* user-owned file-state hashes, and the canonical operations. Secret-bearing
|
|
199
|
+
* files are excluded by construction, so a fingerprint never absorbs `.env*`,
|
|
200
|
+
* `*.pem`, or `*.key`. Returns `{ identity, hash }`.
|
|
201
|
+
*/
|
|
202
|
+
export function projectIdentity(
|
|
203
|
+
projectDir,
|
|
204
|
+
manifest,
|
|
205
|
+
operations,
|
|
206
|
+
pins = computePins({ projectDir }),
|
|
207
|
+
) {
|
|
208
|
+
const derived = {}
|
|
209
|
+
const userOwned = {}
|
|
210
|
+
const name = typeof manifest?.name === 'string' ? manifest.name : ''
|
|
211
|
+
for (const root of ['app', 'lib']) {
|
|
212
|
+
for (const rel of walkFiles(path.join(projectDir, root))) {
|
|
213
|
+
const projectRel = `${root}/${rel}`
|
|
214
|
+
if (isSecret(projectRel)) continue
|
|
215
|
+
const abs = path.join(projectDir, projectRel)
|
|
216
|
+
const fileClass = classifyProjectRel(projectRel, name)
|
|
217
|
+
const target = fileClass === 'derived' ? derived : userOwned
|
|
218
|
+
target[projectRel] = sha256File(abs)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const identity = {
|
|
222
|
+
formatVersion: PLAN_FORMAT_VERSION,
|
|
223
|
+
manifest,
|
|
224
|
+
pins,
|
|
225
|
+
derived,
|
|
226
|
+
userOwned,
|
|
227
|
+
operations,
|
|
228
|
+
}
|
|
229
|
+
return { identity, hash: sha256(stableStringify(identity)) }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function resultEnvelope(command, ok, exitCode, diagnostics = [], result = null) {
|
|
233
|
+
return {
|
|
234
|
+
formatVersion: PLAN_FORMAT_VERSION,
|
|
235
|
+
command,
|
|
236
|
+
ok,
|
|
237
|
+
exitCode,
|
|
238
|
+
diagnostics,
|
|
239
|
+
result,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The stable diagnostic for an unexpected internal failure (E1 task 4.11). It
|
|
244
|
+
// carries no stack, no absolute path, and no captured value — only the error
|
|
245
|
+
// class name — so the envelope is safe to print even when the underlying error
|
|
246
|
+
// message embeds a secret or a machine path. The raw error is logged to stderr
|
|
247
|
+
// (sanitized) by `runGuarded`, so it is never swallowed.
|
|
248
|
+
export function unexpectedDiagnostic(error) {
|
|
249
|
+
return {
|
|
250
|
+
code: 'E_UNEXPECTED',
|
|
251
|
+
severity: 'error',
|
|
252
|
+
message: 'unexpected internal error',
|
|
253
|
+
detail: { name: error instanceof Error ? error.name : 'Error' },
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Replace the project root and the OS temp root with stable placeholders before
|
|
258
|
+
// a message reaches stderr, so an unexpected error cannot leak an absolute
|
|
259
|
+
// machine path even in the log.
|
|
260
|
+
function sanitizeForLog(text, projectDir) {
|
|
261
|
+
let out = text
|
|
262
|
+
if (projectDir) out = out.split(projectDir).join('<project>')
|
|
263
|
+
out = out.split(tmpdir()).join('<tmp>')
|
|
264
|
+
return out
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Run a plan/apply body and convert an *unexpected* throw into an exit-2
|
|
268
|
+
// envelope instead of an unhandled rejection. Handled failures already return
|
|
269
|
+
// their own exit-1 envelope from inside the body, so they never reach here.
|
|
270
|
+
async function runGuarded(command, projectDir, body) {
|
|
271
|
+
try {
|
|
272
|
+
return await body()
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const name = error instanceof Error ? error.name : 'Error'
|
|
275
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
276
|
+
console.error(
|
|
277
|
+
`storefront ${command}: unexpected internal error (${name}): ${sanitizeForLog(message, projectDir)}`,
|
|
278
|
+
)
|
|
279
|
+
return {
|
|
280
|
+
envelope: resultEnvelope(command, false, 2, [unexpectedDiagnostic(error)]),
|
|
281
|
+
exitCode: 2,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function diffManifests(before, after) {
|
|
287
|
+
return { before, after }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function diffFileState(before, after) {
|
|
291
|
+
const added = []
|
|
292
|
+
const removed = []
|
|
293
|
+
const changed = []
|
|
294
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
|
|
295
|
+
for (const key of [...keys].sort()) {
|
|
296
|
+
if (!hasOwn(before, key)) added.push(key)
|
|
297
|
+
else if (!hasOwn(after, key)) removed.push(key)
|
|
298
|
+
else if (before[key] !== after[key]) changed.push(key)
|
|
299
|
+
}
|
|
300
|
+
return { added, removed, changed }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Capture the prior bytes of every file the attempt will touch plus the prior
|
|
304
|
+
// existence of each ancestor directory under the project. Recording directories
|
|
305
|
+
// is what lets a rollback remove the directories a create-only attempt made,
|
|
306
|
+
// instead of leaving orphaned empty dirs behind a restored tree (E1 task 4.7).
|
|
307
|
+
function takeSnapshot(projectDir, absPaths) {
|
|
308
|
+
const files = new Map()
|
|
309
|
+
const dirs = new Map()
|
|
310
|
+
for (const abs of absPaths) {
|
|
311
|
+
if (existsSync(abs) && statSync(abs).isFile()) {
|
|
312
|
+
files.set(abs, { existed: true, content: readFileSync(abs) })
|
|
313
|
+
} else {
|
|
314
|
+
files.set(abs, { existed: false })
|
|
315
|
+
}
|
|
316
|
+
let dir = path.dirname(abs)
|
|
317
|
+
while (dir !== projectDir && dir.startsWith(`${projectDir}${path.sep}`)) {
|
|
318
|
+
if (!dirs.has(dir)) dirs.set(dir, existsSync(dir))
|
|
319
|
+
dir = path.dirname(dir)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { files, dirs }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function restoreSnapshot(snapshot) {
|
|
326
|
+
for (const [abs, prior] of snapshot.files) {
|
|
327
|
+
if (prior.existed) {
|
|
328
|
+
mkdirSync(path.dirname(abs), { recursive: true })
|
|
329
|
+
writeFileSync(abs, prior.content)
|
|
330
|
+
} else if (existsSync(abs)) {
|
|
331
|
+
rmSync(abs, { force: true })
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Remove directories the attempt created, deepest first, once empty. A dir
|
|
335
|
+
// that held a preexisting file is not in this set, so prior structure stays.
|
|
336
|
+
const created = [...snapshot.dirs.entries()]
|
|
337
|
+
.filter(([, existed]) => !existed)
|
|
338
|
+
.map(([dir]) => dir)
|
|
339
|
+
.sort((a, b) => b.length - a.length)
|
|
340
|
+
for (const dir of created) {
|
|
341
|
+
if (existsSync(dir) && statSync(dir).isDirectory() && readdirSync(dir).length === 0) {
|
|
342
|
+
rmSync(dir, { recursive: true, force: true })
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Remove files for explicitly removed views/features, reconcile derived files
|
|
348
|
+
// to the desired manifest, then write the manifest LAST (Decision 7). Returns
|
|
349
|
+
// the changed derived files and the preserved customized files.
|
|
350
|
+
//
|
|
351
|
+
// `inject(phase, context)` is a controlled test seam (E1 task 4.7): production
|
|
352
|
+
// callers pass nothing, so it is inert in the CLI. A test throws from it at a
|
|
353
|
+
// named phase — `after-derived-writes` (writes and deletions have landed, the
|
|
354
|
+
// manifest is not yet published), `before-manifest-write`, or
|
|
355
|
+
// `after-manifest-write` — to prove the handled-failure rollback. It is never
|
|
356
|
+
// read from the environment, so an untrusted env cannot arm it.
|
|
357
|
+
async function commitReduction(projectDir, reduction, { snapshot = false, inject = null } = {}) {
|
|
358
|
+
const dry = await reconcile(projectDir, reduction.manifest, { dryRun: true })
|
|
359
|
+
if (!dry.ok) return { ok: false, diagnostics: preflightDiagnostics(dry.errors) }
|
|
360
|
+
|
|
361
|
+
const removalRemoved = []
|
|
362
|
+
const preserved = []
|
|
363
|
+
for (const rem of reduction.removals) {
|
|
364
|
+
// A `section.remove` drops an editorial instance from the manifest but keeps
|
|
365
|
+
// the instance's user-owned content: `reconcile` never rewrites the view's
|
|
366
|
+
// `content.ts`, so the removal only reports that file's location as
|
|
367
|
+
// preserved (E3 task 3.2, CU-15). Nothing is deleted or rewritten here.
|
|
368
|
+
if (rem.kind === 'section') {
|
|
369
|
+
const abs = path.join(projectDir, rem.contentPath)
|
|
370
|
+
if (existsSync(abs) && statSync(abs).isFile()) preserved.push(rem.contentPath)
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
const result =
|
|
374
|
+
rem.kind === 'view'
|
|
375
|
+
? await removeViewFiles(projectDir, reduction.manifest, rem.name, { dryRun: true })
|
|
376
|
+
: await removeFeatureFiles(projectDir, reduction.manifest, rem.name, { dryRun: true })
|
|
377
|
+
removalRemoved.push(...result.removed)
|
|
378
|
+
preserved.push(...result.preserved)
|
|
379
|
+
}
|
|
380
|
+
for (const rem of reduction.removals) {
|
|
381
|
+
if (rem.kind !== 'feature') continue
|
|
382
|
+
for (const mount of getFeature(rem.name)?.mounts ?? []) {
|
|
383
|
+
for (const rel of await customizedViewFiles(projectDir, reduction.manifest, mount.host)) {
|
|
384
|
+
if (!preserved.includes(rel)) preserved.push(rel)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const touched = new Set([
|
|
390
|
+
...dry.changed.map((rel) => path.resolve(projectDir, rel)),
|
|
391
|
+
...removalRemoved.map((rel) => path.resolve(projectDir, rel)),
|
|
392
|
+
manifestPath(projectDir),
|
|
393
|
+
])
|
|
394
|
+
const snapshotState = snapshot ? takeSnapshot(projectDir, [...touched]) : null
|
|
395
|
+
try {
|
|
396
|
+
for (const rem of reduction.removals) {
|
|
397
|
+
if (rem.kind === 'view') await removeViewFiles(projectDir, reduction.manifest, rem.name)
|
|
398
|
+
if (rem.kind === 'feature') await removeFeatureFiles(projectDir, reduction.manifest, rem.name)
|
|
399
|
+
}
|
|
400
|
+
const result = await reconcile(projectDir, reduction.manifest)
|
|
401
|
+
if (!result.ok) throw new Error(result.errors.join('; '))
|
|
402
|
+
inject?.('after-derived-writes', { projectDir, manifest: reduction.manifest })
|
|
403
|
+
inject?.('before-manifest-write', {
|
|
404
|
+
projectDir,
|
|
405
|
+
manifest: reduction.manifest,
|
|
406
|
+
manifestPath: manifestPath(projectDir),
|
|
407
|
+
})
|
|
408
|
+
writeManifest(projectDir, reduction.manifest)
|
|
409
|
+
inject?.('after-manifest-write', {
|
|
410
|
+
projectDir,
|
|
411
|
+
manifest: reduction.manifest,
|
|
412
|
+
manifestPath: manifestPath(projectDir),
|
|
413
|
+
})
|
|
414
|
+
return { ok: true, changed: result.changed, preserved: [...new Set(preserved)] }
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (snapshotState) restoreSnapshot(snapshotState)
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
diagnostics: [
|
|
420
|
+
diagnostic('E_WRITE_FAILED', error instanceof Error ? error.message : String(error)),
|
|
421
|
+
],
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Reduce and commit a set of operations against a project in one step. This is
|
|
428
|
+
* the shared path behind the `view`/`feature` aliases and the apply command's
|
|
429
|
+
* write step. Returns `{ ok, diagnostics, changed, preserved, manifest }`.
|
|
430
|
+
*/
|
|
431
|
+
export async function executeOperations(
|
|
432
|
+
projectDir,
|
|
433
|
+
manifest,
|
|
434
|
+
operations,
|
|
435
|
+
{ force = true, inject = null, registry = null } = {},
|
|
436
|
+
) {
|
|
437
|
+
const reduction = reduceManifest(manifest, operations, { force, registry })
|
|
438
|
+
if (!reduction.ok) return { ok: false, diagnostics: reduction.diagnostics }
|
|
439
|
+
// Ownership conflicts are a write-boundary check (E1 task 5.1 / CU-08): the
|
|
440
|
+
// legacy aliases must reject a customized contract that no longer provides a
|
|
441
|
+
// selected variant's data shape before any file is touched, exactly like
|
|
442
|
+
// plan/apply.
|
|
443
|
+
const conflicts = detectOwnershipConflicts(projectDir, reduction.manifest)
|
|
444
|
+
if (!conflicts.ok) return { ok: false, diagnostics: conflicts.diagnostics }
|
|
445
|
+
const commit = await commitReduction(projectDir, reduction, { snapshot: true, inject })
|
|
446
|
+
if (!commit.ok) return { ok: false, diagnostics: commit.diagnostics }
|
|
447
|
+
return {
|
|
448
|
+
ok: true,
|
|
449
|
+
diagnostics: [],
|
|
450
|
+
changed: commit.changed,
|
|
451
|
+
preserved: commit.preserved,
|
|
452
|
+
manifest: reduction.manifest,
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function withStagedProject(projectDir, reduction, fn) {
|
|
457
|
+
const stage = mkdtempSync(path.join(tmpdir(), 'cs-plan-stage-'))
|
|
458
|
+
try {
|
|
459
|
+
// Stage `app`/`lib` without secret-bearing files, so the predicted after-state
|
|
460
|
+
// is computed from the same secret-free domain as the before-state and no
|
|
461
|
+
// credential is copied into the temp dir (E1 task 4.13).
|
|
462
|
+
for (const rel of ['app', 'lib']) {
|
|
463
|
+
const from = path.join(projectDir, rel)
|
|
464
|
+
if (existsSync(from)) await copyTreeExcludingSecrets(from, path.join(stage, rel))
|
|
465
|
+
}
|
|
466
|
+
writeManifest(stage, reduction.manifest)
|
|
467
|
+
await commitReduction(stage, reduction, { snapshot: false })
|
|
468
|
+
return await fn(stage)
|
|
469
|
+
} finally {
|
|
470
|
+
rmSync(stage, { recursive: true, force: true })
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Plan a set of operations: validate the whole intended state, record the
|
|
476
|
+
* before/after identity fingerprints and diffs, and write only the `--out`
|
|
477
|
+
* artifact (atomically) when requested. Never mutates the project. Returns
|
|
478
|
+
* `{ envelope, exitCode, outWritten }`.
|
|
479
|
+
*/
|
|
480
|
+
export function planProject(projectDir, rawOperations, options = {}) {
|
|
481
|
+
return runGuarded('plan', projectDir, () =>
|
|
482
|
+
planProjectInner(projectDir, rawOperations, options),
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function planProjectInner(projectDir, rawOperations, { outPath = null, operationsPath = null, inject = null, registry = null } = {}) {
|
|
487
|
+
// Every input goes through the versioned envelope parser (E1 task 4.10): a
|
|
488
|
+
// bare operations array, a missing/wrong `formatVersion`, or a non-array
|
|
489
|
+
// `operations` is `E_INVALID_ENVELOPE` and writes nothing. `registry` selects
|
|
490
|
+
// the active operation vocabulary (defaults to E1's six); E3 passes its own so
|
|
491
|
+
// a `section.add` is accepted and an unregistered op is still rejected.
|
|
492
|
+
const parsed = parseOperationsEnvelope(rawOperations, { registry })
|
|
493
|
+
const manifest = readManifest(projectDir)
|
|
494
|
+
if (!manifest) {
|
|
495
|
+
return {
|
|
496
|
+
envelope: resultEnvelope('plan', false, 1, [
|
|
497
|
+
...parsed.diagnostics,
|
|
498
|
+
diagnostic('E_NO_MANIFEST', 'No storefront.config.json found in the project.'),
|
|
499
|
+
]),
|
|
500
|
+
exitCode: 1,
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Validate every parsed operation against the resulting complete state even
|
|
504
|
+
// when some operations failed to parse, so parse-level and semantic errors are
|
|
505
|
+
// all reported together (E1 task 4.3). Any diagnostic means no plan is emitted
|
|
506
|
+
// and no file is written. The active `registry` dispatches E3's namespaced
|
|
507
|
+
// reducers through the same engine.
|
|
508
|
+
const reduction = reduceManifest(manifest, parsed.operations, { force: true, registry })
|
|
509
|
+
const diagnostics = [...parsed.diagnostics, ...reduction.diagnostics]
|
|
510
|
+
if (diagnostics.length > 0) {
|
|
511
|
+
return { envelope: resultEnvelope('plan', false, 1, diagnostics), exitCode: 1 }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Ownership conflicts are detected before staging so `plan` reports the file
|
|
515
|
+
// and differing fields and emits no plan; it stays read-only against the
|
|
516
|
+
// project (E1 task 5.1 / CU-08).
|
|
517
|
+
const conflicts = detectOwnershipConflicts(projectDir, reduction.manifest)
|
|
518
|
+
if (!conflicts.ok) {
|
|
519
|
+
return { envelope: resultEnvelope('plan', false, 1, conflicts.diagnostics), exitCode: 1 }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Controlled programmatic seam (E1 task 4.11): production callers pass
|
|
523
|
+
// nothing, so it is inert in the CLI and is never read from the environment.
|
|
524
|
+
// A test throws here to exercise the unexpected-internal-error branch (exit
|
|
525
|
+
// 2) on the real engine.
|
|
526
|
+
inject?.('plan-after-validation', { projectDir, operations: parsed.operations })
|
|
527
|
+
|
|
528
|
+
// Compute the pins once and reuse them for both states so the after-state
|
|
529
|
+
// retains the same catalog/generator/toolset identity as the before-state.
|
|
530
|
+
const pins = computePins({ projectDir })
|
|
531
|
+
const before = projectIdentity(projectDir, manifest, parsed.operations, pins)
|
|
532
|
+
const after = await withStagedProject(projectDir, reduction, (stage) =>
|
|
533
|
+
projectIdentity(stage, reduction.manifest, parsed.operations, pins),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
const result = {
|
|
537
|
+
beforeFingerprint: before.hash,
|
|
538
|
+
afterFingerprint: after.hash,
|
|
539
|
+
operations: parsed.operations,
|
|
540
|
+
manifestDiff: diffManifests(manifest, reduction.manifest),
|
|
541
|
+
fileDiff: {
|
|
542
|
+
derived: diffFileState(before.identity.derived, after.identity.derived),
|
|
543
|
+
userOwned: diffFileState(before.identity.userOwned, after.identity.userOwned),
|
|
544
|
+
},
|
|
545
|
+
}
|
|
546
|
+
const envelope = resultEnvelope('plan', true, 0, [], result)
|
|
547
|
+
|
|
548
|
+
let outWritten = false
|
|
549
|
+
if (outPath) {
|
|
550
|
+
const safety = assertSafeOutPath(projectDir, outPath, operationsPath)
|
|
551
|
+
if (!safety.ok) {
|
|
552
|
+
// A refused path inside the project is reported as a project-root-relative
|
|
553
|
+
// POSIX path (E1 task 4.11); a path outside the project has no such path
|
|
554
|
+
// and is reported by message only.
|
|
555
|
+
const extra = safety.path ? { path: safety.path } : {}
|
|
556
|
+
return {
|
|
557
|
+
envelope: resultEnvelope('plan', false, 1, [
|
|
558
|
+
diagnostic('E_OUT_PATH_REFUSED', safety.message, extra),
|
|
559
|
+
]),
|
|
560
|
+
exitCode: 1,
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
atomicWrite(resolveOut(outPath), `${JSON.stringify(envelope, null, 2)}\n`, inject)
|
|
564
|
+
outWritten = true
|
|
565
|
+
}
|
|
566
|
+
return { envelope, exitCode: 0, outWritten }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// `--out` resolves against the consumer cwd, not the project root (Decision 12).
|
|
570
|
+
function resolveOut(outPath) {
|
|
571
|
+
return path.resolve(outPath)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const REFUSED_TOP_SEGMENTS = new Set([
|
|
575
|
+
'app',
|
|
576
|
+
'public',
|
|
577
|
+
'lib',
|
|
578
|
+
'node_modules',
|
|
579
|
+
'.next',
|
|
580
|
+
'.git',
|
|
581
|
+
'.opencode',
|
|
582
|
+
'.yarn',
|
|
583
|
+
'skills',
|
|
584
|
+
'commands',
|
|
585
|
+
'recipes',
|
|
586
|
+
'scripts',
|
|
587
|
+
])
|
|
588
|
+
const REFUSED_BASENAMES = new Set([
|
|
589
|
+
'package.json',
|
|
590
|
+
'package-lock.json',
|
|
591
|
+
'yarn.lock',
|
|
592
|
+
'pnpm-lock.yaml',
|
|
593
|
+
'pnpm-workspace.yaml',
|
|
594
|
+
'storefront.config.json',
|
|
595
|
+
'storefront.json',
|
|
596
|
+
'.npmrc',
|
|
597
|
+
'.yarnrc',
|
|
598
|
+
'.yarnrc.yml',
|
|
599
|
+
'components.json',
|
|
600
|
+
'tsconfig.json',
|
|
601
|
+
'jsconfig.json',
|
|
602
|
+
'next.config.js',
|
|
603
|
+
'next.config.mjs',
|
|
604
|
+
'next.config.ts',
|
|
605
|
+
])
|
|
606
|
+
|
|
607
|
+
// Resolve the canonical path of a possibly non-existent target by realpath-ing
|
|
608
|
+
// its longest existing ancestor and re-appending the missing tail. `realpathSync`
|
|
609
|
+
// alone throws `ENOENT` for a target whose *parent* is a symlink into a protected
|
|
610
|
+
// tree (e.g. `link -> project/lib`, `--out link/plan.json`), so the lexical path
|
|
611
|
+
// would otherwise look outside the project and be allowed while the write lands
|
|
612
|
+
// in the owned tree. Returns the input unchanged when no ancestor can be resolved.
|
|
613
|
+
function realpathThroughExistingAncestor(abs) {
|
|
614
|
+
const missing = []
|
|
615
|
+
let current = abs
|
|
616
|
+
while (!existsSync(current)) {
|
|
617
|
+
const parent = path.dirname(current)
|
|
618
|
+
if (parent === current) return abs
|
|
619
|
+
missing.unshift(path.basename(current))
|
|
620
|
+
current = parent
|
|
621
|
+
}
|
|
622
|
+
let resolved
|
|
623
|
+
try {
|
|
624
|
+
resolved = realpathSync(current)
|
|
625
|
+
} catch {
|
|
626
|
+
return abs
|
|
627
|
+
}
|
|
628
|
+
return missing.length > 0 ? path.join(resolved, ...missing) : resolved
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Refuse an `--out` path that resolves to an owned, derived, secret-bearing, or
|
|
633
|
+
* installation file/path tree, a symlink-resolved equivalent, or the operation
|
|
634
|
+
* input file (Decision 12). Returns `{ ok }` or
|
|
635
|
+
* `{ ok: false, message, path? }`, where `path` is the project-root-relative
|
|
636
|
+
* POSIX path of the refused candidate when it lies inside the project (a
|
|
637
|
+
* diagnostic path is project-root-relative POSIX; E1 task 4.11).
|
|
638
|
+
*/
|
|
639
|
+
export function assertSafeOutPath(projectDir, outPath, operationsPath = null) {
|
|
640
|
+
const abs = path.resolve(outPath)
|
|
641
|
+
// Both the lexical path and its symlink-resolved equivalent are candidates, so
|
|
642
|
+
// a link whose target is owned/derived/secret/installation (including a
|
|
643
|
+
// non-existent child reached through a symlinked parent) is refused even when
|
|
644
|
+
// the link itself lives outside the project.
|
|
645
|
+
const candidates = [abs]
|
|
646
|
+
const resolved = realpathThroughExistingAncestor(abs)
|
|
647
|
+
if (resolved !== abs) candidates.push(resolved)
|
|
648
|
+
|
|
649
|
+
// The operations input must never be overwritten by `--out`. This is an input
|
|
650
|
+
// collision check, NOT a project-membership check, so it is compared directly
|
|
651
|
+
// against the out path (lexical and symlink-resolved) rather than by adding
|
|
652
|
+
// the input to the candidate tree list — which would otherwise refuse every
|
|
653
|
+
// safe `--out` because the input always equals itself.
|
|
654
|
+
if (operationsPath) {
|
|
655
|
+
const opsLexical = path.resolve(operationsPath)
|
|
656
|
+
const opsResolved = realpathThroughExistingAncestor(opsLexical)
|
|
657
|
+
if (candidates.includes(opsLexical) || candidates.includes(opsResolved)) {
|
|
658
|
+
return { ok: false, message: '--out must not overwrite the operations input.' }
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
for (const candidate of candidates) {
|
|
663
|
+
const rel = path.relative(projectDir, candidate)
|
|
664
|
+
const inside = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
|
665
|
+
const projectRel = inside && rel !== '' ? rel.split(path.sep).join('/') : null
|
|
666
|
+
const refuse = (message) =>
|
|
667
|
+
projectRel !== null ? { ok: false, message, path: projectRel } : { ok: false, message }
|
|
668
|
+
if (rel === '') return refuse('--out must not resolve to the project root.')
|
|
669
|
+
// Owned/derived/installation trees are project-relative: a path *outside*
|
|
670
|
+
// the project is a safe explicit artifact even if a segment shares a name
|
|
671
|
+
// (e.g. `/tmp/lib/plan.json`). Only a candidate that is genuinely inside the
|
|
672
|
+
// project — lexically or through a symlink — is checked against the tree set.
|
|
673
|
+
if (inside) {
|
|
674
|
+
const segments = rel.split(path.sep)
|
|
675
|
+
if (segments.some((segment) => REFUSED_TOP_SEGMENTS.has(segment))) {
|
|
676
|
+
return refuse(`--out resolves inside the owned tree "${segments[0]}".`)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const base = path.basename(candidate)
|
|
680
|
+
if (REFUSED_BASENAMES.has(base) || SECRET_BASENAME.some((re) => re.test(base))) {
|
|
681
|
+
return refuse(`--out resolves to the owned or secret-bearing file "${base}".`)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return { ok: true }
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Atomic artifact write: a same-directory temp file plus `rename` so a reader
|
|
688
|
+
// never observes a partial plan, and the pre-existing artifact stays byte-exact
|
|
689
|
+
// until the rename commits. On any failure between the temp write and the
|
|
690
|
+
// rename, the attempt's OWN temp file is removed; pre-existing directories and
|
|
691
|
+
// files are never touched. `inject(phase, context)` is a controlled test seam
|
|
692
|
+
// (never read from the environment): production callers pass nothing, and a test
|
|
693
|
+
// throws from `plan-after-temp-write` to prove the cleanup and byte-stability.
|
|
694
|
+
function atomicWrite(abs, text, inject = null) {
|
|
695
|
+
mkdirSync(path.dirname(abs), { recursive: true })
|
|
696
|
+
const tmp = `${abs}.tmp-${process.pid}-${Date.now()}`
|
|
697
|
+
try {
|
|
698
|
+
writeFileSync(tmp, text)
|
|
699
|
+
inject?.('plan-after-temp-write', { outPath: abs, tmpPath: tmp })
|
|
700
|
+
renameSync(tmp, abs)
|
|
701
|
+
} catch (error) {
|
|
702
|
+
try {
|
|
703
|
+
unlinkSync(tmp)
|
|
704
|
+
} catch {
|
|
705
|
+
// The temp may never have been created, or may already be gone.
|
|
706
|
+
}
|
|
707
|
+
throw error
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Exclusive-create retry budget for the local apply lock (E1 task 4.6). The
|
|
712
|
+
// wait must comfortably exceed a normal apply so a concurrent run observes the
|
|
713
|
+
// winner's after-state and resolves to a verified no-op instead of giving up
|
|
714
|
+
// with `E_LOCK_HELD`. A lock left by a crashed process is NOT stolen: there is no
|
|
715
|
+
// PID probing and no liveness check, so a stale lock can only be cleared by a
|
|
716
|
+
// human removing `.opencode/.storefront-apply.lock` (the lock this worker
|
|
717
|
+
// created, never another process's). Interruption recovery is `sync`, which
|
|
718
|
+
// repairs the manifest/derived divergence and does not need or release the lock
|
|
719
|
+
// (E1 task 4.8).
|
|
720
|
+
const LOCK_RETRIES = 300
|
|
721
|
+
const LOCK_WAIT_MS = 20
|
|
722
|
+
|
|
723
|
+
function sleepSync(ms) {
|
|
724
|
+
const buffer = new Int32Array(new SharedArrayBuffer(4))
|
|
725
|
+
Atomics.wait(buffer, 0, 0, ms)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function acquireLock(projectDir) {
|
|
729
|
+
const lockPath = path.join(projectDir, '.opencode', '.storefront-apply.lock')
|
|
730
|
+
mkdirSync(path.dirname(lockPath), { recursive: true })
|
|
731
|
+
for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) {
|
|
732
|
+
try {
|
|
733
|
+
const fd = openSync(lockPath, 'wx')
|
|
734
|
+
writeFileSync(fd, String(process.pid))
|
|
735
|
+
closeSync(fd)
|
|
736
|
+
return { ok: true, lockPath }
|
|
737
|
+
} catch (error) {
|
|
738
|
+
if (error.code !== 'EEXIST') return { ok: false, message: error.message }
|
|
739
|
+
sleepSync(LOCK_WAIT_MS)
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return { ok: false, message: 'another apply holds the project lock' }
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function releaseLock(lock) {
|
|
746
|
+
if (!lock?.lockPath) return
|
|
747
|
+
try {
|
|
748
|
+
unlinkSync(lock.lockPath)
|
|
749
|
+
} catch {
|
|
750
|
+
// A lock already gone is not an error.
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Apply a recorded plan. Under a local lock, re-evaluate the current state into
|
|
756
|
+
* three branches: matching the before-state applies; matching the recorded
|
|
757
|
+
* after-state is a verified no-op; matching neither is `E_STALE_PLAN`. All
|
|
758
|
+
* validation happens before any write, and a handled write failure restores
|
|
759
|
+
* every touched file. Returns `{ envelope, exitCode }`.
|
|
760
|
+
*/
|
|
761
|
+
export function applyProject(projectDir, planArtifact, options = {}) {
|
|
762
|
+
return runGuarded('apply', projectDir, () => applyProjectInner(projectDir, planArtifact, options))
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function applyProjectInner(projectDir, planArtifact, { inject = null, registry = null } = {}) {
|
|
766
|
+
if (
|
|
767
|
+
!isPlainObject(planArtifact) ||
|
|
768
|
+
planArtifact.formatVersion !== PLAN_FORMAT_VERSION ||
|
|
769
|
+
!isPlainObject(planArtifact.result) ||
|
|
770
|
+
!Array.isArray(planArtifact.result.operations)
|
|
771
|
+
) {
|
|
772
|
+
return {
|
|
773
|
+
envelope: resultEnvelope('apply', false, 1, [
|
|
774
|
+
diagnostic('E_PLAN_INVALID', 'the plan artifact is missing a version-1 result envelope.'),
|
|
775
|
+
]),
|
|
776
|
+
exitCode: 1,
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// Controlled programmatic seam (E1 task 4.11) invoked BEFORE the handled
|
|
780
|
+
// commit region: production callers pass nothing; a test throws here to
|
|
781
|
+
// exercise the unexpected-internal-error branch (exit 2) rather than the
|
|
782
|
+
// handled-failure rollback (which is reached only through `commitReduction`).
|
|
783
|
+
inject?.('apply-before-lock', { projectDir, operations: planArtifact.result.operations })
|
|
784
|
+
const { operations, beforeFingerprint, afterFingerprint } = planArtifact.result
|
|
785
|
+
const lock = acquireLock(projectDir)
|
|
786
|
+
if (!lock.ok) {
|
|
787
|
+
return {
|
|
788
|
+
envelope: resultEnvelope('apply', false, 1, [diagnostic('E_LOCK_HELD', lock.message)]),
|
|
789
|
+
exitCode: 1,
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
try {
|
|
793
|
+
const manifest = readManifest(projectDir)
|
|
794
|
+
if (!manifest) {
|
|
795
|
+
return {
|
|
796
|
+
envelope: resultEnvelope('apply', false, 1, [
|
|
797
|
+
diagnostic('E_NO_MANIFEST', 'No storefront.config.json found in the project.'),
|
|
798
|
+
]),
|
|
799
|
+
exitCode: 1,
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const reduction = reduceManifest(manifest, operations, { force: true, registry })
|
|
803
|
+
if (!reduction.ok) {
|
|
804
|
+
return { envelope: resultEnvelope('apply', false, 1, reduction.diagnostics), exitCode: 1 }
|
|
805
|
+
}
|
|
806
|
+
// Re-check ownership conflicts under the lock, before any state resolution
|
|
807
|
+
// or write, so an apply never renders against an unprovided shape
|
|
808
|
+
// (E1 task 5.1 / CU-08).
|
|
809
|
+
const conflicts = detectOwnershipConflicts(projectDir, reduction.manifest)
|
|
810
|
+
if (!conflicts.ok) {
|
|
811
|
+
return { envelope: resultEnvelope('apply', false, 1, conflicts.diagnostics), exitCode: 1 }
|
|
812
|
+
}
|
|
813
|
+
const current = projectIdentity(projectDir, manifest, operations, computePins({ projectDir }))
|
|
814
|
+
if (current.hash === afterFingerprint) {
|
|
815
|
+
return {
|
|
816
|
+
envelope: resultEnvelope('apply', true, 0, [], { state: 'verified-noop' }),
|
|
817
|
+
exitCode: 0,
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (current.hash !== beforeFingerprint) {
|
|
821
|
+
return {
|
|
822
|
+
envelope: resultEnvelope('apply', false, 1, [
|
|
823
|
+
diagnostic('E_STALE_PLAN', 'the project state matches neither the before-state nor the after-state.'),
|
|
824
|
+
]),
|
|
825
|
+
exitCode: 1,
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
const commit = await commitReduction(projectDir, reduction, { snapshot: true, inject })
|
|
829
|
+
if (!commit.ok) {
|
|
830
|
+
return { envelope: resultEnvelope('apply', false, 1, commit.diagnostics), exitCode: 1 }
|
|
831
|
+
}
|
|
832
|
+
return {
|
|
833
|
+
envelope: resultEnvelope('apply', true, 0, [], {
|
|
834
|
+
state: 'applied',
|
|
835
|
+
changed: commit.changed,
|
|
836
|
+
preserved: commit.preserved,
|
|
837
|
+
}),
|
|
838
|
+
exitCode: 0,
|
|
839
|
+
}
|
|
840
|
+
} finally {
|
|
841
|
+
releaseLock(lock)
|
|
842
|
+
}
|
|
843
|
+
}
|