uniweb 0.61.0 → 0.63.0

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.
@@ -16,7 +16,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
16
16
  import { join, relative, basename } from 'node:path'
17
17
  import prompts from 'prompts'
18
18
  import yaml from 'js-yaml'
19
- import { resolveFoundationSrcPath } from '@uniweb/build'
19
+ import { resolveFoundationSrcPath, readFoundationName } from '@uniweb/build'
20
20
  import {
21
21
  scaffoldFoundation,
22
22
  scaffoldSite,
@@ -42,6 +42,10 @@ import {
42
42
  resolveUniqueName
43
43
  } from '../utils/names.js'
44
44
  import { findWorkspaceRoot } from '../utils/workspace.js'
45
+ import {
46
+ normalizeFoundationName,
47
+ ensureFoundationName
48
+ } from '../utils/foundation-name.js'
45
49
  import {
46
50
  detectPackageManager,
47
51
  detectWorkspacePm,
@@ -366,6 +370,12 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
366
370
  let { packageName } = placement
367
371
  const fullPath = join(rootDir, relativePath)
368
372
 
373
+ // The foundation's own name — what it registers as, written to main.js — is
374
+ // the name asked for, before any workspace suffix below; with none (`src`),
375
+ // the project's.
376
+ const askedName =
377
+ normalizeFoundationName(packageName) || normalizeFoundationName(projectName)
378
+
369
379
  // Validate the derived package name (format + reserved-name check). The
370
380
  // auto-derived `src` default is grandfathered in (`src` IS reserved
371
381
  // but `src` is the convention for "the package that lives in src/").
@@ -435,10 +445,15 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
435
445
  }
436
446
 
437
447
  // Scaffold
448
+ const registryName = await unclaimedFoundationName(rootDir, [
449
+ askedName,
450
+ normalizeFoundationName(packageName)
451
+ ])
438
452
  await scaffoldFoundation(
439
453
  fullPath,
440
454
  {
441
455
  name: packageName,
456
+ registryName,
442
457
  projectName,
443
458
  isExtension: false
444
459
  },
@@ -447,9 +462,11 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
447
462
  }
448
463
  )
449
464
 
450
- // Apply template content if --from specified
465
+ // Apply template content if --from specified. Its main.js replaces the
466
+ // scaffolded one, so the name is given again unless the template names it.
451
467
  if (opts.from) {
452
468
  await applyFromTemplate(opts.from, 'foundation', fullPath, projectName)
469
+ ensureFoundationName(fullPath, registryName)
453
470
  }
454
471
 
455
472
  // Register the package in pnpm-workspace.yaml — by exact path, not by glob.
@@ -685,11 +702,18 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
685
702
  process.exit(1)
686
703
  }
687
704
 
