uniweb 0.12.2 → 0.12.4
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 +32 -22
- package/package.json +4 -4
- package/src/commands/add.js +88 -12
- package/src/commands/build.js +199 -28
- package/src/commands/deploy.js +318 -16
- package/src/commands/doctor.js +172 -130
- package/src/commands/handoff.js +1 -1
- package/src/commands/invite.js +2 -2
- package/src/commands/publish.js +297 -54
- package/src/commands/rename.js +310 -0
- package/src/framework-index.json +4 -4
- package/src/index.js +14 -5
- package/src/utils/receipt.js +91 -0
- package/src/utils/registry.js +33 -0
- package/templates/workspace/package.json.hbs +2 -4
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rename Command
|
|
3
|
+
*
|
|
4
|
+
* Renames packages across the workspace transactionally. Currently
|
|
5
|
+
* supports `rename foundation <old> <new>`; sites and extensions can be
|
|
6
|
+
* added with the same scaffolding when needed.
|
|
7
|
+
*
|
|
8
|
+
* What a foundation rename touches (in order):
|
|
9
|
+
* 1. The foundation's own package.json::name.
|
|
10
|
+
* 2. Folder name on disk (when folder leaf matches old package name).
|
|
11
|
+
* 3. Each site's package.json: dep key (old → new) + file: path.
|
|
12
|
+
* 4. Each site's site.yml::foundation.
|
|
13
|
+
* 5. pnpm-workspace.yaml::packages and package.json::workspaces
|
|
14
|
+
* (when the folder rename moved the path).
|
|
15
|
+
* 6. Root scripts (`pnpm --filter <old>` references).
|
|
16
|
+
*
|
|
17
|
+
* Pre-flight checks run before any mutation. If anything would conflict
|
|
18
|
+
* (target name already taken, foundation not found, folder collision)
|
|
19
|
+
* we bail with a clear message and no partial state.
|
|
20
|
+
*
|
|
21
|
+
* Out of scope: registry side. The publish id (`package.json::uniweb.id`)
|
|
22
|
+
* is independent of the workspace name and stays untouched. Users who
|
|
23
|
+
* want to also rename on the registry run `uniweb publish --name <new>`.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* uniweb rename foundation <old> <new>
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync } from 'node:fs'
|
|
30
|
+
import { readFile, writeFile, rename as fsRename } from 'node:fs/promises'
|
|
31
|
+
import { join, relative, dirname, basename } from 'node:path'
|
|
32
|
+
import yaml from 'js-yaml'
|
|
33
|
+
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
34
|
+
import {
|
|
35
|
+
discoverFoundations,
|
|
36
|
+
discoverSites,
|
|
37
|
+
readWorkspaceConfig,
|
|
38
|
+
writeWorkspaceConfig,
|
|
39
|
+
readRootPackageJson,
|
|
40
|
+
writeRootPackageJson,
|
|
41
|
+
updateRootScripts,
|
|
42
|
+
} from '../utils/config.js'
|
|
43
|
+
import { getExistingPackageNames, validatePackageName } from '../utils/names.js'
|
|
44
|
+
import { detectPackageManager, installCmd } from '../utils/pm.js'
|
|
45
|
+
import { getCliPrefix } from '../utils/interactive.js'
|
|
46
|
+
|
|
47
|
+
const colors = {
|
|
48
|
+
reset: '\x1b[0m',
|
|
49
|
+
bright: '\x1b[1m',
|
|
50
|
+
dim: '\x1b[2m',
|
|
51
|
+
cyan: '\x1b[36m',
|
|
52
|
+
green: '\x1b[32m',
|
|
53
|
+
yellow: '\x1b[33m',
|
|
54
|
+
red: '\x1b[31m',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const success = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`)
|
|
58
|
+
const error = (msg) => console.error(`${colors.red}✗${colors.reset} ${msg}`)
|
|
59
|
+
const info = (msg) => console.log(`${colors.dim}${msg}${colors.reset}`)
|
|
60
|
+
const log = console.log
|
|
61
|
+
|
|
62
|
+
export async function rename(args = []) {
|
|
63
|
+
const [subcommand, oldName, newName] = args
|
|
64
|
+
const prefix = getCliPrefix()
|
|
65
|
+
|
|
66
|
+
if (!subcommand || subcommand === '--help' || subcommand === '-h') {
|
|
67
|
+
showHelp(prefix)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (subcommand !== 'foundation') {
|
|
72
|
+
error(`Unknown subcommand: ${subcommand}`)
|
|
73
|
+
log(`Supported: rename foundation <old> <new>`)
|
|
74
|
+
process.exit(1)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!oldName || !newName) {
|
|
78
|
+
error('Missing arguments.')
|
|
79
|
+
log(`Usage: ${prefix} rename foundation <old> <new>`)
|
|
80
|
+
process.exit(1)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (oldName === newName) {
|
|
84
|
+
error('Old and new names are identical — nothing to do.')
|
|
85
|
+
process.exit(1)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const rootDir = findWorkspaceRoot()
|
|
89
|
+
if (!rootDir) {
|
|
90
|
+
error('Not in a Uniweb workspace.')
|
|
91
|
+
log(`Run this command from your project root or a site/foundation directory.`)
|
|
92
|
+
process.exit(1)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await renameFoundation(rootDir, oldName, newName, prefix)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function renameFoundation(rootDir, oldName, newName, prefix) {
|
|
99
|
+
// ─── Pre-flight ───────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
// Validate the new name (format + reserved-name check). `src` is
|
|
102
|
+
// grandfathered in for the same reason add foundation grandfathers
|
|
103
|
+
// it (the convention for "the package that lives in src/").
|
|
104
|
+
if (newName !== 'src') {
|
|
105
|
+
const valid = validatePackageName(newName)
|
|
106
|
+
if (valid !== true) {
|
|
107
|
+
error(valid)
|
|
108
|
+
process.exit(1)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The foundation must exist under its current name.
|
|
113
|
+
const foundations = await discoverFoundations(rootDir)
|
|
114
|
+
const target = foundations.find(f => f.name === oldName)
|
|
115
|
+
if (!target) {
|
|
116
|
+
error(`No foundation named ${colors.bright}${oldName}${colors.reset} in this workspace.`)
|
|
117
|
+
if (foundations.length > 0) {
|
|
118
|
+
log(`Available: ${foundations.map(f => f.name).join(', ')}`)
|
|
119
|
+
}
|
|
120
|
+
process.exit(1)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The new name must be free across the entire workspace.
|
|
124
|
+
const existingNames = await getExistingPackageNames(rootDir)
|
|
125
|
+
if (existingNames.has(newName)) {
|
|
126
|
+
error(`Cannot rename: a package named ${colors.bright}${newName}${colors.reset} already exists in this workspace.`)
|
|
127
|
+
process.exit(1)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const oldFoundationPath = target.path // workspace-relative
|
|
131
|
+
const oldFoundationDir = join(rootDir, oldFoundationPath)
|
|
132
|
+
const folderLeaf = basename(oldFoundationPath)
|
|
133
|
+
|
|
134
|
+
// Decide the new folder path. Rule: if the folder leaf matches the
|
|
135
|
+
// old package name, rename the leaf to match the new package name
|
|
136
|
+
// (preserving any parent dirs like `foundations/`). Otherwise leave
|
|
137
|
+
// the folder alone — the user named the folder differently than the
|
|
138
|
+
// package, and we honor that.
|
|
139
|
+
let newFoundationPath = oldFoundationPath
|
|
140
|
+
let newFoundationDir = oldFoundationDir
|
|
141
|
+
const folderWillRename = folderLeaf === oldName
|
|
142
|
+
if (folderWillRename) {
|
|
143
|
+
const parent = dirname(oldFoundationPath)
|
|
144
|
+
newFoundationPath = parent === '.' ? newName : join(parent, newName)
|
|
145
|
+
newFoundationDir = join(rootDir, newFoundationPath)
|
|
146
|
+
if (existsSync(newFoundationDir)) {
|
|
147
|
+
error(`Cannot rename: target folder ${colors.bright}${newFoundationPath}/${colors.reset} already exists.`)
|
|
148
|
+
process.exit(1)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Find every site that depends on the foundation. Two signals must
|
|
153
|
+
// agree: site.yml::foundation === oldName AND package.json has the
|
|
154
|
+
// dep key. If they disagree (which doctor would have flagged) we
|
|
155
|
+
// still rename whichever signals point to the old name — the goal
|
|
156
|
+
// is to leave the workspace consistent under the new name.
|
|
157
|
+
const sites = await discoverSites(rootDir)
|
|
158
|
+
const affectedSites = []
|
|
159
|
+
for (const site of sites) {
|
|
160
|
+
const sitePkgPath = join(rootDir, site.path, 'package.json')
|
|
161
|
+
const siteYmlPath = join(rootDir, site.path, 'site.yml')
|
|
162
|
+
let pkg, ymlText, ymlData
|
|
163
|
+
try {
|
|
164
|
+
pkg = JSON.parse(await readFile(sitePkgPath, 'utf-8'))
|
|
165
|
+
} catch {
|
|
166
|
+
pkg = null
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
ymlText = await readFile(siteYmlPath, 'utf-8')
|
|
170
|
+
ymlData = yaml.load(ymlText) || {}
|
|
171
|
+
} catch {
|
|
172
|
+
ymlText = null
|
|
173
|
+
ymlData = null
|
|
174
|
+
}
|
|
175
|
+
const hasDep = pkg?.dependencies && oldName in pkg.dependencies
|
|
176
|
+
const ymlMatches = ymlData?.foundation === oldName
|
|
177
|
+
if (hasDep || ymlMatches) {
|
|
178
|
+
affectedSites.push({
|
|
179
|
+
path: site.path,
|
|
180
|
+
name: site.name,
|
|
181
|
+
pkg,
|
|
182
|
+
sitePkgPath,
|
|
183
|
+
siteYmlPath,
|
|
184
|
+
ymlData,
|
|
185
|
+
hasDep,
|
|
186
|
+
ymlMatches,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Read workspace config + root package.json once. Both manifests
|
|
192
|
+
// are kept in sync by addWorkspaceGlob; we update both here too
|
|
193
|
+
// (the multi-PM compatibility invariant).
|
|
194
|
+
const wsConfig = await readWorkspaceConfig(rootDir)
|
|
195
|
+
const rootPkg = await readRootPackageJson(rootDir)
|
|
196
|
+
|
|
197
|
+
// ─── Print plan, then execute ────────────────────────────────
|
|
198
|
+
|
|
199
|
+
log('')
|
|
200
|
+
log(`${colors.bright}Rename foundation${colors.reset}: ${colors.yellow}${oldName}${colors.reset} → ${colors.green}${newName}${colors.reset}`)
|
|
201
|
+
log('')
|
|
202
|
+
if (folderWillRename) {
|
|
203
|
+
info(` Folder: ${oldFoundationPath}/ → ${newFoundationPath}/`)
|
|
204
|
+
} else {
|
|
205
|
+
info(` Folder: ${oldFoundationPath}/ (unchanged — leaf doesn't match package name)`)
|
|
206
|
+
}
|
|
207
|
+
info(` package.json::name: "${oldName}" → "${newName}"`)
|
|
208
|
+
if (affectedSites.length === 0) {
|
|
209
|
+
info(` Sites depending on this foundation: none`)
|
|
210
|
+
} else {
|
|
211
|
+
info(` Sites depending on this foundation:`)
|
|
212
|
+
for (const s of affectedSites) {
|
|
213
|
+
info(` • ${s.name} at ${s.path}/`)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
log('')
|
|
217
|
+
|
|
218
|
+
// 1. Rename the folder (if applicable). Do this first because every
|
|
219
|
+
// later write needs paths under the new location.
|
|
220
|
+
if (folderWillRename) {
|
|
221
|
+
await fsRename(oldFoundationDir, newFoundationDir)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 2. Rewrite the foundation's package.json::name.
|
|
225
|
+
const fndPkgPath = join(newFoundationDir, 'package.json')
|
|
226
|
+
const fndPkg = JSON.parse(await readFile(fndPkgPath, 'utf-8'))
|
|
227
|
+
fndPkg.name = newName
|
|
228
|
+
await writeFile(fndPkgPath, JSON.stringify(fndPkg, null, 2) + '\n')
|
|
229
|
+
|
|
230
|
+
// 3 + 4. For each affected site, update package.json deps and site.yml.
|
|
231
|
+
for (const s of affectedSites) {
|
|
232
|
+
if (s.hasDep) {
|
|
233
|
+
// Rename the dep key. Recompute the file: path against the new
|
|
234
|
+
// foundation location (the old one no longer exists if the
|
|
235
|
+
// folder was renamed).
|
|
236
|
+
const newRel = relative(join(rootDir, s.path), newFoundationDir) || '.'
|
|
237
|
+
const oldValue = s.pkg.dependencies[oldName]
|
|
238
|
+
delete s.pkg.dependencies[oldName]
|
|
239
|
+
s.pkg.dependencies[newName] = oldValue.startsWith('file:')
|
|
240
|
+
? `file:${newRel}`
|
|
241
|
+
: oldValue // npm-pinned, leave it; rename-then-republish would
|
|
242
|
+
// need a separate `pnpm update` step that's out of scope.
|
|
243
|
+
await writeFile(s.sitePkgPath, JSON.stringify(s.pkg, null, 2) + '\n')
|
|
244
|
+
}
|
|
245
|
+
if (s.ymlMatches) {
|
|
246
|
+
const newYmlData = { ...s.ymlData, foundation: newName }
|
|
247
|
+
await writeFile(s.siteYmlPath, yaml.dump(newYmlData, { flowLevel: -1, quotingType: "'" }))
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 5. Update workspace manifests. Both pnpm-workspace.yaml and
|
|
252
|
+
// package.json::workspaces (sync invariant) — only the entry
|
|
253
|
+
// matching the old folder path moves; bare globs like
|
|
254
|
+
// `foundations/*` don't change.
|
|
255
|
+
if (folderWillRename) {
|
|
256
|
+
if (wsConfig.packages.includes(oldFoundationPath)) {
|
|
257
|
+
const idx = wsConfig.packages.indexOf(oldFoundationPath)
|
|
258
|
+
wsConfig.packages[idx] = newFoundationPath
|
|
259
|
+
await writeWorkspaceConfig(rootDir, wsConfig)
|
|
260
|
+
}
|
|
261
|
+
if (Array.isArray(rootPkg.workspaces) && rootPkg.workspaces.includes(oldFoundationPath)) {
|
|
262
|
+
const idx = rootPkg.workspaces.indexOf(oldFoundationPath)
|
|
263
|
+
rootPkg.workspaces[idx] = newFoundationPath
|
|
264
|
+
await writeRootPackageJson(rootDir, rootPkg)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 6. Root scripts can reference the foundation by name (e.g.
|
|
269
|
+
// `pnpm --filter <name> build`). updateRootScripts regenerates
|
|
270
|
+
// them from the current discoverSites output, which has fresh
|
|
271
|
+
// names after the writes above.
|
|
272
|
+
const pm = detectPackageManager()
|
|
273
|
+
const freshSites = await discoverSites(rootDir)
|
|
274
|
+
await updateRootScripts(rootDir, freshSites, pm)
|
|
275
|
+
|
|
276
|
+
// ─── Done ─────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
log('')
|
|
279
|
+
success(`Renamed foundation ${colors.bright}${oldName}${colors.reset} → ${colors.bright}${newName}${colors.reset}`)
|
|
280
|
+
log('')
|
|
281
|
+
log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset} ${colors.dim}(refresh symlinks under the new name)${colors.reset}`)
|
|
282
|
+
log(` ${colors.cyan}${getCliPrefix()} doctor${colors.reset} ${colors.dim}(verify wiring)${colors.reset}`)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function showHelp(prefix) {
|
|
286
|
+
log(`
|
|
287
|
+
${colors.cyan}${colors.bright}Uniweb Rename${colors.reset}
|
|
288
|
+
|
|
289
|
+
Rename a package across the workspace, keeping all wiring in sync.
|
|
290
|
+
|
|
291
|
+
${colors.bright}Usage:${colors.reset}
|
|
292
|
+
${prefix} rename foundation <old-name> <new-name>
|
|
293
|
+
|
|
294
|
+
${colors.bright}What it does (foundation):${colors.reset}
|
|
295
|
+
• Updates the foundation's package.json::name.
|
|
296
|
+
• Renames the folder if its leaf matched the old package name.
|
|
297
|
+
• Updates every site's package.json dependency key + file: path.
|
|
298
|
+
• Updates every site's site.yml::foundation reference.
|
|
299
|
+
• Updates pnpm-workspace.yaml + package.json::workspaces (kept in sync).
|
|
300
|
+
• Regenerates root scripts.
|
|
301
|
+
|
|
302
|
+
${colors.bright}What it does NOT do:${colors.reset}
|
|
303
|
+
• Push to the registry. The publish id (package.json::uniweb.id) is
|
|
304
|
+
independent. To rename on the registry too, run \`${prefix} publish --name <new>\`.
|
|
305
|
+
|
|
306
|
+
${colors.bright}Examples:${colors.reset}
|
|
307
|
+
${prefix} rename foundation src marketing-src
|
|
308
|
+
${prefix} rename foundation marketing acme-marketing
|
|
309
|
+
`)
|
|
310
|
+
}
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-04-
|
|
3
|
+
"generatedAt": "2026-04-30T00:37:08.969Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.13.
|
|
6
|
+
"version": "0.13.3",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"deps": []
|
|
83
83
|
},
|
|
84
84
|
"@uniweb/templates": {
|
|
85
|
-
"version": "0.7.
|
|
85
|
+
"version": "0.7.38",
|
|
86
86
|
"path": "framework/templates",
|
|
87
87
|
"deps": []
|
|
88
88
|
},
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"deps": []
|
|
93
93
|
},
|
|
94
94
|
"@uniweb/unipress": {
|
|
95
|
-
"version": "0.4.
|
|
95
|
+
"version": "0.4.2",
|
|
96
96
|
"path": "framework/unipress",
|
|
97
97
|
"deps": [
|
|
98
98
|
"@uniweb/build",
|
package/src/index.js
CHANGED
|
@@ -495,8 +495,8 @@ async function main() {
|
|
|
495
495
|
// Handle doctor command (dynamic import — depends on @uniweb/build)
|
|
496
496
|
if (command === 'doctor') {
|
|
497
497
|
const { doctor } = await importProjectCommand('./commands/doctor.js')
|
|
498
|
-
await doctor(args.slice(1))
|
|
499
|
-
|
|
498
|
+
const result = await doctor(args.slice(1))
|
|
499
|
+
process.exit(result?.errors > 0 ? 1 : 0)
|
|
500
500
|
}
|
|
501
501
|
|
|
502
502
|
// Handle update command
|
|
@@ -518,6 +518,13 @@ async function main() {
|
|
|
518
518
|
return
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
+
// Handle rename command (dynamic import — depends on @uniweb/build via deps)
|
|
522
|
+
if (command === 'rename') {
|
|
523
|
+
const { rename } = await importProjectCommand('./commands/rename.js')
|
|
524
|
+
await rename(args.slice(1))
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
|
|
521
528
|
// Handle publish command (dynamic import — depends on @uniweb/build)
|
|
522
529
|
if (command === 'publish') {
|
|
523
530
|
const { publish } = await importProjectCommand('./commands/publish.js')
|
|
@@ -815,6 +822,7 @@ ${colors.bright}Usage:${colors.reset}
|
|
|
815
822
|
${colors.bright}Commands:${colors.reset}
|
|
816
823
|
create [name] Create a new project
|
|
817
824
|
add <type> [name] Add a foundation, site, or extension to a project
|
|
825
|
+
rename <type> Rename a workspace package (foundation today)
|
|
818
826
|
build Build the current project
|
|
819
827
|
deploy Deploy a site to Uniweb hosting
|
|
820
828
|
publish Publish a foundation to the Uniweb Registry
|
|
@@ -876,11 +884,12 @@ ${colors.bright}Deploy Options:${colors.reset}
|
|
|
876
884
|
|
|
877
885
|
${colors.bright}Build Options:${colors.reset}
|
|
878
886
|
--target <type> Build target (foundation, site) - auto-detected if not specified
|
|
879
|
-
--
|
|
880
|
-
--
|
|
887
|
+
--link Site: data-only pipeline (Uniweb-edge hosting)
|
|
888
|
+
--bundle Site: vite-built static-host artifact (default)
|
|
889
|
+
--prerender Force pre-rendering (bundle mode only; overrides site.yml)
|
|
890
|
+
--no-prerender Skip pre-rendering (bundle mode only; overrides site.yml)
|
|
881
891
|
--foundation-dir Path to foundation directory (for prerendering)
|
|
882
892
|
--platform <name> Deployment platform (e.g., vercel) for platform-specific output
|
|
883
|
-
--shell Build site without embedded content (for dynamic backend serving)
|
|
884
893
|
|
|
885
894
|
At workspace root, builds all foundations first, then all sites.
|
|
886
895
|
Pre-rendering is enabled by default when build.prerender: true in site.yml
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dist/publish.json` receipt — shared shape used by `publish` and `deploy`.
|
|
3
|
+
*
|
|
4
|
+
* The receipt is a per-checkout cache of the last publish; it lets the
|
|
5
|
+
* deploy verb decide whether a workspace-local foundation needs republishing
|
|
6
|
+
* without a network round-trip on the happy path. It's gitignored, so it
|
|
7
|
+
* never travels with the source — fresh clones, CI runs, and teammates all
|
|
8
|
+
* start with no cache. Both verbs refill the cache lazily by reading the
|
|
9
|
+
* registry's index when the local file is missing.
|
|
10
|
+
*
|
|
11
|
+
* See `kb/framework/build/workspace-ergonomics.md` for the full rationale.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compose the canonical six-field receipt body. All callers MUST go through
|
|
16
|
+
* this helper so the runbook's pp-10 schema check stays meaningful.
|
|
17
|
+
*
|
|
18
|
+
* @param {Object} params
|
|
19
|
+
* @param {string|null} params.gitSha
|
|
20
|
+
* @param {boolean} params.gitDirty
|
|
21
|
+
* @param {string} params.url
|
|
22
|
+
* @param {string} params.publishedAt
|
|
23
|
+
* @param {string} params.classification 'propagate' | 'silent'
|
|
24
|
+
* @returns {Object}
|
|
25
|
+
*/
|
|
26
|
+
export function composeReceipt({ gitSha, gitDirty, url, publishedAt, classification }) {
|
|
27
|
+
return {
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
publishedFromGitSha: gitSha,
|
|
30
|
+
publishedFromGitDirty: gitDirty,
|
|
31
|
+
url,
|
|
32
|
+
publishedAt,
|
|
33
|
+
classification,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the canonical receipt URL given (in priority order):
|
|
39
|
+
* 1. A `publishResult.url` from a fresh upload — server-rendered, handles
|
|
40
|
+
* empty-scope rewrites the CLI can't synthesize.
|
|
41
|
+
* 2. An `existingEntry.url` recorded by a previous publish (refill path).
|
|
42
|
+
* 3. A synthesized canonical form — `file://` for local registries,
|
|
43
|
+
* `<apiUrl>/foundations/<name>@<version>/foundation.js` for remote.
|
|
44
|
+
* The remote form mirrors the path the worker returns in `publishResult.url`,
|
|
45
|
+
* which keeps the receipt's URL parseable by the regex in
|
|
46
|
+
* `deploy.js::deriveLocalFoundationRef` even when the registry's index
|
|
47
|
+
* entry doesn't carry an explicit `url` field. (Unicloud's index entries
|
|
48
|
+
* don't; uniweb-edge's index entries don't either — both rely on the
|
|
49
|
+
* response shape, not the index shape.)
|
|
50
|
+
*
|
|
51
|
+
* Path-shaped candidates (e.g. `/foundations/...`) are joined with the
|
|
52
|
+
* registry's `apiUrl` so the receipt always carries an absolute URL.
|
|
53
|
+
*/
|
|
54
|
+
export function deriveReceiptUrl({ publishResult, existingEntry, registry, name, version, isLocal }) {
|
|
55
|
+
const candidate = publishResult?.url || existingEntry?.url
|
|
56
|
+
if (candidate) {
|
|
57
|
+
if (candidate.startsWith('http') || candidate.startsWith('file://')) return candidate
|
|
58
|
+
if (registry?.apiUrl) return new URL(candidate, registry.apiUrl).toString()
|
|
59
|
+
}
|
|
60
|
+
if (isLocal) return `file://${registry.getPackagePath(name, version)}/`
|
|
61
|
+
return `${registry.apiUrl.replace(/\/$/, '')}/foundations/${name}@${version}/foundation.js`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build a receipt from an existing registry version entry, used to refill a
|
|
66
|
+
* missing `dist/publish.json` from server-of-record state. Returns null if
|
|
67
|
+
* the entry doesn't carry enough provenance to make the receipt useful for
|
|
68
|
+
* staleness checks (the only field that strictly must be present is
|
|
69
|
+
* `publishedFromGitSha` — without it, the deploy verb can't compare against
|
|
70
|
+
* HEAD, and refilling would just re-trigger the auto-publish next run).
|
|
71
|
+
*/
|
|
72
|
+
export function receiptFromRegistryEntry({ existingEntry, registry, name, version, isLocal, isPropagateDefault }) {
|
|
73
|
+
if (!existingEntry || !existingEntry.publishedFromGitSha) return null
|
|
74
|
+
return composeReceipt({
|
|
75
|
+
gitSha: existingEntry.publishedFromGitSha,
|
|
76
|
+
gitDirty: existingEntry.publishedFromGitDirty ?? false,
|
|
77
|
+
url: deriveReceiptUrl({ existingEntry, registry, name, version, isLocal }),
|
|
78
|
+
publishedAt: existingEntry.publishedAt || new Date().toISOString(),
|
|
79
|
+
classification: existingEntry.classification || (isPropagateDefault ? 'propagate' : 'silent'),
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Split `@ns/name@ver`, `~user/name@ver`, or `name@ver` into name + version.
|
|
85
|
+
* Returns null on any shape we don't recognize.
|
|
86
|
+
*/
|
|
87
|
+
export function splitRegistryRef(ref) {
|
|
88
|
+
if (typeof ref !== 'string') return null
|
|
89
|
+
const m = /^(@[^/]+\/[^@]+|~[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(ref)
|
|
90
|
+
return m ? { name: m[1], version: m[2] } : null
|
|
91
|
+
}
|
package/src/utils/registry.js
CHANGED
|
@@ -142,6 +142,21 @@ export class LocalRegistry {
|
|
|
142
142
|
return index[name]?.versions || []
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Get the full version entry for `name@version`, or null if absent.
|
|
147
|
+
* Used by `publish` and `deploy` to refill `dist/publish.json` from
|
|
148
|
+
* server-of-record state when the local cache is missing — see
|
|
149
|
+
* `kb/framework/build/workspace-ergonomics.md` (receipt-as-cache).
|
|
150
|
+
*
|
|
151
|
+
* @param {string} name
|
|
152
|
+
* @param {string} version
|
|
153
|
+
* @returns {Promise<Object|null>}
|
|
154
|
+
*/
|
|
155
|
+
async getVersionEntry(name, version) {
|
|
156
|
+
const versions = await this.getVersions(name)
|
|
157
|
+
return versions.find(v => v.version === version) || null
|
|
158
|
+
}
|
|
159
|
+
|
|
145
160
|
/**
|
|
146
161
|
* Publish a foundation to the local registry.
|
|
147
162
|
* Copies the dist directory and updates the index.
|
|
@@ -253,6 +268,24 @@ export class RemoteRegistry {
|
|
|
253
268
|
return index[name]?.versions || []
|
|
254
269
|
}
|
|
255
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Get the full version entry for `name@version`, or null if absent.
|
|
273
|
+
* Used by `publish` and `deploy` to refill `dist/publish.json` from
|
|
274
|
+
* server-of-record state when the local cache is missing.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} name
|
|
277
|
+
* @param {string} version
|
|
278
|
+
* @returns {Promise<Object|null>}
|
|
279
|
+
*/
|
|
280
|
+
async getVersionEntry(name, version) {
|
|
281
|
+
try {
|
|
282
|
+
const versions = await this.getVersions(name)
|
|
283
|
+
return versions.find(v => v.version === version) || null
|
|
284
|
+
} catch {
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
256
289
|
/**
|
|
257
290
|
* Publish a foundation to the remote registry.
|
|
258
291
|
* Reads files from distDir, encodes as base64, and POSTs to the server.
|
|
@@ -8,13 +8,11 @@
|
|
|
8
8
|
"{{@key}}": "{{this}}"{{#unless @last}},{{/unless}}
|
|
9
9
|
{{/each}}
|
|
10
10
|
},
|
|
11
|
-
{{#if workspaceGlobs.length}}
|
|
12
|
-
"workspaces": [
|
|
11
|
+
"workspaces": [{{#if workspaceGlobs.length}}
|
|
13
12
|
{{#each workspaceGlobs}}
|
|
14
13
|
"{{this}}"{{#unless @last}},{{/unless}}
|
|
15
14
|
{{/each}}
|
|
16
|
-
],
|
|
17
|
-
{{/if}}
|
|
15
|
+
{{/if}}],
|
|
18
16
|
"devDependencies": {
|
|
19
17
|
"@types/node": "^22.0.0",
|
|
20
18
|
"uniweb": "{{version "uniweb"}}"
|