uniweb 1.15.5 → 1.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.15.5",
3
+ "version": "1.16.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
+ "@uniweb/schemas": "~1.6.5",
44
45
  "@uniweb/core": "~1.4.0",
45
46
  "@uniweb/semantic-parser": "~1.12.2",
46
- "@uniweb/schemas": "~1.6.4",
47
47
  "@uniweb/content-writer": "~1.3.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/content-reader": "~1.5.4",
51
50
  "@uniweb/semantic-parser": "~1.12.2",
51
+ "@uniweb/content-reader": "~1.5.4",
52
52
  "@uniweb/build": "~1.7.4"
53
53
  },
54
54
  "peerDependenciesMeta": {
@@ -2851,11 +2851,12 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2851
2851
  **Content authors work visually in the Uniweb App.** They compose the same extended markdown and set the same component params you defined, through a visual editor — never touching code, git, or the CLI. They see exactly the section types your foundation offers and exactly the knobs each one exposes, because **every `meta.js` is registered as the foundation's schema** when you publish. Your `meta.js` is the app's UI (see *meta.js* in Part 4).
2852
2852
 
2853
2853
  > ⚠️ **Every push, pull and publish works in ONE workspace — the one chosen at `uniweb login`.**
2854
- > A workspace is your personal one or an organization's. A site a push or publish **creates** is
2854
+ > A workspace is your personal one, an organization's, or a sub-organization's or project's in one
2855
+ > (`@studio:acme`). A site a push or publish **creates** is
2855
2856
  > created — owned, and its storage billed — there, and there is no CLI command to move it
2856
2857
  > afterwards. A site the backend keeps in **another** workspace stops the command, which says how
2857
- > to switch. With no organization, the login works in your personal workspace; with
2858
- > organizations, the login asks, or you name it:
2858
+ > to switch. With no organization or project, the login works in your personal workspace;
2859
+ > otherwise the login asks, or you name it:
2859
2860
  >
2860
2861
  > ```bash
2861
2862
  > uniweb login --org @acme # work in an organization
@@ -2865,7 +2866,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2865
2866
  >
2866
2867
  > Already logged in, `uniweb login --org @other` (or `--personal`) switches the workspace on the
2867
2868
  > backend you are logged in to, without logging in again. **Without a
2868
- > terminal — you, an agent — a login with organizations and no workspace named signs you in but
2869
+ > terminal — you, an agent — a login with organizations or projects and no workspace named signs you in but
2869
2870
  > exits 2**: `uniweb login --org @acme` (or `--personal`) finishes it without signing in again.
2870
2871
  > Until then every command that works on a site refuses, and so does registering a foundation whose
2871
2872
  > name has no scope. A process logged in with `UNIWEB_TOKEN` names its workspace with
@@ -165,6 +165,11 @@ export function workspaceHandle(value) {
165
165
 
166
166
  const UNIT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
167
167
 
168
+ /** Whether `value` is shaped like a unit's uuid — how the header names a unit with no handle. */
169
+ export function isUnitUuid(value) {
170
+ return typeof value === 'string' && UNIT_UUID.test(value.trim())
171
+ }
172
+
168
173
  /**
169
174
  * A workspace as a sentence names it: `@acme`, `the unit <uuid>`, or `your personal
170
175
  * workspace` — for null, and for a login's stored `personal`.
@@ -180,8 +185,8 @@ export function describeWorkspace(workspace) {
180
185
  *
181
186
  * ⚠️ A bare value is taken for a uuid only when it is shaped like one, and our own
182
187
  * handle grammar admits uuid-shaped handles (3–39 lowercase letters, digits, hyphens).
183
- * So a HANDLE reaches this already dressed — `--org` through `workspaceHandle`, a
184
- * login's choice as stored — and a bare uuid here is a unit.
188
+ * So a HANDLE reaches this already dressed — `--org` through `namedWorkspace`
189
+ * (`workspace.js`), a login's choice as stored — and a bare uuid here is a unit.
185
190
  *
186
191
  * @param {unknown} value
187
192
  * @returns {string|null}
@@ -214,20 +219,16 @@ export class WorkspaceMismatchError extends Error {
214
219
  constructor({ named, answer, nowhere = false, source = 'login', origin = null, message = null }) {
215
220
  const where = (w) =>
216
221
  !w ? 'your personal workspace' : w.startsWith('@') ? w : `the unit ${w}`
217
- // How to work in the site's workspace, said for the way this one was chosen.
222
+ // How to work in the site's workspace, said for the way this one was chosen. A unit with
223
+ // no handle is named by its uuid, as the header names it. ⛔ *Until 2026-10-22 this said
224
+ // "It has no handle, so the CLI cannot work in it": a login could choose only what the
225
+ // orgs read listed, and that list left handle-less units out (`workspace.js`).*
218
226
  const switchTo = !answer
219
227
  ? { flag: 'pass --personal', env: 'set UNIWEB_WORKSPACE=personal' }
220
- : answer.startsWith('@')
221
- ? { flag: `pass --org ${answer}`, env: `set UNIWEB_WORKSPACE=${answer}` }
222
- : null
223
- const login = !answer
224
- ? `${loginCommand(origin)} --personal`
225
- : answer.startsWith('@')
226
- ? `${loginCommand(origin)} --org ${answer}`
227
- : null
228
- const fix = !switchTo
229
- ? 'It has no handle, so the CLI cannot work in it.'
230
- : source === 'flag'
228
+ : { flag: `pass --org ${answer}`, env: `set UNIWEB_WORKSPACE=${answer}` }
229
+ const login = !answer ? `${loginCommand(origin)} --personal` : `${loginCommand(origin)} --org ${answer}`
230
+ const fix =
231
+ source === 'flag'
231
232
  ? `To work on it, ${switchTo.flag}.`
232
233
  : source === 'env'
233
234
  ? `To work on it, ${switchTo.env}.`
@@ -23,13 +23,19 @@
23
23
  *
24
24
  * ⛔ **The personal workspace is named by naming NONE.** `@<your handle>` is your personal
25
25
  * SCOPE — a registry namespace for foundations and data schemas — not a workspace.
26
+ *
27
+ * ⭐ **A workspace is any unit the backend lets a request name** (backend, 2026-10-22): an
28
+ * organization, and the units below one — a sub-org, a project — each under its child handle,
29
+ * `@top:part` (`@studio:acme`); a unit with no handle by its uuid. The login checks and offers
30
+ * them from the orgs read's `workspaces`, never its `orgs`, which are the SCOPES and leave every
31
+ * child unit out (`registry-orgs.js`). A child handle names a workspace and no scope.
26
32
  */
27
33
 
28
34
  import { readOrgFlag } from '../utils/args.js'
29
35
  import { loginCommand } from '../utils/config.js'
30
36
  import { fetchOrgs, bareHandle, validateHandle } from '../utils/registry-orgs.js'
31
37
  import { readRegistryAuth } from '../utils/registry-auth.js'
32
- import { workspaceHandle, describeWorkspace } from './client.js'
38
+ import { workspaceHandle, describeWorkspace, isUnitUuid } from './client.js'
33
39
 
34
40
  export { describeWorkspace }
35
41
 
@@ -40,18 +46,85 @@ export const PERSONAL = 'personal'
40
46
  export const WORKSPACE_ENV = 'UNIWEB_WORKSPACE'
41
47
 
42
48
  /**
43
- * A workspace choice as it may be written — `@acme`, `acme`, `personal` — or null when
44
- * the value names none.
49
+ * A workspace as `--org` names it, in the header's form: a unit's uuid when the value is
50
+ * written bare and shaped like one — the header's own rule, where the `@` tells the two
51
+ * apart — else a handle, `@acme` or `@studio:acme`. Null when the value names none.
45
52
  *
46
53
  * @param {unknown} value
47
- * @returns {string|null} `@acme`, `PERSONAL`, or null
54
+ * @returns {string|null}
55
+ */
56
+ export function namedWorkspace(value) {
57
+ if (typeof value !== 'string' || !value.trim()) return null
58
+ const v = value.trim()
59
+ return isUnitUuid(v) ? v.toLowerCase() : workspaceHandle(v)
60
+ }
61
+
62
+ /**
63
+ * A workspace choice as it may be written — `@acme`, `acme`, `@studio:acme`, a unit's uuid,
64
+ * `personal` — or null when the value names none.
65
+ *
66
+ * @param {unknown} value
67
+ * @returns {string|null} `@acme`, a uuid, `PERSONAL`, or null
48
68
  */
49
69
  export function parseWorkspaceChoice(value) {
50
70
  if (typeof value !== 'string' || !value.trim()) return null
51
71
  const v = value.trim()
52
- return v === PERSONAL ? PERSONAL : workspaceHandle(v)
72
+ return v === PERSONAL ? PERSONAL : namedWorkspace(v)
73
+ }
74
+
75
+ /**
76
+ * The workspaces the orgs read lists — what the header admits — each by the name a request
77
+ * gives it: `@handle`, or the unit's uuid when it has none. Null when the answer carries no
78
+ * list, which a workspace reader refuses (`noWorkspaceList`).
79
+ *
80
+ * @param {{ workspaces?: Array<{ unit_uuid?: string, handle?: string|null, kind?: string, is_primary?: boolean }>|null }|null} envelope
81
+ * @returns {Array<{ name: string, kind: string|null, primary: boolean }>|null}
82
+ */
83
+ export function listedWorkspaces(envelope) {
84
+ if (!Array.isArray(envelope?.workspaces)) return null
85
+ return envelope.workspaces
86
+ .map((w) => ({
87
+ name: w?.handle ? workspaceHandle(w.handle) : isUnitUuid(w?.unit_uuid) ? w.unit_uuid.trim().toLowerCase() : null,
88
+ kind: typeof w?.kind === 'string' ? w.kind : null,
89
+ primary: w?.is_primary === true
90
+ }))
91
+ .filter((w) => w.name)
92
+ }
93
+
94
+ /**
95
+ * The top-level organization of a unit named by its child handle — `@studio` for `@studio:acme`,
96
+ * the part before the colon (backend, 2026-10-22).
97
+ *
98
+ * @param {string} handle - `@top:part`
99
+ * @returns {string} `@top`
100
+ */
101
+ export function topOfChildHandle(handle) {
102
+ return `@${String(handle).replace(/^@/, '').split(':')[0]}`
53
103
  }
54
104
 
105
+ /**
106
+ * The account's own handle, as the header would name it (`@jane`), from the orgs read — or null.
107
+ *
108
+ * ⛔ **An account's handle names no workspace** (backend, 2026-10-22, pinned by its tests). A
109
+ * handle names a person or an org, and only an org is a workspace: the header answers
110
+ * `@<account handle>` with `400`, never as the personal workspace, which a request names by
111
+ * naming none. So `--org @<your handle>` is refused, saying what names your own.
112
+ */
113
+ export function ownHandle(envelope) {
114
+ return envelope?.account_handle ? workspaceHandle(envelope.account_handle) : null
115
+ }
116
+
117
+ /** What naming your own handle as a workspace is told — `--personal`, or `UNIWEB_WORKSPACE=personal`. */
118
+ export const ownHandleNamesNone = (named, personal) =>
119
+ `${named} is your own handle, which names no workspace — your personal workspace is ${personal}.`
120
+
121
+ /**
122
+ * What a backend whose orgs read lists no workspaces is told. ⛔ The CLI does not check
123
+ * against `orgs` in its place: that list leaves out every unit below an org.
124
+ */
125
+ export const noWorkspaceList = (origin) =>
126
+ `${origin} lists no workspaces, so the CLI cannot check or offer one — that backend is older than this CLI.`
127
+
55
128
  /** A choice in the header's form: `@acme`, or null for the personal workspace. */
56
129
  export function headerOf(choice) {
57
130
  return choice === PERSONAL ? null : choice
@@ -62,7 +135,7 @@ export const SOURCE_LABEL = {
62
135
  flag: 'named on this command',
63
136
  env: WORKSPACE_ENV,
64
137
  login: 'your login',
65
- personal: 'you belong to no organization',
138
+ personal: 'you belong to no organization or project',
66
139
  offline: 'not resolved in a preview'
67
140
  }
68
141
 
@@ -88,7 +161,7 @@ const choose = (origin) =>
88
161
  */
89
162
  export async function resolveWorkspace({ client, args = [], offline = false }) {
90
163
  const flag = readOrgFlag(args)
91
- if (flag) return { workspace: workspaceHandle(flag), source: 'flag' }
164
+ if (flag) return { workspace: namedWorkspace(flag), source: 'flag' }
92
165
  if (args.includes('--personal')) return { workspace: null, source: 'flag' }
93
166
 
94
167
  const raw = process.env[WORKSPACE_ENV]
@@ -118,11 +191,14 @@ export async function resolveWorkspace({ client, args = [], offline = false }) {
118
191
  const now = await fromSession()
119
192
  if (now) return { workspace: headerOf(now), source: 'login' }
120
193
 
121
- const { orgs } = await client.fetchOrgs()
122
- if (!orgs.length) return { workspace: null, source: 'personal' }
194
+ // Personal only when it is the one workspace there is — a project counts as much as an
195
+ // organization (`listedWorkspaces`).
196
+ const listed = listedWorkspaces(await client.fetchOrgs())
197
+ if (!listed) return { refused: true, reason: noWorkspaceList(client.origin) }
198
+ if (!listed.length) return { workspace: null, source: 'personal' }
123
199
  return {
124
200
  refused: true,
125
- reason: `You belong to organizations, so no workspace is assumed.\n ${choose(client.origin)}`
201
+ reason: `You belong to organizations or projects, so no workspace is assumed.\n ${choose(client.origin)}`
126
202
  }
127
203
  }
128
204
 
@@ -136,20 +212,37 @@ export async function resolveWorkspace({ client, args = [], offline = false }) {
136
212
  * decides which SITE a command works on; here it only answers the question a bare name
137
213
  * asks the first time it registers.
138
214
  *
215
+ * ⭐ **A unit below an organization — a sub-org, a project — takes its organization's scope**
216
+ * *[Diego, 2026-10-22]*: its child handle (`@studio:acme`) names no scope (backend, the same day),
217
+ * and a bare name registered from it defaults to the top-level org's handle, `@studio`, when that
218
+ * org is among the account's organizations — the orgs read's `orgs`, the scopes. Not otherwise: a
219
+ * project's guest belongs to the project alone and has only their own scope, and so do the people
220
+ * of a sub-org whose belonging stops there — which the orgs read cannot tell apart, so membership is
221
+ * read from `orgs` and nothing else.
222
+ *
139
223
  * Null when the workspace names no scope, and the caller derives one as before
140
224
  * (`deriveScope`): none is chosen (you belong to organizations and none is named), the
141
- * command is a preview, the workspace is a unit without a handle, or the account has no
142
- * handle (a service account).
225
+ * command is a preview, the workspace is a unit without a handle, the workspace is a unit
226
+ * below an organization the account is not a member of, or the account has no handle (a
227
+ * service account).
143
228
  *
144
229
  * @param {{ workspace?: string|null, source?: string, refused?: boolean }} ws -
145
230
  * `resolveWorkspace`'s answer
146
231
  * @param {string|null} accountHandle - the account's own handle (`GET /dev/orgs`), read
147
232
  * only for the personal workspace
233
+ * @param {Array<{ handle?: string }>} [orgs] - the orgs read's `orgs`, read only for a unit
234
+ * below an organization
148
235
  * @returns {string|null} `@handle`
149
236
  */
150
- export function scopeOfWorkspace(ws, accountHandle) {
237
+ export function scopeOfWorkspace(ws, accountHandle, orgs = []) {
151
238
  if (!ws || ws.refused || ws.source === 'offline') return null
152
- if (typeof ws.workspace === 'string') return ws.workspace.startsWith('@') ? ws.workspace : null
239
+ if (typeof ws.workspace === 'string') {
240
+ if (!ws.workspace.startsWith('@')) return null
241
+ if (!ws.workspace.includes(':')) return ws.workspace
242
+ const top = topOfChildHandle(ws.workspace)
243
+ const member = (Array.isArray(orgs) ? orgs : []).some((o) => `@${bareHandle(o?.handle)}` === top)
244
+ return member ? top : null
245
+ }
153
246
  const h = bareHandle(accountHandle || '')
154
247
  return h && !validateHandle(h) ? `@${h}` : null
155
248
  }
@@ -158,42 +251,48 @@ export function scopeOfWorkspace(ws, accountHandle) {
158
251
  * The workspace a login works in — asked once, at `uniweb login`, and stored with the
159
252
  * session.
160
253
  *
161
- * `--org @acme` must be an organization you belong to
254
+ * `--org @acme` must be a workspace the orgs read lists — an organization, or
255
+ * a sub-org or a project under its child handle (`@studio:acme`),
256
+ * or a unit with no handle by its uuid
162
257
  * `--personal` your personal workspace
163
- * neither, no organization your personal workspace — said, not asked
164
- * neither, organizations a pick at a terminal; refused without one
258
+ * neither, none listed your personal workspace — said, not asked
259
+ * neither, some listed a pick at a terminal; refused without one
165
260
  *
166
261
  * @param {object} p
167
262
  * @param {string} p.apiBase
168
263
  * @param {string} p.token
169
264
  * @param {string[]} [p.args]
170
265
  * @returns {Promise<{ choice: string, note?: string } | { refused: true, reason: string }>}
171
- * `choice` is `@acme` or `PERSONAL`
266
+ * `choice` is `@acme`, a unit's uuid, or `PERSONAL`
172
267
  */
173
268
  export async function chooseWorkspace({ apiBase, token, args = [] }) {
174
269
  const flag = readOrgFlag(args)
175
270
  if (!flag && args.includes('--personal')) return { choice: PERSONAL }
176
271
 
177
- const { orgs } = await fetchOrgs({ apiBase, token })
178
- const mine = orgs.map((o) => `@${o.handle}`)
272
+ const envelope = await fetchOrgs({ apiBase, token })
273
+ const listed = listedWorkspaces(envelope)
274
+ if (!listed) return { refused: true, reason: noWorkspaceList(apiBase) }
275
+ const mine = listed.map((w) => w.name)
179
276
  if (flag) {
180
- const h = workspaceHandle(flag)
181
- if (!mine.includes(h)) {
277
+ const named = namedWorkspace(flag)
278
+ if (!named) return { refused: true, reason: `--org ${flag} names no workspace — give @org, or --personal.` }
279
+ if (named === ownHandle(envelope)) return { refused: true, reason: ownHandleNamesNone(named, '--personal') }
280
+ if (!mine.includes(named)) {
182
281
  return {
183
282
  refused: true,
184
283
  reason:
185
- `You are not a member of ${h} on ${apiBase}. ` +
284
+ `${describeWorkspace(named)} is not a workspace you can open on ${apiBase}. ` +
186
285
  (mine.length
187
286
  ? `Your workspaces: ${[...mine, `--personal`].join(', ')}.`
188
- : 'You belong to no organization — use --personal.')
287
+ : 'You belong to no organization or project — use --personal.')
189
288
  }
190
289
  }
191
- return { choice: h }
290
+ return { choice: named }
192
291
  }
193
- if (!orgs.length) {
292
+ if (!listed.length) {
194
293
  return {
195
294
  choice: PERSONAL,
196
- note: 'You belong to no organization, so you work in your personal workspace.'
295
+ note: 'You belong to no organization or project, so you work in your personal workspace.'
197
296
  }
198
297
  }
199
298
 
@@ -207,7 +306,7 @@ export async function chooseWorkspace({ apiBase, token, args = [] }) {
207
306
  if (isNonInteractive(args)) {
208
307
  return {
209
308
  refused: true,
210
- reason: `You belong to organizations, so no workspace is assumed. ${howToChoose}`
309
+ reason: `You belong to organizations or projects, so no workspace is assumed. ${howToChoose}`
211
310
  }
212
311
  }
213
312
  const prompts = (await import('prompts')).default
@@ -218,9 +317,9 @@ export async function chooseWorkspace({ apiBase, token, args = [] }) {
218
317
  message: 'Which workspace do you work in?',
219
318
  choices: [
220
319
  { title: 'Personal — your own sites', value: PERSONAL },
221
- ...orgs.map((o) => ({
222
- title: `@${o.handle}${o.is_primary ? ' (primary)' : ''}`,
223
- value: `@${o.handle}`
320
+ ...listed.map((w) => ({
321
+ title: `${describeWorkspace(w.name)}${w.kind === 'project' ? ' — a project' : ''}${w.primary ? ' (primary)' : ''}`,
322
+ value: w.name
224
323
  }))
225
324
  ],
226
325
  initial: 0
@@ -573,9 +573,14 @@ export async function publish(args = []) {
573
573
  say.dim(`This publish creates the site in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source]}).`)
574
574
  }
575
575
 
576
- // 2. Build the site data (link mode): dist/site-content.json (+ per-locale),
577
- // dist/data/*, dist/assets/*. Spawn the SAME CLI binary so the inner
578
- // build can't resolve to a different installed version.
576
+ // 2. Build the site data (link mode): dist/site-content.json (+ per-locale)
577
+ // and dist/data/*. No media is processed: 4b uploads it from the site's
578
+ // own files. Spawn the SAME CLI binary so the inner build can't resolve to
579
+ // a different installed version.
580
+ //
581
+ // ⛔ *This listed `dist/assets/*` until 2026-10-22; the link build has made
582
+ // no media copies since 2026-10-19 (`site/build-site-data.js`, "Does NOT
583
+ // emit").*
579
584
  //
580
585
  // ⛔ NO SEARCH INDEX. This listed `dist/_search/*` until 2026-08-26; the
581
586
  // link lane stopped emitting one on 2026-08-01 (`@uniweb/build`
@@ -619,8 +624,9 @@ export async function publish(args = []) {
619
624
  if (!guard.ok) return { exitCode: 1 }
620
625
  }
621
626
 
622
- // 3. Partition collections by schema presence (a first emit reads `schemaless`
623
- // — collections with no data schema, delivered statically via the ball).
627
+ // 3. Partition the queries by schema presence (a first emit reads `schemaless`
628
+ // — the queries with no data schema, whose compiled files 4c uploads; no
629
+ // ball carries them since 2026-08-18).
624
630
  let probe
625
631
  try {
626
632
  probe = await emitSyncPackages(siteDir, {
@@ -846,13 +852,14 @@ export async function publish(args = []) {
846
852
  siteDir,
847
853
  note: (m) => say.dim(m)
848
854
  })
849
- // Stamp deploy-derived info on the site-content entity: the data-bundle URL,
850
- // and the PINNED foundation ref (`@scope/name@version`) from the bring-along.
851
- // Delivery is version-pinned end-to-end (the gateway serves a foundation only
852
- // by a concrete version — collab framework↔backend), so pinning the
853
- // released version on the wire is required when site.yml uses an unversioned
854
- // local ref; injectInfo overrides info.foundation. A registry/URL ref → fnd.ref
855
- // is null → the site.yml ref is forwarded verbatim (already pinned).
855
+ // Stamp the PINNED foundation ref (`@scope/name@version`) from the bring-along
856
+ // on the site-content entity — the one key `injectInfo` carries since the
857
+ // data-bundle URL went (2026-08-18). Delivery is version-pinned end-to-end (a
858
+ // foundation is delivered only by a concrete version, agreed with backend and
859
+ // measured end to end on 2026-06-25), so pinning the released version on the
860
+ // wire is required when site.yml uses an unversioned local ref; injectInfo
861
+ // overrides info.foundation. A registry/URL ref → fnd.ref is null → the
862
+ // site.yml ref is forwarded verbatim (already pinned).
856
863
  // ⛔ DO NOT STAMP A FILE MAP INTO `info` HERE. `injectInfo` WINS the merge
857
864
  // (`sync-package.js`: `{...siteDoc.info, ...injectInfo}`), so any name stamped
858
865
  // here silently replaces what the author's document carries under it, and the
@@ -861,8 +868,8 @@ export async function publish(args = []) {
861
868
  // that moved to `settings.fetch`, and the warning holds for any name.
862
869
  //
863
870
  // A file map needs a name nothing else claims (`static_data` / `data_files`
864
- // were proposed) AND a consumer that reads it — neither settled. See
865
- // one object per file, no bundle.
871
+ // were proposed) AND a consumer that reads it — neither settled. Until then
872
+ // the schema-less data goes as 4c sends it: one object per file, no bundle.
866
873
  const injectInfo = {
867
874
  ...(fnd.ref ? { foundation: fnd.ref } : {})
868
875
  }
@@ -90,7 +90,7 @@ import {
90
90
  } from '../utils/code-upload.js'
91
91
  import { deriveScope, publishScope, belongsToScope } from '../utils/registry-orgs.js'
92
92
  import { BackendClient } from '../backend/client.js'
93
- import { resolveWorkspace, scopeOfWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
93
+ import { resolveWorkspace, scopeOfWorkspace, topOfChildHandle, SOURCE_LABEL } from '../backend/workspace.js'
94
94
  import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
95
95
  import {
96
96
  findWorkspaceRoot,
@@ -395,18 +395,31 @@ export async function settleFoundationScope(targetDir, { args, isPreview, flagSc
395
395
  async function deriveScopeFromLogin(client, args) {
396
396
  const token = await client.token()
397
397
  const ws = await resolveWorkspace({ client, args })
398
- const personal = !ws.refused && ws.source !== 'offline' && ws.workspace === null
399
- const accountHandle = personal ? (await client.fetchOrgs()).account_handle : null
400
- const fromWorkspace = scopeOfWorkspace(ws, accountHandle)
398
+ const live = !ws.refused && ws.source !== 'offline'
399
+ const personal = live && ws.workspace === null
400
+ // A unit below an organization — a sub-org, a project — names no scope; its organization's is
401
+ // the default for the organization's members (`scopeOfWorkspace`), read from the same answer.
402
+ const child = live && typeof ws.workspace === 'string' && ws.workspace.includes(':')
403
+ const envelope = personal || child ? await client.fetchOrgs() : null
404
+ const fromWorkspace = scopeOfWorkspace(ws, envelope?.account_handle ?? null, envelope?.orgs)
401
405
  if (fromWorkspace) {
402
406
  const named = `${colors.bright}${fromWorkspace}${colors.reset}`
403
407
  console.error(
404
408
  personal
405
409
  ? `Registering under your personal scope ${named} — you work in your personal workspace (${SOURCE_LABEL[ws.source]}). For an org: --scope @org.`
406
- : `Registering under ${named} — the workspace you work in (${SOURCE_LABEL[ws.source]}). For another scope: --scope @org.`
410
+ : child
411
+ ? `Registering under ${named} — the organization of ${ws.workspace}, the workspace you work in (${SOURCE_LABEL[ws.source]}). For another scope: --scope @org.`
412
+ : `Registering under ${named} — the workspace you work in (${SOURCE_LABEL[ws.source]}). For another scope: --scope @org.`
407
413
  )
408
414
  return fromWorkspace
409
415
  }
416
+ // Not a member of the unit's organization — a project's guest — so it gives no scope, and a bare
417
+ // name is asked for one as when no workspace names any.
418
+ if (child) {
419
+ console.error(
420
+ `${colors.dim}${ws.workspace}, the workspace you work in, names no scope, and ${topOfChildHandle(ws.workspace)} is not among your organizations.${colors.reset}`
421
+ )
422
+ }
410
423
  const sess = await readRegistryAuth(client.origin)
411
424
  const derived = await deriveScope({
412
425
  apiBase: client.origin,
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-21T16:45:14.911Z",
3
+ "generatedAt": "2026-10-22T17:15:55.292Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "1.6.3"
6
+ "version": "1.6.4"
7
7
  },
8
8
  "@uniweb/build": {
9
9
  "version": "1.7.4"
@@ -42,7 +42,7 @@
42
42
  "version": "1.0.1"
43
43
  },
44
44
  "@uniweb/schemas": {
45
- "version": "1.6.4"
45
+ "version": "1.6.5"
46
46
  },
47
47
  "@uniweb/scholar": {
48
48
  "version": "1.1.0"
package/src/index.js CHANGED
@@ -1759,7 +1759,9 @@ ${colors.bright}Options:${colors.reset}
1759
1759
  org's — and write it into the name (refused when the name has another
1760
1760
  scope). A schemas-only package: publish under @scope; default:
1761
1761
  package.json uniweb.scope
1762
- --org @org Work in @org for this command: a bare name registers under it
1762
+ --org @org Work in @org for this command: a bare name registers under it —
1763
+ a sub-org's or project's (@studio:acme): under its organization's,
1764
+ when you are a member of it
1763
1765
  --personal Work in your personal workspace: a bare name registers under your
1764
1766
  personal scope
1765
1767
  --dry-run Print the .uwx; submit nothing
@@ -1815,16 +1817,17 @@ Without --server: https://uniweb.app (or \$UNIWEB_SERVER). No command talks
1815
1817
  to a backend you are not logged in to — run one before logging in and it asks first.
1816
1818
 
1817
1819
  ${colors.bright}The workspace you work in.${colors.reset} A login works in ONE workspace — your
1818
- personal one, or an organization's — and every push, pull and publish works in it:
1819
- a site it creates is created there, and a site kept in another workspace is refused.
1820
- With no organization it is your personal workspace; with organizations you are asked,
1821
- or name it. Already logged in, \`uniweb login --org @other\` (or \`--personal\`) switches
1820
+ personal one, an organization's, or a sub-organization's or project's in one
1821
+ (@studio:acme) — and every push, pull and publish works in it: a site it creates is
1822
+ created there, and a site kept in another workspace is refused. With no organization
1823
+ or project it is your personal workspace; with some you are asked, or name it. Already logged in, \`uniweb login --org @other\` (or \`--personal\`) switches
1822
1824
  the workspace on the backend you are logged in to, without logging in again.
1823
1825
 
1824
1826
  ${colors.bright}Options:${colors.reset}
1825
1827
  --server <url> The backend to log in to (without it: the default backend — or, for
1826
1828
  --org / --personal alone, the one you are logged in to)
1827
- --org @org Work in @org (an organization you belong to)
1829
+ --org @org Work in @org — an organization, sub-organization or project you
1830
+ belong to (@studio:acme); a unit with no handle, by its uuid
1828
1831
  --personal Work in your personal workspace
1829
1832
  --token <bearer> Seed + verify a session from a bearer token (non-interactive)
1830
1833
  --browser Force the browser method
@@ -1833,7 +1836,7 @@ ${colors.bright}Options:${colors.reset}
1833
1836
 
1834
1837
  In non-interactive mode (no TTY — an agent, a script), pass \`--token <bearer>\` with
1835
1838
  \`--org @org\` or \`--personal\`, or set \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`.
1836
- A login with organizations that names no workspace signs you in but exits 2;
1839
+ A login with organizations or projects that names no workspace signs you in but exits 2;
1837
1840
  \`uniweb login --org @org\` (or \`--personal\`) finishes it without signing in again.
1838
1841
  \`uniweb logout\` logs you out.
1839
1842
 
@@ -950,9 +950,18 @@ async function reportTokenProcess({ named, token }) {
950
950
  )
951
951
 
952
952
  // Their workspace is UNIWEB_WORKSPACE's — never the stored session's — and, with none
953
- // named, personal for an account in no organization (resolveWorkspace, workspace.js).
954
- const { WORKSPACE_ENV, PERSONAL, parseWorkspaceChoice, headerOf, describeWorkspace } =
955
- await import('../backend/workspace.js')
953
+ // named, personal for an account in no organization or project (resolveWorkspace,
954
+ // workspace.js). Checked, as a login's `--org` is, against the workspaces the orgs read lists.
955
+ const {
956
+ WORKSPACE_ENV,
957
+ PERSONAL,
958
+ parseWorkspaceChoice,
959
+ headerOf,
960
+ describeWorkspace,
961
+ listedWorkspaces,
962
+ noWorkspaceList,
963
+ ownHandle
964
+ } = await import('../backend/workspace.js')
956
965
  const unsettled = (reason, { commandsRefuse }) => {
957
966
  console.error(`\x1b[31m✗\x1b[0m ${reason}`)
958
967
  if (commandsRefuse) {
@@ -967,28 +976,41 @@ async function reportTokenProcess({ named, token }) {
967
976
  commandsRefuse: true
968
977
  })
969
978
  }
970
- let orgs
979
+ let envelope
971
980
  try {
972
- orgs = (await (await import('./registry-orgs.js')).fetchOrgs({ apiBase: origin, token })).orgs
981
+ envelope = await (await import('./registry-orgs.js')).fetchOrgs({ apiBase: origin, token })
973
982
  } catch (err) {
974
983
  console.error(`\x1b[33m⚠\x1b[0m ${err.message} — the workspace is not checked.`)
975
984
  return
976
985
  }
977
- const mine = orgs.map((o) => `@${o.handle}`)
986
+ const listed = listedWorkspaces(envelope)
987
+ if (!listed) {
988
+ console.error(`\x1b[33m⚠\x1b[0m ${noWorkspaceList(origin)}`)
989
+ return
990
+ }
991
+ const mine = listed.map((w) => w.name)
992
+ if (choice && choice === ownHandle(envelope)) {
993
+ // As a login refuses `--org @<own handle>` (chooseWorkspace): a handle names a person or an org,
994
+ // and only an org is a workspace.
995
+ unsettled(
996
+ `${WORKSPACE_ENV}=${raw} names the account's own handle, which names no workspace — its personal workspace is ${WORKSPACE_ENV}=personal.`,
997
+ { commandsRefuse: false }
998
+ )
999
+ }
978
1000
  if (choice && choice !== PERSONAL && !mine.includes(choice)) {
979
1001
  // As a login refuses `--org` for one (chooseWorkspace).
980
- unsettled(`${WORKSPACE_ENV}=${raw} is not one of this account's organizations on ${origin}.`, {
1002
+ unsettled(`${WORKSPACE_ENV}=${raw} is not one of this account's workspaces on ${origin}.`, {
981
1003
  commandsRefuse: false
982
1004
  })
983
1005
  }
984
1006
  if (!choice && mine.length) {
985
1007
  unsettled(
986
- `The account belongs to organizations, so no workspace is assumed: set ${WORKSPACE_ENV}=${mine[0]} (or another, or personal).`,
1008
+ `The account belongs to organizations or projects, so no workspace is assumed: set ${WORKSPACE_ENV}=${mine[0]} (or another, or personal).`,
987
1009
  { commandsRefuse: true }
988
1010
  )
989
1011
  }
990
1012
  const where = describeWorkspace(headerOf(choice || PERSONAL))
991
1013
  console.error(
992
- ` They work in \x1b[1m${where}\x1b[0m${choice ? ` (${WORKSPACE_ENV})` : ' — the account belongs to no organization'}.`
1014
+ ` They work in \x1b[1m${where}\x1b[0m${choice ? ` (${WORKSPACE_ENV})` : ' — the account belongs to no organization or project'}.`
993
1015
  )
994
1016
  }
@@ -26,8 +26,20 @@
26
26
  *
27
27
  * GET /dev/orgs → {
28
28
  * account_handle: string|null, // the caller's account handle
29
- * orgs: [{ handle, is_primary }] // memberships, primary first;
30
- * } // handle-less units filtered server-side
29
+ * orgs: [{ handle, is_primary }], // the SCOPES: top-level orgs, primary first;
30
+ * // handle-less units filtered server-side
31
+ * workspaces: [{ unit_uuid, handle, kind, is_primary }]
32
+ * } // what `x-uniweb-workspace` admits
33
+ *
34
+ * ⭐ TWO LISTS, BECAUSE A WORKSPACE IS NOT A SCOPE (backend, 2026-10-22). `orgs` are the
35
+ * scopes a foundation registers under — the login's scope picker (`deriveScope`) and the
36
+ * membership read (`belongsToScope`) stay on it. `workspaces` are where a command may work:
37
+ * the organizations AND the units below them — a sub-org, a project (`kind: "project"`) —
38
+ * each under its child handle, `top:part` (`studio:acme`), and a unit with no handle by its
39
+ * `unit_uuid`. They differ exactly where a workspace is no scope: a child handle names none.
40
+ * The login's `--org` and its pick read `workspaces` (`backend/workspace.js`). ⛔ *Until
41
+ * 2026-10-22 both read `orgs`, which listed sub-orgs then; since that day it lists none, so a
42
+ * login refused a project or a sub-org even when a `409` had just named it.*
31
43
  *
32
44
  * POST /dev/orgs { handle } → { handle, uuid, is_primary }
33
45
  *
@@ -114,9 +126,13 @@ export function validateHandle(handle) {
114
126
  }
115
127
 
116
128
  /**
117
- * The picker read: memberships + the caller's account handle + whether the
118
- * personal org already exists.
119
- * @returns {Promise<{account_handle: string|null, orgs: Array<{handle: string, is_primary: boolean}>}>}
129
+ * The orgs read: the caller's account handle, the scopes (`orgs`) and the workspaces.
130
+ *
131
+ * `workspaces` is null when the answer carries no list — a backend older than the list
132
+ * (2026-10-22). The workspace readers refuse then, saying so (`backend/workspace.js`);
133
+ * they never stand `orgs` in for it, which leaves the units below an org out.
134
+ *
135
+ * @returns {Promise<{account_handle: string|null, orgs: Array<{handle: string, is_primary: boolean}>, workspaces: Array<{unit_uuid?: string, handle?: string|null, kind?: string, is_primary?: boolean}>|null}>}
120
136
  */
121
137
  export async function fetchOrgs({ apiBase, token, fetchImpl = globalThis.fetch }) {
122
138
  const res = await fetchImpl(`${apiBase.replace(/\/$/, '')}${ORGS_PATH}`, {
@@ -129,7 +145,8 @@ export async function fetchOrgs({ apiBase, token, fetchImpl = globalThis.fetch }
129
145
  const data = await res.json().catch(() => null)
130
146
  return {
131
147
  account_handle: data?.account_handle ?? null,
132
- orgs: Array.isArray(data?.orgs) ? data.orgs : []
148
+ orgs: Array.isArray(data?.orgs) ? data.orgs : [],
149
+ workspaces: Array.isArray(data?.workspaces) ? data.workspaces : null
133
150
  }
134
151
  }
135
152