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 +3 -3
- package/partials/agents.md +5 -4
- package/src/backend/client.js +15 -14
- package/src/backend/workspace.js +130 -31
- package/src/commands/publish.js +21 -14
- package/src/commands/register.js +18 -5
- package/src/framework-index.json +3 -3
- package/src/index.js +10 -7
- package/src/utils/registry-auth.js +31 -9
- package/src/utils/registry-orgs.js +23 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.
|
|
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": {
|
package/partials/agents.md
CHANGED
|
@@ -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
|
|
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;
|
|
2858
|
-
>
|
|
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
|
package/src/backend/client.js
CHANGED
|
@@ -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 `
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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}.`
|
package/src/backend/workspace.js
CHANGED
|
@@ -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
|
|
44
|
-
* the
|
|
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}
|
|
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 :
|
|
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:
|
|
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
|
-
|
|
122
|
-
|
|
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,
|
|
142
|
-
*
|
|
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')
|
|
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
|
|
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,
|
|
164
|
-
* neither,
|
|
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
|
|
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
|
|
178
|
-
const
|
|
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
|
|
181
|
-
if (!
|
|
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
|
-
|
|
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:
|
|
290
|
+
return { choice: named }
|
|
192
291
|
}
|
|
193
|
-
if (!
|
|
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
|
-
...
|
|
222
|
-
title:
|
|
223
|
-
value:
|
|
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
|
package/src/commands/publish.js
CHANGED
|
@@ -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
|
|
578
|
-
// build can't resolve to
|
|
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
|
|
623
|
-
// —
|
|
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
|
|
850
|
-
//
|
|
851
|
-
// Delivery is version-pinned end-to-end (
|
|
852
|
-
// by a concrete version
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
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.
|
|
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
|
}
|
package/src/commands/register.js
CHANGED
|
@@ -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
|
|
399
|
-
const
|
|
400
|
-
|
|
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
|
-
:
|
|
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,
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-22T17:15:55.292Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "1.6.
|
|
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.
|
|
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,
|
|
1819
|
-
|
|
1820
|
-
|
|
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
|
|
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,
|
|
954
|
-
|
|
955
|
-
|
|
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
|
|
979
|
+
let envelope
|
|
971
980
|
try {
|
|
972
|
-
|
|
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
|
|
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
|
|
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 }]
|
|
30
|
-
*
|
|
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
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|