uniweb 0.62.0 → 0.63.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.
@@ -21,8 +21,13 @@
21
21
  * uniweb register Build the .uwx, submit it, then deliver
22
22
  * the foundation's dist/ code (plan +
23
23
  * upload — see utils/code-upload.js)
24
- * uniweb register --scope @org Publish under @org (resolves @/x -> @org/x).
25
- * Default: the package's package.json "uniweb.scope".
24
+ * uniweb register --scope @org A foundation whose name is bare: register it under
25
+ * @org (resolves @/x -> @org/x) and write the scope
26
+ * into its name in main.js. Refused when the name
27
+ * already carries another scope — a foundation's
28
+ * scope is the one in its name.
29
+ * A schemas-only package: publish under @org.
30
+ * Default: its package.json "uniweb.scope".
26
31
  * uniweb register --schema-only Skip the code delivery (schemas land, no dist upload)
27
32
  * uniweb register --dry-run Print the .uwx + the code file plan; submit nothing
28
33
  * uniweb register -o foundation.uwx Write the .uwx to a file; submit nothing
@@ -71,7 +76,8 @@ import {
71
76
  isSchemasPackage,
72
77
  collectStandaloneSchemas,
73
78
  readFoundationName,
74
- checkFoundationName
79
+ checkFoundationName,
80
+ splitFoundationName
75
81
  } from '@uniweb/build'
76
82
  import {
77
83
  suggestFoundationName,
@@ -143,8 +149,10 @@ function cliVersion() {
143
149
  }
144
150
  }
145
151
 
