uniweb 1.15.4 → 1.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.15.4",
3
+ "version": "1.16.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/schemas": "~1.6.3",
45
44
  "@uniweb/core": "~1.4.0",
45
+ "@uniweb/schemas": "~1.6.5",
46
46
  "@uniweb/semantic-parser": "~1.12.2",
47
47
  "@uniweb/content-writer": "~1.3.0"
48
48
  },
49
49
  "peerDependencies": {
50
+ "@uniweb/build": "~1.7.4",
50
51
  "@uniweb/semantic-parser": "~1.12.2",
51
- "@uniweb/content-reader": "~1.5.4",
52
- "@uniweb/build": "~1.7.3"
52
+ "@uniweb/content-reader": "~1.5.4"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -2631,6 +2631,18 @@ read that way comes back with `via: 'rbac'`. It is one step, and a backend refus
2631
2631
  to a field entries already fill. `grantable: false` keeps entries from being shared with a
2632
2632
  person or a team; omitted, they can be.
2633
2633
 
2634
+ A reference can also carry a purchase. `sold_with: true` — a boolean, and only on a reference
2635
+ or a list of them — means a buyer of the entry reads what the field names, through it, and onward
2636
+ along those entries' own `sold_with` fields, up to three steps:
2637
+
2638
+ ```yaml
2639
+ # foundation/schemas/course.yml
2640
+ fields:
2641
+ lessons: { ref: '@/lesson', many: true, sold_with: true } # a buyer of the course reads its lessons
2642
+ ```
2643
+
2644
+ An unmarked reference carries nothing, and `false` is the same as leaving it out.
2645
+
2634
2646
  ### Acting as an administrator
2635
2647
 
2636
2648
  On a site, an administrator's session reads what their role reaches — the operator reads
@@ -2839,11 +2851,12 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2839
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).
2840
2852
 
2841
2853
  > ⚠️ **Every push, pull and publish works in ONE workspace — the one chosen at `uniweb login`.**
2842
- > 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
2843
2856
  > created — owned, and its storage billed — there, and there is no CLI command to move it
2844
2857
  > afterwards. A site the backend keeps in **another** workspace stops the command, which says how
2845
- > to switch. With no organization, the login works in your personal workspace; with
2846
- > 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:
2847
2860
  >
2848
2861
  > ```bash
2849
2862
  > uniweb login --org @acme # work in an organization
@@ -2853,7 +2866,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2853
2866
  >
2854
2867
  > Already logged in, `uniweb login --org @other` (or `--personal`) switches the workspace on the
2855
2868
  > backend you are logged in to, without logging in again. **Without a
2856
- > 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
2857
2870
  > exits 2**: `uniweb login --org @acme` (or `--personal`) finishes it without signing in again.
2858
2871
  > Until then every command that works on a site refuses, and so does registering a foundation whose
2859
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,58 @@ 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.
52
+ *
53
+ * @param {unknown} value
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.
45
65
  *
46
66
  * @param {unknown} value
47
- * @returns {string|null} `@acme`, `PERSONAL`, or null
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)
53
73
  }
54
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
+ * What a backend whose orgs read lists no workspaces is told. ⛔ The CLI does not check
96
+ * against `orgs` in its place: that list leaves out every unit below an org.
97
+ */
98
+ export const noWorkspaceList = (origin) =>
99
+ `${origin} lists no workspaces, so the CLI cannot check or offer one — that backend is older than this CLI.`
100
+
55
101
  /** A choice in the header's form: `@acme`, or null for the personal workspace. */
56
102
  export function headerOf(choice) {
57
103
  return choice === PERSONAL ? null : choice
@@ -62,7 +108,7 @@ export const SOURCE_LABEL = {
62
108
  flag: 'named on this command',
63
109
  env: WORKSPACE_ENV,
64
110
  login: 'your login',
65
- personal: 'you belong to no organization',
111
+ personal: 'you belong to no organization or project',
66
112
  offline: 'not resolved in a preview'
67
113
  }
68
114
 
@@ -88,7 +134,7 @@ const choose = (origin) =>
88
134
  */
89
135
  export async function resolveWorkspace({ client, args = [], offline = false }) {
90
136
  const flag = readOrgFlag(args)
91
- if (flag) return { workspace: workspaceHandle(flag), source: 'flag' }
137
+ if (flag) return { workspace: namedWorkspace(flag), source: 'flag' }
92
138
  if (args.includes('--personal')) return { workspace: null, source: 'flag' }
93
139
 
94
140
  const raw = process.env[WORKSPACE_ENV]
@@ -118,11 +164,14 @@ export async function resolveWorkspace({ client, args = [], offline = false }) {
118
164
  const now = await fromSession()
119
165
  if (now) return { workspace: headerOf(now), source: 'login' }
120
166
 
121
- const { orgs } = await client.fetchOrgs()
122
- if (!orgs.length) return { workspace: null, source: 'personal' }
167
+ // Personal only when it is the one workspace there is — a project counts as much as an
168
+ // organization (`listedWorkspaces`).
169
+ const listed = listedWorkspaces(await client.fetchOrgs())
170
+ if (!listed) return { refused: true, reason: noWorkspaceList(client.origin) }
171
+ if (!listed.length) return { workspace: null, source: 'personal' }
123
172
  return {
124
173
  refused: true,
125
- reason: `You belong to organizations, so no workspace is assumed.\n ${choose(client.origin)}`
174
+ reason: `You belong to organizations or projects, so no workspace is assumed.\n ${choose(client.origin)}`
126
175
  }
127
176
  }
128
177
 
@@ -138,8 +187,9 @@ export async function resolveWorkspace({ client, args = [], offline = false }) {
138
187
  *
139
188
  * Null when the workspace names no scope, and the caller derives one as before
140
189
  * (`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).
