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.
@@ -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 → `collectionsToProject` (the folder + record files).
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-collections Pull pages only; skip the folder (collections) lane
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` / `splitCollectionsPull`). Verified live
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
- collectionsToProject,
75
- resolveCollectionsConfig,
74
+ recordsToProject,
76
75
  readZip,
77
76
  computeUnitHashes,
78
77
  collectUnitUuids,
79
- collectCollectionUuids
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
- writeCollectionUuids,
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 splitCollectionsPull(payload) {
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 splitCollectionsPull understands into a
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 noCollections =
523
- args.includes('--no-collections') || args.includes('--content-only')
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 (!noCollections) info(`Dry run — would also pull collections`)
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 readCollectionUuids).
695
- writeCollectionUuids(siteDir, collectCollectionUuids(siteDoc))
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
- // collectionsToProject keeps its synchronous contract. A 304 leaves files as-is.
781
- if (!noCollections) {
782
- const folder = await getDocs('collections', () =>
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 } = splitCollectionsPull(folder.docs)
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
- const collectionsConfig = await resolveCollectionsConfig(siteDir).catch(
799
- () => null
800
- )
801
- const report = collectionsToProject({
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', 'collections.yml'].map((f) =>
844
+ ...['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml'].map((f) =>
844
845
  join(siteDir, f)
845
846
  )
846
847
  ],
@@ -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 { reportSchemalessCollections } from '../utils/schemaless-report.js'
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
- readCollectionUuids,
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 `collections` section — see readCollectionUuids. Keyed by
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
- collectionUuids: readCollectionUuids(siteDir),
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, collections, siteContentUuid, warnings, skipped } = pkg
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
- reportSchemalessCollections(pkg.schemaless, { warn, dim: note })
526
+ reportSchemalessQueries(pkg.schemaless, { warn, dim: note })
517
527
 
518
528
  const totalEntities =
519
- (siteContent?.entityCount || 0) + (collections?.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 (collections) {
533
- const n = collections.entityCount
542
+ if (records) {
543
+ const n = records.entityCount
534
544
  info(
535
- `${colors.bright}collections${colors.reset} (${n} entit${n === 1 ? 'y' : 'ies'}) → ${collections.models.join(', ')}`
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 (collections)
546
- writeFileSync(resolve(`${base}.collections.uwx`), collections.buffer)
555
+ if (records)
556
+ writeFileSync(resolve(`${base}.records.uwx`), records.buffer)
547
557
  const lanes = [
548
558
  siteContent && 'site-content',
549
- collections && 'collections'
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 (collections) {
571
+ if (records) {
562
572
  info(
563
573
  `Dry run — would push the folder at ${colors.dim}${client.origin}${colors.reset}`
564
574
  )
@@ -269,7 +269,7 @@ export async function validate(args = []) {
269
269
  return { exitCode: 0 }
270
270
  }
271
271
 
272
- // The data pipeline (collectSiteContent / processCollections) prints progress
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.
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-28T23:49:23.885Z",
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.29.1",
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.13.1",
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.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.14.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.1",
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.1",
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.10.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.16",
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
- await runRegistryLogin({
818
- apiBase: resolveBackendOrigin(originFlag),
819
- args: loginArgs
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
 
@@ -77,12 +77,12 @@ const VERBS = {
77
77
  pull: [
78
78
  '--backend', '--content-only', '--dry-run', '--force', '--merge',
79
79
  '--no-assets',
80
- '--no-collections', '--no-delete', '--no-prune', '--registry', '--token',
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-collections', '--path',
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
- 'collections.yml',
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.collections || 'collections')
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 collections publishing without a data schema.
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 reportSchemalessCollections(schemaless, out) {
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 ? 'collection' : 'collections'
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 collection.`
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** collections have no entity model, so their compiled
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 (`collections/articles.json` with a
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
+ }