688
- // Scaffold foundation with extension flag
705
+ // Scaffold foundation with extension flag. Its name — what it registers as —
706
+ // is the one asked for, not the package's suffixed one, unless a foundation in
707
+ // the workspace already registers as that.
708
+ const registryName = await unclaimedFoundationName(rootDir, [
709
+ normalizeFoundationName(name),
710
+ normalizeFoundationName(extensionPackageName)
711
+ ])
689
712
  await scaffoldFoundation(
690
713
  fullPath,
691
714
  {
692
715
  name: extensionPackageName,
716
+ registryName,
693
717
  projectName,
694
718
  isExtension: true
695
719
  },
@@ -701,6 +725,7 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
701
725
  // Apply template content if --from specified
702
726
  if (opts.from) {
703
727
  await applyFromTemplate(opts.from, 'extension', fullPath, projectName)
728
+ ensureFoundationName(fullPath, registryName)
704
729
  }
705
730
 
706
731
  // Update workspace globs
@@ -848,12 +873,18 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
848
873
 
849
874
  const progressCb = (msg) => info(` ${msg}`)
850
875
 
851
- // Scaffold foundation (folder: src/, package name: <project>-src)
876
+ // Scaffold foundation (folder: src/, package name: <project>-src, name — what
877
+ // it registers as — <project>)
852
878
  info(`Creating foundation: ${foundationPkgName}...`)
879
+ const registryName = await unclaimedFoundationName(rootDir, [
880
+ normalizeFoundationName(name),
881
+ normalizeFoundationName(foundationPkgName)
882
+ ])
853
883
  await scaffoldFoundation(
854
884
  join(projectDir, 'src'),
855
885
  {
856
886
  name: foundationPkgName,
887
+ registryName,
857
888
  projectName,
858
889
  isExtension: false
859
890
  },
@@ -882,6 +913,7 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
882
913
  join(projectDir, 'src'),
883
914
  projectName
884
915
  )
916
+ ensureFoundationName(join(projectDir, 'src'), registryName)
885
917
  await applyFromTemplate(
886
918
  opts.from,
887
919
  'site',
@@ -987,6 +1019,32 @@ function computeFoundationPath(sitePath, foundationPath) {
987
1019
  return `file:${rel}`
988
1020
  }
989
1021
 
1022
+ /**
1023
+ * The first of `candidates` that no foundation or extension in the workspace
1024
+ * registers as — null when each is taken or missing, and `uniweb register` asks.
1025
+ *
1026
+ * A package name is kept unique by suffixing (`ui-ext`), but what a package
1027
+ * REGISTERS as is its main.js name: two foundations named `ui` in one workspace
1028
+ * would both register `@org/ui`, and the second would land on the first's
1029
+ * version. Compared without scope, so `@acme/ui` counts as `ui`.
1030
+ *
1031
+ * @param {string} rootDir
1032
+ * @param {Array<string|null>} candidates
1033
+ * @returns {Promise<string|null>}
1034
+ */
1035
+ async function unclaimedFoundationName(rootDir, candidates) {
1036
+ const taken = new Set()
1037
+ for (const f of await discoverFoundations(rootDir)) {
1038
+ try {
1039
+ const { name } = await readFoundationName(join(rootDir, f.path))
1040
+ if (name) taken.add(name.replace(/^@[^/]+\//, ''))
1041
+ } catch {
1042
+ // An unreadable main.js — the build reports it; it claims no name here.
1043
+ }
1044
+ }
1045
+ return candidates.find((c) => c && !taken.has(c)) || null
1046
+ }
1047
+
990
1048
  /**
991
1049
  * Apply content from a template to a scaffolded package
992
1050
  *
@@ -39,6 +39,10 @@
39
39
  * uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
40
40
  * uniweb clone <uuid> --project docs Co-located docs/site
41
41
  * uniweb clone <uuid> --no-records Pull pages only; skip records
42
+ * uniweb clone <uuid> --org @org Read it as @org — stops if the backend works on
43
+ * the site from another workspace. Without it,
44
+ * the one the backend names is recorded in
45
+ * sync.json for every command after.
42
46
  *
43
47
  * Backend: via BackendClient (the site-content pull lane). Origin from
44
48
  * UNIWEB_REGISTER_URL > the local default (internal dev overrides;
@@ -55,7 +59,13 @@ import { resolvePlacement, SITE_KIND } from '../utils/placement.js'
55
59
  import { findWorkspaceRoot } from '../utils/workspace.js'
56
60
  import { addWorkspaceGlob } from '../utils/config.js'
57
61
  import { detectWorkspacePm, installCmd } from '../utils/pm.js'
58
- import { BackendClient } from '../backend/client.js'
62
+ import {
63
+ BackendClient,
64
+ describeRequestError,
65
+ refusalDetail,
66
+ workspaceHandle
67
+ } from '../backend/client.js'
68
+ import { readOrgFlag } from '../utils/args.js'
59
69
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
60
70
  import { extractFoundationRef } from '../utils/site-content-refs.js'
61
71
  import { readUwxDocuments } from '../utils/uwx-read.js'
@@ -143,7 +153,7 @@ export function extractCloneSeeds(document) {
143
153
  // `site.yml`'s equivalent needed a hand-rolled dependency-free WRITER for exactly
144
154
  // this. The store stays the one writer everywhere else; this seeds a fresh file.
145
155
  // `test/clone.test.js` covers it end to end with `skipInstall: true`.
146
- function seedSyncJson(siteDir, origin, uuid) {
156
+ function seedSyncJson(siteDir, origin, uuid, workspace = null) {
147
157
  const file = join(siteDir, 'sync.json')
148
158
  let doc = { version: 1, backends: {} }
149
159
  if (existsSync(file)) {
@@ -155,7 +165,20 @@ function seedSyncJson(siteDir, origin, uuid) {
155
165
  }
156
166
  }
157
167
  const key = new URL(origin).origin
158
- doc.backends[key] = { ...(doc.backends[key] || {}), site: { ...(doc.backends[key]?.site || {}), uuid } }
168
+ // The workspace the read named — `--org`, or the one the backend answered with — so
169
+ // the delegated pull, and every command after it, names it from the first request.
170
+ // As the create records it: a handle bare in `org`, a handle-less unit's uuid in
171
+ // `unit` (site-sync.js::readSiteWorkspace).
172
+ const w = typeof workspace === 'string' && workspace ? workspace : null
173
+ const named = w
174
+ ? w.startsWith('@')
175
+ ? { org: w.slice(1) }
176
+ : { unit: w }
177
+ : {}
178
+ doc.backends[key] = {
179
+ ...(doc.backends[key] || {}),
180
+ site: { ...(doc.backends[key]?.site || {}), uuid, ...named }
181
+ }
159
182
  mkdirSync(dirname(file), { recursive: true })
160
183
  writeFileSync(file, JSON.stringify(doc, null, 2) + '\n')
161
184
  }
@@ -206,6 +229,11 @@ export async function clone(args = [], deps = {}) {
206
229
  args,
207
230
  command: 'Cloning'
208
231
  })
232
+ // The workspace the reads name: `--org` explicitly — a mismatch then stops the clone —
233
+ // else none, and the client adopts the one the backend works on the site from. There
234
+ // is no project yet to record it in; `seedSyncJson` does, below.
235
+ const orgFlag = readOrgFlag(args)
236
+ client.setWorkspace(orgFlag ? workspaceHandle(orgFlag) : null, { explicit: Boolean(orgFlag) })
209
237
 
210
238
  // 1. GET the site-content document.
211
239
  //
@@ -228,13 +256,15 @@ export async function clone(args = [], deps = {}) {
228
256
  }
229
257
  if (!res.ok) {
230
258
  error(`Could not read the site: HTTP ${res.status} ${res.statusText}`)
259
+ const detail = await refusalDetail(res)
260
+ if (detail) note(detail)
231
261
  if (res.status === 401 || res.status === 403)
232
262
  note('Run `uniweb login` first.')
233
263
  return { exitCode: 1 }
234
264
  }
235
265
  documents = readUwxDocuments(Buffer.from(await res.arrayBuffer()))
236
266
  } catch (err) {
237
- error(`Could not reach the backend at ${client.origin}: ${err.message}`)
267
+ error(describeRequestError(err, client.origin))
238
268
  return { exitCode: 1 }
239
269
  }
240
270
 
@@ -355,7 +385,7 @@ export async function clone(args = [], deps = {}) {
355
385
  // the old `$backend`: the uuid came from whoever we read and nothing on disk said
356
386
  // so, and a teammate who pulled while logged in elsewhere sent it to the wrong
357
387
  // backend. Stored under the origin, that cannot be expressed.
358
- seedSyncJson(siteDir, client.origin, siteUuid)
388
+ seedSyncJson(siteDir, client.origin, siteUuid, client.workspace)
359
389
  success(
360
390
  `Scaffolded the site harness${foundationRef ? ` (foundation: ${foundationRef})` : ''}.`
361
391
  )
@@ -399,7 +429,7 @@ export async function clone(args = [], deps = {}) {
399
429
  // bytes on disk, not of what the scaffolder first wrote.
400
430
  recordWritten(
401
431
  siteDir,
402
- ['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml']
432
+ ['site.yml', 'theme.yml', 'head.html', 'queries.yml', join('records', 'folder.yml')]
403
433
  .map((f) => join(siteDir, f))
404
434
  .filter((p) => existsSync(p))
405
435
  )
@@ -29,15 +29,15 @@
29
29
  * uniweb publish --dry-run Resolve everything; POST nothing
30
30
  * uniweb publish --yes Skip confirmations (CI); never block on a prompt
31
31
  * uniweb publish --force Overwrite upstream app-side edits (drop the push gate)
32
- * uniweb publish --org @org Publish under @org (alias: --as-org). Only the
33
- * FIRST publish of a site reads it — that create is
34
- * what decides which org owns the site and whose
35
- * storage its assets are charged to. It is then
36
- * recorded in `sync.json` and replayed, so it
37
- * never has to be re-typed.
38
- * uniweb publish --personal Own the new site personally, deliberately. Sends
39
- * NO `as_org` — byte-identical to the wire before
40
- * the owner prompt existed. First publish only.
32
+ * uniweb publish --org @org Publish under @org. On the FIRST publish of a
33
+ * site it decides which org owns the site and whose
34
+ * storage its assets are charged to; recorded in
35
+ * `sync.json`, and every request after names it, so
36
+ * it never has to be re-typed. Later, it must match
37
+ * the workspace the backend works on the site from,
38
+ * or publish stops.
39
+ * uniweb publish --personal Own the new site personally, deliberately: its
40
+ * requests name no workspace.
41
41
  * uniweb publish --no-save Do not record this publish in deploy.yml
42
42
  */
43
43
 
@@ -65,7 +65,11 @@ import {
65
65
  import { isSiteRelativeExtensionUrl } from '@uniweb/build'
66
66
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
67
67
 
68
- import { BackendClient } from '../backend/client.js'
68
+ import {
69
+ BackendClient,
70
+ describeRequestError,
71
+ WorkspaceMismatchError
72
+ } from '../backend/client.js'
69
73
  import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
70
74
  import { resolveSiteDir } from './deploy.js'
71
75
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
@@ -90,7 +94,9 @@ import {
90
94
  clearRemoteSyncStateIfUnbound,
91
95
  dropSiteBoundValues,
92
96
  pushSyncPackages,
93
- resolveSiteOrgForCreate
97
+ resolveSiteOrgForCreate,
98
+ nameSiteWorkspace,
99
+ EXPLICIT_OWNER
94
100
  } from '../backend/site-sync.js'
95
101
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
96
102
  import {
@@ -320,7 +326,6 @@ export async function publish(args = []) {
320
326
  say.dim(org.reason)
321
327
  return { exitCode: 2 }
322
328
  }
323
- const asOrg = org.asOrg
324
329
 
325
330
  // ⛔ There is no capability gate here any more, deliberately.
326
331
  //
@@ -508,6 +513,17 @@ export async function publish(args = []) {
508
513
  // be the silent-wrong-success this whole branch exists to prevent.
509
514
  if (!fnd.proceed) return { exitCode: fnd.refused ? 1 : 0 }
510
515
 
516
+ // ⭐ Name the workspace every site request works in — the create, the push, the
517
+ // publish and the data lane. The user's own choice is explicit, so a `409
518
+ // wrong_workspace` stops the publish; a recorded one is adopted afresh when stale
519
+ // (`nameSiteWorkspace`).
520
+ nameSiteWorkspace(client, {
521
+ siteDir,
522
+ workspace: org.workspace,
523
+ explicit: EXPLICIT_OWNER.has(org.source),
524
+ note: (m) => say.dim(m)
525
+ })
526
+
511
527
  // 2. Build the site data (link mode): dist/site-content.json (+ per-locale),
512
528
  // dist/data/*, dist/assets/*. Spawn the SAME CLI binary so the inner
513
529
  // build can't resolve to a different installed version.
@@ -562,8 +578,6 @@ export async function publish(args = []) {
562
578
  backend: client.origin,
563
579
  // Placement identity for the folder — see writeFolderItemUuids.
564
580
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
565
- // Resolves a foundation-relative `@/x` model ref into `@org/x`.
566
- ...(asOrg ? { org: asOrg } : {}),
567
581
  ...(foundationDir ? { foundationDir } : {}),
568
582
  resolveModel
569
583
  })
@@ -612,7 +626,6 @@ export async function publish(args = []) {
612
626
  siteDir,
613
627
  name: siteYml.name,
614
628
  foundation: fnd.ref || siteYml.foundation,
615
- asOrg,
616
629
  note: (m) => say.dim(m)
617
630
  })
618
631
  if (!site.uuid) {
@@ -727,6 +740,8 @@ export async function publish(args = []) {
727
740
  apiBase: client.origin,
728
741
  token: await client.token(),
729
742
  siteUuid: site.uuid,
743
+ // The plan and every direct PUT name the workspace the rest of the publish did.
744
+ workspace: client.workspace,
730
745
  ball,
731
746
  onProgress: (m) => say.dim(` ${m}`)
732
747
  })
@@ -949,8 +964,6 @@ export async function publish(args = []) {
949
964
  ...(declaration.declare ? {} : { declareServices: false }),
950
965
  // Placement identity for the folder — see writeFolderItemUuids.
951
966
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
952
- // Resolves a foundation-relative `@/x` model ref into `@org/x`.
953
- ...(asOrg ? { org: asOrg } : {}),
954
967
  ...(foundationDir ? { foundationDir } : {}),
955
968
  resolveModel,
956
969
  priorHashes,
@@ -978,7 +991,6 @@ export async function publish(args = []) {
978
991
  client,
979
992
  siteDir,
980
993
  pkg,
981
- asOrg,
982
994
  report
983
995
  })
984
996
  if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
@@ -998,8 +1010,9 @@ export async function publish(args = []) {
998
1010
  ...(languages ? { languages } : {})
999
1011
  })
1000
1012
  } catch (err) {
1001
- say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
1002
- say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
1013
+ say.err(describeRequestError(err, client.origin))
1014
+ if (!(err instanceof WorkspaceMismatchError))
1015
+ say.dim('Is that the backend you meant? Switch with: uniweb login --backend <url>')
1003
1016
  return { exitCode: 1 }
1004
1017
  }
1005
1018
  if (!pubRes.ok) {
@@ -65,13 +65,14 @@ import {
65
65
  import { tmpdir } from 'node:os'
66
66
  import { createHash } from 'node:crypto'
67
67
  import { createInterface } from 'node:readline/promises'
68
- import { join, dirname, relative } from 'node:path'
68
+ import { join, dirname, relative, resolve } from 'node:path'
69
69
  import yaml from 'js-yaml'
70
70
  import { downloadMissingAssets } from '../backend/asset-download.js'
71
71
  import {
72
72
  readBackendState,
73
73
  siteContentDocumentToProject,
74
74
  recordsToProject,
75
+ siteSelfScope,
75
76
  readZip,
76
77
  computeUnitHashes,
77
78
  collectUnitUuids,
@@ -89,8 +90,11 @@ import {
89
90
  mergeBaseVersions,
90
91
  mergeItemBaseVersions,
91
92
  writeUnitBases,
92
- writeItemUuids
93
+ writeItemUuids,
94
+ nameSiteWorkspace,
95
+ readSiteWorkspace
93
96
  } from '../backend/site-sync.js'
97
+ import { readOrgFlag } from '../utils/args.js'
94
98
  import {
95
99
  uncommittedUnder,
96
100
  siteContentRoots,
@@ -98,7 +102,13 @@ import {
98
102
  mergeFile
99
103
  } from '../utils/git.js'
100
104
  import { isNonInteractive } from '../utils/interactive.js'
101
- import { BackendClient } from '../backend/client.js'
105
+ import {
106
+ BackendClient,
107
+ describeRequestError,
108
+ refusalDetail,
109
+ workspaceHandle,
110
+ WorkspaceMismatchError
111
+ } from '../backend/client.js'
102
112
  import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
103
113
  import { checkFlags } from '../utils/flag-guard.js'
104
114
  import {
@@ -591,6 +601,15 @@ export async function pull(args = [], deps = {}) {
591
601
  args,
592
602
  command: 'Pulling'
593
603
  })
604
+ // The workspace these requests name: `--org` explicitly, else the one this project
605
+ // recorded for this backend — adopted afresh from the backend's answer when stale.
606
+ const orgFlag = readOrgFlag(args)
607
+ nameSiteWorkspace(client, {
608
+ siteDir,
609
+ workspace: orgFlag ? workspaceHandle(orgFlag) : readSiteWorkspace(siteDir, client.origin),
610
+ explicit: Boolean(orgFlag),
611
+ note
612
+ })
594
613
 
595
614
  // ⭐ No scope check any more — and none is needed. This project's identity on
596
615
  // `client.origin` is read from that origin's own section of sync.json, so a
@@ -649,7 +668,8 @@ export async function pull(args = [], deps = {}) {
649
668
  // body — `readPullDocuments` reads the entity files out of it (JSON fallback). A
650
669
  // conditional request whose ETag matches returns `{ notModified: true }` (304, empty
651
670
  // body). `doRequest` is a thunk returning the client's Response promise. 404 / any
652
- // failure → null (the lane is skipped, not fatal).
671
+ // failure → null (the lane is skipped, not fatal) — except a workspace mismatch,
672
+ // `{ refused: true }`, which stops the pull: the other lane would only say it again.
653
673
  const getDocs = async (label, doRequest) => {
654
674
  info(
655
675
  `Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`
@@ -658,7 +678,8 @@ export async function pull(args = [], deps = {}) {
658
678
  try {
659
679
  res = await doRequest()
660
680
  } catch (err) {
661
- error(`Could not reach the backend at ${client.origin}: ${err.message}`)
681
+ error(describeRequestError(err, client.origin))
682
+ if (err instanceof WorkspaceMismatchError) return { refused: true }
662
683
  note('Is that the backend you meant? Switch with: uniweb login --backend <url>')
663
684
  return null
664
685
  }
@@ -672,6 +693,8 @@ export async function pull(args = [], deps = {}) {
672
693
  }
673
694
  if (!res.ok) {
674
695
  error(`${label} pull failed: HTTP ${res.status} ${res.statusText}`)
696
+ const detail = await refusalDetail(res)
697
+ if (detail) note(detail)
675
698
  if (res.status === 401 || res.status === 403)
676
699
  note(
677
700
  "Credentials weren't accepted — log in again (`uniweb login --backend <url>`), or check UNIWEB_TOKEN."
@@ -709,11 +732,28 @@ export async function pull(args = [], deps = {}) {
709
732
  let etagContent = cache.content
710
733
  let etagFolder = cache.folder
711
734
 
735
+ // ⭐ THE SCOPE THE PULLED RECORDS AND QUERIES WERE QUALIFIED WITH — the pushed site's
736
+ // FOUNDATION's: the scope of the pinned ref it carries (`info.foundation`), else the
737
+ // local foundation's name. A push qualifies `@/x` with it, so this is what undoes it,
738
+ // and a record lands back in `records/article/` rather than `records/acme/article/`.
739
+ // Resolved once, up front — it may load main.js — and handed to the synchronous
740
+ // projections, as Models are. ⛔ It was the site owner's org, from sync.json, until
741
+ // 2026-09-22; a foundation may be registered under any org its author belongs to.
742
+ let pullScope
743
+ const scopeFor = async (doc) => {
744
+ if (pullScope === undefined) {
745
+ const pinned = doc?.info?.foundation
746
+ pullScope = await siteSelfScope(siteDir, pinned !== undefined ? { foundation: pinned } : {})
747
+ }
748
+ return pullScope
749
+ }
750
+
712
751
  // Lane 1 — content → config + pages/** + layout/**. The .uwx carries a single
713
752
  // entity (the site-content document). A 304 (unchanged) leaves local files as-is.
714
753
  const content = await getDocs('content', () =>
715
754
  client.pullSiteContent(siteContentUuid, { etag: etagContent })
716
755
  )
756
+ if (content?.refused) return { exitCode: 1 }
717
757
  if (content && !content.notModified) {
718
758
  const siteDoc =
719
759
  content.docs &&
@@ -806,7 +846,9 @@ export async function pull(args = [], deps = {}) {
806
846
  // it a pull leaves every image pointing at a backend route.
807
847
  backend: client.origin,
808
848
  prune,
809
- keepAuthoredFoundation
849
+ keepAuthoredFoundation,
850
+ // So a query's `@acme/member` comes back as the author's `@/member`.
851
+ scope: await scopeFor(siteDoc)
810
852
  })
811
853
  wrote.push(...report.pages, ...report.sections, ...report.layout)
812
854
  removed.push(
@@ -828,6 +870,7 @@ export async function pull(args = [], deps = {}) {
828
870
  const folder = await getDocs('records', () =>
829
871
  client.pullFolder(siteContentUuid, { etag: etagFolder })
830
872
  )
873
+ if (folder?.refused) return { exitCode: 1 }
831
874
  if (folder && !folder.notModified && folder.docs?.length) {
832
875
  const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
833
876
  const resolveModel = makeModelResolver({ client })
@@ -843,22 +886,31 @@ export async function pull(args = [], deps = {}) {
843
886
  }
844
887
  // ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its
845
888
  // `$model` names the pool folder — not by any query that happens to select
846
- // it. `recordsToProject` reads the site's org itself (this backend's, from
847
- // `sync.json`), so a `@/x` model the producer resolved to `@org/x` is placed
848
- // back where the author wrote it.
889
+ // it. Handed the foundation's scope (`scopeFor`), `recordsToProject` places a
890
+ // `@/x` model the producer resolved to `@acme/x` back where the author wrote it.
849
891
  const report = recordsToProject({
850
892
  folderDoc,
851
893
  recordDocs,
852
894
  siteRoot: siteDir,
853
895
  opts: {
854
- // Whose record map to read and extend, and whose org to place models under.
896
+ // Whose record map to read and extend.
855
897
  backend: client.origin,
856
- resolveDeclaration: (name) => declByModel.get(name) || null
898
+ resolveDeclaration: (name) => declByModel.get(name) || null,
899
+ // The scope a model's `@/x` was qualified with, so `@acme/article` is placed
900
+ // back in `records/article/` — see `scopeFor`.
901
+ scope: await scopeFor(null)
857
902
  }
858
903
  })
859
- if (report.records === 'updated') info('Wrote records.yml')
860
- // The backend's folder has no sub-folders, and records.yml holds nothing else.
861
- else if (report.records === 'removed') info('Removed records.yml — the folder has no sub-folders')
904
+ // The folder's organization, `folder.yml` in the records directory — named as
905
+ // the author would write it (`records/folder.yml`, or under `paths.records`).
906
+ if (report.records === 'updated') {
907
+ info(`Wrote ${report.recordsFile}`)
908
+ wrote.push(resolve(siteDir, report.recordsFile))
909
+ } else if (report.records === 'removed') {
910
+ // The backend's folder has no sub-folders, and the file holds nothing else.
911
+ info(`Removed ${report.recordsFile} — the folder has no sub-folders`)
912
+ removed.push(resolve(siteDir, report.recordsFile))
913
+ }
862
914
  records += report.placed.length + report.updated.length
863
915
  for (const s of report.skipped)
864
916
  note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
@@ -892,7 +944,8 @@ export async function pull(args = [], deps = {}) {
892
944
  siteDir,
893
945
  [
894
946
  ...wrote,
895
- ...['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml'].map((f) =>
947
+ // The records folder's `folder.yml` is in `wrote` when this pull wrote it.
948
+ ...['site.yml', 'theme.yml', 'head.html', 'queries.yml'].map((f) =>
896
949
  join(siteDir, f)
897
950
  )
898
951
  ],
@@ -27,15 +27,15 @@
27
27
  *
28
28
  * Usage:
29
29
  * uniweb push Build, push both lanes, back-fill $uuid
30
- * uniweb push --org @org Own the new site under @org (alias: --as-org).
31
- * Read only on the FIRST push of a site — it
32
- * decides which org owns it, and whose storage
33
- * its assets are charged to. Recorded in
34
- * `sync.json` and replayed after that.
35
- * Without it, you are asked once.
36
- * uniweb push --personal Own the new site personally, deliberately.
37
- * Sends NO `as_org` — the same wire as before
38
- * this prompt existed. First push only.
30
+ * uniweb push --org @org Own the new site under @org. On the FIRST
31
+ * push it decides which org owns the site, and
32
+ * whose storage its assets are charged to;
33
+ * recorded in `sync.json`, and every request
34
+ * after names it. Without it, you are asked
35
+ * once. Later, it must match the workspace the
36
+ * backend works on the site from, or push stops.
37
+ * uniweb push --personal Own the new site personally, deliberately:
38
+ * its requests name no workspace.
39
39
  * uniweb push --dry-run Report what would be pushed; submit nothing
40
40
  * uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
41
41
  * uniweb push --foundation <dir> Use this local foundation for the Model schema
@@ -92,7 +92,9 @@ import {
92
92
  clearRemoteSyncStateIfUnbound,
93
93
  dropSiteBoundValues,
94
94
  pushSyncPackages,
95
- resolveSiteOrgForCreate
95
+ resolveSiteOrgForCreate,
96
+ nameSiteWorkspace,
97
+ EXPLICIT_OWNER
96
98
  } from '../backend/site-sync.js'
97
99
 
98
100
  // Re-exported for downstream importers (pull.js, push.test.js) that read these
@@ -231,7 +233,6 @@ export async function push(args = [], deps = {}) {
231
233
  note(org.reason)
232
234
  return { exitCode: 2 }
233
235
  }
234
- const asOrg = org.asOrg
235
236
 
236
237
  // Bring the foundation along — BEFORE any asset upload, because an upload is
237
238
  // chargeable and a push that aborts after one has spent the user's money for
@@ -288,6 +289,17 @@ export async function push(args = [], deps = {}) {
288
289
  }
289
290
  if (!fnd.proceed) return { exitCode: 1 }
290
291
 
292
+ // ⭐ Name the workspace every site request works in. The user's own choice (`--org`,
293
+ // `--personal`, the picker) is explicit, so a `409 wrong_workspace` stops the push; a
294
+ // recorded one is adopted afresh from the backend's answer when stale
295
+ // (`nameSiteWorkspace`).
296
+ nameSiteWorkspace(client, {
297
+ siteDir,
298
+ workspace: org.workspace,
299
+ explicit: EXPLICIT_OWNER.has(org.source),
300
+ note
301
+ })
302
+
291
303
  // Build BOTH directional packages (the producer side). Each carries its own
292
304
  // `index` — the per-entity source-file map for back-fill, correlated by submission
293
305
  // position. Non-local Models are fetched from the registry on demand. `priorHashes`
@@ -406,8 +418,6 @@ export async function push(args = [], deps = {}) {
406
418
  backend: client.origin,
407
419
  // Placement identity for the folder — see writeFolderItemUuids.
408
420
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
409
- // Resolves a foundation-relative `@/x` model ref into `@org/x`.
410
- ...(asOrg ? { org: asOrg } : {}),
411
421
  ...(foundationDir ? { foundationDir } : {}),
412
422
  resolveModel: makeModelResolver({ client, offline: false })
413
423
  })
@@ -433,7 +443,6 @@ export async function push(args = [], deps = {}) {
433
443
  const site = await ensureSiteExists({
434
444
  client,
435
445
  siteDir,
436
- asOrg,
437
446
  note,
438
447
  ...(fnd.ref ? { foundation: fnd.ref } : {})
439
448
  })
@@ -508,8 +517,6 @@ export async function push(args = [], deps = {}) {
508
517
  // Identity for the `queries` section — see readQueryUuids. Keyed by
509
518
  // name, because a declaration has no file for a path-keyed map to hold.
510
519
  queryUuids: readQueryUuids(siteDir, client.origin),
511
- // Resolves a foundation-relative `@/x` model ref into `@org/x`.
512
- ...(asOrg ? { org: asOrg } : {}),
513
520
  ...(foundationDir ? { foundationDir } : {}),
514
521
  resolveModel: makeModelResolver({
515
522
  client,
@@ -602,7 +609,6 @@ export async function push(args = [], deps = {}) {
602
609
  client,
603
610
  siteDir,
604
611
  pkg,
605
- asOrg,
606
612
  report: {
607
613
  info,
608
614
  note,