146
- // The foundation's recorded publish org, from its package.json `uniweb.scope`
147
- // (`{ "uniweb": { "scope": "@acme" } }`) — the default when `--scope` is absent.
152
+ // A SCHEMAS-ONLY package's recorded publish org, from its package.json `uniweb.scope`
153
+ // (`{ "uniweb": { "scope": "@acme" } }`) — the default when `--scope` is absent. It
154
+ // has no main.js whose name could carry it. ⛔ A foundation's scope is part of its name
155
+ // (2026-09-22); its `uniweb.scope` is refused by `readFoundationName`.
148
156
  function readPkgScope(foundationDir) {
149
157
  try {
150
158
  const pkg = JSON.parse(
@@ -156,9 +164,10 @@ function readPkgScope(foundationDir) {
156
164
  }
157
165
  }
158
166
 
159
- // Record the chosen publish scope in the foundation's package.json so it travels
160
- // with the foundation (read back by readPkgScope on the next register). Preserves
161
- // the file's existing JSON style.
167
+ // Record the chosen publish scope in a schemas-only package's package.json so it
168
+ // travels with the package (read back by readPkgScope on the next register). Preserves
169
+ // the file's existing JSON style. A foundation's goes into its name instead
170
+ // (`settleFoundationScope`).
162
171
  async function writePkgScope(foundationDir, scope) {
163
172
  const path = join(foundationDir, 'package.json')
164
173
  const pkg = JSON.parse(readFileSync(path, 'utf8'))
@@ -293,6 +302,95 @@ export async function settleFoundationName(targetDir, { args, isPreview }) {
293
302
  return true
294
303
  }
295
304
 
305
+ /**
306
+ * Settle the scope a foundation registers under — the one in its name.
307
+ *
308
+ * ⭐ THE SCOPE IS PART OF THE NAME (2026-09-22): `name: '@acme/marketing'` in main.js.
309
+ * A scoped name decides it, and a `--scope` naming another org is refused — which org
310
+ * was meant is not ours to guess, and today's alternative was a split identity (the
311
+ * foundation under one org, its data schemas under the other). A bare name has not
312
+ * registered yet: it takes `--scope`, else one derived from your orgs, and the chosen
313
+ * scope is WRITTEN INTO THE NAME, so it is chosen once and travels with the code.
314
+ * ⛔ Until then it was written to `package.json::uniweb.scope`, which is refused now
315
+ * (`readFoundationName`, naming the line to write).
316
+ *
317
+ * A preview (`--dry-run`, `-o`) writes nothing: a bare name previews under `--scope`,
318
+ * or unscoped.
319
+ *
320
+ * @returns {Promise<{ scope: string|null, source: string|null } | { cancelled: true } | null>}
321
+ * null when refused (the reason is printed); `cancelled` when no org was chosen
322
+ */
323
+ export async function settleFoundationScope(targetDir, { args, isPreview, flagScope, client }) {
324
+ let read
325
+ try {
326
+ read = await readFoundationName(targetDir)
327
+ } catch (err) {
328
+ error(err.message)
329
+ return null
330
+ }
331
+ const where = relative(process.cwd(), read.mainFile) || read.mainFile
332
+ const { scope: nameScope, bare } = splitFoundationName(read.name)
333
+
334
+ if (nameScope) {
335
+ if (flagScope && flagScope !== nameScope) {
336
+ error(
337
+ `${where} names this foundation ${read.name}, so it registers under ${nameScope} — ` +
338
+ `--scope ${flagScope} names another org.`
339
+ )
340
+ log(
341
+ ` ${colors.dim}To register it under ${flagScope}, change its name in ${where}: ` +
342
+ `name: '${flagScope}/${bare}'${colors.reset}`
343
+ )
344
+ return null
345
+ }
346
+ return { scope: nameScope, source: 'main.js' }
347
+ }
348
+
349
+ if (isPreview) return { scope: flagScope, source: flagScope ? '--scope' : null }
350
+
351
+ let scope = flagScope
352
+ let source = flagScope ? '--scope' : null
353
+ if (!scope) {
354
+ scope = await deriveScopeFromLogin(client, args)
355
+ if (!scope) return { cancelled: true }
356
+ source = 'login'
357
+ }
358
+
359
+ const scoped = `${scope}/${bare}`
360
+ const written = writeFoundationName(read.mainFile, scoped, {
361
+ replace: read.source === 'main.js' ? read.name : null
362
+ })
363
+ if (!written.ok) {
364
+ error(`Could not write the scope into the foundation's name: ${written.reason}.`)
365
+ log(` Name it in the default export in ${colors.bright}${where}${colors.reset}: name: '${scoped}',`)
366
+ return null
367
+ }
368
+ // Read it back through the build's own rule — the text edit is only a means.
369
+ const again = await readFoundationName(targetDir).catch(() => null)
370
+ if (again?.name !== scoped) {
371
+ error(`${where} now names the foundation '${scoped}', but it still reads as "${again?.name}".`)
372
+ return null
373
+ }
374
+ success(`This foundation registers as ${colors.bright}${scoped}${colors.reset} — kept in ${where}.`)
375
+ return { scope, source }
376
+ }
377
+
378
+ /**
379
+ * A publish scope derived from the login's org memberships (list → 1 use / 0 create /
380
+ * N pick), as `@handle` — or null when none was chosen.
381
+ */
382
+ async function deriveScopeFromLogin(client, args) {
383
+ const token = await client.token()
384
+ const sess = await readRegistryAuth(client.origin)
385
+ const derived = await deriveScope({
386
+ apiBase: client.origin,
387
+ token,
388
+ accountHandle: sess?.handle || null,
389
+ args
390
+ })
391
+ return derived ? `@${derived}` : null
392
+ }
393
+
296
394
  // Directories that are never foundation source. `dist` is the output we compare
297
395
  // against; the rest are tooling. Dotted entries are skipped wholesale.
298
396
  const NON_SOURCE_DIRS = new Set(['dist', 'node_modules'])
@@ -487,10 +585,13 @@ async function runRegister(args = []) {
487
585
  ? process.cwd()
488
586
  : await resolveFoundationDir(args)
489
587
 
490
- // Scope: --scope flag, else package.json `uniweb.scope`, else (real submit
491
- // only) derived from login membership in the bootstrap below. Either spelling,
492
- // `@acme` or `acme`, and from here on the one form: `@acme` (publishScope).
493
- const pkgScope = readPkgScope(targetDir)
588
+ // Scope. ⭐ A FOUNDATION's is the one in its name (`name: '@acme/marketing'` in
589
+ // main.js), settled below before the build; a bare name takes --scope, else one
590
+ // derived from your orgs, and register writes it into the name (2026-09-22).
591
+ // A SCHEMAS-ONLY package has no name to carry one: --scope, else package.json
592
+ // `uniweb.scope`, else (real submit only) derived from login membership. Either
593
+ // spelling, `@acme` or `acme`, and from here on the one form: `@acme` (publishScope).
594
+ const pkgScope = standalone ? readPkgScope(targetDir) : null
494
595
  const givenScope = scopeFlag || pkgScope
495
596
  let scope = publishScope(givenScope)
496
597
  let scopeSource = scopeFlag
@@ -530,6 +631,19 @@ async function runRegister(args = []) {
530
631
  if (!(await settleFoundationName(targetDir, { args, isPreview }))) {
531
632
  return { exitCode: 2 }
532
633
  }
634
+ // ⭐ BEFORE THE BUILD, so the build embeds the name the foundation registers as.
635
+ // Settled after it, a scope written into main.js would leave dist/ describing the
636
+ // bare name — stale the moment it was built.
637
+ const settled = await settleFoundationScope(targetDir, {
638
+ args,
639
+ isPreview,
640
+ flagScope: scope,
641
+ client
642
+ })
643
+ if (!settled) return { exitCode: 2 }
644
+ if (settled.cancelled) return { exitCode: 0 }
645
+ scope = settled.scope
646
+ scopeSource = settled.source
533
647
  // Build-if-stale (`foundationNeedsBuild`): a missing or stale dist/ gets
534
648
  // (re)built before we read its schema. Preview paths
535
649
  // (--dry-run / -o) must not write to dist/, so they require a pre-built
@@ -578,24 +692,18 @@ async function runRegister(args = []) {
578
692
  }
579
693
  }
580
694
 
581
- // No scope for a real submit → derive it from login membership (list → 1 use /
582
- // 0 create / N pick), persist to package.json, and reuse the session token.
583
- if (!scope && !isPreview) {
584
- const token = await client.token()
585
- const sess = await readRegistryAuth(client.origin)
586
- const derived = await deriveScope({
587
- apiBase: client.origin,
588
- token,
589
- accountHandle: sess?.handle || null,
590
- args
591
- })
695
+ // A schemas-only package with no scope, on a real submit → derive it from login
696
+ // membership (list → 1 use / 0 create / N pick) and persist it to package.json.
697
+ // (A foundation's was settled before its build — `settleFoundationScope`.)
698
+ if (standalone && !scope && !isPreview) {
699
+ const derived = await deriveScopeFromLogin(client, args)
592
700
  if (!derived) return { exitCode: 0 }
593
- scope = `@${derived}`
701
+ scope = derived
594
702
  scopeSource = 'login'
595
703
  try {
596
704
  await writePkgScope(targetDir, scope)
597
705
  info(
598
- `Saved ${colors.bright}${scope}${colors.reset} as this ${standalone ? 'package' : 'foundation'}'s publish scope (package.json).`
706
+ `Saved ${colors.bright}${scope}${colors.reset} as this package's publish scope (package.json).`
599
707
  )
600
708
  } catch {
601
709
  log(
@@ -692,7 +800,9 @@ async function runRegister(args = []) {
692
800
  // Submit requires a concrete scope — the registry rejects @/… fail-closed.
693
801
  if (!scope) {
694
802
  error(
695
- 'No publish scope — set "uniweb.scope" in package.json, or pass --scope @org.'
803
+ standalone
804
+ ? 'No publish scope — set "uniweb.scope" in package.json, or pass --scope @org.'
805
+ : "No publish scope — name the foundation '@org/<name>' in main.js, or pass --scope @org."
696
806
  )
697
807
  log(
698
808
  ` ${colors.dim}Without a scope, names stay @/… and the registry rejects them.${colors.reset}`
@@ -25,8 +25,14 @@ import { join } from 'node:path'
25
25
  import yaml from 'js-yaml'
26
26
 
27
27
  import { resolveSiteDir } from './deploy.js'
28
- import { probeUnpushed } from '../backend/site-sync.js'
29
- import { BackendClient, resolveBackendOrigin } from '../backend/client.js'
28
+ import { probeUnpushed, nameSiteWorkspace, readSiteWorkspace } from '../backend/site-sync.js'
29
+ import { readOrgFlag } from '../utils/args.js'
30
+ import {
31
+ BackendClient,
32
+ resolveBackendOrigin,
33
+ workspaceHandle,
34
+ WorkspaceMismatchError
35
+ } from '../backend/client.js'
30
36
  import { readBackendState } from '@uniweb/build/uwx'
31
37
  import {
32
38
  resolveLocalFoundation,
@@ -115,12 +121,22 @@ export async function status(args = []) {
115
121
  let site = null
116
122
  let fdnLatest = null
117
123
  let foundationFresh = null // true/false when both digests are known; else null
124
+ let remoteError = null // a refusal about the workspace — the one remote failure worth saying
118
125
  if (remote) {
119
126
  try {
120
127
  const client = new BackendClient({
121
128
  args,
122
129
  command: 'Status'
123
130
  })
131
+ // The workspace the site's requests name — as pull names it. A stale record is
132
+ // re-adopted from the backend's answer; `--json` keeps stdout to the document.
133
+ const orgFlag = readOrgFlag(args)
134
+ nameSiteWorkspace(client, {
135
+ siteDir,
136
+ workspace: orgFlag ? workspaceHandle(orgFlag) : readSiteWorkspace(siteDir, client.origin),
137
+ explicit: Boolean(orgFlag),
138
+ note: jsonMode ? undefined : say.dim
139
+ })
124
140
  if (uuid) site = await client.siteStatus(uuid)
125
141
  // Foundation freshness: prefer the LOCAL foundation's scoped name (so a
126
142
  // local-foundation site can be checked too); fall back to a scoped
@@ -134,8 +150,11 @@ export async function status(args = []) {
134
150
  const localDigest = computeFoundationDigest(join(local.dir, 'dist'))
135
151
  if (localDigest) foundationFresh = localDigest === fdnLatest.digest
136
152
  }
137
- } catch {
138
- // degrade silently
153
+ } catch (err) {
154
+ // Degrade silently — except a refusal about the workspace: `--org` named one the
155
+ // backend does not work on this site from, or the deployment has another. Saying
156
+ // nothing would read as "fine".
157
+ if (err instanceof WorkspaceMismatchError || err?.status === 409) remoteError = err.message
139
158
  }
140
159
  }
141
160
 
@@ -153,7 +172,8 @@ export async function status(args = []) {
153
172
  remote: {
154
173
  site,
155
174
  foundation_latest: fdnLatest?.latest_version ?? null,
156
- foundation_fresh: foundationFresh
175
+ foundation_fresh: foundationFresh,
176
+ ...(remoteError ? { error: remoteError } : {})
157
177
  }
158
178
  }
159
179
  : {})
@@ -202,6 +222,7 @@ export async function status(args = []) {
202
222
 
203
223
  // Remote signals
204
224
  if (remote) {
225
+ if (remoteError) say.warn(remoteError)
205
226
  if (site) {
206
227
  if (site.draft_dirty) {
207
228
  say.info(
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-21T23:16:47.962Z",
3
+ "generatedAt": "2026-09-23T15:24:08.497Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.4.1",
6
+ "version": "0.5.0",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.58.0",
13
+ "version": "0.59.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -96,7 +96,7 @@
96
96
  "deps": []
97
97
  },
98
98
  "@uniweb/schemas": {
99
- "version": "0.3.3",
99
+ "version": "0.3.4",
100
100
  "path": "framework/schemas",
101
101
  "deps": []
102
102
  },
@@ -116,7 +116,7 @@
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.16.0",
119
+ "version": "0.16.1",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },
@@ -126,7 +126,7 @@
126
126
  "deps": []
127
127
  },
128
128
  "@uniweb/unipress": {
129
- "version": "0.10.1",
129
+ "version": "0.10.2",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",
package/src/index.js CHANGED
@@ -1456,11 +1456,12 @@ ${colors.bright}Options:${colors.reset}
1456
1456
  bind to, and the app cannot open such a site.
1457
1457
  --no-save Do not record this deploy in deploy.yml
1458
1458
  --no-validate Skip the content-conformance check (it only warns)
1459
- --org @org Publish under @org (membership-gated; alias: --as-org). Read
1460
- only on a site's FIRST publish — that create decides which org
1461
- owns the site, and whose storage its assets are charged to.
1462
- Recorded in sync.json and replayed, so it is never
1463
- re-typed. Without it, you are asked once.
1459
+ --org @org Publish under @org (membership-gated). On a site's FIRST
1460
+ publish it decides which org owns the site, and whose storage
1461
+ its assets are charged to. Recorded in sync.json and named on
1462
+ every request after, so it is never re-typed. Without it, you
1463
+ are asked once. Later, it must match the workspace the backend
1464
+ works on the site from, or publish stops.
1464
1465
  --personal Create the site under your personal account, deliberately.
1465
1466
  Only needed on a first publish, and only to answer the owner
1466
1467
  question without a prompt (CI, agents, scripts).
@@ -1725,13 +1726,18 @@ Auto-detects what you run it in:
1725
1726
  • a schemas-only pkg just its data schemas, no foundation — e.g. @uniweb/schemas,
1726
1727
  any @org/schemas package, or a bare schemas/*.yml folder
1727
1728
 
1728
- Schema scopes (set the org with --scope, or package.json uniweb.scope):
1729
- @/name your own schema, scoped to the publish org (@/x -> @org/x)
1729
+ A foundation's scope is part of its name — name: '@org/<name>' in main.js. A bare
1730
+ name takes --scope (or one of your orgs), and register writes it into the name.
1731
+
1732
+ Schema scopes:
1733
+ @/name your own schema, in the foundation's scope (@/x -> @org/x)
1730
1734
  @std/name a shared standard schema (from @uniweb/schemas)
1731
1735
  @org/name another org's published schema, referenced by name
1732
1736
 
1733
1737
  ${colors.bright}Options:${colors.reset}
1734
- --scope @org Publish under @org (resolves @/x -> @org/x); default: package.json uniweb.scope
1738
+ --scope @org A foundation with a bare name: register under @org, and write it into
1739
+ the name (refused when the name has another scope). A schemas-only
1740
+ package: publish under @org; default: package.json uniweb.scope
1735
1741
  --dry-run Print the .uwx; submit nothing
1736
1742
  -o, --output <f> Write the .uwx to a file; submit nothing
1737
1743
  --non-interactive Fail with usage info instead of prompting
package/src/utils/args.js CHANGED
@@ -21,25 +21,17 @@
21
21
  * @returns {string | null | undefined}
22
22
  */
23
23
  /**
24
- * The org a site is created under. `--org` is the documented spelling; `--as-org`
25
- * is a working alias.
24
+ * The workspace a site command names — `--org @acme` — and, on a site's first push or
25
+ * publish, the org it is created under.
26
26
  *
27
- * `--as-org` mirrors the wire (`?as_org=`) and names an *acting capacity* — the
28
- * request is made as a member of that org, membership-gated. That is accurate, and
29
- * it is also not the name anyone reaches for: asked in one day, the backend's docs
30
- * said `--as-unit` and a second reader said `--org`; nobody produced `--as-org`.
31
- * Since the flag's main job is answering *who owns this site*, `--org` is the name
32
- * that matches the question, and the alias costs one `||` (the same shape
33
- * `--backend` already uses).
34
- *
35
- * `||`, not `??`, on purpose: a valueless `--org` falls through to `--as-org`
36
- * rather than shadowing it.
27
+ * ⛔ `--as-org` is RETIRED (2026-09-23). It mirrored the wire's `?as_org=`, which the
28
+ * `x-uniweb-workspace` header replaced; `flag-guard.js` refuses it and names `--org`.
37
29
  *
38
30
  * @param {string[]} args
39
31
  * @returns {string|null|undefined}
40
32
  */
41
33
  export function readOrgFlag(args) {
42
- return readFlagValue(args, '--org') || readFlagValue(args, '--as-org')
34
+ return readFlagValue(args, '--org')
43
35
  }
44
36
 
45
37
  /**
@@ -97,7 +89,7 @@ function editDistance(a, b) {
97
89
 
98
90
  /**
99
91
  * The closest known flag to `flag`, or null when nothing is close enough.
100
- * The threshold scales with length so `--org` → `--as-org` is offered while two
92
+ * The threshold scales with length so `--forcee` → `--force` is offered while two
101
93
  * unrelated short flags are not.
102
94
  * @param {string} flag
103
95
  * @param {string[]} known
@@ -50,12 +50,11 @@ const VIA_DEPLOY = ['--target', '--host', '--no-save']
50
50
  /**
51
51
  * Per-verb flag sets. Derived by scanning each command for dash-literals AND the
52
52
  * helpers it calls — not from the help text, which has drifted from the parser in
53
- * both directions (`--as-org` was implemented and undocumented; `--yes` is
54
- * documented on `publish` and consumed two files away).
53
+ * both directions (`--yes` is documented on `publish` and consumed two files away).
55
54
  */
56
55
  const VERBS = {
57
56
  push: [
58
- '--all', '--as-org', '--org', '--dry-run', '--force',
57
+ '--all', '--org', '--dry-run', '--force',
59
58
  '--foundation', '--output', '-o', '--personal',
60
59
  // read in utils/conformance.js and backend/site-sync.js respectively —
61
60
  // neither appears in push.js
@@ -69,7 +68,7 @@ const VERBS = {
69
68
  '--no-release', ...VIA_DEPLOY
70
69
  ],
71
70
  publish: [
72
- '--as-org', '--org', '--dry-run', '--force', '--foundation',
71
+ '--org', '--dry-run', '--force', '--foundation',
73
72
  '--personal',
74
73
  // read in utils/conformance.js, backend/site-sync.js, and
75
74
  // backend/foundation-bring-along.js — none appear in publish.js
@@ -80,15 +79,15 @@ const VERBS = {
80
79
  '--no-assets',
81
80
  '--no-records', '--no-delete', '--no-prune',
82
81
  // via backend/site-sync.js (the owner resolver) and utils/conformance.js
83
- '--yes', '--org', '--as-org', '--no-validate', ...VIA_DEPLOY
82
+ '--yes', '--org', '--no-validate', ...VIA_DEPLOY
84
83
  ],
85
84
  clone: [
86
85
  '--content-only', '--no-assets', '--no-records', '--path',
87
- '--project', '--org', '--as-org'
86
+ '--project', '--org'
88
87
  ],
89
88
  register: [
90
89
  '--dry-run', '--json', '--output', '-o',
91
- '--schema-only', '--scope', '--org', '--as-org'
90
+ '--schema-only', '--scope', '--org'
92
91
  ],
93
92
  /**
94
93
  * `forget` = remove one backend's records (`--backend <url>`), or everything a
@@ -99,7 +98,7 @@ const VERBS = {
99
98
  forget: ['--backend', '--all'],
100
99
  status: [
101
100
  '--json', '--remote', '--dry-run',
102
- '--force', '--no-verify', '--no-validate', '--yes', '--org', '--as-org',
101
+ '--force', '--no-verify', '--no-validate', '--yes', '--org',
103
102
  // inert here, reachable through the bring-along module status imports for
104
103
  // `resolveLocalFoundation` — listed per the over-approximation note above
105
104
  '--no-release', ...VIA_DEPLOY
@@ -119,7 +118,7 @@ const VERBS = {
119
118
  */
120
119
  refresh: [
121
120
  '--no-backend', '--no-git',
122
- '--as-org', '--org', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
121
+ '--org', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
123
122
  ]
124
123
  }
125
124
 
@@ -213,6 +212,15 @@ export function checkFlags(verb, args = []) {
213
212
  ].join('\n')
214
213
  }
215
214
  }
215
+ // ⭐ `--as-org` is RETIRED (2026-09-23) with the `?as_org=` it mirrored — the request
216
+ // names its workspace in a header now, and `--org` is the one spelling.
217
+ if (flag === '--as-org' && all.includes('--org')) {
218
+ return {
219
+ flag,
220
+ suggestion: '--org',
221
+ message: `\`--as-org\` is retired — use \`--org @org\` (e.g. \`uniweb ${verb} --org @acme\`).`
222
+ }
223
+ }
216
224
  const suggestion = didYouMean(flag, all)
217
225
  const lines = [`Unknown flag \`${flag}\` for \`uniweb ${verb}\`.`]
218
226
  if (suggestion) lines.push(` Did you mean \`${suggestion}\`?`)
@@ -61,8 +61,9 @@ export function bareHandle(scope) {
61
61
 
62
62
  /**
63
63
  * A publish scope in the one form the registry names things by — `@acme` — from any
64
- * spelling `--scope` or `package.json::uniweb.scope` may carry it in (`@acme`, `acme`,
65
- * `@acme/…`). Null when there is no handle in it.
64
+ * spelling `--scope` or a schemas-only package's `package.json::uniweb.scope` may carry
65
+ * it in (`@acme`, `acme`, `@acme/…`). Null when there is no handle in it. (A
66
+ * foundation's scope is part of its name, `@acme/marketing`, and needs no reading.)
66
67
  *
67
68
  * ⛔ Every name built from a scope goes through this. The `.uwx` assembly
68
69
  * (`@uniweb/build`'s `buildRegistryPackage`) has always accepted `acme` as well as
@@ -51,6 +51,7 @@
51
51
  * demonstrated.**
52
52
  */
53
53
 
54
+ import { WORKSPACE_HEADER } from '../backend/client.js'
54
55
  import { createHash } from 'node:crypto'
55
56
  import { humanBytes } from './bytes.js'
56
57
  import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
@@ -62,6 +63,9 @@ import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
62
63
  * @param {string} opts.apiBase - backend origin
63
64
  * @param {string} opts.token - bearer, used on the direct arm only
64
65
  * @param {string} opts.siteUuid - the site these files belong to (path segment)
66
+ * @param {string|null} [opts.workspace] - the workspace the requests name, `@handle`
67
+ * (`x-uniweb-workspace`) — on the plan and on every DIRECT PUT, never on a presigned
68
+ * one, which is self-authorizing and must carry nothing it was not signed with
65
69
  * @param {{ data: Record<string, unknown> }|null} opts.ball - source of the set:
66
70
  * `{ "<relpath under dist/data>": <json> }`, media refs already rewritten
67
71
  * @param {(m: string) => void} [opts.onProgress]
@@ -72,9 +76,13 @@ export async function uploadSiteData({
72
76
  apiBase,
73
77
  token,
74
78
  siteUuid,
79
+ workspace = null,
75
80
  ball,
76
81
  onProgress = () => {}
77
82
  }) {
83
+ // A plan made with the header mints BARE PUT urls: the PUT names its workspace with
84
+ // the header like every other request (backend, 2026-09-23).
85
+ const workspaceHeader = workspace ? { [WORKSPACE_HEADER]: workspace } : {}
78
86
  // ⛔ An EMPTY set still posts a plan, and that is the whole point of this lane
79
87
  // being a manifest rather than a stream of files.
80
88
  //
@@ -131,7 +139,8 @@ export async function uploadSiteData({
131
139
  method: 'POST',
132
140
  headers: {
133
141
  'Content-Type': 'application/json',
134
- Authorization: `Bearer ${token}`
142
+ Authorization: `Bearer ${token}`,
143
+ ...workspaceHeader
135
144
  },
136
145
  body: JSON.stringify({
137
146
  files: files.map(({ path, content_type, size, sha256 }) => ({
@@ -172,7 +181,9 @@ export async function uploadSiteData({
172
181
  // PUT is a bearer-authed backend route; a presigned URL is self-authorizing
173
182
  // and must NOT carry a foreign bearer, which can break signature validation.
174
183
  const authHeaders =
175
- plan.mode === 'presigned' ? {} : { Authorization: `Bearer ${token}` }
184
+ plan.mode === 'presigned'
185
+ ? {}
186
+ : { Authorization: `Bearer ${token}`, ...workspaceHeader }
176
187
 
177
188
  const uploaded = []
178
189
  const failed = []