190
+ * command is a preview, the workspace is a unit without a handle, the workspace is a unit
191
+ * below an organization — a sub-org or a project, whose child handle (`@studio:acme`) names
192
+ * no scope (backend, 2026-10-22) — or the account has no handle (a service account).
143
193
  *
144
194
  * @param {{ workspace?: string|null, source?: string, refused?: boolean }} ws -
145
195
  * `resolveWorkspace`'s answer
@@ -149,7 +199,9 @@ export async function resolveWorkspace({ client, args = [], offline = false }) {
149
199
  */
150
200
  export function scopeOfWorkspace(ws, accountHandle) {
151
201
  if (!ws || ws.refused || ws.source === 'offline') return null
152
- if (typeof ws.workspace === 'string') return ws.workspace.startsWith('@') ? ws.workspace : null
202
+ if (typeof ws.workspace === 'string') {
203
+ return ws.workspace.startsWith('@') && !ws.workspace.includes(':') ? ws.workspace : null
204
+ }
153
205
  const h = bareHandle(accountHandle || '')
154
206
  return h && !validateHandle(h) ? `@${h}` : null
155
207
  }
@@ -158,42 +210,46 @@ export function scopeOfWorkspace(ws, accountHandle) {
158
210
  * The workspace a login works in — asked once, at `uniweb login`, and stored with the
159
211
  * session.
160
212
  *
161
- * `--org @acme` must be an organization you belong to
213
+ * `--org @acme` must be a workspace the orgs read lists — an organization, or
214
+ * a sub-org or a project under its child handle (`@studio:acme`),
215
+ * or a unit with no handle by its uuid
162
216
  * `--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
217
+ * neither, none listed your personal workspace — said, not asked
218
+ * neither, some listed a pick at a terminal; refused without one
165
219
  *
166
220
  * @param {object} p
167
221
  * @param {string} p.apiBase
168
222
  * @param {string} p.token
169
223
  * @param {string[]} [p.args]
170
224
  * @returns {Promise<{ choice: string, note?: string } | { refused: true, reason: string }>}
171
- * `choice` is `@acme` or `PERSONAL`
225
+ * `choice` is `@acme`, a unit's uuid, or `PERSONAL`
172
226
  */
173
227
  export async function chooseWorkspace({ apiBase, token, args = [] }) {
174
228
  const flag = readOrgFlag(args)
175
229
  if (!flag && args.includes('--personal')) return { choice: PERSONAL }
176
230
 
177
- const { orgs } = await fetchOrgs({ apiBase, token })
178
- const mine = orgs.map((o) => `@${o.handle}`)
231
+ const listed = listedWorkspaces(await fetchOrgs({ apiBase, token }))
232
+ if (!listed) return { refused: true, reason: noWorkspaceList(apiBase) }
233
+ const mine = listed.map((w) => w.name)
179
234
  if (flag) {
180
- const h = workspaceHandle(flag)
181
- if (!mine.includes(h)) {
235
+ const named = namedWorkspace(flag)
236
+ if (!named) return { refused: true, reason: `--org ${flag} names no workspace — give @org, or --personal.` }
237
+ if (!mine.includes(named)) {
182
238
  return {
183
239
  refused: true,
184
240
  reason:
185
- `You are not a member of ${h} on ${apiBase}. ` +
241
+ `${describeWorkspace(named)} is not a workspace you can open on ${apiBase}. ` +
186
242
  (mine.length
187
243
  ? `Your workspaces: ${[...mine, `--personal`].join(', ')}.`
188
- : 'You belong to no organization — use --personal.')
244
+ : 'You belong to no organization or project — use --personal.')
189
245
  }
190
246
  }
191
- return { choice: h }
247
+ return { choice: named }
192
248
  }
193
- if (!orgs.length) {
249
+ if (!listed.length) {
194
250
  return {
195
251
  choice: PERSONAL,
196
- note: 'You belong to no organization, so you work in your personal workspace.'
252
+ note: 'You belong to no organization or project, so you work in your personal workspace.'
197
253
  }
198
254
  }
199
255
 
@@ -207,7 +263,7 @@ export async function chooseWorkspace({ apiBase, token, args = [] }) {
207
263
  if (isNonInteractive(args)) {
208
264
  return {
209
265
  refused: true,
210
- reason: `You belong to organizations, so no workspace is assumed. ${howToChoose}`
266
+ reason: `You belong to organizations or projects, so no workspace is assumed. ${howToChoose}`
211
267
  }
212
268
  }
213
269
  const prompts = (await import('prompts')).default
@@ -218,9 +274,9 @@ export async function chooseWorkspace({ apiBase, token, args = [] }) {
218
274
  message: 'Which workspace do you work in?',
219
275
  choices: [
220
276
  { title: 'Personal — your own sites', value: PERSONAL },
221
- ...orgs.map((o) => ({
222
- title: `@${o.handle}${o.is_primary ? ' (primary)' : ''}`,
223
- value: `@${o.handle}`
277
+ ...listed.map((w) => ({
278
+ title: `${describeWorkspace(w.name)}${w.kind === 'project' ? ' — a project' : ''}${w.primary ? ' (primary)' : ''}`,
279
+ value: w.name
224
280
  }))
225
281
  ],
226
282
  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
  }
@@ -407,6 +407,13 @@ async function deriveScopeFromLogin(client, args) {
407
407
  )
408
408
  return fromWorkspace
409
409
  }
410
+ // A unit below an organization — a sub-org, a project — is a workspace and no scope (backend,
411
+ // 2026-10-22), so a bare name is asked for one as when no workspace names any.
412
+ if (typeof ws.workspace === 'string' && ws.workspace.includes(':')) {
413
+ console.error(
414
+ `${colors.dim}${ws.workspace}, the workspace you work in, is a unit below an organization, and names no scope.${colors.reset}`
415
+ )
416
+ }
410
417
  const sess = await readRegistryAuth(client.origin)
411
418
  const derived = await deriveScope({
412
419
  apiBase: client.origin,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-21T04:14:08.358Z",
3
+ "generatedAt": "2026-10-22T17:15:55.292Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "1.6.2"
6
+ "version": "1.6.4"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.7.3"
9
+ "version": "1.7.4"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.5.4"
@@ -42,7 +42,7 @@
42
42
  "version": "1.0.1"
43
43
  },
44
44
  "@uniweb/schemas": {
45
- "version": "1.6.3"
45
+ "version": "1.6.5"
46
46
  },
47
47
  "@uniweb/scholar": {
48
48
  "version": "1.1.0"
package/src/index.js CHANGED
@@ -1815,16 +1815,17 @@ Without --server: https://uniweb.app (or \$UNIWEB_SERVER). No command talks
1815
1815
  to a backend you are not logged in to — run one before logging in and it asks first.
1816
1816
 
1817
1817
  ${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
1818
+ personal one, an organization's, or a sub-organization's or project's in one
1819
+ (@studio:acme) — and every push, pull and publish works in it: a site it creates is
1820
+ created there, and a site kept in another workspace is refused. With no organization
1821
+ 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
1822
  the workspace on the backend you are logged in to, without logging in again.
1823
1823
 
1824
1824
  ${colors.bright}Options:${colors.reset}
1825
1825
  --server <url> The backend to log in to (without it: the default backend — or, for
1826
1826
  --org / --personal alone, the one you are logged in to)
1827
- --org @org Work in @org (an organization you belong to)
1827
+ --org @org Work in @org — an organization, sub-organization or project you
1828
+ belong to (@studio:acme); a unit with no handle, by its uuid
1828
1829
  --personal Work in your personal workspace
1829
1830
  --token <bearer> Seed + verify a session from a bearer token (non-interactive)
1830
1831
  --browser Force the browser method
@@ -1833,7 +1834,7 @@ ${colors.bright}Options:${colors.reset}
1833
1834
 
1834
1835
  In non-interactive mode (no TTY — an agent, a script), pass \`--token <bearer>\` with
1835
1836
  \`--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;
1837
+ A login with organizations or projects that names no workspace signs you in but exits 2;
1837
1838
  \`uniweb login --org @org\` (or \`--personal\`) finishes it without signing in again.
1838
1839
  \`uniweb logout\` logs you out.
1839
1840
 
@@ -950,8 +950,9 @@ 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 } =
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 { WORKSPACE_ENV, PERSONAL, parseWorkspaceChoice, headerOf, describeWorkspace, listedWorkspaces, noWorkspaceList } =
955
956
  await import('../backend/workspace.js')
956
957
  const unsettled = (reason, { commandsRefuse }) => {
957
958
  console.error(`\x1b[31m✗\x1b[0m ${reason}`)
@@ -967,28 +968,32 @@ async function reportTokenProcess({ named, token }) {
967
968
  commandsRefuse: true
968
969
  })
969
970
  }
970
- let orgs
971
+ let listed
971
972
  try {
972
- orgs = (await (await import('./registry-orgs.js')).fetchOrgs({ apiBase: origin, token })).orgs
973
+ listed = listedWorkspaces(await (await import('./registry-orgs.js')).fetchOrgs({ apiBase: origin, token }))
973
974
  } catch (err) {
974
975
  console.error(`\x1b[33m⚠\x1b[0m ${err.message} — the workspace is not checked.`)
975
976
  return
976
977
  }
977
- const mine = orgs.map((o) => `@${o.handle}`)
978
+ if (!listed) {
979
+ console.error(`\x1b[33m⚠\x1b[0m ${noWorkspaceList(origin)}`)
980
+ return
981
+ }
982
+ const mine = listed.map((w) => w.name)
978
983
  if (choice && choice !== PERSONAL && !mine.includes(choice)) {
979
984
  // As a login refuses `--org` for one (chooseWorkspace).
980
- unsettled(`${WORKSPACE_ENV}=${raw} is not one of this account's organizations on ${origin}.`, {
985
+ unsettled(`${WORKSPACE_ENV}=${raw} is not one of this account's workspaces on ${origin}.`, {
981
986
  commandsRefuse: false
982
987
  })
983
988
  }
984
989
  if (!choice && mine.length) {
985
990
  unsettled(
986
- `The account belongs to organizations, so no workspace is assumed: set ${WORKSPACE_ENV}=${mine[0]} (or another, or personal).`,
991
+ `The account belongs to organizations or projects, so no workspace is assumed: set ${WORKSPACE_ENV}=${mine[0]} (or another, or personal).`,
987
992
  { commandsRefuse: true }
988
993
  )
989
994
  }
990
995
  const where = describeWorkspace(headerOf(choice || PERSONAL))
991
996
  console.error(
992
- ` They work in \x1b[1m${where}\x1b[0m${choice ? ` (${WORKSPACE_ENV})` : ' — the account belongs to no organization'}.`
997
+ ` They work in \x1b[1m${where}\x1b[0m${choice ? ` (${WORKSPACE_ENV})` : ' — the account belongs to no organization or project'}.`
993
998
  )
994
999
  }
@@ -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