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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.64.1",
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/runtime": "^0.27.4",
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/semantic-parser": "^1.4.1",
49
- "@uniweb/content-writer": "^0.3.4"
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/semantic-parser": "^1.4.1",
52
+ "@uniweb/build": "^0.59.1",
53
53
  "@uniweb/content-reader": "^1.2.5",
54
- "@uniweb/build": "^0.59.1"
54
+ "@uniweb/semantic-parser": "^1.4.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -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 push --org @acme # First push/publish of a site: who owns it (see below)
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 <url>) or out
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
- > ⚠️ **The FIRST push or publish of a site asks who owns it — and refuses if it cannot ask.**
2577
- > That create decides which organization owns the site (and whose storage its assets are billed
2578
- > to), it is the only moment the choice is made, and there is no CLI command to change it
2579
- > afterwards. At a terminal you get a picker. **Without a terminal — CI, a script, or you, an
2580
- > agent — the command exits non-zero instead of choosing**, and `--yes` refuses too, because
2581
- > guessing an owner is not an answer. Name it and it never asks again:
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 publish --org @acme # an organization
2585
- > uniweb publish --personal # your personal account, deliberately
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
- > The answer is recorded in `sync.json` and committed, so it is a one-time choice per site,
2589
- > not per machine. **Ask the human which one to use** rather than picking for them — a site in
2590
- > the wrong org cannot be moved from here. Sites that already exist are unaffected: their
2591
- > ownership is settled, so nothing is asked. After the create, every command names the
2592
- > recorded org for you; an `--org` that disagrees with where the backend keeps the site
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.
@@ -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
- * record through `readSiteWorkspace` — and a bare uuid here is a unit.
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 request named its workspace EXPLICITLY — `--org`, `--personal`, a choice made at
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. It is the mismatch check [Diego, 2026-09-22: "there is no org
174
- * mismatch when the site is set for an org and the user tries a different one"], and it
175
- * is the backend's `409` that decides it, never a handle comparison here: a site in
176
- * `@acme/labs` is legitimately worked on from `@acme`. Adopting the answer would override
177
- * a choice the user made.
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 workspace the request
182
- * named, and the one the backend named (`@handle`, null for personal)
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
- const flag = named ? '--org' : '--personal'
188
- super(
189
- `The backend works on this site from ${where(answer)}, not ${where(named)} ` +
190
- `(${named ? `--org ${named}` : flag}). ` +
191
- // `--org` takes a handle; a unit without one is reached by naming nothing.
192
- (answer?.startsWith('@') ? `Pass --org ${answer}, or drop ${flag}.` : `Drop ${flag}.`)
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._workspaceExplicit = false
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 to
286
- * name none (`workspaceHeader`)
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 {boolean} [opts.explicit=false] - the user named it (`--org`, `--personal`,
289
- * the create picker). A `409 wrong_workspace` then STOPS the command
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, { explicit = false, onAdopted = null } = {}) {
312
+ setWorkspace(value, { source = 'login' } = {}) {
297
313
  this._workspace = workspaceHeader(value)
298
- this._workspaceExplicit = Boolean(explicit)
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`, which is
336
- * answered here: adopted and retried once when the workspace was not named, and
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 that
353
- // names the wrong one for an existing site is refused with `409 wrong_workspace`,
354
- // naming a workspace the caller CAN open that contains the site — its own unit, or a
355
- // parent the caller belongs to. Two answers, and which one depends on the user:
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
- // Naming what the backend names and still refused: nothing to adopt, or to report
371
- // as a mismatch. The caller reads the 409 like any other.
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
- if (this._workspaceExplicit) {
374
- throw new WorkspaceMismatchError({ named: this._workspace, answer })
375
- }
376
- this._workspace = answer
377
- if (this._onWorkspaceAdopted) await this._onWorkspaceAdopted(answer)
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. */
@@ -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 project's requests name on `backend`, as the header names it —
656
- * `@handle`, or a unit's bare uuid when that unit has no handle — or null for none.
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
- * Read back from `backend`'s section of `sync.json`: `site.org` holds a handle, stored
659
- * bare and re-dressed with its `@`; `site.unit` holds a unit's uuid, for a workspace
660
- * that has no handle. At most one is set (`writeSiteWorkspace`). ⛔ They are two keys
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
- * Recorded at create — the site's owner — or adopted later from a backend's `409
665
- * wrong_workspace`, which may name a PARENT workspace that contains the site rather
666
- * than the site's own unit. ⇒ It is "the workspace these requests name", never
667
- * printed as "the site's org".
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 project's requests name on `backend` — a handle in
685
- * `site.org` (bare), a handle-less unit in `site.unit`, clearing the other; null
686
- * clears both (the personal workspace, named by naming none). Returns what was
687
- * recorded, in the header's form.
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 — `nameSiteWorkspace` set it.
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