@thorprovider/storefront 0.4.0-mvp.0 → 0.4.0-mvp.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +173 -153
  2. package/VENDOR.md +69 -0
  3. package/assets/openspec/config.yaml +38 -0
  4. package/assets/templates/AGENTS.md +76 -0
  5. package/assets/templates/app/app/[[...slug]]/page.tsx +42 -1
  6. package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
  7. package/assets/templates/app/app/globals.css +5 -8
  8. package/assets/templates/app/app/layout.tsx +1 -5
  9. package/assets/templates/app/app/robots.ts +9 -3
  10. package/assets/templates/app/app/sitemap.ts +64 -5
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +70 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +15 -5
  13. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +71 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +36 -4
  16. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +82 -50
  17. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +29 -14
  19. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +31 -7
  22. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +4 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +56 -26
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +165 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +26 -7
  26. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +2 -1
  27. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  30. package/assets/templates/features/checkout/handlers.ts +6 -0
  31. package/assets/templates/features/customer-auth/state.ts +10 -1
  32. package/assets/templates/views/account/index.tsx +1 -1
  33. package/assets/templates/views/account/registry.tsx +80 -19
  34. package/assets/templates/views/account/schema.ts +1 -0
  35. package/assets/templates/views/cart/content.ts +11 -0
  36. package/assets/templates/views/cart/data.ts +23 -0
  37. package/assets/templates/views/cart/index.tsx +53 -0
  38. package/assets/templates/views/cart/props.ts +12 -0
  39. package/assets/templates/views/cart/registry.tsx +81 -0
  40. package/assets/templates/views/cart/schema.ts +52 -0
  41. package/assets/templates/views/category/assembly/assembly.json +1 -0
  42. package/assets/templates/views/category/assembly/index.skeleton.tsx +64 -6
  43. package/assets/templates/views/category/assembly/registry.skeleton.tsx +2 -1
  44. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  45. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  46. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  47. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  48. package/assets/templates/views/category/index.tsx +76 -7
  49. package/assets/templates/views/category/props.ts +7 -0
  50. package/assets/templates/views/category/registry.tsx +19 -0
  51. package/assets/templates/views/category/schema.ts +13 -0
  52. package/assets/templates/views/home/assembly/assembly.json +9 -0
  53. package/assets/templates/views/home/assembly/index.skeleton.tsx +22 -4
  54. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +1 -1
  55. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +2 -2
  56. package/assets/templates/views/home/data.ts +9 -0
  57. package/assets/templates/views/home/index.tsx +23 -5
  58. package/assets/templates/views/home/registry.tsx +2 -2
  59. package/assets/templates/views/product/data.ts +4 -2
  60. package/assets/templates/views/product/index.tsx +49 -5
  61. package/assets/templates/views/product/props.ts +7 -0
  62. package/assets/templates/views/product/registry.tsx +25 -0
  63. package/assets/templates/views/product/schema.ts +13 -0
  64. package/assets/templates/views/static/content.ts +4 -4
  65. package/assets/templates/views/static/index.tsx +1 -1
  66. package/bin/storefront.js +1231 -154
  67. package/examples/merchandising/category-led.json +37 -0
  68. package/examples/merchandising/collection-led.json +37 -0
  69. package/package.json +11 -7
  70. package/recipes/archetypes.json +9 -22
  71. package/recipes/catalog.json +40 -1
  72. package/recipes/features.json +25 -2
  73. package/recipes/recipe.schema.json +1 -1
  74. package/recipes/recipes.json +32 -30
  75. package/recipes/sections.json +10 -0
  76. package/recipes/validate.mjs +54 -0
  77. package/scripts/candidate.mjs +4 -4
  78. package/scripts/composers.mjs +25 -5
  79. package/scripts/env-file.mjs +86 -0
  80. package/scripts/file-classes.mjs +5 -0
  81. package/scripts/migrate-english-routes.mjs +276 -0
  82. package/scripts/pinning.mjs +74 -5
  83. package/scripts/plan-apply.mjs +149 -5
  84. package/scripts/reconcile.mjs +49 -11
  85. package/scripts/render-templates.mjs +97 -44
  86. package/scripts/section-config.mjs +253 -0
  87. package/scripts/starter.mjs +17 -0
  88. package/scripts/theme/color.mjs +185 -0
  89. package/scripts/theme/css-theme.mjs +233 -0
  90. package/scripts/theme/fonts.mjs +123 -0
  91. package/scripts/theme/import-export.mjs +125 -0
  92. package/scripts/theme/preset.mjs +93 -0
  93. package/scripts/theme/theme-plan.mjs +292 -0
  94. package/scripts/theme/tokens.mjs +137 -0
  95. package/scripts/view-assembly.mjs +47 -1
  96. package/skills/sf-feature/SKILL.md +32 -19
  97. package/skills/sf-setup/SKILL.md +79 -0
  98. package/skills/sf-theme-gen/SKILL.md +99 -36
  99. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  100. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  101. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  102. package/skills/sf-view-gen/SKILL.md +63 -40
  103. package/skills/sf-view-gen/assets/recipes/archetypes.json +9 -22
  104. package/skills/sf-view-gen/assets/recipes/catalog.json +40 -1
  105. package/skills/sf-view-gen/assets/recipes/features.json +25 -2
  106. package/skills/sf-view-gen/assets/recipes/recipes.json +32 -30
  107. package/skills/sf-view-gen/assets/recipes/sections.json +10 -0
  108. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  109. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  110. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  111. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  112. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  113. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  114. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  115. package/skills/sf-view-gen/references/views.md +28 -37
  116. package/skills/sf-view-gen/scripts/validate.mjs +54 -0
  117. package/skills/storefront-workflow/SKILL.md +94 -31
  118. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  119. package/CHANGELOG.md +0 -84
  120. package/assets/templates/views/user-panel/index.tsx +0 -106
  121. package/assets/templates/views/user-panel/registry.tsx +0 -54
  122. package/assets/templates/views/user-panel/schema.ts +0 -79
  123. package/commands/sf-add-view.md +0 -57
  124. package/commands/sf-feature.md +0 -28
  125. package/commands/sf-theme.md +0 -26
  126. package/commands/sf-view.md +0 -67
  127. package/skills/building-storefronts/SKILL.md +0 -178
  128. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  129. package/skills/json-render-remotion/SKILL.md +0 -111
  130. package/skills/json-render-solid/SKILL.md +0 -204
  131. package/skills/nextjs-shadcn/SKILL.md +0 -367
  132. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  133. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  134. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  135. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  136. package/skills/nextjs-shadcn/references/styling.md +0 -555
  137. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  138. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  139. package/skills/shadcn-theming/SKILL.md +0 -104
  140. package/skills/storefront-best-practices/SKILL.md +0 -421
  141. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  142. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  143. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  144. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  145. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  146. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  147. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  148. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  149. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  150. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  151. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  152. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  153. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  154. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  155. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  156. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  157. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  158. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  159. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  160. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  161. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  162. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  163. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  164. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  165. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  166. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -42,6 +42,9 @@ const RULES = [
42
42
  {
43
43
  class: FILE_CLASSES.derived,
44
44
  patterns: [
45
+ // The managed root guidance file is rendered from the package template,
46
+ // refreshed by sync, and drift-checked by doctor — never hand-edited.
47
+ 'AGENTS.md',
45
48
  'app/app/[[...slug]]/**',
46
49
  'app/lib/*/handlers.ts',
47
50
  'app/lib/*/handlers.core.ts',
@@ -55,6 +58,8 @@ const RULES = [
55
58
  'app/lib/*/cart-state.ts',
56
59
  'app/lib/*/actions.ts',
57
60
  'app/lib/*/catalog.ts',
61
+ 'app/lib/*/section-sources.ts',
62
+ 'app/lib/*/section-observation.ts',
58
63
  'app/lib/*/runtime.ts',
59
64
  'app/lib/*/registry.tsx',
60
65
  'app/lib/*/schema.ts',
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ // Opt-in migration for the retired Spanish route literals in an existing
3
+ // consumer's USER-OWNED content files (change: storefront-english-routes,
4
+ // task 3.4 / AC-EN-5.4).
5
+ //
6
+ // WHY THIS EXISTS: `storefront sync` never rewrites a user-owned file, so a
7
+ // consumer created before the English-route change keeps Spanish links in its
8
+ // own `lib/<name>/shell/content.ts` (and the user-owned static-page `path`
9
+ // declarations in `lib/<name>/views/static/content.ts`). Changing the package
10
+ // default cannot change an existing consumer's links, so this script is the
11
+ // documented, CLI-bounded, opt-in transition. It:
12
+ // * rewrites ONLY the retired route literal at the START of an `href`/`path`
13
+ // string property value (root-relative), preserving the backend handle and
14
+ // any `?query`/`#hash`,
15
+ // * ONLY inside the two user-owned content files on the allow-list,
16
+ // * ONLY when run with `--apply` (dry-run by default),
17
+ // * keeps a no-clobber backup of each file it rewrites, and writes
18
+ // transactionally (all files or none),
19
+ // * and preserves unrelated prose, comments, absolute URLs, and nested
20
+ // `/api/...` paths — it never re-parses the whole file as a grammar.
21
+ //
22
+ // Usage:
23
+ // node scripts/migrate-english-routes.mjs <project-dir> # dry run
24
+ // node scripts/migrate-english-routes.mjs <project-dir> --apply # write
25
+
26
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
27
+ import { resolve, join, relative, dirname, isAbsolute, sep } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+ import { readManifest } from './manifest.mjs'
30
+ import { assertInsideProject, atomicWrite, restoreSnapshot, takeSnapshot } from './plan-apply.mjs'
31
+
32
+ // Retired route literal -> English route literal. These are matched ONLY as the
33
+ // FIRST path segment of an `href`/`path` string property value.
34
+ export const RETIRED = [
35
+ ['/categorias', '/categories'],
36
+ ['/productos', '/products'],
37
+ ['/cuenta', '/account'],
38
+ ['/contacto', '/contact'],
39
+ ['/terminos', '/terms'],
40
+ ['/privacidad', '/privacy'],
41
+ ['/sobre-nosotros', '/about'],
42
+ ]
43
+
44
+ // A safe single-segment storefront name: no separators, no `..`, no leading dot.
45
+ const SAFE_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/
46
+ // The property keys whose string value is a route.
47
+ const ROUTE_PROP = /(?:^|[^A-Za-z0-9_$.-])(?:href|path)\s*:\s*$/
48
+ const QUOTES = new Set(["'", '"', '`'])
49
+ const BACKUP_DIR = join('.opencode', 'backups', 'english-routes')
50
+
51
+ /** The user-owned content files, relative to the project, that may carry a route literal. */
52
+ export function userOwnedRouteFiles(name) {
53
+ return [join('lib', name, 'shell', 'content.ts'), join('lib', name, 'views', 'static', 'content.ts')]
54
+ }
55
+
56
+ /**
57
+ * Rewrite a route value only when it is a retired root-relative route:
58
+ * `/productos/<handle>?q#h` -> `/products/<handle>?q#h` (handle/query/hash kept).
59
+ * Returns null when the value is not a retired route (absolute URL, `/api/...`,
60
+ * prose, an already-English route, or a different route).
61
+ */
62
+ export function migrateRouteValue(value) {
63
+ for (const [from, to] of RETIRED) {
64
+ if (
65
+ value === from ||
66
+ value.startsWith(`${from}/`) ||
67
+ value.startsWith(`${from}?`) ||
68
+ value.startsWith(`${from}#`)
69
+ ) {
70
+ return to + value.slice(from.length)
71
+ }
72
+ }
73
+ return null
74
+ }
75
+
76
+ // Split the source into code / string / comment tokens with a small scanner, so
77
+ // a retired literal inside a comment or prose is never touched and only a string
78
+ // token that directly follows an `href:`/`path:` property key is considered.
79
+ function scan(text) {
80
+ const tokens = []
81
+ const n = text.length
82
+ let i = 0
83
+ let codeStart = 0
84
+ const pushCode = (end) => {
85
+ if (end > codeStart) tokens.push({ type: 'code', text: text.slice(codeStart, end) })
86
+ }
87
+ while (i < n) {
88
+ const c = text[i]
89
+ if (c === '/' && text[i + 1] === '/') {
90
+ pushCode(i)
91
+ let j = text.indexOf('\n', i)
92
+ if (j === -1) j = n
93
+ tokens.push({ type: 'comment', text: text.slice(i, j) })
94
+ i = j
95
+ codeStart = i
96
+ continue
97
+ }
98
+ if (c === '/' && text[i + 1] === '*') {
99
+ pushCode(i)
100
+ let j = text.indexOf('*/', i + 2)
101
+ j = j === -1 ? n : j + 2
102
+ tokens.push({ type: 'comment', text: text.slice(i, j) })
103
+ i = j
104
+ codeStart = i
105
+ continue
106
+ }
107
+ if (QUOTES.has(c)) {
108
+ pushCode(i)
109
+ let j = i + 1
110
+ while (j < n) {
111
+ if (text[j] === '\\') {
112
+ j += 2
113
+ continue
114
+ }
115
+ if (text[j] === c) {
116
+ j++
117
+ break
118
+ }
119
+ if (text[j] === '\n' && c !== '`') break
120
+ j++
121
+ }
122
+ tokens.push({ type: 'string', text: text.slice(i, j) })
123
+ i = j
124
+ codeStart = i
125
+ continue
126
+ }
127
+ i++
128
+ }
129
+ pushCode(n)
130
+ return tokens
131
+ }
132
+
133
+ /** Rewrite the retired route literals in an `href`/`path` value. Idempotent. */
134
+ export function migrateText(text) {
135
+ const tokens = scan(text)
136
+ for (let k = 1; k < tokens.length; k++) {
137
+ const token = tokens[k]
138
+ if (token.type !== 'string') continue
139
+ const prev = tokens[k - 1]
140
+ if (!prev || prev.type !== 'code' || !ROUTE_PROP.test(prev.text)) continue
141
+ if (token.text.length < 2 || token.text[0] !== token.text[token.text.length - 1]) continue
142
+ const quote = token.text[0]
143
+ const migrated = migrateRouteValue(token.text.slice(1, -1))
144
+ if (migrated === null) continue
145
+ tokens[k] = { type: 'string', text: quote + migrated + quote }
146
+ }
147
+ return tokens.map((token) => token.text).join('')
148
+ }
149
+
150
+ // Containment: the resolved file must live under the project root, both
151
+ // lexically and (when it exists) through any symlinked ancestor.
152
+ function insideProject(projectDir, abs) {
153
+ const rel = relative(projectDir, abs)
154
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return false
155
+ if (!existsSync(abs)) return true
156
+ try {
157
+ const realRoot = realpathSync(projectDir)
158
+ const realAbs = realpathSync(abs)
159
+ return realAbs === realRoot || realAbs.startsWith(`${realRoot}${sep}`)
160
+ } catch {
161
+ return false
162
+ }
163
+ }
164
+
165
+ /** Plan the migration: the allow-listed files whose text would change. */
166
+ export async function planMigration(projectDir) {
167
+ const manifest = await readManifest(projectDir)
168
+ if (!manifest) return { ok: false, error: `no storefront.config.json found in ${projectDir}` }
169
+ const name = manifest.name
170
+ if (typeof name !== 'string' || !SAFE_NAME.test(name)) {
171
+ return { ok: false, error: `storefront name ${JSON.stringify(name)} is not a safe path segment` }
172
+ }
173
+ const root = resolve(projectDir)
174
+ const changes = []
175
+ for (const rel of userOwnedRouteFiles(name)) {
176
+ const abs = join(root, rel)
177
+ if (!insideProject(root, abs)) {
178
+ return { ok: false, error: `allow-listed path escapes the project: ${rel}` }
179
+ }
180
+ if (!existsSync(abs)) continue
181
+ const raw = readFileSync(abs, 'utf8')
182
+ const next = migrateText(raw)
183
+ if (next === raw) continue
184
+ changes.push({ file: rel, abs, raw, next })
185
+ }
186
+ return { ok: true, root, name, changes }
187
+ }
188
+
189
+ // No-clobber backup: create exclusively; an existing backup is reused only when
190
+ // it already holds this exact pre-migration content, otherwise the apply refuses.
191
+ function ensureBackup(root, rel, raw) {
192
+ const abs = join(root, BACKUP_DIR, rel)
193
+ // A symlinked `.opencode/backups` must never redirect the backup outside the
194
+ // project; the same containment guard the retirement backup uses.
195
+ assertInsideProject(root, abs)
196
+ mkdirSync(dirname(abs), { recursive: true })
197
+ if (existsSync(abs)) {
198
+ if (readFileSync(abs, 'utf8') !== raw) {
199
+ throw new Error(`refusing to overwrite an existing backup with different content: ${abs}`)
200
+ }
201
+ return { path: abs, created: false }
202
+ }
203
+ writeFileSync(abs, raw, { flag: 'wx' })
204
+ return { path: abs, created: true }
205
+ }
206
+
207
+ export async function migrateProject(projectDir, { apply = false } = {}) {
208
+ const plan = await planMigration(projectDir)
209
+ if (!plan.ok) return { ok: false, error: plan.error, changed: [] }
210
+
211
+ const planned = plan.changes.map((change) => ({ file: change.file, applied: false }))
212
+ if (!apply || plan.changes.length === 0) {
213
+ return { ok: true, apply, changed: planned, files: plan.changes.map((c) => c.abs), backups: [] }
214
+ }
215
+
216
+ const snapshot = takeSnapshot(plan.root, plan.changes.map((c) => c.abs))
217
+ const backups = []
218
+ const createdBackups = []
219
+ try {
220
+ for (const change of plan.changes) {
221
+ const backup = ensureBackup(plan.root, change.file, change.raw)
222
+ backups.push(backup.path)
223
+ if (backup.created) createdBackups.push(backup.path)
224
+ atomicWrite(change.abs, change.next)
225
+ }
226
+ } catch (error) {
227
+ restoreSnapshot(snapshot)
228
+ for (const backup of createdBackups) rmSync(backup, { force: true })
229
+ return { ok: false, error: error instanceof Error ? error.message : String(error), changed: [] }
230
+ }
231
+ return {
232
+ ok: true,
233
+ apply,
234
+ changed: plan.changes.map((change) => ({ file: change.file, applied: true })),
235
+ files: plan.changes.map((c) => c.abs),
236
+ backups,
237
+ }
238
+ }
239
+
240
+ async function main() {
241
+ const args = process.argv.slice(2)
242
+ const projectDir = args.find((a) => !a.startsWith('-'))
243
+ const apply = args.includes('--apply')
244
+ if (!projectDir) {
245
+ console.error('Usage: node scripts/migrate-english-routes.mjs <project-dir> [--apply]')
246
+ return 1
247
+ }
248
+ const result = await migrateProject(resolve(projectDir), { apply })
249
+ if (!result.ok) {
250
+ console.error(result.error)
251
+ return 1
252
+ }
253
+ if (result.changed.length === 0) {
254
+ console.log('No retired Spanish route literal found in the user-owned content files — nothing to migrate.')
255
+ return 0
256
+ }
257
+ for (const entry of result.changed) {
258
+ console.log(` ${apply ? 'migrated' : 'would migrate'} ${entry.file}`)
259
+ }
260
+ for (const backup of result.backups) console.log(` backup ${backup}`)
261
+ if (!apply) {
262
+ console.log('Dry run — re-run with --apply to rewrite these user-owned files.')
263
+ }
264
+ return 0
265
+ }
266
+
267
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
268
+ main()
269
+ .then((code) => {
270
+ process.exitCode = code
271
+ })
272
+ .catch((err) => {
273
+ console.error(err)
274
+ process.exitCode = 1
275
+ })
276
+ }
@@ -41,7 +41,9 @@ export const TOOLCHAIN = {
41
41
  // The managed suite is the package-owned surface installed into `.opencode/`.
42
42
  // Its BOM is what `doctor` and `update` compare; user-owned storefront files
43
43
  // live outside it. Only these directories are ever treated as package-owned.
44
- export const MANAGED_SUITE_DIRS = ['commands', 'skills']
44
+ // The suite ships skills only — the root `AGENTS.md` is a rendered project file
45
+ // covered by derived drift (reconcile dry-run), not by this BOM.
46
+ export const MANAGED_SUITE_DIRS = ['skills']
45
47
 
46
48
  // The CLI pins itself as a direct dependency so a generated project can
47
49
  // reproduce the exact toolchain that produced it.
@@ -80,18 +82,54 @@ export function satisfiesRange(version, range) {
80
82
  return semver.satisfies(version, range)
81
83
  }
82
84
 
85
+ /**
86
+ * The package's operative skill set, read from the single declared source: the
87
+ * package skill layout. Under each managed suite directory, a directory that
88
+ * directly contains a `SKILL.md` is an operative skill; any other file under a
89
+ * skill is a scoped reference, never a skill. Returns sorted `<dir>/<name>`
90
+ * POSIX-relative paths. Installation, migration/refresh, and validation all
91
+ * read this one source — there is no separate registry or manifest that
92
+ * restates the set.
93
+ */
94
+ export function declaredOperativeSkills(pkgRoot, dirs = MANAGED_SUITE_DIRS) {
95
+ const skills = []
96
+ for (const dir of dirs) {
97
+ const base = join(pkgRoot, dir)
98
+ if (!existsSync(base)) continue
99
+ for (const entry of readdirSync(base, { withFileTypes: true }).sort((a, b) =>
100
+ a.name.localeCompare(b.name)
101
+ )) {
102
+ if (!entry.isDirectory()) continue
103
+ if (existsSync(join(base, entry.name, 'SKILL.md'))) skills.push(`${dir}/${entry.name}`)
104
+ }
105
+ }
106
+ return skills.sort()
107
+ }
108
+
109
+ /**
110
+ * The scoped reference files shipped under the operative skills: every managed
111
+ * suite file that is not a skill's own root `SKILL.md`. References travel with
112
+ * their owning skill (so the installed skill can load them) but are never
113
+ * counted or installed as skills.
114
+ */
115
+ export function declaredSuiteReferences(pkgRoot, dirs = MANAGED_SUITE_DIRS) {
116
+ const skillRoots = new Set(declaredOperativeSkills(pkgRoot, dirs).map((skill) => `${skill}/SKILL.md`))
117
+ return managedSuitePaths(pkgRoot, dirs).filter((rel) => !skillRoots.has(rel))
118
+ }
119
+
83
120
  /**
84
121
  * The package-owned suite file set (sorted POSIX-relative paths) under the
85
122
  * managed suite directories of `pkgRoot`. This is the *complete owned asset
86
123
  * set*: only files shipped by the package are ever hashed or compared, so a
87
124
  * user's own skill/command files under `.opencode/` are neither stamped nor
88
- * treated as drift.
125
+ * treated as drift. The set is derived from the declared skill layout
126
+ * (`declaredOperativeSkills`), so a directory without a `SKILL.md` is not part
127
+ * of the suite.
89
128
  */
90
129
  export function managedSuitePaths(pkgRoot, dirs = MANAGED_SUITE_DIRS) {
91
130
  const paths = []
92
- for (const dir of dirs) {
93
- const base = join(pkgRoot, dir)
94
- if (!existsSync(base)) continue
131
+ for (const skillRel of declaredOperativeSkills(pkgRoot, dirs)) {
132
+ const base = join(pkgRoot, skillRel)
95
133
  const walk = (current) => {
96
134
  for (const entry of readdirSync(current, { withFileTypes: true }).sort((a, b) =>
97
135
  a.name.localeCompare(b.name)
@@ -129,6 +167,37 @@ export async function hashManagedSuite(rootDir, dirs = MANAGED_SUITE_DIRS) {
129
167
  return hashPaths(rootDir, managedSuitePaths(rootDir, dirs))
130
168
  }
131
169
 
170
+ /**
171
+ * The owned-file set recorded in an install record, or `null` when the record
172
+ * predates the BOM (a legacy record) and the set cannot be derived. Callers
173
+ * MUST treat `null` conservatively: no file is retired from an undetermined
174
+ * set. Returns the `relpath -> digest` map itself (never a copy silently
175
+ * dropped), so callers can compare a recorded digest against the live file.
176
+ */
177
+ export function recordedOwnedSet(record) {
178
+ if (!isPlainObject(record)) return null
179
+ if (isPlainObject(record.pinning) && isPlainObject(record.pinning.assets)) {
180
+ return record.pinning.assets
181
+ }
182
+ // An intermediate flattened shape that carried the BOM at the record root.
183
+ if (isPlainObject(record.assets)) return record.assets
184
+ return null
185
+ }
186
+
187
+ /**
188
+ * Retirement candidates: every path in the previously recorded owned-file set
189
+ * (the install-record BOM) that the incoming managed set no longer ships. A
190
+ * `null` or malformed recorded set yields no candidates, so retirement is never
191
+ * derived from an unknown set (the conservative legacy-record path).
192
+ */
193
+ export function retirementCandidates(recordedAssets, incomingPaths) {
194
+ if (!isPlainObject(recordedAssets)) return []
195
+ const incoming = new Set(incomingPaths)
196
+ return Object.keys(recordedAssets)
197
+ .filter((rel) => !incoming.has(rel))
198
+ .sort()
199
+ }
200
+
132
201
  /**
133
202
  * Read the consumer `package.json`. Returns `{ ok, pkg }` or `{ ok:false,
134
203
  * error }`; a missing or malformed file is an error, never an empty object.
@@ -35,6 +35,7 @@ import { classifyFile } from './file-classes.mjs'
35
35
  import { manifestPath, readManifest, writeManifest } from './manifest.mjs'
36
36
  import { parseOperationsEnvelope, reduceManifest } from './operations.mjs'
37
37
  import { detectOwnershipConflicts } from './ownership-conflict.mjs'
38
+ import { preflightManifest } from './preflight.mjs'
38
39
  import { customizedViewFiles, reconcile, removeFeatureFiles, removeViewFiles } from './reconcile.mjs'
39
40
 
40
41
  const here = path.dirname(fileURLToPath(import.meta.url))
@@ -304,7 +305,7 @@ function diffFileState(before, after) {
304
305
  // existence of each ancestor directory under the project. Recording directories
305
306
  // is what lets a rollback remove the directories a create-only attempt made,
306
307
  // instead of leaving orphaned empty dirs behind a restored tree (E1 task 4.7).
307
- function takeSnapshot(projectDir, absPaths) {
308
+ export function takeSnapshot(projectDir, absPaths) {
308
309
  const files = new Map()
309
310
  const dirs = new Map()
310
311
  for (const abs of absPaths) {
@@ -322,7 +323,7 @@ function takeSnapshot(projectDir, absPaths) {
322
323
  return { files, dirs }
323
324
  }
324
325
 
325
- function restoreSnapshot(snapshot) {
326
+ export function restoreSnapshot(snapshot) {
326
327
  for (const [abs, prior] of snapshot.files) {
327
328
  if (prior.existed) {
328
329
  mkdirSync(path.dirname(abs), { recursive: true })
@@ -372,7 +373,10 @@ async function commitReduction(projectDir, reduction, { snapshot = false, inject
372
373
  }
373
374
  const result =
374
375
  rem.kind === 'view'
375
- ? await removeViewFiles(projectDir, reduction.manifest, rem.name, { dryRun: true })
376
+ ? await removeViewFiles(projectDir, reduction.manifest, rem.name, {
377
+ dryRun: true,
378
+ legacyDerivedFiles: rem.legacyDerivedFiles,
379
+ })
376
380
  : await removeFeatureFiles(projectDir, reduction.manifest, rem.name, { dryRun: true })
377
381
  removalRemoved.push(...result.removed)
378
382
  preserved.push(...result.preserved)
@@ -394,7 +398,11 @@ async function commitReduction(projectDir, reduction, { snapshot = false, inject
394
398
  const snapshotState = snapshot ? takeSnapshot(projectDir, [...touched]) : null
395
399
  try {
396
400
  for (const rem of reduction.removals) {
397
- if (rem.kind === 'view') await removeViewFiles(projectDir, reduction.manifest, rem.name)
401
+ if (rem.kind === 'view') {
402
+ await removeViewFiles(projectDir, reduction.manifest, rem.name, {
403
+ legacyDerivedFiles: rem.legacyDerivedFiles,
404
+ })
405
+ }
398
406
  if (rem.kind === 'feature') await removeFeatureFiles(projectDir, reduction.manifest, rem.name)
399
407
  }
400
408
  const result = await reconcile(projectDir, reduction.manifest)
@@ -453,6 +461,142 @@ export async function executeOperations(
453
461
  }
454
462
  }
455
463
 
464
+ // The retired view ids the bounded `view remove` cleanup may still transition.
465
+ // Exactly this list is permitted; any other unknown id fails closed so an
466
+ // arbitrary unknown entry is never purged from a user-owned manifest
467
+ // (change: storefront-retire-user-panel, Decision 4 / AC-UP-1.2).
468
+ const LEGACY_VIEW_IDS = new Set(['user-panel'])
469
+ // The derived files the retired view template shipped. They are regenerable and
470
+ // unregistered after retirement, so the bounded cleanup removes exactly these
471
+ // from a stale generated view directory; user-owned `schema.ts` and any unknown
472
+ // file are preserved and reported (never a blind directory delete).
473
+ const LEGACY_DERIVED_VIEW_FILES = Object.freeze({ 'user-panel': ['index.tsx', 'registry.tsx'] })
474
+ const LEGACY_BACKUP_DIR = path.join('.opencode', 'backups', 'retire-user-panel')
475
+
476
+ export function isLegacyViewId(name) {
477
+ return LEGACY_VIEW_IDS.has(name)
478
+ }
479
+
480
+ /**
481
+ * Containment guard for a path a caller is about to create under the project.
482
+ * Rejects a lexically-escaping path and, crucially, an existing ancestor whose
483
+ * realpath leaves the project (a symlinked `.opencode`/`backups` must never
484
+ * redirect a backup write outside the project). `root` must exist.
485
+ */
486
+ export function assertInsideProject(root, abs) {
487
+ const rel = path.relative(root, abs)
488
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
489
+ throw new Error(`refusing to write outside the project: ${abs}`)
490
+ }
491
+ const realRoot = realpathSync(root)
492
+ let probe = abs
493
+ while (!existsSync(probe)) {
494
+ const parent = path.dirname(probe)
495
+ if (parent === probe) break
496
+ probe = parent
497
+ }
498
+ if (existsSync(probe)) {
499
+ const realProbe = realpathSync(probe)
500
+ if (realProbe !== realRoot && !realProbe.startsWith(`${realRoot}${path.sep}`)) {
501
+ throw new Error(`refusing to write through a symlinked path outside the project: ${abs}`)
502
+ }
503
+ }
504
+ return abs
505
+ }
506
+
507
+ // No-clobber durable backup of the manifest before the bounded cleanup writes.
508
+ // An existing backup is reused only when it already holds this exact content;
509
+ // otherwise the cleanup refuses, so a backup is never silently replaced.
510
+ function ensureLegacyManifestBackup(projectDir) {
511
+ const abs = path.join(projectDir, LEGACY_BACKUP_DIR, 'storefront.config.json')
512
+ assertInsideProject(projectDir, abs)
513
+ const raw = readFileSync(manifestPath(projectDir), 'utf8')
514
+ mkdirSync(path.dirname(abs), { recursive: true })
515
+ if (existsSync(abs)) {
516
+ if (readFileSync(abs, 'utf8') !== raw) {
517
+ throw new Error(`refusing to overwrite an existing backup with different content: ${abs}`)
518
+ }
519
+ return { path: abs, created: false }
520
+ }
521
+ writeFileSync(abs, raw, { flag: 'wx' })
522
+ return { path: abs, created: true }
523
+ }
524
+
525
+ /**
526
+ * Bounded legacy cleanup for a retired view id (`user-panel`). A manifest
527
+ * written before the view was retired carries an entry the registry no longer
528
+ * knows, so the normal reduce path refuses the input. This cleanup instead
529
+ * validates the resulting FUTURE manifest through the structural owner
530
+ * (`preflightManifest`), backs the manifest up, and commits transactionally
531
+ * through `commitReduction` — the same reconcile/plan-apply write boundary, no
532
+ * runtime-engine bypass and no re-enable. Only a `LEGACY_VIEW_IDS` member is
533
+ * permitted; any other id fails closed with an actionable diagnostic and no
534
+ * write. Idempotent: an already-absent entry is a truthful no-op.
535
+ */
536
+ export async function removeLegacyView(projectDir, name, { inject = null } = {}) {
537
+ if (!isLegacyViewId(name)) {
538
+ const allowed = [...LEGACY_VIEW_IDS].map((id) => `"${id}"`).join(', ')
539
+ return {
540
+ ok: false,
541
+ diagnostics: [
542
+ diagnostic(
543
+ 'E_UNKNOWN_VIEW',
544
+ `Unknown view "${name}". Only the retired legacy id ${allowed} can be removed by this cleanup; edit the canonical storefront.config.json to remove any other view.`,
545
+ ),
546
+ ],
547
+ }
548
+ }
549
+ const manifest = readManifest(projectDir)
550
+ if (!manifest) {
551
+ return {
552
+ ok: false,
553
+ diagnostics: [diagnostic('E_NO_MANIFEST', 'No storefront.config.json found in the project.')],
554
+ }
555
+ }
556
+ if (!manifest.views.some((view) => view.name === name)) {
557
+ return { ok: true, diagnostics: [], changed: [], preserved: [], alreadyRemoved: true }
558
+ }
559
+ const future = { ...manifest, views: manifest.views.filter((view) => view.name !== name) }
560
+ const preflight = preflightManifest(future)
561
+ if (!preflight.ok) {
562
+ return { ok: false, diagnostics: preflightDiagnostics(preflight.errors) }
563
+ }
564
+ // Same write-boundary ownership check as `executeOperations`: the future
565
+ // composition must not render a selected variant against a customized
566
+ // user-owned contract that no longer provides its data shape (E1 task 5.1).
567
+ const conflicts = detectOwnershipConflicts(projectDir, future)
568
+ if (!conflicts.ok) return { ok: false, diagnostics: conflicts.diagnostics }
569
+ let backup
570
+ try {
571
+ backup = ensureLegacyManifestBackup(projectDir)
572
+ } catch (error) {
573
+ return {
574
+ ok: false,
575
+ diagnostics: [
576
+ diagnostic('E_BACKUP_FAILED', error instanceof Error ? error.message : String(error)),
577
+ ],
578
+ }
579
+ }
580
+ const commit = await commitReduction(
581
+ projectDir,
582
+ {
583
+ ok: true,
584
+ manifest: future,
585
+ removals: [{ kind: 'view', name, legacyDerivedFiles: LEGACY_DERIVED_VIEW_FILES[name] ?? [] }],
586
+ },
587
+ { snapshot: true, inject },
588
+ )
589
+ if (!commit.ok) return { ok: false, diagnostics: commit.diagnostics, backup: backup.path }
590
+ return {
591
+ ok: true,
592
+ diagnostics: [],
593
+ changed: commit.changed,
594
+ preserved: commit.preserved,
595
+ backup: backup.path,
596
+ alreadyRemoved: false,
597
+ }
598
+ }
599
+
456
600
  async function withStagedProject(projectDir, reduction, fn) {
457
601
  const stage = mkdtempSync(path.join(tmpdir(), 'cs-plan-stage-'))
458
602
  try {
@@ -691,7 +835,7 @@ export function assertSafeOutPath(projectDir, outPath, operationsPath = null) {
691
835
  // files are never touched. `inject(phase, context)` is a controlled test seam
692
836
  // (never read from the environment): production callers pass nothing, and a test
693
837
  // throws from `plan-after-temp-write` to prove the cleanup and byte-stability.
694
- function atomicWrite(abs, text, inject = null) {
838
+ export function atomicWrite(abs, text, inject = null) {
695
839
  mkdirSync(path.dirname(abs), { recursive: true })
696
840
  const tmp = `${abs}.tmp-${process.pid}-${Date.now()}`
697
841
  try {