@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.
- package/README.md +173 -153
- package/VENDOR.md +69 -0
- package/assets/openspec/config.yaml +38 -0
- package/assets/templates/AGENTS.md +76 -0
- package/assets/templates/app/app/[[...slug]]/page.tsx +42 -1
- package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
- package/assets/templates/app/app/globals.css +5 -8
- package/assets/templates/app/app/layout.tsx +1 -5
- package/assets/templates/app/app/robots.ts +9 -3
- package/assets/templates/app/app/sitemap.ts +64 -5
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +70 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +15 -5
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +71 -0
- package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +36 -4
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +82 -50
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -0
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +29 -14
- package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
- package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +31 -7
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +4 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +56 -26
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +165 -0
- package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +26 -7
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +2 -1
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
- package/assets/templates/features/checkout/handlers.ts +6 -0
- package/assets/templates/features/customer-auth/state.ts +10 -1
- package/assets/templates/views/account/index.tsx +1 -1
- package/assets/templates/views/account/registry.tsx +80 -19
- package/assets/templates/views/account/schema.ts +1 -0
- package/assets/templates/views/cart/content.ts +11 -0
- package/assets/templates/views/cart/data.ts +23 -0
- package/assets/templates/views/cart/index.tsx +53 -0
- package/assets/templates/views/cart/props.ts +12 -0
- package/assets/templates/views/cart/registry.tsx +81 -0
- package/assets/templates/views/cart/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +1 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +64 -6
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +2 -1
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
- package/assets/templates/views/category/index.tsx +76 -7
- package/assets/templates/views/category/props.ts +7 -0
- package/assets/templates/views/category/registry.tsx +19 -0
- package/assets/templates/views/category/schema.ts +13 -0
- package/assets/templates/views/home/assembly/assembly.json +9 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +22 -4
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +1 -1
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +2 -2
- package/assets/templates/views/home/data.ts +9 -0
- package/assets/templates/views/home/index.tsx +23 -5
- package/assets/templates/views/home/registry.tsx +2 -2
- package/assets/templates/views/product/data.ts +4 -2
- package/assets/templates/views/product/index.tsx +49 -5
- package/assets/templates/views/product/props.ts +7 -0
- package/assets/templates/views/product/registry.tsx +25 -0
- package/assets/templates/views/product/schema.ts +13 -0
- package/assets/templates/views/static/content.ts +4 -4
- package/assets/templates/views/static/index.tsx +1 -1
- package/bin/storefront.js +1231 -154
- package/examples/merchandising/category-led.json +37 -0
- package/examples/merchandising/collection-led.json +37 -0
- package/package.json +11 -7
- package/recipes/archetypes.json +9 -22
- package/recipes/catalog.json +40 -1
- package/recipes/features.json +25 -2
- package/recipes/recipe.schema.json +1 -1
- package/recipes/recipes.json +32 -30
- package/recipes/sections.json +10 -0
- package/recipes/validate.mjs +54 -0
- package/scripts/candidate.mjs +4 -4
- package/scripts/composers.mjs +25 -5
- package/scripts/env-file.mjs +86 -0
- package/scripts/file-classes.mjs +5 -0
- package/scripts/migrate-english-routes.mjs +276 -0
- package/scripts/pinning.mjs +74 -5
- package/scripts/plan-apply.mjs +149 -5
- package/scripts/reconcile.mjs +49 -11
- package/scripts/render-templates.mjs +97 -44
- package/scripts/section-config.mjs +253 -0
- package/scripts/starter.mjs +17 -0
- package/scripts/theme/color.mjs +185 -0
- package/scripts/theme/css-theme.mjs +233 -0
- package/scripts/theme/fonts.mjs +123 -0
- package/scripts/theme/import-export.mjs +125 -0
- package/scripts/theme/preset.mjs +93 -0
- package/scripts/theme/theme-plan.mjs +292 -0
- package/scripts/theme/tokens.mjs +137 -0
- package/scripts/view-assembly.mjs +47 -1
- package/skills/sf-feature/SKILL.md +32 -19
- package/skills/sf-setup/SKILL.md +79 -0
- package/skills/sf-theme-gen/SKILL.md +99 -36
- package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
- package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
- package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
- package/skills/sf-view-gen/SKILL.md +63 -40
- package/skills/sf-view-gen/assets/recipes/archetypes.json +9 -22
- package/skills/sf-view-gen/assets/recipes/catalog.json +40 -1
- package/skills/sf-view-gen/assets/recipes/features.json +25 -2
- package/skills/sf-view-gen/assets/recipes/recipes.json +32 -30
- package/skills/sf-view-gen/assets/recipes/sections.json +10 -0
- package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
- package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
- package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
- package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
- package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
- package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
- package/skills/sf-view-gen/references/views.md +28 -37
- package/skills/sf-view-gen/scripts/validate.mjs +54 -0
- package/skills/storefront-workflow/SKILL.md +94 -31
- package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
- package/CHANGELOG.md +0 -84
- package/assets/templates/views/user-panel/index.tsx +0 -106
- package/assets/templates/views/user-panel/registry.tsx +0 -54
- package/assets/templates/views/user-panel/schema.ts +0 -79
- package/commands/sf-add-view.md +0 -57
- package/commands/sf-feature.md +0 -28
- package/commands/sf-theme.md +0 -26
- package/commands/sf-view.md +0 -67
- package/skills/building-storefronts/SKILL.md +0 -178
- package/skills/building-storefronts/references/frontend-integration.md +0 -229
- package/skills/json-render-remotion/SKILL.md +0 -111
- package/skills/json-render-solid/SKILL.md +0 -204
- package/skills/nextjs-shadcn/SKILL.md +0 -367
- package/skills/nextjs-shadcn/references/architecture.md +0 -499
- package/skills/nextjs-shadcn/references/project-setup.md +0 -162
- package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
- package/skills/nextjs-shadcn/references/sidebar.md +0 -274
- package/skills/nextjs-shadcn/references/styling.md +0 -555
- package/skills/shadcn-component-discovery/SKILL.md +0 -273
- package/skills/shadcn-component-discovery/references/registries.md +0 -226
- package/skills/shadcn-theming/SKILL.md +0 -104
- package/skills/storefront-best-practices/SKILL.md +0 -421
- package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
- package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
- package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
- package/skills/storefront-best-practices/reference/components/footer.md +0 -112
- package/skills/storefront-best-practices/reference/components/hero.md +0 -241
- package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
- package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
- package/skills/storefront-best-practices/reference/components/popups.md +0 -221
- package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
- package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
- package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
- package/skills/storefront-best-practices/reference/components/search.md +0 -101
- package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
- package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
- package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
- package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
- package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
- package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
- package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
- package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
- package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
- package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
- package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
- package/skills/storefront-best-practices/reference/medusa.md +0 -307
- /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
- /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
package/scripts/file-classes.mjs
CHANGED
|
@@ -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
|
+
}
|
package/scripts/pinning.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
93
|
-
const base = join(pkgRoot,
|
|
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.
|
package/scripts/plan-apply.mjs
CHANGED
|
@@ -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, {
|
|
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')
|
|
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 {
|