uniweb 0.64.1 → 0.65.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 +7 -7
- package/partials/agents.md +16 -16
- package/src/backend/client.js +61 -51
- package/src/backend/site-sync.js +17 -229
- package/src/backend/workspace.js +199 -0
- package/src/commands/clone.js +19 -29
- package/src/commands/publish.js +22 -39
- package/src/commands/pull.js +13 -14
- package/src/commands/push.js +21 -40
- package/src/commands/status.js +7 -12
- package/src/index.js +19 -14
- package/src/utils/flag-guard.js +7 -5
- package/src/utils/registry-auth.js +75 -16
- package/src/utils/registry-orgs.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,17 +41,17 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/
|
|
45
|
-
"@uniweb/schemas": "^0.4.0",
|
|
44
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
46
45
|
"@uniweb/core": "^0.29.8",
|
|
47
46
|
"@uniweb/kit": "^0.19.4",
|
|
48
|
-
"@uniweb/
|
|
49
|
-
"@uniweb/
|
|
47
|
+
"@uniweb/runtime": "^0.27.4",
|
|
48
|
+
"@uniweb/schemas": "^0.4.0",
|
|
49
|
+
"@uniweb/semantic-parser": "^1.4.1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@uniweb/
|
|
52
|
+
"@uniweb/build": "^0.59.1",
|
|
53
53
|
"@uniweb/content-reader": "^1.2.5",
|
|
54
|
-
"@uniweb/
|
|
54
|
+
"@uniweb/semantic-parser": "^1.4.1"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -2527,9 +2527,9 @@ uniweb add ci --target foundation # Publish a foundation for free at permanent v
|
|
|
2527
2527
|
|
|
2528
2528
|
uniweb push / pull / clone / status # Git-style content sync with the Uniweb backend
|
|
2529
2529
|
uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
|
|
2530
|
-
uniweb
|
|
2530
|
+
uniweb login --org @acme # The workspace you work in — new sites are created there (see below)
|
|
2531
2531
|
uniweb register [--scope @scope] # Register a foundation + its data schemas to the registry
|
|
2532
|
-
uniweb login / logout # One backend at a time: log in (--backend
|
|
2532
|
+
uniweb login / logout # One backend, one workspace at a time: log in (--backend, --org) or out
|
|
2533
2533
|
uniweb org list / create <handle> # Your personal scope, and the orgs you belong to
|
|
2534
2534
|
uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
|
|
2535
2535
|
|
|
@@ -2573,24 +2573,24 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2573
2573
|
|
|
2574
2574
|
**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).
|
|
2575
2575
|
|
|
2576
|
-
> ⚠️ **
|
|
2577
|
-
>
|
|
2578
|
-
>
|
|
2579
|
-
> afterwards.
|
|
2580
|
-
>
|
|
2581
|
-
>
|
|
2576
|
+
> ⚠️ **Every push, pull and publish works in ONE workspace — the one chosen at `uniweb login`.**
|
|
2577
|
+
> A workspace is your personal one or an organization's. A site a push or publish **creates** is
|
|
2578
|
+
> created — owned, and its storage billed — there, and there is no CLI command to move it
|
|
2579
|
+
> afterwards. A site the backend keeps in **another** workspace stops the command, which says how
|
|
2580
|
+
> to switch. With no organization, the login works in your personal workspace; with
|
|
2581
|
+
> organizations, the login asks, or you name it:
|
|
2582
2582
|
>
|
|
2583
2583
|
> ```bash
|
|
2584
|
-
> uniweb
|
|
2585
|
-
> uniweb
|
|
2584
|
+
> uniweb login --org @acme # work in an organization
|
|
2585
|
+
> uniweb login --personal # work in your personal workspace
|
|
2586
|
+
> uniweb push --org @acme # this command only
|
|
2586
2587
|
> ```
|
|
2587
2588
|
>
|
|
2588
|
-
>
|
|
2589
|
-
>
|
|
2590
|
-
>
|
|
2591
|
-
>
|
|
2592
|
-
>
|
|
2593
|
-
> **stops the command** rather than acting in another org.
|
|
2589
|
+
> Already logged in, `uniweb login --org @other` switches without logging in again. **Without a
|
|
2590
|
+
> terminal — you, an agent — a login with organizations refuses until one is named**, and so
|
|
2591
|
+
> does a command when none was. A process logged in with `UNIWEB_TOKEN` names its workspace with
|
|
2592
|
+
> `UNIWEB_WORKSPACE=@acme` (or `personal`). **Ask the human which workspace to use** rather than
|
|
2593
|
+
> picking for them — a site created in the wrong one cannot be moved from here.
|
|
2594
2594
|
>
|
|
2595
2595
|
> Relatedly: these commands now **reject flags they do not recognize** instead of ignoring them.
|
|
2596
2596
|
> If you get `Unknown flag`, read the suggestion — it is usually a near miss.
|
package/src/backend/client.js
CHANGED
|
@@ -148,6 +148,15 @@ export function workspaceHandle(value) {
|
|
|
148
148
|
|
|
149
149
|
const UNIT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* A workspace as a sentence names it: `@acme`, `the unit <uuid>`, or `your personal
|
|
153
|
+
* workspace` — for null, and for a login's stored `personal`.
|
|
154
|
+
*/
|
|
155
|
+
export function describeWorkspace(workspace) {
|
|
156
|
+
if (!workspace || workspace === 'personal') return 'your personal workspace'
|
|
157
|
+
return workspace.startsWith('@') ? workspace : `the unit ${workspace}`
|
|
158
|
+
}
|
|
159
|
+
|
|
151
160
|
/**
|
|
152
161
|
* A workspace as the header names it — `@handle`, or a unit's bare uuid when the unit
|
|
153
162
|
* has no handle — or null for none.
|
|
@@ -155,7 +164,7 @@ const UNIT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
|
|
|
155
164
|
* ⚠️ A bare value is taken for a uuid only when it is shaped like one, and our own
|
|
156
165
|
* handle grammar admits uuid-shaped handles (3–39 lowercase letters, digits, hyphens).
|
|
157
166
|
* So a HANDLE reaches this already dressed — `--org` through `workspaceHandle`, a
|
|
158
|
-
*
|
|
167
|
+
* login's choice as stored — and a bare uuid here is a unit.
|
|
159
168
|
*
|
|
160
169
|
* @param {unknown} value
|
|
161
170
|
* @returns {string|null}
|
|
@@ -167,34 +176,45 @@ export function workspaceHeader(value) {
|
|
|
167
176
|
}
|
|
168
177
|
|
|
169
178
|
/**
|
|
170
|
-
* The
|
|
171
|
-
* create — and the backend works on the site from another.
|
|
179
|
+
* The site is in another workspace than the one this command works in.
|
|
172
180
|
*
|
|
173
|
-
* ⛔ Stopping is the point
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
181
|
+
* ⛔ **Stopping is the point, and nothing is adopted.** A command works in ONE workspace —
|
|
182
|
+
* the one chosen with the login (`workspace.js`) — and a site outside it is refused, not
|
|
183
|
+
* worked on anyway *[Diego, 2026-09-23: "that's the intent of the workspace concept"]*.
|
|
184
|
+
* It is the backend's `409` that decides it, never a handle comparison here: a site in
|
|
185
|
+
* `@acme/labs` is legitimately worked on from `@acme`. ⚠️ *Until 2026-09-23 a workspace
|
|
186
|
+
* nobody had named was adopted from the `409` and the request retried.*
|
|
178
187
|
*/
|
|
179
188
|
export class WorkspaceMismatchError extends Error {
|
|
180
189
|
/**
|
|
181
|
-
* @param {{ named: string|null, answer: string|null }} p - the
|
|
182
|
-
* named
|
|
190
|
+
* @param {{ named: string|null, answer: string|null, source?: string }} p - the
|
|
191
|
+
* workspace the request named and the one the backend named (`@handle`, a unit's
|
|
192
|
+
* uuid, null for personal), and where the named one came from (`workspace.js`)
|
|
183
193
|
*/
|
|
184
|
-
constructor({ named, answer }) {
|
|
194
|
+
constructor({ named, answer, source = 'login' }) {
|
|
185
195
|
const where = (w) =>
|
|
186
196
|
!w ? 'your personal workspace' : w.startsWith('@') ? w : `the unit ${w}`
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
197
|
+
// How to work in the site's workspace, said for the way this one was chosen.
|
|
198
|
+
const switchTo = !answer
|
|
199
|
+
? { flag: 'pass --personal', env: 'set UNIWEB_WORKSPACE=personal' }
|
|
200
|
+
: answer.startsWith('@')
|
|
201
|
+
? { flag: `pass --org ${answer}`, env: `set UNIWEB_WORKSPACE=${answer}` }
|
|
202
|
+
: null
|
|
203
|
+
const login = !answer ? 'uniweb login --personal' : answer.startsWith('@') ? `uniweb login --org ${answer}` : null
|
|
204
|
+
const fix = !switchTo
|
|
205
|
+
? 'It has no handle, so the CLI cannot work in it.'
|
|
206
|
+
: source === 'flag'
|
|
207
|
+
? `To work on it, ${switchTo.flag}.`
|
|
208
|
+
: source === 'env'
|
|
209
|
+
? `To work on it, ${switchTo.env}.`
|
|
210
|
+
: `To work on it: ${login}`
|
|
211
|
+
const why = source === 'flag' ? ' (named on this command)' : source === 'env' ? ' (UNIWEB_WORKSPACE)' : ''
|
|
212
|
+
super(`This site is in ${where(answer)}, and you are working in ${where(named)}${why}. ${fix}`)
|
|
194
213
|
this.name = 'WorkspaceMismatchError'
|
|
195
214
|
this.status = 409
|
|
196
215
|
this.named = named
|
|
197
216
|
this.answer = answer
|
|
217
|
+
this.source = source
|
|
198
218
|
}
|
|
199
219
|
}
|
|
200
220
|
|
|
@@ -270,8 +290,7 @@ export class BackendClient {
|
|
|
270
290
|
this._fetch = fetchImpl || ((url, init) => globalThis.fetch(url, init))
|
|
271
291
|
this._discovery = null
|
|
272
292
|
this._workspace = null
|
|
273
|
-
this.
|
|
274
|
-
this._onWorkspaceAdopted = null
|
|
293
|
+
this._workspaceSource = 'login'
|
|
275
294
|
}
|
|
276
295
|
|
|
277
296
|
/** The workspace this client's requests name — `@handle`, or null (none named). */
|
|
@@ -280,23 +299,19 @@ export class BackendClient {
|
|
|
280
299
|
}
|
|
281
300
|
|
|
282
301
|
/**
|
|
283
|
-
* Name the workspace every request from here on works in (`x-uniweb-workspace`)
|
|
302
|
+
* Name the workspace every site request from here on works in (`x-uniweb-workspace`)
|
|
303
|
+
* — the one `resolveWorkspace` answered for this command.
|
|
284
304
|
*
|
|
285
|
-
* @param {string|null} value - `@acme` / `acme`, a handle-less unit's uuid, or null
|
|
286
|
-
*
|
|
305
|
+
* @param {string|null} value - `@acme` / `acme`, a handle-less unit's uuid, or null for
|
|
306
|
+
* the personal workspace (`workspaceHeader`)
|
|
287
307
|
* @param {object} [opts]
|
|
288
|
-
* @param {
|
|
289
|
-
*
|
|
290
|
-
* (`WorkspaceMismatchError`) instead of adopting the backend's answer.
|
|
291
|
-
* @param {(handle: string|null) => void|Promise<void>} [opts.onAdopted] - called when a
|
|
292
|
-
* request that named nothing, or a stale workspace, adopts the one the backend named;
|
|
293
|
-
* the caller records it (sync.json) and says so
|
|
308
|
+
* @param {string} [opts.source='login'] - where it came from (`workspace.js`), so a
|
|
309
|
+
* refusal says how to switch
|
|
294
310
|
* @returns {this}
|
|
295
311
|
*/
|
|
296
|
-
setWorkspace(value, {
|
|
312
|
+
setWorkspace(value, { source = 'login' } = {}) {
|
|
297
313
|
this._workspace = workspaceHeader(value)
|
|
298
|
-
this.
|
|
299
|
-
this._onWorkspaceAdopted = onAdopted || null
|
|
314
|
+
this._workspaceSource = source
|
|
300
315
|
return this
|
|
301
316
|
}
|
|
302
317
|
|
|
@@ -332,9 +347,8 @@ export class BackendClient {
|
|
|
332
347
|
* applies query params, and infers a content-type from the body when unset
|
|
333
348
|
* (string → application/json, Buffer/Uint8Array → application/zip). Returns
|
|
334
349
|
* the raw Response so callers branch on status themselves (409 resume,
|
|
335
|
-
* 404 → null, 401/403 messaging, …) — except a `409 wrong_workspace`,
|
|
336
|
-
*
|
|
337
|
-
* thrown as `WorkspaceMismatchError` when it was (see the body).
|
|
350
|
+
* 404 → null, 401/403 messaging, …) — except a `409 wrong_workspace`, thrown as
|
|
351
|
+
* `WorkspaceMismatchError`: the site is in another workspace than this command's.
|
|
338
352
|
*
|
|
339
353
|
* @param {string} path - leading-slash path, e.g. '/dev/site/content'
|
|
340
354
|
* @param {object} [opts]
|
|
@@ -349,13 +363,10 @@ export class BackendClient {
|
|
|
349
363
|
const res = await this._send(path, opts)
|
|
350
364
|
if (res?.status !== 409) return res
|
|
351
365
|
|
|
352
|
-
// ⭐ THE WORKSPACE A REQUEST NAMES IS CHECKED BY THE BACKEND, NOT HERE. A request
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
// - they named it (`--org`, `--personal`) → stop and say so: the mismatch check;
|
|
357
|
-
// - they named nothing, or a stale one → adopt the backend's answer, let the caller
|
|
358
|
-
// record it, and retry ONCE.
|
|
366
|
+
// ⭐ THE WORKSPACE A REQUEST NAMES IS CHECKED BY THE BACKEND, NOT HERE. A request whose
|
|
367
|
+
// workspace does not contain the site is refused with `409 wrong_workspace`, naming a
|
|
368
|
+
// workspace the caller CAN open that does — the site's own unit, or a parent. The
|
|
369
|
+
// command works in one workspace, so this stops it (`WorkspaceMismatchError`).
|
|
359
370
|
// ⛔ No handle comparison anywhere: a site in `@acme/labs` is worked on from `@acme`.
|
|
360
371
|
const problem =
|
|
361
372
|
typeof res.clone === 'function' ? await res.clone().json().catch(() => null) : null
|
|
@@ -367,15 +378,14 @@ export class BackendClient {
|
|
|
367
378
|
: typeof where.unit_uuid === 'string' && where.unit_uuid
|
|
368
379
|
? where.unit_uuid
|
|
369
380
|
: null
|
|
370
|
-
//
|
|
371
|
-
//
|
|
381
|
+
// Refused while naming what the backend names: nothing to switch to; the caller reads
|
|
382
|
+
// the 409 like any other.
|
|
372
383
|
if (answer === this._workspace) return res
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
return this._send(path, opts)
|
|
384
|
+
throw new WorkspaceMismatchError({
|
|
385
|
+
named: this._workspace,
|
|
386
|
+
answer,
|
|
387
|
+
source: this._workspaceSource
|
|
388
|
+
})
|
|
379
389
|
}
|
|
380
390
|
|
|
381
391
|
/** One request, as `request` documents it — naming the client's workspace on a site route. */
|
|
@@ -571,7 +581,7 @@ export class BackendClient {
|
|
|
571
581
|
|
|
572
582
|
/** GET /dev/orgs → { account_handle, orgs[] }. */
|
|
573
583
|
async fetchOrgs() {
|
|
574
|
-
return fetchOrgsImpl({ apiBase: this.origin, token: await this.token() })
|
|
584
|
+
return fetchOrgsImpl({ apiBase: this.origin, token: await this.token(), fetchImpl: this._fetch })
|
|
575
585
|
}
|
|
576
586
|
|
|
577
587
|
/** POST /dev/orgs { handle } → { handle, uuid, is_primary }. Throws with the server's detail on 409/422. */
|
package/src/backend/site-sync.js
CHANGED
|
@@ -19,6 +19,7 @@ import { humanBytes } from '../utils/bytes.js'
|
|
|
19
19
|
import { isAuthoredPreview } from '../utils/preview.js'
|
|
20
20
|
import {
|
|
21
21
|
describeRequestError,
|
|
22
|
+
describeWorkspace,
|
|
22
23
|
WorkspaceMismatchError,
|
|
23
24
|
workspaceHandle,
|
|
24
25
|
workspaceHeader
|
|
@@ -652,21 +653,19 @@ export function writeItemUuids(siteDir, backend, map) {
|
|
|
652
653
|
}
|
|
653
654
|
|
|
654
655
|
/**
|
|
655
|
-
* The workspace this
|
|
656
|
-
* `@handle`,
|
|
656
|
+
* The workspace this site was created in on `backend` — its owner, as the create
|
|
657
|
+
* answered it — in the header's form: `@handle`, a unit's bare uuid when that unit has
|
|
658
|
+
* no handle, or null for the personal workspace (or no record).
|
|
657
659
|
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
* because the `@` is the only thing telling the two apart — our own handle grammar
|
|
662
|
-
* admits a uuid-shaped handle — and a bare value at rest has lost it.
|
|
660
|
+
* ⭐ A RECORD, NOT A ROUTE (2026-09-23). A command works in the workspace chosen with the
|
|
661
|
+
* login (`workspace.js`); this says where the site was made, and routes nothing. (Until
|
|
662
|
+
* then it was the workspace every request named, adopted from a `409` when stale.)
|
|
663
663
|
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
* Deliberately NOT a fallback for the flag: an explicit `--org` always wins.
|
|
664
|
+
* Read from `backend`'s section of `sync.json`: `site.org` holds a handle, stored bare
|
|
665
|
+
* and re-dressed with its `@`; `site.unit` holds a unit's uuid. At most one is set
|
|
666
|
+
* (`writeSiteWorkspace`). ⛔ Two keys, because the `@` is the only thing telling the two
|
|
667
|
+
* apart — our own handle grammar admits a uuid-shaped handle — and a bare value at rest
|
|
668
|
+
* has lost it.
|
|
670
669
|
*
|
|
671
670
|
* @param {string} siteDir
|
|
672
671
|
* @param {string} backend - the origin whose record to read
|
|
@@ -681,10 +680,10 @@ export function readSiteWorkspace(siteDir, backend) {
|
|
|
681
680
|
}
|
|
682
681
|
|
|
683
682
|
/**
|
|
684
|
-
* Record the workspace this
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
683
|
+
* Record the workspace this site was created in on `backend` — a handle in `site.org`
|
|
684
|
+
* (bare), a handle-less unit in `site.unit`, clearing the other; null clears both (the
|
|
685
|
+
* personal workspace, named by naming none). Returns what was recorded, in the header's
|
|
686
|
+
* form.
|
|
688
687
|
*
|
|
689
688
|
* @param {string} siteDir
|
|
690
689
|
* @param {string} backend
|
|
@@ -702,50 +701,6 @@ function writeSiteWorkspace(siteDir, backend, workspace) {
|
|
|
702
701
|
return w
|
|
703
702
|
}
|
|
704
703
|
|
|
705
|
-
/** A workspace as a sentence names it: `@acme`, `the unit <uuid>`, or `your personal workspace`. */
|
|
706
|
-
export function describeWorkspace(workspace) {
|
|
707
|
-
if (!workspace) return 'your personal workspace'
|
|
708
|
-
return workspace.startsWith('@') ? workspace : `the unit ${workspace}`
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
/**
|
|
712
|
-
* Point `client` at the workspace this project's requests name — `x-uniweb-workspace`.
|
|
713
|
-
*
|
|
714
|
-
* ⭐ THE WORKSPACE IS PER PROJECT, per backend — the one `sync.json` recorded — never
|
|
715
|
-
* per login: a login is one session for the whole machine (`utils/session-file.js`).
|
|
716
|
-
* An explicit choice (`--org`, `--personal`, the create picker) is sent as named, and
|
|
717
|
-
* a `409 wrong_workspace` then STOPS the command. Otherwise the client adopts the
|
|
718
|
-
* workspace the backend names, and this records it — as-is, since it may be a parent
|
|
719
|
-
* workspace, or a unit with no handle — and says so. (`client.js::request`; agreed
|
|
720
|
-
* with backend 2026-09-23.)
|
|
721
|
-
*
|
|
722
|
-
* @param {import('./client.js').BackendClient} client
|
|
723
|
-
* @param {object} p
|
|
724
|
-
* @param {string} p.siteDir
|
|
725
|
-
* @param {string|null} p.workspace - in the header's form (`readSiteWorkspace`, or
|
|
726
|
-
* `workspaceHandle(--org)`), or null to name none
|
|
727
|
-
* @param {boolean} [p.explicit=false]
|
|
728
|
-
* @param {(m: string) => void} [p.note]
|
|
729
|
-
* @returns {import('./client.js').BackendClient}
|
|
730
|
-
*/
|
|
731
|
-
export function nameSiteWorkspace(client, { siteDir, workspace, explicit = false, note }) {
|
|
732
|
-
return client.setWorkspace(workspace, {
|
|
733
|
-
explicit,
|
|
734
|
-
onAdopted: (adopted) => {
|
|
735
|
-
try {
|
|
736
|
-
writeSiteWorkspace(siteDir, client.origin, adopted)
|
|
737
|
-
} catch {
|
|
738
|
-
// Recording is a convenience for the next command; this one still retries.
|
|
739
|
-
}
|
|
740
|
-
note?.(
|
|
741
|
-
adopted
|
|
742
|
-
? `Requests for this site now name ${describeWorkspace(adopted)}, the workspace the backend works on it from (recorded in sync.json).`
|
|
743
|
-
: 'Requests for this site now name no workspace — the backend works on it from your personal one (recorded in sync.json).'
|
|
744
|
-
)
|
|
745
|
-
}
|
|
746
|
-
})
|
|
747
|
-
}
|
|
748
|
-
|
|
749
704
|
/**
|
|
750
705
|
* Record the workspace a just-minted site was created under.
|
|
751
706
|
*
|
|
@@ -768,173 +723,6 @@ function recordSiteWorkspace(siteDir, backend, owner) {
|
|
|
768
723
|
}
|
|
769
724
|
}
|
|
770
725
|
|
|
771
|
-
/**
|
|
772
|
-
* The `source`s of `resolveSiteOrgForCreate` that are the user's OWN choice — named on
|
|
773
|
-
* the command line or picked — and so explicit: a `409 wrong_workspace` for one stops
|
|
774
|
-
* the command rather than adopting the backend's answer (`nameSiteWorkspace`).
|
|
775
|
-
*/
|
|
776
|
-
export const EXPLICIT_OWNER = new Set(['flag', 'personal', 'picked'])
|
|
777
|
-
|
|
778
|
-
/**
|
|
779
|
-
* Resolve WHICH ORG will own a site that is about to be created.
|
|
780
|
-
*
|
|
781
|
-
* The create that mints the site's uuid takes its owner from the workspace the request
|
|
782
|
-
* names (`x-uniweb-workspace`), and the backend never moves ownership afterwards. There is also no CLI verb to transfer
|
|
783
|
-
* or delete a site. So this is a **one-shot, unrepealable** decision — and until
|
|
784
|
-
* this function existed the CLI made it silently, by sending nothing and letting
|
|
785
|
-
* the backend fall back to the session's personal context. A developer who belongs
|
|
786
|
-
* to several orgs could put a company site, and the storage it bills, somewhere
|
|
787
|
-
* they never named.
|
|
788
|
-
*
|
|
789
|
-
* `register` resolves a foundation's scope in the same order — recorded (the scope
|
|
790
|
-
* in its name, `@acme/marketing` in main.js), else explicit (`--scope`), else derived
|
|
791
|
-
* (`deriveScope`) — with one difference: non-interactive, it takes your personal
|
|
792
|
-
* scope and says so. This refuses instead, because a site's owner is the one-shot
|
|
793
|
-
* decision above, and a scope is only a namespace (2026-09-23). ⭐ And the two are
|
|
794
|
-
* separate decisions: a foundation may be registered under any org its author
|
|
795
|
-
* belongs to, whoever owns the site (2026-09-22) — a site's `@/x` refs take the
|
|
796
|
-
* FOUNDATION's scope, never this one. ⚠️ Until 2026-09-21 this said
|
|
797
|
-
* `register` "has refused to guess a scope", which is wrong for a non-interactive
|
|
798
|
-
* run by anyone with an org.
|
|
799
|
-
*
|
|
800
|
-
* Order:
|
|
801
|
-
* 1. `--org @org` — explicit; the create names it as its workspace
|
|
802
|
-
* 2. `--personal` — explicit "no org, I mean it" → names NO workspace
|
|
803
|
-
* 3. the recorded org — `sync.json`, written at this site's own create
|
|
804
|
-
* 4. the site already exists on this backend (a recorded uuid) → null; ownership
|
|
805
|
-
* is settled, ask nothing
|
|
806
|
-
* 5. otherwise ASK (TTY) or REFUSE (non-interactive)
|
|
807
|
-
*
|
|
808
|
-
* ⛔ **`--personal` names no workspace, and is NOT the same as `--org @<handle>`.**
|
|
809
|
-
* The personal *org* `@jane` is an org like any other, lazily created on first use;
|
|
810
|
-
* the session's personal workspace is not an org at all. Whether the backend gives
|
|
811
|
-
* them the same owning unit is **its** business and unverified here, so the
|
|
812
|
-
* deliberate-personal spelling names nothing rather than asserting an equivalence
|
|
813
|
-
* this lane cannot check.
|
|
814
|
-
*
|
|
815
|
-
* @returns {Promise<{ workspace: string|null, source?: string, refused?: true, reason?: string }>}
|
|
816
|
-
* `workspace: null` with no `refused` means "name no workspace" — either a settled site
|
|
817
|
-
* or a deliberate personal choice. `source` says which answer it is: `flag`,
|
|
818
|
-
* `personal` and `picked` are the user's own choice (explicit — a mismatch then
|
|
819
|
-
* stops the command); `recorded`, `existing` and `offline` are not.
|
|
820
|
-
*/
|
|
821
|
-
export async function resolveSiteOrgForCreate({
|
|
822
|
-
client,
|
|
823
|
-
siteDir,
|
|
824
|
-
args = [],
|
|
825
|
-
flag,
|
|
826
|
-
personal = false,
|
|
827
|
-
offline = false
|
|
828
|
-
}) {
|
|
829
|
-
if (flag) return { workspace: workspaceHandle(flag), source: 'flag' }
|
|
830
|
-
if (personal) return { workspace: null, source: 'personal' }
|
|
831
|
-
|
|
832
|
-
const recorded = readSiteWorkspace(siteDir, client?.origin)
|
|
833
|
-
if (recorded) return { workspace: recorded, source: 'recorded' }
|
|
834
|
-
|
|
835
|
-
// Already created ⇒ nothing to decide: ownership was settled at its create and
|
|
836
|
-
// re-asking would be theatre. ⭐ "Created" is a question about THIS backend now —
|
|
837
|
-
// the same project may be brand new on one and long-established on another.
|
|
838
|
-
if (readBackendState(siteDir, client?.origin).site?.uuid) return { workspace: null, source: 'existing' }
|
|
839
|
-
|
|
840
|
-
// An offline preview (`--dry-run` / `-o`) must never authenticate, and it is
|
|
841
|
-
// creating nothing, so there is no decision to force. Say what is unresolved
|
|
842
|
-
// instead of prompting for an answer the run will not use.
|
|
843
|
-
if (offline) return { workspace: null, source: 'offline' }
|
|
844
|
-
|
|
845
|
-
// `--yes` promises "never block on a prompt", so it has to answer this one too —
|
|
846
|
-
// and the only honest non-blocking answer to an unanswerable ownership question
|
|
847
|
-
// is a refusal. Treating it as consent-to-anything would reinstate the silent
|
|
848
|
-
// default this whole path exists to remove, behind a flag that reads like
|
|
849
|
-
// approval.
|
|
850
|
-
const { isNonInteractive } = await import('../utils/interactive.js')
|
|
851
|
-
if (isNonInteractive(args) || args.includes('--yes')) {
|
|
852
|
-
return {
|
|
853
|
-
workspace: null,
|
|
854
|
-
refused: true,
|
|
855
|
-
reason:
|
|
856
|
-
'This site does not exist on the backend yet, and no org was named.\n' +
|
|
857
|
-
' The create decides who OWNS it — and which workspace its storage is billed to —\n' +
|
|
858
|
-
' one time, with no CLI way to change it afterwards. Name it explicitly:\n' +
|
|
859
|
-
' --org @org create it under an organization\n' +
|
|
860
|
-
' --personal create it under your personal account, deliberately'
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// Interactive: offer the real choice. Deliberately NOT `deriveScope` — that one picks
|
|
865
|
-
// a NAMESPACE for a foundation, where `@<your handle>` is your personal scope. Here
|
|
866
|
-
// the answer is an OWNER: your personal workspace (named by naming none) or an org.
|
|
867
|
-
const { fetchOrgs, createOrg, validateHandle, bareHandle } =
|
|
868
|
-
await import('../utils/registry-orgs.js')
|
|
869
|
-
let envelope
|
|
870
|
-
try {
|
|
871
|
-
envelope = await fetchOrgs({
|
|
872
|
-
apiBase: client.origin,
|
|
873
|
-
token: await client.token()
|
|
874
|
-
})
|
|
875
|
-
} catch (err) {
|
|
876
|
-
return { workspace: null, refused: true, reason: err.message }
|
|
877
|
-
}
|
|
878
|
-
const personalHandle = envelope.account_handle || null
|
|
879
|
-
const prompts = (await import('prompts')).default
|
|
880
|
-
const choices = [
|
|
881
|
-
...envelope.orgs.map((o) => ({
|
|
882
|
-
title: `@${o.handle}${o.handle === personalHandle ? ' — your personal org' : o.is_primary ? ' (primary)' : ''}`,
|
|
883
|
-
value: o.handle
|
|
884
|
-
})),
|
|
885
|
-
{
|
|
886
|
-
title: `Personal — no organization${personalHandle ? ` (${personalHandle})` : ''}`,
|
|
887
|
-
value: ':personal'
|
|
888
|
-
},
|
|
889
|
-
{ title: 'A new organization…', value: ':new' }
|
|
890
|
-
]
|
|
891
|
-
const { choice } = await prompts(
|
|
892
|
-
{
|
|
893
|
-
type: 'select',
|
|
894
|
-
name: 'choice',
|
|
895
|
-
message: 'Create this site under which owner?',
|
|
896
|
-
choices,
|
|
897
|
-
initial: 0
|
|
898
|
-
},
|
|
899
|
-
{
|
|
900
|
-
onCancel: () => {
|
|
901
|
-
console.log('\nCancelled.')
|
|
902
|
-
process.exit(0)
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
)
|
|
906
|
-
if (!choice) return { workspace: null, refused: true, reason: 'No owner chosen.' }
|
|
907
|
-
if (choice === ':personal') return { workspace: null, source: 'picked' }
|
|
908
|
-
if (choice !== ':new') return { workspace: `@${choice}`, source: 'picked' }
|
|
909
|
-
|
|
910
|
-
const answer = await prompts(
|
|
911
|
-
{
|
|
912
|
-
type: 'text',
|
|
913
|
-
name: 'handle',
|
|
914
|
-
message: 'Org handle (e.g. acme):',
|
|
915
|
-
validate: (v) => validateHandle(v) || true
|
|
916
|
-
},
|
|
917
|
-
{
|
|
918
|
-
onCancel: () => {
|
|
919
|
-
console.log('\nCancelled.')
|
|
920
|
-
process.exit(0)
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
)
|
|
924
|
-
if (!answer.handle)
|
|
925
|
-
return { workspace: null, refused: true, reason: 'No org handle given.' }
|
|
926
|
-
try {
|
|
927
|
-
const org = await createOrg({
|
|
928
|
-
apiBase: client.origin,
|
|
929
|
-
token: await client.token(),
|
|
930
|
-
handle: bareHandle(answer.handle)
|
|
931
|
-
})
|
|
932
|
-
return { workspace: `@${org.handle}`, source: 'picked' }
|
|
933
|
-
} catch (err) {
|
|
934
|
-
return { workspace: null, refused: true, reason: err.message }
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
|
|
938
726
|
/**
|
|
939
727
|
* Turn a typed site-create refusal into a sentence, or null to fall through to the
|
|
940
728
|
* generic HTTP line.
|
|
@@ -1048,7 +836,7 @@ export async function ensureSiteExists({
|
|
|
1048
836
|
|
|
1049
837
|
let res
|
|
1050
838
|
try {
|
|
1051
|
-
// The owner is the workspace the client names —
|
|
839
|
+
// The owner is the workspace the client names — the command's (`workspace.js`).
|
|
1052
840
|
res = await client.createSite({
|
|
1053
841
|
name: siteName,
|
|
1054
842
|
foundation: siteFoundation
|