uniweb 0.33.1 → 0.34.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 +7 -7
- package/partials/agents.md +73 -38
- package/src/backend/client.js +122 -35
- package/src/backend/site-sync.js +42 -43
- package/src/commands/build.js +17 -17
- package/src/commands/clone.js +7 -7
- package/src/commands/content.js +2 -3
- package/src/commands/doctor.js +34 -10
- package/src/commands/i18n.js +53 -39
- package/src/commands/publish.js +39 -15
- package/src/commands/pull.js +26 -25
- package/src/commands/push.js +24 -14
- package/src/commands/validate.js +1 -1
- package/src/framework-index.json +16 -9
- package/src/index.js +39 -4
- package/src/utils/flag-guard.js +2 -2
- package/src/utils/git.js +8 -2
- package/src/utils/records-guard.js +80 -0
- package/src/utils/schemaless-report.js +4 -4
- package/src/utils/site-data-upload.js +2 -2
- package/src/utils/site-identity.js +65 -2
- package/templates/site/site.yml.hbs +24 -6
package/src/commands/pull.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* - content lane → `siteContentDocumentToProject` (site.yml/theme.yml/head.html,
|
|
12
12
|
* pages/**, layout/**), and
|
|
13
|
-
* - folder lane → `
|
|
13
|
+
* - folder lane → `recordsToProject` (the folder + record files).
|
|
14
14
|
*
|
|
15
15
|
* Pull is a CHECKOUT, not a merge — the "git-pull-like" it used to claim here was
|
|
16
16
|
* misleading. It reconciles the working tree to the backend: section bodies are
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
*
|
|
37
37
|
* Usage:
|
|
38
38
|
* uniweb pull GET both lanes, project to files, prune orphans
|
|
39
|
-
* uniweb pull --no-
|
|
39
|
+
* uniweb pull --no-records Pull pages only; skip the folder (records) lane
|
|
40
40
|
* uniweb pull --no-delete Project, but keep files with no backend item
|
|
41
41
|
* uniweb pull --merge Three-way merge local changes with the backend's
|
|
42
42
|
* uniweb pull --force Pull over uncommitted local changes (discards them)
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
* A project that never pushed has no `$uuid` to pull by — pull is a no-op with a
|
|
53
53
|
* clear message. The backend serves each lane as a `.uwx` (ZIP: `manifest.json` +
|
|
54
54
|
* `entities/<uuid>.json`); `readPullDocuments` reads the entity files out of it, with
|
|
55
|
-
* a tolerant JSON fallback (`extractDocument` / `
|
|
55
|
+
* a tolerant JSON fallback (`extractDocument` / `splitRecordsPull`). Verified live
|
|
56
56
|
* against the playground backend, 2026-06-17.
|
|
57
57
|
*/
|
|
58
58
|
|
|
@@ -71,12 +71,11 @@ import yaml from 'js-yaml'
|
|
|
71
71
|
import { downloadMissingAssets } from '../backend/asset-download.js'
|
|
72
72
|
import {
|
|
73
73
|
siteContentDocumentToProject,
|
|
74
|
-
|
|
75
|
-
resolveCollectionsConfig,
|
|
74
|
+
recordsToProject,
|
|
76
75
|
readZip,
|
|
77
76
|
computeUnitHashes,
|
|
78
77
|
collectUnitUuids,
|
|
79
|
-
|
|
78
|
+
collectQueryUuids
|
|
80
79
|
} from '@uniweb/build/uwx'
|
|
81
80
|
import {
|
|
82
81
|
readWritten,
|
|
@@ -86,7 +85,7 @@ import {
|
|
|
86
85
|
import {
|
|
87
86
|
makeModelResolver,
|
|
88
87
|
rebankSyncHashes,
|
|
89
|
-
|
|
88
|
+
writeQueryUuids,
|
|
90
89
|
mergeBaseVersions,
|
|
91
90
|
mergeItemBaseVersions,
|
|
92
91
|
writeUnitBases,
|
|
@@ -203,7 +202,7 @@ export function extractDocument(payload) {
|
|
|
203
202
|
// Split a collections pull (the folder + the entities it references) into the
|
|
204
203
|
// folder document and the record documents. Tolerant of an array, an
|
|
205
204
|
// `{ entities }` / `{ documents }` list, or an explicit `{ folder, records }`.
|
|
206
|
-
export function
|
|
205
|
+
export function splitRecordsPull(payload) {
|
|
207
206
|
if (payload?.folder)
|
|
208
207
|
return { folderDoc: payload.folder, recordDocs: payload.records || [] }
|
|
209
208
|
const list = Array.isArray(payload)
|
|
@@ -240,7 +239,7 @@ export function readPullDocuments(buf) {
|
|
|
240
239
|
}
|
|
241
240
|
return docs
|
|
242
241
|
}
|
|
243
|
-
// JSON fallback — flatten any envelope
|
|
242
|
+
// JSON fallback — flatten any envelope splitRecordsPull understands into a
|
|
244
243
|
// flat `$`-document list (a raw doc, a list, `{entities}`/`{documents}`, or
|
|
245
244
|
// `{folder, records}`).
|
|
246
245
|
let payload
|
|
@@ -519,8 +518,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
519
518
|
const dryRun = args.includes('--dry-run')
|
|
520
519
|
const tokenFlag = flagValue(args, '--token')
|
|
521
520
|
const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
|
|
522
|
-
const
|
|
523
|
-
args.includes('--no-
|
|
521
|
+
const noRecords =
|
|
522
|
+
args.includes('--no-records') || args.includes('--content-only')
|
|
524
523
|
const force = args.includes('--force')
|
|
525
524
|
const mergeMode = args.includes('--merge')
|
|
526
525
|
|
|
@@ -598,7 +597,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
598
597
|
info(
|
|
599
598
|
`Dry run — would pull content from ${colors.dim}${client.origin}${colors.reset}`
|
|
600
599
|
)
|
|
601
|
-
if (!
|
|
600
|
+
if (!noRecords) info(`Dry run — would also pull records`)
|
|
602
601
|
return { exitCode: 0 }
|
|
603
602
|
}
|
|
604
603
|
|
|
@@ -691,8 +690,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
691
690
|
writeItemUuids(siteDir, collectUnitUuids(siteDoc))
|
|
692
691
|
// The collections section's identity has no file to live in either — same
|
|
693
692
|
// reason, same remedy, keyed by name. A pull is the other route by which a
|
|
694
|
-
// copy can recover it (see
|
|
695
|
-
|
|
693
|
+
// copy can recover it (see readQueryUuids).
|
|
694
|
+
writeQueryUuids(siteDir, collectQueryUuids(siteDoc))
|
|
696
695
|
// Bring the media down BEFORE projecting: a newly-landed asset gains a map
|
|
697
696
|
// entry, and the projection reads that map to put authored paths back. Run
|
|
698
697
|
// after, and this pull's new assets would project as URLs and only restore
|
|
@@ -777,13 +776,13 @@ export async function pull(args = [], deps = {}) {
|
|
|
777
776
|
// Lane 2 — folder → the folder + record files, keyed by the SAME site-content uuid
|
|
778
777
|
// (the backend resolves the site's `@uniweb/folder` from it; the framework never
|
|
779
778
|
// holds a folder uuid). Models are resolved by name (async) up front, so
|
|
780
|
-
//
|
|
781
|
-
if (!
|
|
782
|
-
const folder = await getDocs('
|
|
779
|
+
// recordsToProject keeps its synchronous contract. A 304 leaves files as-is.
|
|
780
|
+
if (!noRecords) {
|
|
781
|
+
const folder = await getDocs('records', () =>
|
|
783
782
|
client.pullFolder(siteContentUuid, { etag: etagFolder })
|
|
784
783
|
)
|
|
785
784
|
if (folder && !folder.notModified && folder.docs?.length) {
|
|
786
|
-
const { folderDoc, recordDocs } =
|
|
785
|
+
const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
|
|
787
786
|
const resolveModel = makeModelResolver({ client })
|
|
788
787
|
const declByModel = new Map()
|
|
789
788
|
for (const model of [
|
|
@@ -795,18 +794,20 @@ export async function pull(args = [], deps = {}) {
|
|
|
795
794
|
note(`! could not resolve model ${model}: ${err.message}`)
|
|
796
795
|
}
|
|
797
796
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
797
|
+
// ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its
|
|
798
|
+
// `$model` names the pool folder — not by any query that happens to select
|
|
799
|
+
// it. `recordsToProject` reads `site.yml::$org` itself, so a `@/x`
|
|
800
|
+
// model the producer resolved to `@org/x` is placed back where the author
|
|
801
|
+
// wrote it.
|
|
802
|
+
const report = recordsToProject({
|
|
802
803
|
folderDoc,
|
|
803
804
|
recordDocs,
|
|
804
805
|
siteRoot: siteDir,
|
|
805
806
|
opts: {
|
|
806
|
-
resolveDeclaration: (name) => declByModel.get(name) || null
|
|
807
|
-
collectionsConfig
|
|
807
|
+
resolveDeclaration: (name) => declByModel.get(name) || null
|
|
808
808
|
}
|
|
809
809
|
})
|
|
810
|
+
if (report.records === 'updated') info('Wrote records.yml')
|
|
810
811
|
records += report.placed.length + report.updated.length
|
|
811
812
|
for (const s of report.skipped)
|
|
812
813
|
note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
|
|
@@ -840,7 +841,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
840
841
|
siteDir,
|
|
841
842
|
[
|
|
842
843
|
...wrote,
|
|
843
|
-
...['site.yml', 'theme.yml', 'head.html', '
|
|
844
|
+
...['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml'].map((f) =>
|
|
844
845
|
join(siteDir, f)
|
|
845
846
|
)
|
|
846
847
|
],
|
package/src/commands/push.js
CHANGED
|
@@ -69,7 +69,7 @@ import { updateAssetMap, ASSET_MAP_FILE } from '@uniweb/build/uwx'
|
|
|
69
69
|
import { BackendClient } from '../backend/client.js'
|
|
70
70
|
import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
|
|
71
71
|
import { warnIfContentDoesNotConform } from '../utils/conformance.js'
|
|
72
|
-
import {
|
|
72
|
+
import { reportSchemalessQueries } from '../utils/schemaless-report.js'
|
|
73
73
|
import { readOrgFlag } from '../utils/args.js'
|
|
74
74
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
75
75
|
import {
|
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
readSiteIdentity
|
|
78
78
|
} from '../utils/site-identity.js'
|
|
79
79
|
import { confirm } from '../utils/interactive.js'
|
|
80
|
+
import { guardEmptyRecords } from '../utils/records-guard.js'
|
|
80
81
|
import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
|
|
81
82
|
import {
|
|
82
83
|
makeModelResolver,
|
|
@@ -85,7 +86,7 @@ import {
|
|
|
85
86
|
readItemBaseVersions,
|
|
86
87
|
readItemUuids,
|
|
87
88
|
readFolderItemUuids,
|
|
88
|
-
|
|
89
|
+
readQueryUuids,
|
|
89
90
|
ensureItemUuids,
|
|
90
91
|
ensureSiteExists,
|
|
91
92
|
clearRemoteSyncStateIfUnbound,
|
|
@@ -369,6 +370,15 @@ export async function push(args = [], deps = {}) {
|
|
|
369
370
|
// It also has to be this emit that carries `assetRewrite` below: the push cache
|
|
370
371
|
// stores hashes of the REWRITTEN content, so the emit compared against it must
|
|
371
372
|
// rewrite too, or every entity reads as changed forever.
|
|
373
|
+
// ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
|
|
374
|
+
// destructive — a placeholder file, created meaning to fill it in — so the count
|
|
375
|
+
// is reported and confirmed before anything is sent. The format stays honest;
|
|
376
|
+
// the asking happens here.
|
|
377
|
+
if (!dryRun) {
|
|
378
|
+
const guard = await guardEmptyRecords({ siteDir, args, warn, note })
|
|
379
|
+
if (!guard.ok) return { exitCode: 1 }
|
|
380
|
+
}
|
|
381
|
+
|
|
372
382
|
let assetRewrite = null
|
|
373
383
|
let assetIds = null
|
|
374
384
|
if (!output && !dryRun) {
|
|
@@ -475,9 +485,9 @@ export async function push(args = [], deps = {}) {
|
|
|
475
485
|
pkg = await emitSyncPackages(siteDir, {
|
|
476
486
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
477
487
|
folderItemUuids: readFolderItemUuids(siteDir),
|
|
478
|
-
// Identity for the `
|
|
488
|
+
// Identity for the `queries` section — see readQueryUuids. Keyed by
|
|
479
489
|
// name, because a declaration has no file for a path-keyed map to hold.
|
|
480
|
-
|
|
490
|
+
queryUuids: readQueryUuids(siteDir),
|
|
481
491
|
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
482
492
|
...(asOrg ? { org: asOrg } : {}),
|
|
483
493
|
...(foundationDir ? { foundationDir } : {}),
|
|
@@ -509,14 +519,14 @@ export async function push(args = [], deps = {}) {
|
|
|
509
519
|
error(`Could not build the sync package: ${err.message}`)
|
|
510
520
|
return { exitCode: 2 }
|
|
511
521
|
}
|
|
512
|
-
const { siteContent,
|
|
522
|
+
const { siteContent, records, siteContentUuid, warnings, skipped } = pkg
|
|
513
523
|
log('')
|
|
514
524
|
for (const w of warnings) note(`! ${w}`)
|
|
515
525
|
// Warn level, not dim: this is the author choosing entities vs static files.
|
|
516
|
-
|
|
526
|
+
reportSchemalessQueries(pkg.schemaless, { warn, dim: note })
|
|
517
527
|
|
|
518
528
|
const totalEntities =
|
|
519
|
-
(siteContent?.entityCount || 0) + (
|
|
529
|
+
(siteContent?.entityCount || 0) + (records?.entityCount || 0)
|
|
520
530
|
|
|
521
531
|
// Nothing changed since the last push — the backend is already up to date.
|
|
522
532
|
if (totalEntities === 0) {
|
|
@@ -529,10 +539,10 @@ export async function push(args = [], deps = {}) {
|
|
|
529
539
|
info(
|
|
530
540
|
`${colors.bright}site-content${colors.reset} → ${siteContent.models.join(', ')}`
|
|
531
541
|
)
|
|
532
|
-
if (
|
|
533
|
-
const n =
|
|
542
|
+
if (records) {
|
|
543
|
+
const n = records.entityCount
|
|
534
544
|
info(
|
|
535
|
-
`${colors.bright}
|
|
545
|
+
`${colors.bright}records${colors.reset} (${n} entit${n === 1 ? 'y' : 'ies'}) → ${records.models.join(', ')}`
|
|
536
546
|
)
|
|
537
547
|
}
|
|
538
548
|
if (skipped) note(`${skipped} unchanged, skipped`)
|
|
@@ -542,11 +552,11 @@ export async function push(args = [], deps = {}) {
|
|
|
542
552
|
const base = output.replace(/\.uwx$/, '')
|
|
543
553
|
if (siteContent)
|
|
544
554
|
writeFileSync(resolve(`${base}.site-content.uwx`), siteContent.buffer)
|
|
545
|
-
if (
|
|
546
|
-
writeFileSync(resolve(`${base}.
|
|
555
|
+
if (records)
|
|
556
|
+
writeFileSync(resolve(`${base}.records.uwx`), records.buffer)
|
|
547
557
|
const lanes = [
|
|
548
558
|
siteContent && 'site-content',
|
|
549
|
-
|
|
559
|
+
records && 'records'
|
|
550
560
|
].filter(Boolean)
|
|
551
561
|
success(`Wrote ${lanes.join(' + ')} .uwx — not submitted`)
|
|
552
562
|
return { exitCode: 0 }
|
|
@@ -558,7 +568,7 @@ export async function push(args = [], deps = {}) {
|
|
|
558
568
|
`Dry run — would ${verb} content at ${colors.dim}${client.origin}${colors.reset}`
|
|
559
569
|
)
|
|
560
570
|
}
|
|
561
|
-
if (
|
|
571
|
+
if (records) {
|
|
562
572
|
info(
|
|
563
573
|
`Dry run — would push the folder at ${colors.dim}${client.origin}${colors.reset}`
|
|
564
574
|
)
|
package/src/commands/validate.js
CHANGED
|
@@ -269,7 +269,7 @@ export async function validate(args = []) {
|
|
|
269
269
|
return { exitCode: 0 }
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
// The data pipeline (collectSiteContent /
|
|
272
|
+
// The data pipeline (collectSiteContent / processQueries) prints progress
|
|
273
273
|
// via console.log. Route that to stderr while the engine runs so stdout stays
|
|
274
274
|
// clean — pure JSON for `--json`, just the report otherwise. `log` captured
|
|
275
275
|
// the original stdout writer at module load, so our own output is unaffected.
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-31T13:37:55.117Z",
|
|
4
4
|
"packages": {
|
|
5
|
+
"@uniweb/api": {
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"path": "framework/api",
|
|
8
|
+
"deps": [
|
|
9
|
+
"@uniweb/core"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
5
12
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.
|
|
13
|
+
"version": "0.30.0",
|
|
7
14
|
"path": "framework/build",
|
|
8
15
|
"deps": [
|
|
9
16
|
"@uniweb/content-reader",
|
|
@@ -27,7 +34,7 @@
|
|
|
27
34
|
"deps": []
|
|
28
35
|
},
|
|
29
36
|
"@uniweb/core": {
|
|
30
|
-
"version": "0.
|
|
37
|
+
"version": "0.14.1",
|
|
31
38
|
"path": "framework/core",
|
|
32
39
|
"deps": [
|
|
33
40
|
"@uniweb/semantic-parser",
|
|
@@ -40,14 +47,14 @@
|
|
|
40
47
|
"deps": []
|
|
41
48
|
},
|
|
42
49
|
"@uniweb/icons": {
|
|
43
|
-
"version": "0.4.
|
|
50
|
+
"version": "0.4.5",
|
|
44
51
|
"path": "framework/icons",
|
|
45
52
|
"deps": [
|
|
46
53
|
"@uniweb/core"
|
|
47
54
|
]
|
|
48
55
|
},
|
|
49
56
|
"@uniweb/kit": {
|
|
50
|
-
"version": "0.
|
|
57
|
+
"version": "0.15.0",
|
|
51
58
|
"path": "framework/kit",
|
|
52
59
|
"deps": [
|
|
53
60
|
"@uniweb/core",
|
|
@@ -66,7 +73,7 @@
|
|
|
66
73
|
"deps": []
|
|
67
74
|
},
|
|
68
75
|
"@uniweb/projections": {
|
|
69
|
-
"version": "0.5.
|
|
76
|
+
"version": "0.5.2",
|
|
70
77
|
"path": "framework/projections",
|
|
71
78
|
"deps": [
|
|
72
79
|
"@uniweb/content-writer",
|
|
@@ -74,7 +81,7 @@
|
|
|
74
81
|
]
|
|
75
82
|
},
|
|
76
83
|
"@uniweb/runtime": {
|
|
77
|
-
"version": "0.13.
|
|
84
|
+
"version": "0.13.3",
|
|
78
85
|
"path": "framework/runtime",
|
|
79
86
|
"deps": [
|
|
80
87
|
"@uniweb/core",
|
|
@@ -102,7 +109,7 @@
|
|
|
102
109
|
"deps": []
|
|
103
110
|
},
|
|
104
111
|
"@uniweb/templates": {
|
|
105
|
-
"version": "0.
|
|
112
|
+
"version": "0.11.0",
|
|
106
113
|
"path": "framework/templates",
|
|
107
114
|
"deps": []
|
|
108
115
|
},
|
|
@@ -112,7 +119,7 @@
|
|
|
112
119
|
"deps": []
|
|
113
120
|
},
|
|
114
121
|
"@uniweb/unipress": {
|
|
115
|
-
"version": "0.8.
|
|
122
|
+
"version": "0.8.17",
|
|
116
123
|
"path": "framework/unipress",
|
|
117
124
|
"deps": [
|
|
118
125
|
"@uniweb/build",
|
package/src/index.js
CHANGED
|
@@ -814,10 +814,45 @@ async function main() {
|
|
|
814
814
|
const originFlag =
|
|
815
815
|
readFlagValue(loginArgs, '--backend') ||
|
|
816
816
|
readFlagValue(loginArgs, '--registry')
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
817
|
+
const apiBase = resolveBackendOrigin(originFlag)
|
|
818
|
+
|
|
819
|
+
// ⭐ The project says where it belongs — say so BEFORE authenticating elsewhere.
|
|
820
|
+
//
|
|
821
|
+
// `login` is deliberately NOT given the `site.yml::$backend` tier the site verbs
|
|
822
|
+
// get (see resolveBackendOrigin): the session it writes is machine-wide, so letting
|
|
823
|
+
// cwd pick the account you log into would be a silent surprise. But staying silent
|
|
824
|
+
// does not remove the failure, it MOVES it — you log into the default, and the next
|
|
825
|
+
// push/pull/publish resolves to `$backend` and warns about the mismatch. That is the
|
|
826
|
+
// routed-not-nagged case `$backend` was added for, missing at the one command a
|
|
827
|
+
// teammate runs FIRST after cloning.
|
|
828
|
+
//
|
|
829
|
+
// ⛔ Silent when the origin was named explicitly (--backend / --registry /
|
|
830
|
+
// UNIWEB_REGISTER_URL). A deliberate aim is not a mistake to warn about; a genuinely
|
|
831
|
+
// wrong one is still caught by the session-mismatch guard in BackendClient.token().
|
|
832
|
+
if (!originFlag && !process.env.UNIWEB_REGISTER_URL) {
|
|
833
|
+
try {
|
|
834
|
+
const { findNearbySiteBackend } = await import(
|
|
835
|
+
'./utils/site-identity.js'
|
|
836
|
+
)
|
|
837
|
+
const nearby = findNearbySiteBackend(process.cwd())
|
|
838
|
+
if (nearby && nearby.backend !== apiBase) {
|
|
839
|
+
console.error(
|
|
840
|
+
`\x1b[33m⚠\x1b[0m This project syncs with ${nearby.backend} (site.yml::$backend), but login is targeting ${apiBase}.`
|
|
841
|
+
)
|
|
842
|
+
console.error(
|
|
843
|
+
` Log in where the project belongs: uniweb login --backend ${nearby.backend}`
|
|
844
|
+
)
|
|
845
|
+
console.error(
|
|
846
|
+
` Continuing with ${apiBase} — a session is machine-wide, so this is a heads-up, not a block.\n`
|
|
847
|
+
)
|
|
848
|
+
}
|
|
849
|
+
} catch {
|
|
850
|
+
// Advisory only. A malformed site.yml, an unreadable directory or anything else
|
|
851
|
+
// here must never be the reason someone cannot log in.
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
await runRegistryLogin({ apiBase, args: loginArgs })
|
|
821
856
|
return
|
|
822
857
|
}
|
|
823
858
|
|
package/src/utils/flag-guard.js
CHANGED
|
@@ -77,12 +77,12 @@ const VERBS = {
|
|
|
77
77
|
pull: [
|
|
78
78
|
'--backend', '--content-only', '--dry-run', '--force', '--merge',
|
|
79
79
|
'--no-assets',
|
|
80
|
-
'--no-
|
|
80
|
+
'--no-records', '--no-delete', '--no-prune', '--registry', '--token',
|
|
81
81
|
// via backend/site-sync.js (the owner resolver) and utils/conformance.js
|
|
82
82
|
'--yes', '--org', '--as-org', '--no-validate', ...VIA_DEPLOY
|
|
83
83
|
],
|
|
84
84
|
clone: [
|
|
85
|
-
'--backend', '--content-only', '--no-assets', '--no-
|
|
85
|
+
'--backend', '--content-only', '--no-assets', '--no-records', '--path',
|
|
86
86
|
'--project', '--registry', '--token', '--org', '--as-org'
|
|
87
87
|
],
|
|
88
88
|
register: [
|
package/src/utils/git.js
CHANGED
|
@@ -32,11 +32,17 @@ import yaml from 'js-yaml'
|
|
|
32
32
|
* rather than assuming the defaults.
|
|
33
33
|
*/
|
|
34
34
|
export function siteContentRoots(siteDir) {
|
|
35
|
+
// ⚠️ `queries.yml` IS AT THE SITE ROOT, and that is why it must be named here.
|
|
36
|
+
// Its predecessor lived at `collections/collections.yml`, so the `collections`
|
|
37
|
+
// root added below already covered it and the bare `'collections.yml'` entry
|
|
38
|
+
// that used to sit in this list resolved to a path no site ever had. A
|
|
39
|
+
// root-level file has no directory entry standing in for it.
|
|
35
40
|
const roots = new Set([
|
|
36
41
|
'site.yml',
|
|
37
42
|
'theme.yml',
|
|
38
43
|
'head.html',
|
|
39
|
-
'
|
|
44
|
+
'queries.yml',
|
|
45
|
+
'records.yml',
|
|
40
46
|
'locales'
|
|
41
47
|
])
|
|
42
48
|
let paths = {}
|
|
@@ -48,7 +54,7 @@ export function siteContentRoots(siteDir) {
|
|
|
48
54
|
}
|
|
49
55
|
roots.add(paths.pages || 'pages')
|
|
50
56
|
roots.add(paths.layout || 'layout')
|
|
51
|
-
roots.add(paths.
|
|
57
|
+
roots.add(paths.entities || 'entities')
|
|
52
58
|
return [...roots]
|
|
53
59
|
}
|
|
54
60
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// ⛔ THE ONE PLACE AN ORDINARY ACT IS DESTRUCTIVE.
|
|
2
|
+
//
|
|
3
|
+
// `records.yml` is the sync control, and `missing` and `empty` deliberately mean
|
|
4
|
+
// different things: missing leaves the server's folder untouched, empty says the
|
|
5
|
+
// folder holds nothing and the backend removes what is there. The asymmetry is
|
|
6
|
+
// well-shaped — the safe state is the ABSENCE of a file, so a live folder cannot
|
|
7
|
+
// be wiped by deleting one, and the destructive act requires affirmatively
|
|
8
|
+
// creating one.
|
|
9
|
+
//
|
|
10
|
+
// ⚠️ WHICH LEAVES EXACTLY ONE SHARP EDGE: a PLACEHOLDER. Someone creates an empty
|
|
11
|
+
// `records.yml` intending to fill it in, pushes, and the live folder empties.
|
|
12
|
+
// That is plausible and it is the only path where a normal act destroys content.
|
|
13
|
+
//
|
|
14
|
+
// ⭐ THE FORMAT STAYS HONEST AND THE CLI DOES THE ASKING. Never make "empty" mean
|
|
15
|
+
// "missing" to dodge this: that would delete a capability to avoid writing a
|
|
16
|
+
// prompt.
|
|
17
|
+
//
|
|
18
|
+
// The count comes from the placement identity a previous push banked — what WE
|
|
19
|
+
// last saw the folder hold. It needs no network call, and it is the right source:
|
|
20
|
+
// a site that has never pushed has nothing to lose and is never asked.
|
|
21
|
+
|
|
22
|
+
import { readRecordsConfig, FOLDER_EMPTY } from '@uniweb/build/uwx'
|
|
23
|
+
import { readFolderItemUuids } from '../backend/site-sync.js'
|
|
24
|
+
import { confirm, isNonInteractive, getCliPrefix } from './interactive.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Leaf placements in a banked path→uuid map.
|
|
28
|
+
*
|
|
29
|
+
* A branch's path is a prefix of every path beneath it, so anything that is a
|
|
30
|
+
* prefix of another key is a folder rather than a record. Counting raw keys would
|
|
31
|
+
* report a two-record site inside one folder as three things to lose.
|
|
32
|
+
*/
|
|
33
|
+
export function countPlacedRecords(pathToUuid) {
|
|
34
|
+
const paths = Object.keys(pathToUuid || {})
|
|
35
|
+
return paths.filter((p) => !paths.some((q) => q !== p && q.startsWith(`${p}/`))).length
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Stop an empty `records.yml` from silently emptying a live folder.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} params
|
|
42
|
+
* @param {string} params.siteDir
|
|
43
|
+
* @param {string[]} params.args - the verb's argv, for --yes / non-interactive
|
|
44
|
+
* @param {(m: string) => void} params.warn - the CALLER's reporter. Each verb owns
|
|
45
|
+
* its own output style; a second copy here would drift from all of them.
|
|
46
|
+
* @param {(m: string) => void} params.note
|
|
47
|
+
* @returns {Promise<{ ok: boolean, count: number }>} `ok: false` means abort
|
|
48
|
+
*/
|
|
49
|
+
export async function guardEmptyRecords({ siteDir, args = [], warn, note }) {
|
|
50
|
+
const cfg = await readRecordsConfig(siteDir)
|
|
51
|
+
if (cfg.state !== FOLDER_EMPTY) return { ok: true, count: 0 }
|
|
52
|
+
|
|
53
|
+
const count = countPlacedRecords(readFolderItemUuids(siteDir))
|
|
54
|
+
// Nothing banked ⇒ nothing this push can remove. A first push of an empty
|
|
55
|
+
// folder is a legitimate (if odd) thing to do, and asking about it would train
|
|
56
|
+
// people to type y.
|
|
57
|
+
if (count === 0) return { ok: true, count: 0 }
|
|
58
|
+
|
|
59
|
+
warn(
|
|
60
|
+
`records.yml is empty, and this push would REMOVE ${count} record${count === 1 ? '' : 's'} ` +
|
|
61
|
+
`from the live folder.`
|
|
62
|
+
)
|
|
63
|
+
note(
|
|
64
|
+
'An empty records.yml means "the folder holds nothing" — it is not the same as ' +
|
|
65
|
+
'having no records.yml, which leaves the live folder alone. If you meant to ' +
|
|
66
|
+
'start listing records, delete the file until you have.'
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// ⚠️ `--yes` ONLY. `-y` is not a flag this CLI has anywhere, and adding one here
|
|
70
|
+
// would have been caught by `flag-guard-coverage.test.js` — which it was.
|
|
71
|
+
if (args.includes('--yes')) return { ok: true, count }
|
|
72
|
+
if (isNonInteractive(args)) {
|
|
73
|
+
warn(`Refusing to remove ${count} record${count === 1 ? '' : 's'} without confirmation.`)
|
|
74
|
+
note(`Re-run with --yes if that is what you want: ${getCliPrefix()} push --yes`)
|
|
75
|
+
return { ok: false, count }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const yes = await confirm(`Remove ${count} record${count === 1 ? '' : 's'} from the live folder?`, false)
|
|
79
|
+
return { ok: yes, count }
|
|
80
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Report
|
|
2
|
+
* Report queries publishing without a data schema.
|
|
3
3
|
*
|
|
4
4
|
* ⭐ This is a PRODUCT decision the author is making, usually without knowing:
|
|
5
5
|
* entities or static files. It is reported at warn level for that reason — the
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
* @param {Array<{name: string, model?: string}>} schemaless
|
|
15
15
|
* @param {{ warn: (m: string) => void, dim: (m: string) => void }} out
|
|
16
16
|
*/
|
|
17
|
-
export function
|
|
17
|
+
export function reportSchemalessQueries(schemaless, out) {
|
|
18
18
|
if (!schemaless?.length) return
|
|
19
19
|
const names = schemaless.map((c) => c.name)
|
|
20
|
-
const label = names.length === 1 ? '
|
|
20
|
+
const label = names.length === 1 ? 'query' : 'queries'
|
|
21
21
|
out.warn(
|
|
22
22
|
`${names.length} ${label} shipping as STATIC FILES, not entities: ${names.join(', ')}`
|
|
23
23
|
)
|
|
@@ -25,6 +25,6 @@ export function reportSchemalessCollections(schemaless, out) {
|
|
|
25
25
|
out.dim(
|
|
26
26
|
`Declare a data schema to get entities. Each resolves its schema by subfolder name (${schemaless
|
|
27
27
|
.map((c) => `${c.name} → ${c.model || c.name}`)
|
|
28
|
-
.join(', ')}), or set \`schema:\` on the
|
|
28
|
+
.join(', ')}), or set \`schema:\` on the query.`
|
|
29
29
|
)
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static collection data — the passthrough lane.
|
|
3
3
|
*
|
|
4
|
-
* A site's **schema-less**
|
|
4
|
+
* A site's **schema-less** queries have no entity model, so their compiled
|
|
5
5
|
* `dist/data/**` JSON is delivered as files rather than synced as entities.
|
|
6
6
|
* This plans and uploads that set: one plan call, one object per file, each
|
|
7
7
|
* PUT to the target the backend returns.
|
|
@@ -93,7 +93,7 @@ export async function uploadSiteData({
|
|
|
93
93
|
//
|
|
94
94
|
// ⛔ And the failure is INVISIBLE from here: the plan succeeds, the PUT
|
|
95
95
|
// succeeds, and only a visitor's fetch 404s. Confirmed against the
|
|
96
|
-
// shipped contract's own example (`
|
|
96
|
+
// shipped contract's own example (`data/articles.json` with a
|
|
97
97
|
// `/data/`-bearing `serve_base`), which supersedes an earlier
|
|
98
98
|
// parenthetical that showed the prefix.
|
|
99
99
|
path: relPath,
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
* and it has to move the readers and the writers together or it makes the split worse.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
32
|
-
import { join } from 'node:path'
|
|
31
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
32
|
+
import { join, dirname } from 'node:path'
|
|
33
33
|
import yaml from 'js-yaml'
|
|
34
34
|
import { DEFAULT_BACKEND_ORIGIN } from './config.js'
|
|
35
35
|
|
|
@@ -232,3 +232,66 @@ export function assertSiteBackendScope(siteDir, origin) {
|
|
|
232
232
|
hint
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The `$backend` of the site project `startDir` sits in — for verbs that are NOT site
|
|
238
|
+
* verbs and so never resolve a site directory of their own.
|
|
239
|
+
*
|
|
240
|
+
* ⛔ **This does NOT feed the origin ladder, deliberately.** `login` writes a
|
|
241
|
+
* MACHINE-WIDE session (`~/.uniweb/registry-auth.json`), not a per-project one, so
|
|
242
|
+
* letting whichever directory you happen to stand in decide which backend you
|
|
243
|
+
* authenticate against would be a silent surprise — the same class of surprise
|
|
244
|
+
* `$backend` exists to remove. What this enables is a **notice**: the project says
|
|
245
|
+
* where it belongs, so we say so before authenticating somewhere else.
|
|
246
|
+
*
|
|
247
|
+
* ⭐ **Conservative by construction — it answers only when there is exactly ONE
|
|
248
|
+
* candidate.** A workspace of several sites has no single answer, and a confident
|
|
249
|
+
* *"did you mean localhost?"* aimed at the wrong one of three sites is worse than
|
|
250
|
+
* saying nothing. Ambiguity returns null and the caller stays quiet.
|
|
251
|
+
*
|
|
252
|
+
* ⚠️ Build-free, like everything else in this file — `login` is a STANDALONE command
|
|
253
|
+
* that must work outside a project, where `@uniweb/build` is not installed. That is why
|
|
254
|
+
* this cannot reuse `resolveSiteDir` (`commands/deploy.js`), which pulls build in.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} startDir
|
|
257
|
+
* @returns {{ siteDir: string, backend: string }|null}
|
|
258
|
+
*/
|
|
259
|
+
export function findNearbySiteBackend(startDir) {
|
|
260
|
+
// 1. Walk UP for the site we are standing in or under. Bounded: a `site.yml` more
|
|
261
|
+
// than a few levels above is not "the project you are in", it is a coincidence,
|
|
262
|
+
// and at the filesystem root it would be someone else's entirely.
|
|
263
|
+
let dir = startDir
|
|
264
|
+
for (let i = 0; i < 4; i++) {
|
|
265
|
+
if (existsSync(join(dir, 'site.yml'))) {
|
|
266
|
+
const { backend } = readSiteIdentity(dir)
|
|
267
|
+
return backend ? { siteDir: dir, backend } : null
|
|
268
|
+
}
|
|
269
|
+
const up = dirname(dir)
|
|
270
|
+
if (up === dir) break
|
|
271
|
+
dir = up
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 2. Standing AT a project root, the site is one level down — `site/` in the default
|
|
275
|
+
// layout, or a lone entry under `sites/`. Two or more candidates is a workspace,
|
|
276
|
+
// which is exactly the ambiguity above.
|
|
277
|
+
const candidates = []
|
|
278
|
+
if (existsSync(join(startDir, 'site', 'site.yml')))
|
|
279
|
+
candidates.push(join(startDir, 'site'))
|
|
280
|
+
const sitesDir = join(startDir, 'sites')
|
|
281
|
+
if (existsSync(sitesDir)) {
|
|
282
|
+
let entries = []
|
|
283
|
+
try {
|
|
284
|
+
entries = readdirSync(sitesDir, { withFileTypes: true })
|
|
285
|
+
} catch {
|
|
286
|
+
entries = []
|
|
287
|
+
}
|
|
288
|
+
for (const e of entries) {
|
|
289
|
+
if (!e.isDirectory()) continue
|
|
290
|
+
const d = join(sitesDir, e.name)
|
|
291
|
+
if (existsSync(join(d, 'site.yml'))) candidates.push(d)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (candidates.length !== 1) return null
|
|
295
|
+
const { backend } = readSiteIdentity(candidates[0])
|
|
296
|
+
return backend ? { siteDir: candidates[0], backend } : null
|
|
297
|
+
}
|