uniweb 0.56.9 → 0.56.11
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/package.json +3 -3
- package/src/backend/foundation-bring-along.js +20 -3
- package/src/commands/register.js +19 -7
- package/src/commands/rename.js +50 -21
- package/src/framework-index.json +3 -3
- package/src/utils/code-upload.js +48 -8
- package/src/utils/semver-precedence.js +57 -0
- package/src/utils/yaml-edit.js +115 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.56.
|
|
3
|
+
"version": "0.56.11",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
44
45
|
"@uniweb/core": "^0.29.4",
|
|
45
46
|
"@uniweb/runtime": "^0.26.4",
|
|
47
|
+
"@uniweb/schemas": "^0.3.3",
|
|
46
48
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
47
|
-
"@uniweb/schemas": "^0.3.2",
|
|
48
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
49
49
|
"@uniweb/kit": "^0.19.3"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
@@ -40,6 +40,7 @@ import { detectFoundationType, isExtensionUrl } from '@uniweb/build'
|
|
|
40
40
|
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
41
41
|
import { readFlagValue } from '../utils/args.js'
|
|
42
42
|
import { isNonInteractive } from '../utils/interactive.js'
|
|
43
|
+
import { compareSemverPrecedence } from '../utils/semver-precedence.js'
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Resolve the site's LOCAL foundation — the one publish should bring along — or
|
|
@@ -117,13 +118,19 @@ export function resolveLocalExtensions(siteDir, siteYml) {
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
// The foundation's scoped catalog name (`@org/name`) from its package.json — an
|
|
120
|
-
// already-scoped
|
|
121
|
+
// already-scoped name, else `uniweb.scope` + a bare one. Null when neither
|
|
121
122
|
// yields a scoped name (then we can't look up the registered version, so the
|
|
122
123
|
// caller treats the foundation as "release it and let register pick the scope").
|
|
124
|
+
//
|
|
125
|
+
// ⛔ The name is `uniweb.id` when set, exactly as the build reads it for the
|
|
126
|
+
// schema that `register` submits (`build/src/schema.js`). Reading `name` alone —
|
|
127
|
+
// as this did until 2026-09-17 — looked a `uniweb.id` foundation up under a name
|
|
128
|
+
// the catalog does not have, so every push re-released it, and pinned the site
|
|
129
|
+
// to that same wrong name.
|
|
123
130
|
function foundationScopedName(dir) {
|
|
124
131
|
try {
|
|
125
132
|
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
126
|
-
const name = pkg?.name
|
|
133
|
+
const name = pkg?.uniweb?.id || pkg?.name
|
|
127
134
|
if (typeof name === 'string' && name.startsWith('@')) return name
|
|
128
135
|
const scope = pkg?.uniweb?.scope
|
|
129
136
|
if (scope && name) return `${String(scope).replace(/\/+$/, '')}/${name}`
|
|
@@ -344,8 +351,18 @@ async function bringLocalCodeAlong({
|
|
|
344
351
|
)
|
|
345
352
|
return { released: false, proceed: true, ref: registeredRef(reg) }
|
|
346
353
|
}
|
|
354
|
+
// ⚖️ The registry takes a NEW version only when it is greater than every one
|
|
355
|
+
// it holds — but an older local version may be one it already has, and
|
|
356
|
+
// re-registering that resumes. The CLI cannot tell which from here, so it
|
|
357
|
+
// does not refuse: it submits, says what will decide, and `register` prints
|
|
358
|
+
// the registry's answer if it is a no.
|
|
359
|
+
const order = compareSemverPrecedence(local.version, reg.latest_version)
|
|
347
360
|
say.info(
|
|
348
|
-
|
|
361
|
+
order === 1
|
|
362
|
+
? `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
|
|
363
|
+
: order === null
|
|
364
|
+
? `Releasing the ${kind} ${label} (registered latest is ${reg.latest_version})…`
|
|
365
|
+
: `Releasing the ${kind} ${label} — not newer than the registered latest ${reg.latest_version}, so the registry takes it only if ${local.version} is already registered with this code…`
|
|
349
366
|
)
|
|
350
367
|
return {
|
|
351
368
|
released: releaseFoundation(local, args, cliBin, say),
|
package/src/commands/register.js
CHANGED
|
@@ -633,8 +633,8 @@ async function runRegister(args = []) {
|
|
|
633
633
|
// Resume path: a registered version is immutable, so re-running after a
|
|
634
634
|
// partial code delivery hits the duplicate rejection here — a STRUCTURED
|
|
635
635
|
// 409 (problem+json, title "Conflict") — and proceeds to phase 2 (the
|
|
636
|
-
// code-uploads plan authorizes against the REGISTERED version;
|
|
637
|
-
//
|
|
636
|
+
// code-uploads plan authorizes against the REGISTERED version; files it
|
|
637
|
+
// already stores come back `present` and are skipped).
|
|
638
638
|
const isDuplicate = !standalone && res.status === 409
|
|
639
639
|
if (isDuplicate) {
|
|
640
640
|
alreadyRegistered = true
|
|
@@ -712,14 +712,18 @@ async function runRegister(args = []) {
|
|
|
712
712
|
)
|
|
713
713
|
}
|
|
714
714
|
log(
|
|
715
|
-
` ${colors.dim}Re-run \`uniweb register\` to resume —
|
|
715
|
+
` ${colors.dim}Re-run \`uniweb register\` to resume — files already stored are skipped.${colors.reset}`
|
|
716
716
|
)
|
|
717
717
|
return { exitCode: 1 }
|
|
718
718
|
}
|
|
719
|
+
// "4 files" on a first upload; "1 uploaded, 3 already stored" on a resume.
|
|
720
|
+
const delivered = result.stored?.length
|
|
721
|
+
? `${result.uploaded.length} uploaded, ${result.stored.length} already stored`
|
|
722
|
+
: `${result.uploaded.length} files`
|
|
719
723
|
if (result.verified === true) {
|
|
720
724
|
// serveBase is guaranteed here — it is what gated the check.
|
|
721
725
|
success(
|
|
722
|
-
`Code delivered (${
|
|
726
|
+
`Code delivered (${delivered}) — entry verified live at ${colors.dim}${result.serveBase}${colors.reset}`
|
|
723
727
|
)
|
|
724
728
|
} else if (result.verified === false) {
|
|
725
729
|
error(
|
|
@@ -727,9 +731,7 @@ async function runRegister(args = []) {
|
|
|
727
731
|
)
|
|
728
732
|
return { exitCode: 1 }
|
|
729
733
|
} else {
|
|
730
|
-
success(
|
|
731
|
-
`Code delivered (${result.uploaded.length} files, ${result.mode} mode)`
|
|
732
|
-
)
|
|
734
|
+
success(`Code delivered (${delivered}, ${result.mode} mode)`)
|
|
733
735
|
// Say WHY nothing was checked, rather than skipping in silence. The CLI
|
|
734
736
|
// never reconstructs a serve URL (see utils/code-upload.js), so a plan
|
|
735
737
|
// without `serve_base` means the location is not ours to know — name it
|
|
@@ -741,6 +743,16 @@ async function runRegister(args = []) {
|
|
|
741
743
|
}
|
|
742
744
|
}
|
|
743
745
|
} catch (err) {
|
|
746
|
+
// ⚖️ A 4xx is the registry REFUSING the plan — e.g. a stored file
|
|
747
|
+
// declared at a different size (`version_content_changed`). Running the
|
|
748
|
+
// same command again gets the same answer, so do not suggest it: print
|
|
749
|
+
// the refusal's own sentence, which says what does help.
|
|
750
|
+
if (err.status >= 400 && err.status < 500 && err.detail) {
|
|
751
|
+
error(`Code delivery refused: HTTP ${err.status}`)
|
|
752
|
+
log(` ${err.detail}`)
|
|
753
|
+
if (err.code) log(` ${colors.dim}(${err.code})${colors.reset}`)
|
|
754
|
+
return { exitCode: 1 }
|
|
755
|
+
}
|
|
744
756
|
error(`Code delivery failed: ${err.message}`)
|
|
745
757
|
log(
|
|
746
758
|
` ${colors.dim}The schema registration above succeeded; re-run \`uniweb register\` to deliver the code.${colors.reset}`
|
package/src/commands/rename.js
CHANGED
|
@@ -60,6 +60,7 @@ import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
|
|
|
60
60
|
import { getExistingPackageNames, validatePackageName } from '../utils/names.js'
|
|
61
61
|
import { detectPackageManager, installCmd } from '../utils/pm.js'
|
|
62
62
|
import { getCliPrefix } from '../utils/interactive.js'
|
|
63
|
+
import { replaceInTopLevelList, setTopLevelScalar } from '../utils/yaml-edit.js'
|
|
63
64
|
|
|
64
65
|
const colors = {
|
|
65
66
|
reset: '\x1b[0m',
|
|
@@ -208,6 +209,24 @@ async function rewritePackageJsonName(pkgPath, newName) {
|
|
|
208
209
|
await writeJsonPreservingStyleAsync(pkgPath, pkg, src)
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Stop, before any file moves, when a site's `site.yml` names the package in a
|
|
214
|
+
* form the in-place edit cannot reach. The alternative is rewriting the whole
|
|
215
|
+
* file, which loses the author's comments and formatting — so we refuse and say
|
|
216
|
+
* how to write it instead.
|
|
217
|
+
*/
|
|
218
|
+
function refuseUneditableSiteYml(sitePaths, key, hint) {
|
|
219
|
+
if (sitePaths.length === 0) return
|
|
220
|
+
for (const path of sitePaths) {
|
|
221
|
+
error(
|
|
222
|
+
`Cannot rename: ${colors.bright}${path}/site.yml${colors.reset} writes \`${key}:\` in a form this command cannot edit without rewriting the file.`
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
log(`Nothing was changed. ${hint}`)
|
|
226
|
+
log('Then run the rename again.')
|
|
227
|
+
process.exit(1)
|
|
228
|
+
}
|
|
229
|
+
|
|
211
230
|
// ─── Foundation rename ───────────────────────────────────────────
|
|
212
231
|
|
|
213
232
|
async function renameFoundation(rootDir, oldName, newName, prefix) {
|
|
@@ -262,7 +281,7 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
|
|
|
262
281
|
for (const site of sites) {
|
|
263
282
|
const sitePkgPath = join(rootDir, site.path, 'package.json')
|
|
264
283
|
const siteYmlPath = join(rootDir, site.path, 'site.yml')
|
|
265
|
-
let pkg, pkgSrc, ymlData
|
|
284
|
+
let pkg, pkgSrc, ymlText, ymlData
|
|
266
285
|
try {
|
|
267
286
|
pkgSrc = await readFile(sitePkgPath, 'utf-8')
|
|
268
287
|
pkg = JSON.parse(pkgSrc)
|
|
@@ -271,7 +290,8 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
|
|
|
271
290
|
pkgSrc = null
|
|
272
291
|
}
|
|
273
292
|
try {
|
|
274
|
-
|
|
293
|
+
ymlText = await readFile(siteYmlPath, 'utf-8')
|
|
294
|
+
ymlData = yaml.load(ymlText) || {}
|
|
275
295
|
} catch {
|
|
276
296
|
ymlData = null
|
|
277
297
|
}
|
|
@@ -285,12 +305,19 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
|
|
|
285
305
|
pkgSrc,
|
|
286
306
|
sitePkgPath,
|
|
287
307
|
siteYmlPath,
|
|
288
|
-
|
|
308
|
+
// Edited in place — see utils/yaml-edit.js — and computed HERE, before
|
|
309
|
+
// anything moves, so a file it cannot edit stops the rename cleanly.
|
|
310
|
+
newYmlText: ymlMatches ? setTopLevelScalar(ymlText, 'foundation', newName) : null,
|
|
289
311
|
hasDep,
|
|
290
312
|
ymlMatches
|
|
291
313
|
})
|
|
292
314
|
}
|
|
293
315
|
}
|
|
316
|
+
refuseUneditableSiteYml(
|
|
317
|
+
affectedSites.filter((s) => s.ymlMatches && s.newYmlText === null).map((s) => s.path),
|
|
318
|
+
'foundation',
|
|
319
|
+
`Write it on one line: \`foundation: ${oldName}\`.`
|
|
320
|
+
)
|
|
294
321
|
|
|
295
322
|
// ─── Print plan, then execute ────────────────────────────────
|
|
296
323
|
|
|
@@ -333,11 +360,7 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
|
|
|
333
360
|
await writeJsonPreservingStyleAsync(s.sitePkgPath, s.pkg, s.pkgSrc)
|
|
334
361
|
}
|
|
335
362
|
if (s.ymlMatches) {
|
|
336
|
-
|
|
337
|
-
await writeFile(
|
|
338
|
-
s.siteYmlPath,
|
|
339
|
-
yaml.dump(newYmlData, { flowLevel: -1, quotingType: "'" })
|
|
340
|
-
)
|
|
363
|
+
await writeFile(s.siteYmlPath, s.newYmlText)
|
|
341
364
|
}
|
|
342
365
|
}
|
|
343
366
|
|
|
@@ -485,9 +508,10 @@ async function renameExtension(rootDir, oldName, newName, prefix) {
|
|
|
485
508
|
const affectedSites = []
|
|
486
509
|
for (const site of sites) {
|
|
487
510
|
const siteYmlPath = join(rootDir, site.path, 'site.yml')
|
|
488
|
-
let ymlData
|
|
511
|
+
let ymlText, ymlData
|
|
489
512
|
try {
|
|
490
|
-
|
|
513
|
+
ymlText = await readFile(siteYmlPath, 'utf-8')
|
|
514
|
+
ymlData = yaml.load(ymlText) || {}
|
|
491
515
|
} catch {
|
|
492
516
|
continue
|
|
493
517
|
}
|
|
@@ -496,9 +520,23 @@ async function renameExtension(rootDir, oldName, newName, prefix) {
|
|
|
496
520
|
(e) => typeof e === 'string' && e.startsWith(oldUrlPrefix)
|
|
497
521
|
)
|
|
498
522
|
if (hits.length > 0) {
|
|
499
|
-
|
|
523
|
+
const replacements = new Map(
|
|
524
|
+
hits.map((e) => [e, newUrlPrefix + e.slice(oldUrlPrefix.length)])
|
|
525
|
+
)
|
|
526
|
+
affectedSites.push({
|
|
527
|
+
site,
|
|
528
|
+
siteYmlPath,
|
|
529
|
+
hits,
|
|
530
|
+
// Edited in place, and computed before anything moves — as for foundations.
|
|
531
|
+
newYmlText: replaceInTopLevelList(ymlText, 'extensions', replacements)
|
|
532
|
+
})
|
|
500
533
|
}
|
|
501
534
|
}
|
|
535
|
+
refuseUneditableSiteYml(
|
|
536
|
+
affectedSites.filter((a) => a.newYmlText === null).map((a) => a.site.path),
|
|
537
|
+
'extensions',
|
|
538
|
+
`Write each entry on a line of its own, e.g. \`- ${oldUrlPrefix}dist/entry.js\`.`
|
|
539
|
+
)
|
|
502
540
|
|
|
503
541
|
log('')
|
|
504
542
|
log(
|
|
@@ -531,16 +569,7 @@ async function renameExtension(rootDir, oldName, newName, prefix) {
|
|
|
531
569
|
await rewritePackageJsonName(join(newExtDir, 'package.json'), newName)
|
|
532
570
|
|
|
533
571
|
for (const a of affectedSites) {
|
|
534
|
-
|
|
535
|
-
typeof e === 'string' && e.startsWith(oldUrlPrefix)
|
|
536
|
-
? newUrlPrefix + e.slice(oldUrlPrefix.length)
|
|
537
|
-
: e
|
|
538
|
-
)
|
|
539
|
-
const newYmlData = { ...a.ymlData, extensions: newExts }
|
|
540
|
-
await writeFile(
|
|
541
|
-
a.siteYmlPath,
|
|
542
|
-
yaml.dump(newYmlData, { flowLevel: -1, quotingType: "'" })
|
|
543
|
-
)
|
|
572
|
+
await writeFile(a.siteYmlPath, a.newYmlText)
|
|
544
573
|
}
|
|
545
574
|
|
|
546
575
|
if (folderWillRename) {
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-17T03:
|
|
3
|
+
"generatedAt": "2026-09-17T03:53:59.364Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "0.3.6",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"deps": []
|
|
97
97
|
},
|
|
98
98
|
"@uniweb/schemas": {
|
|
99
|
-
"version": "0.3.
|
|
99
|
+
"version": "0.3.3",
|
|
100
100
|
"path": "framework/schemas",
|
|
101
101
|
"deps": []
|
|
102
102
|
},
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"deps": []
|
|
117
117
|
},
|
|
118
118
|
"@uniweb/templates": {
|
|
119
|
-
"version": "0.14.
|
|
119
|
+
"version": "0.14.8",
|
|
120
120
|
"path": "framework/templates",
|
|
121
121
|
"deps": []
|
|
122
122
|
},
|
package/src/utils/code-upload.js
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 1. PLAN — POST {apiBase}/dev/registry/code-uploads with the file list
|
|
9
9
|
* ({ path, content_type, size, sha256? }). The response carries
|
|
10
|
-
* one
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* one entry per file: `present: true` for a file the backend
|
|
11
|
+
* already stores for this version (no URL — it is skipped), or an
|
|
12
|
+
* upload target ({ path, method, url, headers }). Plus mode:
|
|
13
|
+
* 'direct' (dev — URLs point back at the backend) or 'presigned'
|
|
14
|
+
* (prod — storage PUTs; bytes never transit the backend). The CLI
|
|
15
|
+
* never branches on the mode.
|
|
14
16
|
* 2. UPLOAD — PUT each file's raw bytes to its URL with the given headers.
|
|
15
17
|
* The ENTRY uploads LAST: a partial upload never yields a
|
|
16
18
|
* loadable version (practical atomicity — there is no server
|
|
@@ -35,7 +37,8 @@
|
|
|
35
37
|
* plan step's per-version file cap — the cap is an abuse guard, the maps
|
|
36
38
|
* simply don't belong on the CDN.)
|
|
37
39
|
* - a registered version is immutable, code included — changed bytes mean
|
|
38
|
-
* a new version
|
|
40
|
+
* a new version. A stored file is never re-sent (the plan marks it
|
|
41
|
+
* `present`), and one declared at a different size is refused (422)
|
|
39
42
|
*/
|
|
40
43
|
|
|
41
44
|
import { createHash } from 'node:crypto'
|
|
@@ -215,7 +218,10 @@ export function computeFoundationDigest(distDir) {
|
|
|
215
218
|
* @param {string} opts.distDir - the built dist/ directory
|
|
216
219
|
* @param {Array} [opts.files] - pre-collected file list (default: collect)
|
|
217
220
|
* @param {(msg: string) => void} [opts.onProgress]
|
|
218
|
-
* @returns {Promise<{ mode: string, uploaded: string[], failed: Array<{path, status, detail}>, verified: boolean|null, serveBase: string|null }>}
|
|
221
|
+
* @returns {Promise<{ mode: string, uploaded: string[], stored: string[], failed: Array<{path, status, detail}>, verified: boolean|null, serveBase: string|null }>}
|
|
222
|
+
* `stored` — files the plan reported as already stored (`present: true`), not re-sent.
|
|
223
|
+
* @throws {Error} when the plan is refused — with `status`, and the problem+json
|
|
224
|
+
* `detail` and `code` when the body carries them
|
|
219
225
|
*/
|
|
220
226
|
export async function uploadFoundationCode({
|
|
221
227
|
apiBase,
|
|
@@ -259,10 +265,22 @@ export async function uploadFoundationCode({
|
|
|
259
265
|
})
|
|
260
266
|
if (!planRes.ok) {
|
|
261
267
|
const body = await planRes.text().catch(() => '')
|
|
268
|
+
// A refusal is problem+json, and its `detail` is a sentence written for the
|
|
269
|
+
// person reading — for a changed file it says what to do. Carry it (and the
|
|
270
|
+
// `code`) instead of a raw body the reader has to dig the sentence out of.
|
|
271
|
+
let problem = null
|
|
272
|
+
try {
|
|
273
|
+
problem = JSON.parse(body)
|
|
274
|
+
} catch {
|
|
275
|
+
problem = null
|
|
276
|
+
}
|
|
277
|
+
const detail = typeof problem?.detail === 'string' ? problem.detail : null
|
|
262
278
|
const err = new Error(
|
|
263
|
-
`code-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
|
|
279
|
+
`code-uploads plan rejected: HTTP ${planRes.status}${detail ? ` — ${detail}` : body ? ` — ${body.slice(0, 300)}` : ''}`
|
|
264
280
|
)
|
|
265
281
|
err.status = planRes.status
|
|
282
|
+
err.detail = detail
|
|
283
|
+
err.code = typeof problem?.code === 'string' ? problem.code : null
|
|
266
284
|
throw err
|
|
267
285
|
}
|
|
268
286
|
const plan = await planRes.json()
|
|
@@ -275,6 +293,7 @@ export async function uploadFoundationCode({
|
|
|
275
293
|
plan.mode === 'direct' ? { Authorization: `Bearer ${token}` } : {}
|
|
276
294
|
|
|
277
295
|
const uploaded = []
|
|
296
|
+
const stored = []
|
|
278
297
|
const failed = []
|
|
279
298
|
for (const file of uploadOrder(list)) {
|
|
280
299
|
const target = targets.get(file.path)
|
|
@@ -286,6 +305,27 @@ export async function uploadFoundationCode({
|
|
|
286
305
|
})
|
|
287
306
|
continue
|
|
288
307
|
}
|
|
308
|
+
// ⭐ A file the backend already stores for this version comes back
|
|
309
|
+
// `present: true`, with no URL — a stored file of a registered version is
|
|
310
|
+
// never overwritten. Skip it. This is what lets an interrupted upload
|
|
311
|
+
// resume, and a re-run on a fully uploaded version pass.
|
|
312
|
+
//
|
|
313
|
+
// ⛔ Before this branch existed, a present entry was PUT to
|
|
314
|
+
// `new URL(undefined, origin)`, which does not throw — it resolves to
|
|
315
|
+
// `<origin>/undefined` — and came back as a failed upload.
|
|
316
|
+
if (target.present === true) {
|
|
317
|
+
stored.push(file.path)
|
|
318
|
+
onProgress(`${file.path} (already stored)`)
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if (!target.url) {
|
|
322
|
+
failed.push({
|
|
323
|
+
path: file.path,
|
|
324
|
+
status: 0,
|
|
325
|
+
detail: 'the plan gave no upload URL'
|
|
326
|
+
})
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
289
329
|
const bytes = readFileSync(join(distDir, file.path))
|
|
290
330
|
try {
|
|
291
331
|
// ⭐ **Retried.** A single connection-level failure used to fail the whole
|
|
@@ -350,5 +390,5 @@ export async function uploadFoundationCode({
|
|
|
350
390
|
}
|
|
351
391
|
}
|
|
352
392
|
|
|
353
|
-
return { mode: plan.mode || 'direct', uploaded, failed, verified, serveBase }
|
|
393
|
+
return { mode: plan.mode || 'direct', uploaded, stored, failed, verified, serveBase }
|
|
354
394
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SemVer 2.0.0 precedence — how a registry orders a foundation's versions.
|
|
3
|
+
*
|
|
4
|
+
* A pre-release sorts below its release (`1.0.0-rc.1` < `1.0.0`), and build
|
|
5
|
+
* metadata carries no order (`1.2.0+b` equals `1.2.0`).
|
|
6
|
+
*
|
|
7
|
+
* ⚖️ Not `compareSemver` in `dep-survey.js`: that one reads dependency specs
|
|
8
|
+
* (`^1.2.3`) for `uniweb update` and compares major.minor.patch only. This one
|
|
9
|
+
* is strict — anything that is not a SemVer version is `null`, never a guess.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// The regex published with the SemVer 2.0.0 specification (semver.org, §FAQ).
|
|
13
|
+
const SEMVER =
|
|
14
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {unknown} version
|
|
18
|
+
* @returns {{ major: number, minor: number, patch: number, pre: string[] }|null}
|
|
19
|
+
*/
|
|
20
|
+
export function parseSemver(version) {
|
|
21
|
+
if (typeof version !== 'string') return null
|
|
22
|
+
const m = SEMVER.exec(version)
|
|
23
|
+
if (!m) return null
|
|
24
|
+
return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ? m[4].split('.') : [] }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} a
|
|
29
|
+
* @param {string} b
|
|
30
|
+
* @returns {-1|0|1|null} how `a` sorts against `b`; null when either is not SemVer
|
|
31
|
+
*/
|
|
32
|
+
export function compareSemverPrecedence(a, b) {
|
|
33
|
+
const x = parseSemver(a)
|
|
34
|
+
const y = parseSemver(b)
|
|
35
|
+
if (!x || !y) return null
|
|
36
|
+
for (const part of ['major', 'minor', 'patch']) {
|
|
37
|
+
if (x[part] !== y[part]) return x[part] > y[part] ? 1 : -1
|
|
38
|
+
}
|
|
39
|
+
// A version with a pre-release sorts below the same version without one.
|
|
40
|
+
if (!x.pre.length || !y.pre.length) {
|
|
41
|
+
return x.pre.length === y.pre.length ? 0 : x.pre.length ? -1 : 1
|
|
42
|
+
}
|
|
43
|
+
for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
|
|
44
|
+
const p = x.pre[i]
|
|
45
|
+
const q = y.pre[i]
|
|
46
|
+
if (p === undefined) return -1
|
|
47
|
+
if (q === undefined) return 1
|
|
48
|
+
if (p === q) continue
|
|
49
|
+
const pNum = /^\d+$/.test(p)
|
|
50
|
+
const qNum = /^\d+$/.test(q)
|
|
51
|
+
// Numeric identifiers compare numerically and sort below alphanumeric ones.
|
|
52
|
+
if (pNum && qNum) return BigInt(p) > BigInt(q) ? 1 : -1
|
|
53
|
+
if (pNum !== qNum) return pNum ? -1 : 1
|
|
54
|
+
return p > q ? 1 : -1
|
|
55
|
+
}
|
|
56
|
+
return 0
|
|
57
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-place edits to a YAML file a person wrote (`site.yml`), for a command that
|
|
3
|
+
* must change one value and leave everything else as it was: comments, key
|
|
4
|
+
* order, blank lines, quoting, a flow list written on one line.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ Never load a hand-written file and dump it back. js-yaml's `dump` drops every
|
|
7
|
+
* comment and re-flows lists and long strings — `scaffold.js` records what that
|
|
8
|
+
* did to the templates whose comments are the point of them.
|
|
9
|
+
*
|
|
10
|
+
* ⭐ Every edit is VERIFIED: the edited text must parse to exactly the old data
|
|
11
|
+
* with that one value changed. A value written in a form the line-level edit
|
|
12
|
+
* cannot reach (a block scalar, an entry split across lines) returns null
|
|
13
|
+
* rather than a guess, so the caller can refuse before it changes anything.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
17
|
+
import yaml from 'js-yaml'
|
|
18
|
+
|
|
19
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
20
|
+
|
|
21
|
+
/** A scalar as written after `key: `, quoted only when YAML needs it (`@scope/name` does). */
|
|
22
|
+
const scalar = (value) => yaml.dump(value, { lineWidth: -1 }).trim()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Where a trailing ` # comment` begins in the text after `key:`, outside quotes,
|
|
26
|
+
* including the whitespace before it. -1 when the line has none.
|
|
27
|
+
*/
|
|
28
|
+
function commentStart(rest) {
|
|
29
|
+
let quote = null
|
|
30
|
+
for (let i = 0; i < rest.length; i++) {
|
|
31
|
+
const ch = rest[i]
|
|
32
|
+
if (quote) {
|
|
33
|
+
if (ch === quote) quote = null
|
|
34
|
+
} else if (ch === '"' || ch === "'") {
|
|
35
|
+
quote = ch
|
|
36
|
+
} else if (ch === '#' && i > 0 && /\s/.test(rest[i - 1])) {
|
|
37
|
+
let start = i
|
|
38
|
+
while (start > 0 && /\s/.test(rest[start - 1])) start--
|
|
39
|
+
return start
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return -1
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The edited text when it parses to `expected`, else null. */
|
|
46
|
+
function verified(after, expected) {
|
|
47
|
+
try {
|
|
48
|
+
return isDeepStrictEqual(yaml.load(after) ?? {}, expected) ? after : null
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function load(text) {
|
|
55
|
+
try {
|
|
56
|
+
const data = yaml.load(text) ?? {}
|
|
57
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : null
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Set a top-level, one-line scalar `key` to `value`, keeping the line's inline
|
|
65
|
+
* comment and every other line as it was.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} text - the file's contents
|
|
68
|
+
* @param {string} key - a top-level key, e.g. `foundation`
|
|
69
|
+
* @param {string} value
|
|
70
|
+
* @returns {string|null} the new contents, or null when the edit cannot be made in place
|
|
71
|
+
*/
|
|
72
|
+
export function setTopLevelScalar(text, key, value) {
|
|
73
|
+
const data = load(text)
|
|
74
|
+
if (!data) return null
|
|
75
|
+
const match = new RegExp(`^${escapeRegex(key)}:([^\\n]*)$`, 'm').exec(text)
|
|
76
|
+
if (!match) return null
|
|
77
|
+
const at = commentStart(match[1])
|
|
78
|
+
const comment = at === -1 ? '' : match[1].slice(at)
|
|
79
|
+
const line = `${key}: ${scalar(value)}${comment}`
|
|
80
|
+
const after = text.slice(0, match.index) + line + text.slice(match.index + match[0].length)
|
|
81
|
+
return verified(after, { ...data, [key]: value })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Replace entries of a top-level list `key` — each `from` value becomes its `to`
|
|
86
|
+
* — wherever an entry is written on a line of its own or inside a one-line flow
|
|
87
|
+
* list, plain or quoted. A value that only CONTAINS `from` is left alone, and so
|
|
88
|
+
* is a comment line.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} text - the file's contents
|
|
91
|
+
* @param {string} key - a top-level list key, e.g. `extensions`
|
|
92
|
+
* @param {Map<string, string>} replacements - old entry → new entry
|
|
93
|
+
* @returns {string|null} the new contents, or null when the edit cannot be made in place
|
|
94
|
+
*/
|
|
95
|
+
export function replaceInTopLevelList(text, key, replacements) {
|
|
96
|
+
const data = load(text)
|
|
97
|
+
if (!data || !Array.isArray(data[key])) return null
|
|
98
|
+
const expected = { ...data, [key]: data[key].map((v) => (replacements.has(v) ? replacements.get(v) : v)) }
|
|
99
|
+
|
|
100
|
+
const after = text
|
|
101
|
+
.split('\n')
|
|
102
|
+
.map((line) => {
|
|
103
|
+
if (/^\s*#/.test(line)) return line
|
|
104
|
+
for (const [from, to] of replacements) {
|
|
105
|
+
const f = escapeRegex(from)
|
|
106
|
+
line = line
|
|
107
|
+
.replace(new RegExp(`'${f}'`, 'g'), () => `'${to.replace(/'/g, "''")}'`)
|
|
108
|
+
.replace(new RegExp(`"${f}"`, 'g'), () => JSON.stringify(to))
|
|
109
|
+
.replace(new RegExp(`(^|[\\s\\[,])${f}(?=$|[\\s,\\]])`, 'g'), (_, lead) => lead + to)
|
|
110
|
+
}
|
|
111
|
+
return line
|
|
112
|
+
})
|
|
113
|
+
.join('\n')
|
|
114
|
+
return verified(after, expected)
|
|
115
|
+
}
|