uniweb 0.69.5 → 0.70.1
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 +8 -8
- package/partials/agents.md +3 -1
- package/src/backend/client.js +38 -7
- package/src/backend/service-request.js +11 -0
- package/src/backend/site-sync.js +149 -19
- package/src/commands/build.js +8 -2
- package/src/commands/clone.js +6 -0
- package/src/commands/publish.js +42 -14
- package/src/commands/pull.js +56 -5
- package/src/commands/push.js +22 -2
- package/src/commands/register.js +15 -6
- package/src/framework-index.json +11 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.1",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,15 +41,15 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/
|
|
45
|
-
"@uniweb/
|
|
46
|
-
"@uniweb/kit": "^0.19.
|
|
47
|
-
"@uniweb/schemas": "^0.7.
|
|
48
|
-
"@uniweb/
|
|
49
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/core": "^0.30.1",
|
|
45
|
+
"@uniweb/runtime": "^0.27.6",
|
|
46
|
+
"@uniweb/kit": "^0.19.5",
|
|
47
|
+
"@uniweb/schemas": "^0.7.1",
|
|
48
|
+
"@uniweb/semantic-parser": "^1.4.1",
|
|
49
|
+
"@uniweb/content-writer": "^0.3.4"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@uniweb/build": "^0.
|
|
52
|
+
"@uniweb/build": "^0.64.1",
|
|
53
53
|
"@uniweb/content-reader": "^1.2.5",
|
|
54
54
|
"@uniweb/semantic-parser": "^1.4.1"
|
|
55
55
|
},
|
package/partials/agents.md
CHANGED
|
@@ -2717,6 +2717,8 @@ Running `extract` before a build is the usual first mistake — it reads the com
|
|
|
2717
2717
|
{ "e5f6g7h8": { "default": "Learn More", "overrides": { "/pricing:cta": "See Pricing" } } }
|
|
2718
2718
|
```
|
|
2719
2719
|
|
|
2720
|
+
An override key is the page's route and the section's id (`cta` for `2-cta.md`); a page's own title, label, description and keywords are `/pricing:_meta`, and in `locales/records/` a record is named by its manifest context (`std/article/launch`).
|
|
2721
|
+
|
|
2720
2722
|
*Free-form bodies* — when a language needs different structure, different images, or copy rewritten rather than translated. The file replaces one section's content:
|
|
2721
2723
|
|
|
2722
2724
|
```
|
|
@@ -2726,7 +2728,7 @@ locales/freeform/es/records/article/x.md # records work too
|
|
|
2726
2728
|
|
|
2727
2729
|
These are **body only — no frontmatter**; params and config still come from the source section. `uniweb i18n init-freeform es pages/about hero` creates one pre-filled and records a source hash, so `uniweb i18n status --freeform` can tell you when the original moved on (`update-hash` to acknowledge). `move`, `rename`, and `prune --freeform` keep them aligned when pages get reorganized.
|
|
2728
2730
|
|
|
2729
|
-
**Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it.
|
|
2731
|
+
**Records translate in the same `extract` run**, into their own manifest at `locales/records/manifest.json` — keyed by the RECORD, so a record translated once is found by every query that returns it. A record whose query has a data schema translates exactly the fields the schema says are text — never an enum, a URL, a date, a number, a file, a reference or a `translatable: false` field — which are the fields a push carries in each language; mark a string that is a code or a key `translatable: false` and no translator is asked for it. An excerpt the build derives is re-derived from each language's body.
|
|
2730
2732
|
|
|
2731
2733
|
**Component side.** Nothing to do: `content.title` arrives in the active language. The one thing a foundation builds is a switcher.
|
|
2732
2734
|
|
package/src/backend/client.js
CHANGED
|
@@ -74,10 +74,12 @@ export function resolveBackendOrigin() {
|
|
|
74
74
|
* The fallback capability doc for when `GET /dev/config` was not asked for (no
|
|
75
75
|
* credential in hand) or did not answer. Keeps the client non-breaking.
|
|
76
76
|
*
|
|
77
|
-
* ⭐ **It is EMPTY, and that is the accurate shape.** The CLI reads
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
77
|
+
* ⭐ **It is EMPTY, and that is the accurate shape.** The CLI reads only that document's
|
|
78
|
+
* `siteContent` key lists — `queryFields`, `pageFields`, `settingsFields` — and their absence
|
|
79
|
+
* is meaningful: unknown means a push sends no optional key, which every deployment accepts,
|
|
80
|
+
* rather than claiming a deployment takes one. Every other key that used to sit here had no
|
|
81
|
+
* reader. *(The leaf named here was `delivery.siteSubscriptionRequired` until 2026-09-26; it
|
|
82
|
+
* went 2026-08-30.)*
|
|
81
83
|
*
|
|
82
84
|
* ⛔ **Do not restore a key "for completeness".** A default for a field nothing reads is
|
|
83
85
|
* a reader waiting to happen, and it is how this file came to describe a client that
|
|
@@ -457,9 +459,12 @@ export class BackendClient {
|
|
|
457
459
|
* were dropped because a serve location is read from the response that carries it
|
|
458
460
|
* (`serve_base`, `serve_url`, `config.base`), never from a handshake; `auth.loginPath`
|
|
459
461
|
* is not read either — the login path is a hardcoded constant
|
|
460
|
-
* (`utils/registry-auth.js`). What is actually consumed is
|
|
461
|
-
* `
|
|
462
|
-
*
|
|
462
|
+
* (`utils/registry-auth.js`). What is actually consumed is the key lists under
|
|
463
|
+
* `siteContent` — `queryFields`, `pageFields`, `settingsFields`, the keys the deployment's
|
|
464
|
+
* Sections declare (`site-sync.js::deploymentFields`) — which is policy, never an address. Do not
|
|
465
|
+
* add a reader for the rest: each one would be a second place a backend's layout is
|
|
466
|
+
* pinned. (This named `delivery.siteSubscriptionRequired` until 2026-09-26; that leaf
|
|
467
|
+
* went 2026-08-30.)
|
|
463
468
|
*
|
|
464
469
|
* @returns {Promise<object>}
|
|
465
470
|
*/
|
|
@@ -577,6 +582,32 @@ export class BackendClient {
|
|
|
577
582
|
}
|
|
578
583
|
}
|
|
579
584
|
|
|
585
|
+
/**
|
|
586
|
+
* GET /dev/site/foundation-schema/{site}/{@scope}/{name}/{version} → the registered version of a
|
|
587
|
+
* foundation a site uses, as the backend holds it: its `schema` (section types with their
|
|
588
|
+
* `data:`), `info`, `data_schemas` (names), `module_url` and `css_url`. Read through the SITE,
|
|
589
|
+
* which is the licence: the caller must be able to read the site, and the site must use the
|
|
590
|
+
* foundation. Null on a 404 (not registered, or not this site's) and on any failure — a caller
|
|
591
|
+
* degrades, since what it is read for can be done without it, less well.
|
|
592
|
+
* @param {string} siteUuid - the site-content uuid
|
|
593
|
+
* @param {string} ref - the catalog ref, `@scope/name@version`
|
|
594
|
+
* @returns {Promise<object|null>}
|
|
595
|
+
*/
|
|
596
|
+
async readRegisteredFoundation(siteUuid, ref) {
|
|
597
|
+
const m = /^(@[^/]+)\/([^@/]+)@(.+)$/.exec(String(ref || ''))
|
|
598
|
+
if (!siteUuid || !m) return null
|
|
599
|
+
try {
|
|
600
|
+
const res = await this.request(
|
|
601
|
+
`/dev/site/foundation-schema/${encodeURIComponent(siteUuid)}/${m[1]}/${encodeURIComponent(m[2])}/${encodeURIComponent(m[3])}`
|
|
602
|
+
)
|
|
603
|
+
if (!res.ok) return null
|
|
604
|
+
const body = await res.json().catch(() => null)
|
|
605
|
+
return body && typeof body === 'object' && body.schema && typeof body.schema === 'object' ? body : null
|
|
606
|
+
} catch {
|
|
607
|
+
return null
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
580
611
|
// ── Orgs ──────────────────────────────────────────────────────────────────────
|
|
581
612
|
|
|
582
613
|
/** GET /dev/orgs → { account_handle, orgs[] }. */
|
|
@@ -76,6 +76,11 @@ export function fingerprintDeclaration(declared) {
|
|
|
76
76
|
.slice(0, 16)
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** Absent, or an empty list — a stored request that asks for nothing. */
|
|
80
|
+
function isNothing(value) {
|
|
81
|
+
return value === undefined || value === null || (Array.isArray(value) && value.length === 0)
|
|
82
|
+
}
|
|
83
|
+
|
|
79
84
|
/** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
|
|
80
85
|
function stableString(value) {
|
|
81
86
|
return JSON.stringify(value, (_k, v) =>
|
|
@@ -212,6 +217,12 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
|
|
|
212
217
|
const base = baseFingerprint || null
|
|
213
218
|
|
|
214
219
|
if (local === remote) return { action: 'none', local, remote }
|
|
220
|
+
// ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict. With
|
|
221
|
+
// nothing stored either — an empty list is the store's nothing — there is nothing to do; with
|
|
222
|
+
// something stored, the file is merely behind. Until 2026-09-26 this was a `conflict`, and the
|
|
223
|
+
// first publish of a site whose file is silent warned that its services "were changed
|
|
224
|
+
// elsewhere, and site.yml changed too", listing both as nothing.
|
|
225
|
+
if (local === null) return { action: isNothing(remoteValue) ? 'none' : 'adopt', local, remote }
|
|
215
226
|
if (!base) return { action: 'conflict', local, remote }
|
|
216
227
|
|
|
217
228
|
const localMoved = local !== base
|
package/src/backend/site-sync.js
CHANGED
|
@@ -46,7 +46,10 @@ import {
|
|
|
46
46
|
harvestRecordItems,
|
|
47
47
|
storedRecordItems,
|
|
48
48
|
reprintRecordItems,
|
|
49
|
-
matchStoredRecords
|
|
49
|
+
matchStoredRecords,
|
|
50
|
+
parseCatalogRef,
|
|
51
|
+
readRegisteredFoundation,
|
|
52
|
+
writeRegisteredFoundation
|
|
50
53
|
} from '@uniweb/build/uwx'
|
|
51
54
|
|
|
52
55
|
// First entity `$`-document out of a `.uwx` we produced or the backend served.
|
|
@@ -326,18 +329,46 @@ function changedSummary(finalized) {
|
|
|
326
329
|
// 404 → null (the emitter then says "register it first"). The bearer is acquired lazily
|
|
327
330
|
// by the client, so a fully-local sync never authenticates.
|
|
328
331
|
//
|
|
329
|
-
// `offline` (set for `-o` / `--dry-run`)
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
+
// `offline` (set for `-o` / `--dry-run`, and for `status`) never touches the backend — an
|
|
333
|
+
// offline emit must never authenticate. It answers with the Models a push or a pull KEPT for
|
|
334
|
+
// this backend (`keptModels`), or null. ⭐ With `siteDir`, an online read keeps each Model it
|
|
335
|
+
// reads, per backend — so a clone, whose foundation is not in the project to declare them, can
|
|
336
|
+
// still compare offline. ⛔ Until 2026-09-26 nothing was kept, and `uniweb status` in a clone
|
|
337
|
+
// could not resolve a Model ("… could not be resolved").
|
|
338
|
+
export function makeModelResolver({ client, offline = false, siteDir = null, backend = null }) {
|
|
339
|
+
const origin = backend ?? client?.origin ?? null
|
|
340
|
+
const kept = siteDir && origin ? keptModels(siteDir, origin) : {}
|
|
332
341
|
const cache = new Map()
|
|
333
342
|
return async (modelName) => {
|
|
334
343
|
if (cache.has(modelName)) return cache.get(modelName)
|
|
335
|
-
|
|
344
|
+
let decl
|
|
345
|
+
if (offline) {
|
|
346
|
+
decl = kept[modelName] ?? null
|
|
347
|
+
} else {
|
|
348
|
+
decl = await client.readDataSchema(modelName)
|
|
349
|
+
if (decl && siteDir && origin) keepModels(siteDir, origin, { [modelName]: decl })
|
|
350
|
+
}
|
|
336
351
|
cache.set(modelName, decl)
|
|
337
352
|
return decl
|
|
338
353
|
}
|
|
339
354
|
}
|
|
340
355
|
|
|
356
|
+
/**
|
|
357
|
+
* The Models a push or a pull read from this backend, by name — kept in `.uniweb/backend-cache.json`
|
|
358
|
+
* so an offline emit resolves what an online one did. Regenerable: losing them costs `status` its
|
|
359
|
+
* comparison until the next push or pull, never a wrong answer.
|
|
360
|
+
*/
|
|
361
|
+
export function keptModels(siteDir, backend) {
|
|
362
|
+
const models = readSyncCacheFile(siteDir, backend).models
|
|
363
|
+
return models && typeof models === 'object' ? models : {}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Keep Models read from this backend, beside the ones already kept. */
|
|
367
|
+
export function keepModels(siteDir, backend, models) {
|
|
368
|
+
if (!models || !Object.keys(models).length) return
|
|
369
|
+
updateSyncCache(siteDir, backend, { models: { ...keptModels(siteDir, backend), ...models } })
|
|
370
|
+
}
|
|
371
|
+
|
|
341
372
|
// `.uniweb/backend-cache.json` — what a backend had and what we last sent it, so the
|
|
342
373
|
// next transfer can skip work. Gitignored, per clone, safe to delete.
|
|
343
374
|
//
|
|
@@ -653,6 +684,50 @@ export function readAppliedInjections(siteDir, backend) {
|
|
|
653
684
|
return readMap(siteDir, backend, 'applied')
|
|
654
685
|
}
|
|
655
686
|
|
|
687
|
+
/**
|
|
688
|
+
* ⭐ THE REGISTERED FOUNDATION A CLONE KEEPS. A site whose foundation is a catalog ref has no build of
|
|
689
|
+
* it in the project, and the section types' `data:` it declares are what type a query named for a
|
|
690
|
+
* data key — `team:` read as `@acme/member` (`typed_by_data_key`). Read once from the backend,
|
|
691
|
+
* through the site, and kept under `.uniweb/` (`@uniweb/build`'s `registered-foundation.js`), where
|
|
692
|
+
* the build, the push and the pull read it; a registered version never changes, so a kept copy is
|
|
693
|
+
* never read again.
|
|
694
|
+
*
|
|
695
|
+
* Null when the foundation is not a catalog ref, the site is not on this backend yet, or the read
|
|
696
|
+
* fails: nothing is then typed by it — a pull writes such a query's schema out, and a push sends
|
|
697
|
+
* what that says — so both go on.
|
|
698
|
+
*/
|
|
699
|
+
export async function ensureRegisteredFoundation({ client, siteDir, siteUuid, ref }) {
|
|
700
|
+
if (!parseCatalogRef(ref)) return null
|
|
701
|
+
const kept = readRegisteredFoundation(siteDir, ref)
|
|
702
|
+
if (kept) return kept
|
|
703
|
+
const reply = siteUuid ? await client.readRegisteredFoundation(siteUuid, ref) : null
|
|
704
|
+
if (reply) writeRegisteredFoundation(siteDir, ref, reply)
|
|
705
|
+
return reply
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// The Sections whose declared keys a push reads before it sends an optional one.
|
|
709
|
+
const DEPLOYMENT_FIELD_LISTS = ['queryFields', 'pageFields', 'settingsFields']
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* The keys this deployment's `queries`, page and `settings` Sections declare, as it says so itself —
|
|
713
|
+
* `GET /dev/config` → `siteContent.queryFields` / `pageFields` / `settingsFields` — so a push sends
|
|
714
|
+
* an optional key only to a deployment that takes it (`typed_by_data_key`; `og_title` and
|
|
715
|
+
* `og_description`): one that does not declare a key refuses a push carrying it. Offline — a dry
|
|
716
|
+
* run, `--output` — the lists the last push to this backend banked, so the package is the one that
|
|
717
|
+
* push would build.
|
|
718
|
+
*
|
|
719
|
+
* Only the lists in hand are returned, as emit options: with none, nothing optional is sent, which
|
|
720
|
+
* every deployment accepts.
|
|
721
|
+
*/
|
|
722
|
+
export async function deploymentFields({ client, siteDir, offline = false }) {
|
|
723
|
+
const source = offline
|
|
724
|
+
? readAppliedInjections(siteDir, client.origin)
|
|
725
|
+
: (await client.discover())?.siteContent
|
|
726
|
+
const out = {}
|
|
727
|
+
for (const name of DEPLOYMENT_FIELD_LISTS) if (Array.isArray(source?.[name])) out[name] = source[name]
|
|
728
|
+
return out
|
|
729
|
+
}
|
|
730
|
+
|
|
656
731
|
/**
|
|
657
732
|
* Bank the content hashes and the injections that produced them. ⛔ ONE call, both
|
|
658
733
|
* maps: they describe the same document, so writing either alone leaves the cache
|
|
@@ -1430,7 +1505,7 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
|
|
|
1430
1505
|
*/
|
|
1431
1506
|
async function comparisonEmit(
|
|
1432
1507
|
siteDir,
|
|
1433
|
-
{ backend = null, priorHashes = {}, sendAll = false, declarations = null } = {}
|
|
1508
|
+
{ backend = null, priorHashes = {}, sendAll = false, declarations = null, fields = null } = {}
|
|
1434
1509
|
) {
|
|
1435
1510
|
const applied = readAppliedInjections(siteDir, backend)
|
|
1436
1511
|
// ⛔ Per backend: an asset id is minted by one and means nothing to another, so a
|
|
@@ -1446,10 +1521,13 @@ async function comparisonEmit(
|
|
|
1446
1521
|
// of its Models here, and nothing is banked.
|
|
1447
1522
|
resolveModel: declarations
|
|
1448
1523
|
? async (name) => declarations.get(name) ?? null
|
|
1449
|
-
: makeModelResolver({ client: null, offline: true }),
|
|
1524
|
+
: makeModelResolver({ client: null, offline: true, siteDir, backend }),
|
|
1450
1525
|
priorHashes,
|
|
1451
1526
|
sendAll,
|
|
1452
1527
|
...applied,
|
|
1528
|
+
// The deployment's own answer (`deploymentFields`), when the caller has just read it, over what
|
|
1529
|
+
// the last push banked.
|
|
1530
|
+
...(fields || {}),
|
|
1453
1531
|
...(Object.keys(queryUuids).length ? { queryUuids } : {}),
|
|
1454
1532
|
...(Object.keys(assetIds).length ? { assetIds } : {})
|
|
1455
1533
|
// ⛔ No `org`: a site's `@/x` refs resolve into its FOUNDATION's scope, which the
|
|
@@ -1484,9 +1562,12 @@ async function comparisonEmit(
|
|
|
1484
1562
|
* @param {Map<string,object>} [opts.declarations] - the Models the pull read, by name — so a
|
|
1485
1563
|
* site whose foundation is not in the project (a clone's) can be re-banked offline
|
|
1486
1564
|
*/
|
|
1487
|
-
export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, declarations } = {}) {
|
|
1488
|
-
|
|
1489
|
-
|
|
1565
|
+
export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, declarations, fields = null } = {}) {
|
|
1566
|
+
// ⛔ With the keys this deployment's Sections take, when the caller has them (`deploymentFields`):
|
|
1567
|
+
// they shape the document the next push sends (`typed_by_data_key`, `og_title`), so a hash banked
|
|
1568
|
+
// without them marks a clone's first push as changed. Until 2026-09-26 a pull banked none.
|
|
1569
|
+
const pkg = await comparisonEmit(siteDir, { backend, sendAll: true, declarations, fields })
|
|
1570
|
+
writeSyncCache(siteDir, backend, onlyWhatThePullTook(siteDir, backend, pkg, recordDocs), pkg.applied || {})
|
|
1490
1571
|
// ⭐ And the identity of the records' list items, from the documents the pull just took:
|
|
1491
1572
|
// the files it wrote hold the stored items in stored order, so the emit over them pairs
|
|
1492
1573
|
// with each document item for item.
|
|
@@ -1505,6 +1586,44 @@ export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, de
|
|
|
1505
1586
|
return Object.keys(pkg.hashes || {}).length
|
|
1506
1587
|
}
|
|
1507
1588
|
|
|
1589
|
+
/**
|
|
1590
|
+
* The hashes a pull may bank: the tree's, except for a record the pull did not take.
|
|
1591
|
+
*
|
|
1592
|
+
* ⛔ ONLY WHAT CAME FROM THE BACKEND IS AGREED. A record on disk that the backend did not
|
|
1593
|
+
* hand back — written since the last push, or one a push had refused — is not in sync
|
|
1594
|
+
* because a pull ran. Until 2026-09-26 it was banked with the rest, so every later push
|
|
1595
|
+
* skipped it and `uniweb status` said "in sync": a record written before a `uniweb sync`
|
|
1596
|
+
* (which pulls, then pushes) never reached the backend. One this backend never accepted —
|
|
1597
|
+
* no identity from it — is always sent: a hash it has banked can only be that mistake's.
|
|
1598
|
+
* One it did accept keeps what the last push banked. The folder keeps its hash too while
|
|
1599
|
+
* it places such a record. A pull that took no records (`--no-records`, a 304) leaves
|
|
1600
|
+
* every record's hash as it was.
|
|
1601
|
+
*
|
|
1602
|
+
* @param {object[]|undefined} recordDocs - the record documents the pull took
|
|
1603
|
+
*/
|
|
1604
|
+
function onlyWhatThePullTook(siteDir, backend, pkg, recordDocs) {
|
|
1605
|
+
const hashes = { ...(pkg.hashes || {}) }
|
|
1606
|
+
const prior = readSyncCache(siteDir, backend)
|
|
1607
|
+
const keep = (key) => {
|
|
1608
|
+
if (Object.hasOwn(prior, key)) hashes[key] = prior[key]
|
|
1609
|
+
else delete hashes[key]
|
|
1610
|
+
}
|
|
1611
|
+
const taken = new Set((recordDocs || []).map((d) => d?.$uuid).filter(Boolean))
|
|
1612
|
+
const recordUuids = readBackendState(siteDir, backend).records || {}
|
|
1613
|
+
let notTaken = 0
|
|
1614
|
+
for (const entry of pkg.records?.index || []) {
|
|
1615
|
+
if (entry.kind === 'folder') continue // the folder itself — held below while it places one
|
|
1616
|
+
const theirs = entry.ownId ? recordUuids[entry.ownId] : null
|
|
1617
|
+
if (theirs && taken.has(theirs)) continue
|
|
1618
|
+
notTaken++
|
|
1619
|
+
const key = `${entry.model} ${entry.id}`
|
|
1620
|
+
if (theirs) keep(key)
|
|
1621
|
+
else delete hashes[key]
|
|
1622
|
+
}
|
|
1623
|
+
if (notTaken) for (const key of Object.keys(hashes)) if (key.startsWith('@uniweb/folder ')) keep(key)
|
|
1624
|
+
return hashes
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1508
1627
|
/**
|
|
1509
1628
|
* Re-bank the hashes of the records a push sent naming a record it created — `keys`,
|
|
1510
1629
|
* the emit's `namesNew` — as the next push will hash them, and fingerprint their list
|
|
@@ -1714,6 +1833,18 @@ export async function pushSyncPackages({
|
|
|
1714
1833
|
if (problem.detail) note(String(problem.detail))
|
|
1715
1834
|
return null
|
|
1716
1835
|
}
|
|
1836
|
+
// ⭐ A RECORD KEEPS ITS TYPE. A record file moved to another type's folder keeps its `$uuid`, and
|
|
1837
|
+
// the backend refuses it as that type (`type_changed`, naming the record and both types), before
|
|
1838
|
+
// writing anything. Storing it as the new type makes it a new record.
|
|
1839
|
+
if (problem?.reason === 'type_changed') {
|
|
1840
|
+
error(`${label} push refused — a stored record would change its type.`)
|
|
1841
|
+
if (problem.detail) note(String(problem.detail))
|
|
1842
|
+
note(
|
|
1843
|
+
'A record keeps its type. To store it as the new type, make it a new record: delete the ' +
|
|
1844
|
+
'`$uuid` line from its file, then push again.'
|
|
1845
|
+
)
|
|
1846
|
+
return null
|
|
1847
|
+
}
|
|
1717
1848
|
if (problem?.reason === 'stale_base') {
|
|
1718
1849
|
error(
|
|
1719
1850
|
`${label} push refused — the backend has newer content than your last pull.`
|
|
@@ -1798,15 +1929,14 @@ export async function pushSyncPackages({
|
|
|
1798
1929
|
` deleted there → uniweb forget --backend ${client.origin}, then push again: it creates a NEW site`
|
|
1799
1930
|
)
|
|
1800
1931
|
note('Deleting this folder removes only your local copy, either way.')
|
|
1801
|
-
} else if (res.status === 409) {
|
|
1802
|
-
//
|
|
1803
|
-
//
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
)
|
|
1932
|
+
} else if (res.status === 409 && (problem?.title || problem?.detail)) {
|
|
1933
|
+
// ⛔ A 409 names its own cause — a reference the store refuses, a rule of the record's type,
|
|
1934
|
+
// an append-only section — so it is shown as the backend put it, not explained by us. Until
|
|
1935
|
+
// 2026-09-26 every one was explained as "this site's record structure is already established
|
|
1936
|
+
// … push it as a new site", a rule the backend no longer has, and it misled over a refused
|
|
1937
|
+
// reference. (`stale_base` and `item_uuid_conflict` have their own explanations, above.)
|
|
1938
|
+
note([problem.title, problem.detail].filter(Boolean).join(' — '))
|
|
1939
|
+
return null
|
|
1810
1940
|
}
|
|
1811
1941
|
if (body) note(body.slice(0, 800))
|
|
1812
1942
|
return null
|
package/src/commands/build.js
CHANGED
|
@@ -668,7 +668,10 @@ async function buildSiteLink(projectDir, options = {}) {
|
|
|
668
668
|
projectDir,
|
|
669
669
|
i18nConfig.localesDir,
|
|
670
670
|
'records'
|
|
671
|
-
)
|
|
671
|
+
),
|
|
672
|
+
// Free-form record translations live under it — the default was `locales/`
|
|
673
|
+
// whatever the site said.
|
|
674
|
+
localesDir: join(projectDir, i18nConfig.localesDir)
|
|
672
675
|
})
|
|
673
676
|
const recordCount = Object.values(recordOutputs).reduce(
|
|
674
677
|
(sum, localeOutputs) => sum + Object.keys(localeOutputs).length,
|
|
@@ -789,7 +792,10 @@ async function buildSite(projectDir, options = {}) {
|
|
|
789
792
|
projectDir,
|
|
790
793
|
i18nConfig.localesDir,
|
|
791
794
|
'records'
|
|
792
|
-
)
|
|
795
|
+
),
|
|
796
|
+
// Free-form record translations live under it — the default was `locales/`
|
|
797
|
+
// whatever the site said.
|
|
798
|
+
localesDir: join(projectDir, i18nConfig.localesDir)
|
|
793
799
|
})
|
|
794
800
|
|
|
795
801
|
// Count collections translated
|
package/src/commands/clone.js
CHANGED
|
@@ -426,6 +426,12 @@ export async function clone(args = [], deps = {}) {
|
|
|
426
426
|
|
|
427
427
|
const pullExtra = []
|
|
428
428
|
if (noRecords) pullExtra.push('--no-records')
|
|
429
|
+
// The workspace this clone was told to work in: the pull is another process, and asks the same
|
|
430
|
+
// question. ⛔ Until 2026-09-26 it was not told, so it used the login's — or, with none chosen,
|
|
431
|
+
// refused: "none is chosen". A workspace from the login or the environment reaches it by itself.
|
|
432
|
+
const orgFlag = flagValue(args, '--org')
|
|
433
|
+
if (orgFlag) pullExtra.push('--org', orgFlag)
|
|
434
|
+
if (args.includes('--personal')) pullExtra.push('--personal')
|
|
429
435
|
|
|
430
436
|
if (deps.skipPull) {
|
|
431
437
|
note('Skipping pull (test mode).')
|
package/src/commands/publish.js
CHANGED
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
* uniweb publish --no-save Do not record this publish in deploy.yml
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
40
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
41
41
|
import { readFile } from 'node:fs/promises'
|
|
42
|
-
import { join } from 'node:path'
|
|
42
|
+
import { join, dirname } from 'node:path'
|
|
43
43
|
import { execSync } from 'node:child_process'
|
|
44
44
|
import yaml from 'js-yaml'
|
|
45
45
|
|
|
@@ -87,6 +87,8 @@ import {
|
|
|
87
87
|
ensureItemUuids,
|
|
88
88
|
readFolderItemUuids,
|
|
89
89
|
readRecordItemUuids,
|
|
90
|
+
deploymentFields,
|
|
91
|
+
ensureRegisteredFoundation,
|
|
90
92
|
recoverUnbankedIdentity,
|
|
91
93
|
ensureSiteExists,
|
|
92
94
|
refuseUnsendableRecords,
|
|
@@ -143,14 +145,27 @@ function readSiteYml(path) {
|
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
// Languages from the BUILT site
|
|
147
|
-
//
|
|
148
|
-
|
|
148
|
+
// Languages from the BUILT site — the authority after a build: `config.languages` where
|
|
149
|
+
// the site declares them (three accepted shapes: 'en', { value, label }, { code, label }),
|
|
150
|
+
// else its default and each language the build produced (`dist/<locale>/site-content.json`).
|
|
151
|
+
// ⛔ Until 2026-09-26 a site that declares no `languages:` — its languages made by its
|
|
152
|
+
// translation files — went live as `['en']`, though the build had produced the others.
|
|
153
|
+
export function languagesFromContent(siteContent, distDir = null) {
|
|
149
154
|
const langs = siteContent?.config?.languages
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
if (Array.isArray(langs) && langs.length > 0) {
|
|
156
|
+
return langs
|
|
157
|
+
.map((l) => (typeof l === 'string' ? l : l?.value || l?.code))
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
}
|
|
160
|
+
const source = siteContent?.config?.defaultLanguage || 'en'
|
|
161
|
+
const built =
|
|
162
|
+
distDir && existsSync(distDir)
|
|
163
|
+
? readdirSync(distDir, { withFileTypes: true })
|
|
164
|
+
.filter((e) => e.isDirectory() && e.name !== source && existsSync(join(distDir, e.name, 'site-content.json')))
|
|
165
|
+
.map((e) => e.name)
|
|
166
|
+
.sort()
|
|
167
|
+
: []
|
|
168
|
+
return [source, ...built]
|
|
154
169
|
}
|
|
155
170
|
|
|
156
171
|
// Languages from site.yml — used only for the dry-run summary (no build yet).
|
|
@@ -326,9 +341,11 @@ export async function publish(args = []) {
|
|
|
326
341
|
// (2026-08-30). Restoring a reader for it would re-create a check that cannot fail
|
|
327
342
|
// while implying a capability that was never negotiable.
|
|
328
343
|
//
|
|
329
|
-
// Discovery is not consulted
|
|
330
|
-
// (`
|
|
331
|
-
//
|
|
344
|
+
// Discovery is not consulted for a gate: the CLI reads the key lists under `siteContent`
|
|
345
|
+
// (`queryFields`, `pageFields`, `settingsFields`), which say what the push below may SEND
|
|
346
|
+
// (`deploymentFields`), never whether to publish. (`delivery.siteSubscriptionRequired`
|
|
347
|
+
// was deleted 2026-08-30; this said it was still read, after the site create, until
|
|
348
|
+
// 2026-09-26.)
|
|
332
349
|
|
|
333
350
|
// ⛔ NOTHING about a runtime is sent from here. `site.yml::runtime` was a
|
|
334
351
|
// vestigial prop and is no longer read [Diego, 2026-08-22]; `?runtime=` is no
|
|
@@ -537,7 +554,7 @@ export async function publish(args = []) {
|
|
|
537
554
|
}
|
|
538
555
|
|
|
539
556
|
// Non-local @std/registry Model schemas resolve through the backend (same as push).
|
|
540
|
-
const resolveModel = makeModelResolver({ client, offline: false })
|
|
557
|
+
const resolveModel = makeModelResolver({ client, offline: false, siteDir })
|
|
541
558
|
|
|
542
559
|
// ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
|
|
543
560
|
// is destructive — a directory emptied by accident, or kept with only a
|
|
@@ -948,8 +965,19 @@ export async function publish(args = []) {
|
|
|
948
965
|
// edited since this clone last synced, the push is refused rather than
|
|
949
966
|
// overwriting them, and nothing goes live. `--force` drops the precondition.
|
|
950
967
|
const forced = args.includes('--force')
|
|
968
|
+
const fields = await deploymentFields({ client, siteDir })
|
|
969
|
+
// A foundation named by catalog ref — a clone's: its kept registered version types a query named
|
|
970
|
+
// for a data key (`ensureRegisteredFoundation`).
|
|
971
|
+
await ensureRegisteredFoundation({
|
|
972
|
+
client,
|
|
973
|
+
siteDir,
|
|
974
|
+
siteUuid: readBackendState(siteDir, client.origin).site?.uuid,
|
|
975
|
+
ref: siteYml?.foundation
|
|
976
|
+
})
|
|
951
977
|
const emitOptions = {
|
|
952
978
|
backend: client.origin,
|
|
979
|
+
// The keys this deployment's Sections take — see deploymentFields.
|
|
980
|
+
...fields,
|
|
953
981
|
...(declaration.declare ? {} : { declareServices: false }),
|
|
954
982
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
955
983
|
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
@@ -1005,7 +1033,7 @@ export async function publish(args = []) {
|
|
|
1005
1033
|
|
|
1006
1034
|
// 7. Go live — make the just-pushed composite live (its current backend state).
|
|
1007
1035
|
const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
|
|
1008
|
-
const languages = languagesFromContent(siteContent)
|
|
1036
|
+
const languages = languagesFromContent(siteContent, dirname(contentPath))
|
|
1009
1037
|
say.info(`Publishing to ${c.dim}${client.origin}${c.reset} …`)
|
|
1010
1038
|
let pubRes
|
|
1011
1039
|
try {
|
package/src/commands/pull.js
CHANGED
|
@@ -60,7 +60,9 @@ import {
|
|
|
60
60
|
writeFileSync,
|
|
61
61
|
mkdirSync,
|
|
62
62
|
mkdtempSync,
|
|
63
|
-
rmSync
|
|
63
|
+
rmSync,
|
|
64
|
+
readdirSync,
|
|
65
|
+
statSync
|
|
64
66
|
} from 'node:fs'
|
|
65
67
|
import { tmpdir } from 'node:os'
|
|
66
68
|
import { createHash } from 'node:crypto'
|
|
@@ -87,6 +89,8 @@ import {
|
|
|
87
89
|
} from '../utils/pull-written.js'
|
|
88
90
|
import {
|
|
89
91
|
makeModelResolver,
|
|
92
|
+
ensureRegisteredFoundation,
|
|
93
|
+
deploymentFields,
|
|
90
94
|
rebankSyncHashes,
|
|
91
95
|
writeQueryUuids,
|
|
92
96
|
mergeBaseVersions,
|
|
@@ -427,6 +431,27 @@ function readManifestTokens(buf) {
|
|
|
427
431
|
* @param {object} [deps] - injectable seams for testing: `fetch` (default global
|
|
428
432
|
* fetch), `resolveSiteDir`, `getToken` (skip auth).
|
|
429
433
|
*/
|
|
434
|
+
// Every file under `roots` (site-relative paths, files or directories), site-relative.
|
|
435
|
+
function filesUnder(siteDir, roots) {
|
|
436
|
+
const out = []
|
|
437
|
+
const walk = (rel) => {
|
|
438
|
+
const abs = join(siteDir, rel)
|
|
439
|
+
let stat
|
|
440
|
+
try {
|
|
441
|
+
stat = statSync(abs)
|
|
442
|
+
} catch {
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
if (stat.isDirectory()) {
|
|
446
|
+
for (const name of readdirSync(abs)) walk(join(rel, name))
|
|
447
|
+
} else if (stat.isFile()) {
|
|
448
|
+
out.push(rel)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
for (const root of roots) walk(root)
|
|
452
|
+
return out
|
|
453
|
+
}
|
|
454
|
+
|
|
430
455
|
/**
|
|
431
456
|
* Refuse a pull that would overwrite unsaved work. Returns a result object to
|
|
432
457
|
* return from `pull`, or null to proceed.
|
|
@@ -440,7 +465,12 @@ async function checkWorkingTree(siteDir, args) {
|
|
|
440
465
|
let dirty = uncommittedUnder(siteDir, roots)
|
|
441
466
|
|
|
442
467
|
if (dirty === null) {
|
|
443
|
-
// Not a git work tree. Nothing to fall back on if this goes wrong
|
|
468
|
+
// Not a git work tree. Nothing to fall back on if this goes wrong — unless nothing here is the
|
|
469
|
+
// author's: every file under pull's roots is one a pull, or the clone that made this site, wrote
|
|
470
|
+
// and nothing has changed since (`pull-written.js`). ⛔ Until 2026-09-26 a fresh clone's own pull,
|
|
471
|
+
// which runs with no terminal to ask, refused here, and `uniweb clone` could not finish.
|
|
472
|
+
const written = readWritten(siteDir)
|
|
473
|
+
if (filesUnder(siteDir, roots).every((f) => isPullOutput(siteDir, f, written))) return null
|
|
444
474
|
if (isNonInteractive(args)) {
|
|
445
475
|
error('Refusing to pull: this site is not in a git repository.')
|
|
446
476
|
note(
|
|
@@ -684,6 +714,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
684
714
|
args,
|
|
685
715
|
command: 'Pulling'
|
|
686
716
|
})
|
|
717
|
+
// One reader of Models for the whole pull, so a Model is read once whichever lane asks first.
|
|
718
|
+
const readModel = makeModelResolver({ client, siteDir })
|
|
687
719
|
|
|
688
720
|
// ⭐ No scope check any more — and none is needed. This project's identity on
|
|
689
721
|
// `client.origin` is read from that origin's own section of sync.json, so a
|
|
@@ -942,9 +974,28 @@ export async function pull(args = [], deps = {}) {
|
|
|
942
974
|
// which is the pre-existing behaviour and right for a fresh clone.
|
|
943
975
|
}
|
|
944
976
|
|
|
977
|
+
// ⭐ The Models the site's queries name — what a clone, with no foundation to read them from,
|
|
978
|
+
// tells a query's derived `deferred:` from its author's by. ⛔ Until 2026-09-26 a clone wrote
|
|
979
|
+
// the derivation into its queries as though authored. One that cannot be read is judged
|
|
980
|
+
// without it, which keeps the value: the safe direction.
|
|
981
|
+
const models = {}
|
|
982
|
+
for (const model of pulledQueryModels) {
|
|
983
|
+
try {
|
|
984
|
+
const declaration = await readModel(model)
|
|
985
|
+
if (declaration) models[model] = declaration
|
|
986
|
+
} catch {
|
|
987
|
+
// unreadable here
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
// A foundation named by catalog ref — a clone's: keep its registered version before the
|
|
991
|
+
// queries are written, since its section types are what type a query named for a data key.
|
|
992
|
+
if (!keepAuthoredFoundation) {
|
|
993
|
+
await ensureRegisteredFoundation({ client, siteDir, siteUuid: siteContentUuid, ref: siteDoc?.info?.foundation })
|
|
994
|
+
}
|
|
945
995
|
const report = siteContentDocumentToProject({
|
|
946
996
|
document: siteDoc,
|
|
947
997
|
siteRoot: siteDir,
|
|
998
|
+
models,
|
|
948
999
|
// Which backend's asset ids to read back as the author's own paths. Without
|
|
949
1000
|
// it a pull leaves every image pointing at a backend route.
|
|
950
1001
|
backend: client.origin,
|
|
@@ -985,7 +1036,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
985
1036
|
if (folder && !folder.notModified && folder.docs?.length) {
|
|
986
1037
|
const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
|
|
987
1038
|
pulledRecordDocs = recordDocs
|
|
988
|
-
const resolveModel =
|
|
1039
|
+
const resolveModel = readModel
|
|
989
1040
|
const declByModel = new Map()
|
|
990
1041
|
pulledDeclarations = declByModel
|
|
991
1042
|
for (const model of [
|
|
@@ -1102,7 +1153,6 @@ export async function pull(args = [], deps = {}) {
|
|
|
1102
1153
|
// in the project (a clone's) resolves none of them otherwise: every template clone
|
|
1103
1154
|
// said "could not re-bank" until 2026-09-25, and banked nothing.
|
|
1104
1155
|
const declarations = pulledDeclarations || new Map()
|
|
1105
|
-
const readModel = makeModelResolver({ client })
|
|
1106
1156
|
for (const model of pulledQueryModels) {
|
|
1107
1157
|
if (declarations.has(model)) continue
|
|
1108
1158
|
try {
|
|
@@ -1113,7 +1163,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
1113
1163
|
}
|
|
1114
1164
|
await rebankSyncHashes(siteDir, client.origin, {
|
|
1115
1165
|
recordDocs: pulledRecordDocs,
|
|
1116
|
-
...(declarations.size ? { declarations } : {})
|
|
1166
|
+
...(declarations.size ? { declarations } : {}),
|
|
1167
|
+
fields: await deploymentFields({ client, siteDir })
|
|
1117
1168
|
})
|
|
1118
1169
|
} catch (err) {
|
|
1119
1170
|
note(`! could not re-bank the sync cache: ${err.message}`)
|
package/src/commands/push.js
CHANGED
|
@@ -83,6 +83,8 @@ import {
|
|
|
83
83
|
readFolderItemUuids,
|
|
84
84
|
readQueryUuids,
|
|
85
85
|
readRecordItemUuids,
|
|
86
|
+
deploymentFields,
|
|
87
|
+
ensureRegisteredFoundation,
|
|
86
88
|
recoverUnbankedIdentity,
|
|
87
89
|
ensureItemUuids,
|
|
88
90
|
refuseUnsendableRecords,
|
|
@@ -405,7 +407,7 @@ export async function push(args = [], deps = {}) {
|
|
|
405
407
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
406
408
|
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
407
409
|
...(foundationDir ? { foundationDir } : {}),
|
|
408
|
-
resolveModel: makeModelResolver({ client, offline: false })
|
|
410
|
+
resolveModel: makeModelResolver({ client, offline: false, siteDir })
|
|
409
411
|
})
|
|
410
412
|
mediaRefs = probe.localAssets || []
|
|
411
413
|
refusals = probe.refusals || []
|
|
@@ -498,6 +500,21 @@ export async function push(args = [], deps = {}) {
|
|
|
498
500
|
output || dryRun
|
|
499
501
|
? readItemUuids(siteDir, client.origin)
|
|
500
502
|
: await ensureItemUuids({ client, siteDir, note })
|
|
503
|
+
const fields = await deploymentFields({
|
|
504
|
+
client,
|
|
505
|
+
siteDir,
|
|
506
|
+
offline: Boolean(output) || dryRun
|
|
507
|
+
})
|
|
508
|
+
// A foundation named by catalog ref — a clone's: its kept registered version types a query named
|
|
509
|
+
// for a data key (`ensureRegisteredFoundation`). Offline, only what is already kept.
|
|
510
|
+
if (!output && !dryRun) {
|
|
511
|
+
await ensureRegisteredFoundation({
|
|
512
|
+
client,
|
|
513
|
+
siteDir,
|
|
514
|
+
siteUuid: readBackendState(siteDir, client.origin).site?.uuid,
|
|
515
|
+
ref: siteYml?.foundation
|
|
516
|
+
})
|
|
517
|
+
}
|
|
501
518
|
const emitOptions = {
|
|
502
519
|
backend: client.origin,
|
|
503
520
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
@@ -505,12 +522,15 @@ export async function push(args = [], deps = {}) {
|
|
|
505
522
|
// Identity for the `queries` section — see readQueryUuids. Keyed by
|
|
506
523
|
// name, because a declaration has no file for a path-keyed map to hold.
|
|
507
524
|
queryUuids: readQueryUuids(siteDir, client.origin),
|
|
525
|
+
// The keys this deployment's Sections take — see deploymentFields.
|
|
526
|
+
...fields,
|
|
508
527
|
// Identity for the records' list items — see readRecordItemUuids.
|
|
509
528
|
recordItemUuids: readRecordItemUuids(siteDir, client.origin),
|
|
510
529
|
...(foundationDir ? { foundationDir } : {}),
|
|
511
530
|
resolveModel: makeModelResolver({
|
|
512
531
|
client,
|
|
513
|
-
offline: Boolean(output) || dryRun
|
|
532
|
+
offline: Boolean(output) || dryRun,
|
|
533
|
+
siteDir
|
|
514
534
|
}),
|
|
515
535
|
priorHashes,
|
|
516
536
|
sendAll,
|
package/src/commands/register.js
CHANGED
|
@@ -835,10 +835,18 @@ async function runRegister(args = []) {
|
|
|
835
835
|
if (!res.ok) {
|
|
836
836
|
// Resume path: a registered version is immutable, so re-running after a
|
|
837
837
|
// partial code delivery hits the duplicate rejection here — a STRUCTURED
|
|
838
|
-
// 409 (problem+json, title "Conflict"
|
|
839
|
-
// code-uploads plan authorizes against the REGISTERED
|
|
840
|
-
// already stores come back `present` and are skipped).
|
|
841
|
-
|
|
838
|
+
// 409 (problem+json, title "Conflict", naming the `version` it refused) — and
|
|
839
|
+
// proceeds to phase 2 (the code-uploads plan authorizes against the REGISTERED
|
|
840
|
+
// version; files it already stores come back `present` and are skipped).
|
|
841
|
+
//
|
|
842
|
+
// ⛔ ONE STATUS, MORE THAN ONE MEANING. A 409 that names no version is another
|
|
843
|
+
// refusal — a data schema changed where the backend holds records of it
|
|
844
|
+
// (`reason: "destructive_republish"`) — and until 2026-09-26 it was read as a
|
|
845
|
+
// resume too: "already registered — resuming code delivery", then a 404 for a
|
|
846
|
+
// version that was never registered, and the refusal's own sentence never shown.
|
|
847
|
+
// A body with no shape to read resumes, as it always did.
|
|
848
|
+
const isDuplicate =
|
|
849
|
+
!standalone && res.status === 409 && (!parsedBody || typeof parsedBody !== 'object' || parsedBody.version !== undefined)
|
|
842
850
|
if (isDuplicate) {
|
|
843
851
|
alreadyRegistered = true
|
|
844
852
|
info(
|
|
@@ -869,8 +877,9 @@ async function runRegister(args = []) {
|
|
|
869
877
|
// because a 422 the CLI did not know about arrived exactly that way.
|
|
870
878
|
if (parsedBody?.detail) {
|
|
871
879
|
log(` ${parsedBody.detail}`)
|
|
872
|
-
|
|
873
|
-
|
|
880
|
+
const code = parsedBody.code ?? parsedBody.reason
|
|
881
|
+
if (code) {
|
|
882
|
+
log(` ${colors.dim}(${code})${colors.reset}`)
|
|
874
883
|
}
|
|
875
884
|
} else if (rawBody) {
|
|
876
885
|
log(` ${colors.dim}${rawBody.slice(0, 500)}${colors.reset}`)
|
package/src/framework-index.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-26T21:30:58.826Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "0.6.
|
|
6
|
+
"version": "0.6.3",
|
|
7
7
|
"path": "framework/api",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/core"
|
|
10
10
|
]
|
|
11
11
|
},
|
|
12
12
|
"@uniweb/build": {
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.64.1",
|
|
14
14
|
"path": "framework/build",
|
|
15
15
|
"deps": [
|
|
16
16
|
"@uniweb/content-reader",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"deps": []
|
|
36
36
|
},
|
|
37
37
|
"@uniweb/core": {
|
|
38
|
-
"version": "0.
|
|
38
|
+
"version": "0.30.1",
|
|
39
39
|
"path": "framework/core",
|
|
40
40
|
"deps": [
|
|
41
41
|
"@uniweb/semantic-parser",
|
|
@@ -48,14 +48,14 @@
|
|
|
48
48
|
"deps": []
|
|
49
49
|
},
|
|
50
50
|
"@uniweb/icons": {
|
|
51
|
-
"version": "0.4.
|
|
51
|
+
"version": "0.4.21",
|
|
52
52
|
"path": "framework/icons",
|
|
53
53
|
"deps": [
|
|
54
54
|
"@uniweb/core"
|
|
55
55
|
]
|
|
56
56
|
},
|
|
57
57
|
"@uniweb/kit": {
|
|
58
|
-
"version": "0.19.
|
|
58
|
+
"version": "0.19.5",
|
|
59
59
|
"path": "framework/kit",
|
|
60
60
|
"deps": [
|
|
61
61
|
"@uniweb/core",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"deps": []
|
|
76
76
|
},
|
|
77
77
|
"@uniweb/projections": {
|
|
78
|
-
"version": "0.7.
|
|
78
|
+
"version": "0.7.4",
|
|
79
79
|
"path": "framework/projections",
|
|
80
80
|
"deps": [
|
|
81
81
|
"@uniweb/content-writer",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
]
|
|
84
84
|
},
|
|
85
85
|
"@uniweb/runtime": {
|
|
86
|
-
"version": "0.27.
|
|
86
|
+
"version": "0.27.6",
|
|
87
87
|
"path": "framework/runtime",
|
|
88
88
|
"deps": [
|
|
89
89
|
"@uniweb/core",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"deps": []
|
|
97
97
|
},
|
|
98
98
|
"@uniweb/schemas": {
|
|
99
|
-
"version": "0.7.
|
|
99
|
+
"version": "0.7.1",
|
|
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.17.
|
|
119
|
+
"version": "0.17.3",
|
|
120
120
|
"path": "framework/templates",
|
|
121
121
|
"deps": []
|
|
122
122
|
},
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"deps": []
|
|
127
127
|
},
|
|
128
128
|
"@uniweb/unipress": {
|
|
129
|
-
"version": "0.10.
|
|
129
|
+
"version": "0.10.7",
|
|
130
130
|
"path": "framework/unipress",
|
|
131
131
|
"deps": [
|
|
132
132
|
"@uniweb/build",
|