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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * THE WORKSPACE A COMMAND WORKS IN — one at a time, chosen with the login.
3
+ *
4
+ * ⭐ **[Diego, 2026-09-23]** — *"when a user has workspace in the backend, I expected them
5
+ * to explicitly login into one"*, and a site the backend keeps in ANOTHER workspace is
6
+ * refused rather than worked on anyway: *"that's the intent of the workspace concept."*
7
+ * The app keeps one workspace per tab; the CLI keeps one per login, and every site
8
+ * request names it (`x-uniweb-workspace`, `client.js`). A site outside it answers `409
9
+ * wrong_workspace`, and the command stops, saying how to switch — nothing is adopted.
10
+ *
11
+ * Where a command's workspace comes from — the first answer wins:
12
+ *
13
+ * 1. `--org @acme` / `--personal` this command only
14
+ * 2. `UNIWEB_WORKSPACE=@acme|personal` a process logged in with `UNIWEB_TOKEN`, which has
15
+ * no `uniweb login` to choose one (a template
16
+ * installer, an agent driving several backends)
17
+ * 3. the one chosen at `uniweb login` the session's
18
+ * 4. none chosen your personal workspace when you belong to no
19
+ * organization; otherwise refused, naming 1–3
20
+ *
21
+ * New sites are created in it: a create names the workspace, and the workspace is the
22
+ * site's owner.
23
+ *
24
+ * ⛔ **The personal workspace is named by naming NONE.** `@<your handle>` is your personal
25
+ * SCOPE — a registry namespace for foundations and data schemas — not a workspace.
26
+ */
27
+
28
+ import { readOrgFlag } from '../utils/args.js'
29
+ import { fetchOrgs } from '../utils/registry-orgs.js'
30
+ import { readRegistryAuth } from '../utils/registry-auth.js'
31
+ import { workspaceHandle, describeWorkspace } from './client.js'
32
+
33
+ export { describeWorkspace }
34
+
35
+ /** The personal workspace, as a login stores the choice (a handle is stored with its `@`). */
36
+ export const PERSONAL = 'personal'
37
+
38
+ /** Names a process's workspace when it is logged in with `UNIWEB_TOKEN`. */
39
+ export const WORKSPACE_ENV = 'UNIWEB_WORKSPACE'
40
+
41
+ /**
42
+ * A workspace choice as it may be written — `@acme`, `acme`, `personal` — or null when
43
+ * the value names none.
44
+ *
45
+ * @param {unknown} value
46
+ * @returns {string|null} `@acme`, `PERSONAL`, or null
47
+ */
48
+ export function parseWorkspaceChoice(value) {
49
+ if (typeof value !== 'string' || !value.trim()) return null
50
+ const v = value.trim()
51
+ return v === PERSONAL ? PERSONAL : workspaceHandle(v)
52
+ }
53
+
54
+ /** A choice in the header's form: `@acme`, or null for the personal workspace. */
55
+ export function headerOf(choice) {
56
+ return choice === PERSONAL ? null : choice
57
+ }
58
+
59
+ /** What a source is called in a sentence — "(… )" after the workspace. */
60
+ export const SOURCE_LABEL = {
61
+ flag: 'named on this command',
62
+ env: WORKSPACE_ENV,
63
+ login: 'your login',
64
+ personal: 'you belong to no organization',
65
+ offline: 'not resolved in a preview'
66
+ }
67
+
68
+ const CHOOSE = [
69
+ 'Choose the workspace you work in:',
70
+ ' uniweb login --org @acme (or --personal) — for every command after',
71
+ ' --org @acme / --personal — for this command only',
72
+ ` ${WORKSPACE_ENV}=@acme — for a process logged in with UNIWEB_TOKEN`
73
+ ].join('\n')
74
+
75
+ /**
76
+ * The workspace this command works in.
77
+ *
78
+ * @param {object} p
79
+ * @param {import('./client.js').BackendClient} p.client - its origin and token
80
+ * @param {string[]} [p.args]
81
+ * @param {boolean} [p.offline=false] - a preview (`--dry-run`, `-o`): never authenticates,
82
+ * so step 4 answers "not resolved" instead of asking the backend
83
+ * @returns {Promise<{ workspace: string|null, source: string } | { refused: true, reason: string }>}
84
+ * `workspace` in the header's form — `@acme`, or null for the personal workspace
85
+ */
86
+ export async function resolveWorkspace({ client, args = [], offline = false }) {
87
+ const flag = readOrgFlag(args)
88
+ if (flag) return { workspace: workspaceHandle(flag), source: 'flag' }
89
+ if (args.includes('--personal')) return { workspace: null, source: 'flag' }
90
+
91
+ const raw = process.env[WORKSPACE_ENV]
92
+ if (raw !== undefined && raw !== '') {
93
+ const env = parseWorkspaceChoice(raw)
94
+ if (!env) {
95
+ return {
96
+ refused: true,
97
+ reason: `${WORKSPACE_ENV}=${JSON.stringify(raw)} names no workspace — give @org, or ${PERSONAL}.`
98
+ }
99
+ }
100
+ return { workspace: headerOf(env), source: 'env' }
101
+ }
102
+
103
+ // ⛔ A process logged in with UNIWEB_TOKEN is not the session's login: the token may be
104
+ // another account's, so the session's workspace is not its to use.
105
+ const useSession = !process.env.UNIWEB_TOKEN
106
+ const fromSession = async () =>
107
+ useSession ? parseWorkspaceChoice((await readRegistryAuth(client.origin))?.workspace) : null
108
+
109
+ const chosen = await fromSession()
110
+ if (chosen) return { workspace: headerOf(chosen), source: 'login' }
111
+ if (offline) return { workspace: null, source: 'offline' }
112
+
113
+ // Authenticate — a login this triggers chooses a workspace, so ask the session again.
114
+ await client.token()
115
+ const now = await fromSession()
116
+ if (now) return { workspace: headerOf(now), source: 'login' }
117
+
118
+ const { orgs } = await client.fetchOrgs()
119
+ if (!orgs.length) return { workspace: null, source: 'personal' }
120
+ return {
121
+ refused: true,
122
+ reason: `You belong to organizations, so no workspace is assumed.\n ${CHOOSE}`
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The workspace a login works in — asked once, at `uniweb login`, and stored with the
128
+ * session.
129
+ *
130
+ * `--org @acme` must be an organization you belong to
131
+ * `--personal` your personal workspace
132
+ * neither, no organization your personal workspace — said, not asked
133
+ * neither, organizations a pick at a terminal; refused without one
134
+ *
135
+ * @param {object} p
136
+ * @param {string} p.apiBase
137
+ * @param {string} p.token
138
+ * @param {string[]} [p.args]
139
+ * @returns {Promise<{ choice: string, note?: string } | { refused: true, reason: string }>}
140
+ * `choice` is `@acme` or `PERSONAL`
141
+ */
142
+ export async function chooseWorkspace({ apiBase, token, args = [] }) {
143
+ const flag = readOrgFlag(args)
144
+ if (!flag && args.includes('--personal')) return { choice: PERSONAL }
145
+
146
+ const { orgs } = await fetchOrgs({ apiBase, token })
147
+ const mine = orgs.map((o) => `@${o.handle}`)
148
+ if (flag) {
149
+ const h = workspaceHandle(flag)
150
+ if (!mine.includes(h)) {
151
+ return {
152
+ refused: true,
153
+ reason:
154
+ `You are not a member of ${h} on ${apiBase}. ` +
155
+ (mine.length
156
+ ? `Your workspaces: ${[...mine, `--personal`].join(', ')}.`
157
+ : 'You belong to no organization — use --personal.')
158
+ }
159
+ }
160
+ return { choice: h }
161
+ }
162
+ if (!orgs.length) {
163
+ return {
164
+ choice: PERSONAL,
165
+ note: 'You belong to no organization, so you work in your personal workspace.'
166
+ }
167
+ }
168
+
169
+ const { isNonInteractive } = await import('../utils/interactive.js')
170
+ if (isNonInteractive(args)) {
171
+ return {
172
+ refused: true,
173
+ reason: `You belong to organizations — name the workspace you work in: ${[...mine, '--personal'].map((w) => (w.startsWith('@') ? `--org ${w}` : w)).join(' | ')}.`
174
+ }
175
+ }
176
+ const prompts = (await import('prompts')).default
177
+ const { choice } = await prompts(
178
+ {
179
+ type: 'select',
180
+ name: 'choice',
181
+ message: 'Which workspace do you work in?',
182
+ choices: [
183
+ { title: 'Personal — your own sites', value: PERSONAL },
184
+ ...orgs.map((o) => ({
185
+ title: `@${o.handle}${o.is_primary ? ' (primary)' : ''}`,
186
+ value: `@${o.handle}`
187
+ }))
188
+ ],
189
+ initial: 0
190
+ },
191
+ {
192
+ onCancel: () => {
193
+ console.error('\nCancelled.')
194
+ process.exit(0)
195
+ }
196
+ }
197
+ )
198
+ return choice ? { choice } : { refused: true, reason: 'No workspace chosen.' }
199
+ }
@@ -39,10 +39,9 @@
39
39
  * uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
40
40
  * uniweb clone <uuid> --project docs Co-located docs/site
41
41
  * uniweb clone <uuid> --no-records Pull pages only; skip records
42
- * uniweb clone <uuid> --org @org Read it as @org — stops if the backend works on
43
- * the site from another workspace. Without it,
44
- * the one the backend names is recorded in
45
- * sync.json for every command after.
42
+ * uniweb clone <uuid> --org @org Work in @org for this clone, instead of the
43
+ * workspace chosen at `uniweb login`. A site in
44
+ * another workspace stops it.
46
45
  *
47
46
  * Backend: via BackendClient (the site-content pull lane). Origin from
48
47
  * UNIWEB_REGISTER_URL > the local default (internal dev overrides;
@@ -59,13 +58,8 @@ import { resolvePlacement, SITE_KIND } from '../utils/placement.js'
59
58
  import { findWorkspaceRoot } from '../utils/workspace.js'
60
59
  import { addWorkspaceGlob } from '../utils/config.js'
61
60
  import { detectWorkspacePm, installCmd } from '../utils/pm.js'
62
- import {
63
- BackendClient,
64
- describeRequestError,
65
- refusalDetail,
66
- workspaceHandle
67
- } from '../backend/client.js'
68
- import { readOrgFlag } from '../utils/args.js'
61
+ import { BackendClient, describeRequestError, refusalDetail } from '../backend/client.js'
62
+ import { resolveWorkspace } from '../backend/workspace.js'
69
63
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
70
64
  import { extractFoundationRef } from '../utils/site-content-refs.js'
71
65
  import { readUwxDocuments } from '../utils/uwx-read.js'
@@ -153,7 +147,7 @@ export function extractCloneSeeds(document) {
153
147
  // `site.yml`'s equivalent needed a hand-rolled dependency-free WRITER for exactly
154
148
  // this. The store stays the one writer everywhere else; this seeds a fresh file.
155
149
  // `test/clone.test.js` covers it end to end with `skipInstall: true`.
156
- function seedSyncJson(siteDir, origin, uuid, workspace = null) {
150
+ function seedSyncJson(siteDir, origin, uuid) {
157
151
  const file = join(siteDir, 'sync.json')
158
152
  let doc = { version: 1, backends: {} }
159
153
  if (existsSync(file)) {
@@ -165,19 +159,11 @@ function seedSyncJson(siteDir, origin, uuid, workspace = null) {
165
159
  }
166
160
  }
167
161
  const key = new URL(origin).origin
168
- // The workspace the read named — `--org`, or the one the backend answered with — so
169
- // the delegated pull, and every command after it, names it from the first request.
170
- // As the create records it: a handle bare in `org`, a handle-less unit's uuid in
171
- // `unit` (site-sync.js::readSiteWorkspace).
172
- const w = typeof workspace === 'string' && workspace ? workspace : null
173
- const named = w
174
- ? w.startsWith('@')
175
- ? { org: w.slice(1) }
176
- : { unit: w }
177
- : {}
162
+ // ⛔ No workspace: a command works in the one chosen with the login, and the clone
163
+ // does not know which workspace OWNS the site — it may read it from a parent.
178
164
  doc.backends[key] = {
179
165
  ...(doc.backends[key] || {}),
180
- site: { ...(doc.backends[key]?.site || {}), uuid, ...named }
166
+ site: { ...(doc.backends[key]?.site || {}), uuid }
181
167
  }
182
168
  mkdirSync(dirname(file), { recursive: true })
183
169
  writeFileSync(file, JSON.stringify(doc, null, 2) + '\n')
@@ -229,11 +215,15 @@ export async function clone(args = [], deps = {}) {
229
215
  args,
230
216
  command: 'Cloning'
231
217
  })
232
- // The workspace the reads name: `--org` explicitly — a mismatch then stops the clone —
233
- // else none, and the client adopts the one the backend works on the site from. There
234
- // is no project yet to record it in; `seedSyncJson` does, below.
235
- const orgFlag = readOrgFlag(args)
236
- client.setWorkspace(orgFlag ? workspaceHandle(orgFlag) : null, { explicit: Boolean(orgFlag) })
218
+ // The workspace this clone works in — the login's, unless this command names another
219
+ // (`workspace.js`). A site the backend keeps elsewhere stops it, naming how to switch.
220
+ const ws = await resolveWorkspace({ client, args })
221
+ if (ws.refused) {
222
+ error('This clone works in one workspace, and none is chosen.')
223
+ note(ws.reason)
224
+ return { exitCode: 2 }
225
+ }
226
+ client.setWorkspace(ws.workspace, { source: ws.source })
237
227
 
238
228
  // 1. GET the site-content document.
239
229
  //
@@ -385,7 +375,7 @@ export async function clone(args = [], deps = {}) {
385
375
  // the old `$backend`: the uuid came from whoever we read and nothing on disk said
386
376
  // so, and a teammate who pulled while logged in elsewhere sent it to the wrong
387
377
  // backend. Stored under the origin, that cannot be expressed.
388
- seedSyncJson(siteDir, client.origin, siteUuid, client.workspace)
378
+ seedSyncJson(siteDir, client.origin, siteUuid)
389
379
  success(
390
380
  `Scaffolded the site harness${foundationRef ? ` (foundation: ${foundationRef})` : ''}.`
391
381
  )
@@ -29,15 +29,11 @@
29
29
  * uniweb publish --dry-run Resolve everything; POST nothing
30
30
  * uniweb publish --yes Skip confirmations (CI); never block on a prompt
31
31
  * uniweb publish --force Overwrite upstream app-side edits (drop the push gate)
32
- * uniweb publish --org @org Publish under @org. On the FIRST publish of a
33
- * site it decides which org owns the site and whose
34
- * storage its assets are charged to; recorded in
35
- * `sync.json`, and every request after names it, so
36
- * it never has to be re-typed. Later, it must match
37
- * the workspace the backend works on the site from,
38
- * or publish stops.
39
- * uniweb publish --personal Own the new site personally, deliberately: its
40
- * requests name no workspace.
32
+ * uniweb publish --org @org Work in @org for this publish, instead of the
33
+ * uniweb publish --personal workspace chosen at `uniweb login` (or your
34
+ * personal workspace). A site it creates is created
35
+ * — owned — there; a site in another workspace stops
36
+ * it.
41
37
  * uniweb publish --no-save Do not record this publish in deploy.yml
42
38
  */
43
39
 
@@ -73,7 +69,7 @@ import {
73
69
  import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
74
70
  import { resolveSiteDir } from './deploy.js'
75
71
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
76
- import { readFlagValue, readOrgFlag } from '../utils/args.js'
72
+ import { readFlagValue } from '../utils/args.js'
77
73
  import { checkFlags } from '../utils/flag-guard.js'
78
74
  import {
79
75
  syncedElsewhere,
@@ -93,11 +89,9 @@ import {
93
89
  ensureSiteExists,
94
90
  clearRemoteSyncStateIfUnbound,
95
91
  dropSiteBoundValues,
96
- pushSyncPackages,
97
- resolveSiteOrgForCreate,
98
- nameSiteWorkspace,
99
- EXPLICIT_OWNER
92
+ pushSyncPackages
100
93
  } from '../backend/site-sync.js'
94
+ import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
101
95
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
102
96
  import {
103
97
  updateBackendMap,
@@ -309,21 +303,13 @@ export async function publish(args = []) {
309
303
  }
310
304
  }
311
305
 
312
- // WHO will own this site, if this publish is the one that creates it. Resolved
313
- // up front: `ensureSiteExists` below is the create, and it must not be reached
314
- // with the question still open. An already-created site resolves to null without
315
- // asking — its ownership was settled once and cannot be changed from here.
316
- const org = await resolveSiteOrgForCreate({
317
- client,
318
- siteDir,
319
- args,
320
- flag: readOrgFlag(args),
321
- personal: args.includes('--personal'),
322
- offline: dryRun
323
- })
324
- if (org.refused) {
325
- say.err('Refusing to create this site without naming an owner.')
326
- say.dim(org.reason)
306
+ // ⭐ THE WORKSPACE THIS PUBLISH WORKS IN — the one chosen with the login, unless this
307
+ // command names another (`workspace.js`). Resolved up front: `ensureSiteExists` below
308
+ // creates the site in it, and a site the backend keeps elsewhere is refused.
309
+ const ws = await resolveWorkspace({ client, args, offline: dryRun })
310
+ if (ws.refused) {
311
+ say.err('This publish works in one workspace, and none is chosen.')
312
+ say.dim(ws.reason)
327
313
  return { exitCode: 2 }
328
314
  }
329
315
 
@@ -513,16 +499,13 @@ export async function publish(args = []) {
513
499
  // be the silent-wrong-success this whole branch exists to prevent.
514
500
  if (!fnd.proceed) return { exitCode: fnd.refused ? 1 : 0 }
515
501
 
516
- // ⭐ Name the workspace every site request works in — the create, the push, the
517
- // publish and the data lane. The user's own choice is explicit, so a `409
518
- // wrong_workspace` stops the publish; a recorded one is adopted afresh when stale
519
- // (`nameSiteWorkspace`).
520
- nameSiteWorkspace(client, {
521
- siteDir,
522
- workspace: org.workspace,
523
- explicit: EXPLICIT_OWNER.has(org.source),
524
- note: (m) => say.dim(m)
525
- })
502
+ // ⭐ Every site request names the workspace — the create, the push, the publish and
503
+ // the data lane — and a site outside it stops the publish (`WorkspaceMismatchError`).
504
+ // A site this publish creates is created in it — said first.
505
+ client.setWorkspace(ws.workspace, { source: ws.source })
506
+ if (ws.source !== 'offline' && !readBackendState(siteDir, client.origin).site?.uuid) {
507
+ say.dim(`This publish creates the site in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source]}).`)
508
+ }
526
509
 
527
510
  // 2. Build the site data (link mode): dist/site-content.json (+ per-locale),
528
511
  // dist/data/*, dist/assets/*. Spawn the SAME CLI binary so the inner
@@ -90,11 +90,9 @@ import {
90
90
  mergeBaseVersions,
91
91
  mergeItemBaseVersions,
92
92
  writeUnitBases,
93
- writeItemUuids,
94
- nameSiteWorkspace,
95
- readSiteWorkspace
93
+ writeItemUuids
96
94
  } from '../backend/site-sync.js'
97
- import { readOrgFlag } from '../utils/args.js'
95
+ import { resolveWorkspace } from '../backend/workspace.js'
98
96
  import {
99
97
  uncommittedUnder,
100
98
  siteContentRoots,
@@ -106,7 +104,6 @@ import {
106
104
  BackendClient,
107
105
  describeRequestError,
108
106
  refusalDetail,
109
- workspaceHandle,
110
107
  WorkspaceMismatchError
111
108
  } from '../backend/client.js'
112
109
  import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
@@ -601,15 +598,6 @@ export async function pull(args = [], deps = {}) {
601
598
  args,
602
599
  command: 'Pulling'
603
600
  })
604
- // The workspace these requests name: `--org` explicitly, else the one this project
605
- // recorded for this backend — adopted afresh from the backend's answer when stale.
606
- const orgFlag = readOrgFlag(args)
607
- nameSiteWorkspace(client, {
608
- siteDir,
609
- workspace: orgFlag ? workspaceHandle(orgFlag) : readSiteWorkspace(siteDir, client.origin),
610
- explicit: Boolean(orgFlag),
611
- note
612
- })
613
601
 
614
602
  // ⭐ No scope check any more — and none is needed. This project's identity on
615
603
  // `client.origin` is read from that origin's own section of sync.json, so a
@@ -664,6 +652,17 @@ export async function pull(args = [], deps = {}) {
664
652
  return { exitCode: 0 }
665
653
  }
666
654
 
655
+ // The workspace this pull works in — the one chosen with the login, unless this
656
+ // command names another (`workspace.js`). Resolved at the first request, not before:
657
+ // a pull with nothing to pull never needs one. A site kept elsewhere stops it.
658
+ const ws = await resolveWorkspace({ client, args })
659
+ if (ws.refused) {
660
+ error('This pull works in one workspace, and none is chosen.')
661
+ note(ws.reason)
662
+ return { exitCode: 2 }
663
+ }
664
+ client.setWorkspace(ws.workspace, { source: ws.source })
665
+
667
666
  // GET a pull lane via the client and return `{ docs, etag }` from its `.uwx` (ZIP)
668
667
  // body — `readPullDocuments` reads the entity files out of it (JSON fallback). A
669
668
  // conditional request whose ETag matches returns `{ notModified: true }` (304, empty
@@ -27,15 +27,11 @@
27
27
  *
28
28
  * Usage:
29
29
  * uniweb push Build, push both lanes, back-fill $uuid
30
- * uniweb push --org @org Own the new site under @org. On the FIRST
31
- * push it decides which org owns the site, and
32
- * whose storage its assets are charged to;
33
- * recorded in `sync.json`, and every request
34
- * after names it. Without it, you are asked
35
- * once. Later, it must match the workspace the
36
- * backend works on the site from, or push stops.
37
- * uniweb push --personal Own the new site personally, deliberately:
38
- * its requests name no workspace.
30
+ * uniweb push --org @org Work in @org for this push, instead of the
31
+ * uniweb push --personal workspace chosen at `uniweb login` (or your
32
+ * personal workspace). A site this push creates
33
+ * is created — owned — there; a site the backend
34
+ * keeps in another workspace stops the push.
39
35
  * uniweb push --dry-run Report what would be pushed; submit nothing
40
36
  * uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
41
37
  * uniweb push --foundation <dir> Use this local foundation for the Model schema
@@ -62,7 +58,7 @@
62
58
  import { readFileSync, writeFileSync } from 'node:fs'
63
59
  import { join, resolve } from 'node:path'
64
60
  import yaml from 'js-yaml'
65
- import { emitSyncPackages } from '@uniweb/build/uwx'
61
+ import { emitSyncPackages, readBackendState } from '@uniweb/build/uwx'
66
62
  import { findSiteCopies, describeSiteCopies } from '../utils/site-copies.js'
67
63
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
68
64
  import { updateBackendMap, carryServed, SYNC_STORE_FILE } from '@uniweb/build/uwx'
@@ -70,7 +66,6 @@ import { BackendClient } from '../backend/client.js'
70
66
  import { resolveSiteDir } from './deploy.js'
71
67
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
72
68
  import { reportSchemalessQueries } from '../utils/schemaless-report.js'
73
- import { readOrgFlag } from '../utils/args.js'
74
69
  import { checkFlags } from '../utils/flag-guard.js'
75
70
  import {
76
71
  syncedElsewhere,
@@ -91,11 +86,9 @@ import {
91
86
  ensureSiteExists,
92
87
  clearRemoteSyncStateIfUnbound,
93
88
  dropSiteBoundValues,
94
- pushSyncPackages,
95
- resolveSiteOrgForCreate,
96
- nameSiteWorkspace,
97
- EXPLICIT_OWNER
89
+ pushSyncPackages
98
90
  } from '../backend/site-sync.js'
91
+ import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
99
92
 
100
93
  // Re-exported for downstream importers (pull.js, push.test.js) that read these
101
94
  // helpers from this module — their canonical home is now ../backend/site-sync.js.
@@ -216,21 +209,13 @@ export async function push(args = [], deps = {}) {
216
209
  }
217
210
  }
218
211
 
219
- // WHO will own this site, if this push is the one that creates it. Resolved
220
- // before any lane runs, because both create paths below consume it and neither
221
- // should be reached with the question still open. A site that already exists
222
- // resolves to null without asking — ownership was settled at its create.
223
- const org = await resolveSiteOrgForCreate({
224
- client,
225
- siteDir,
226
- args,
227
- flag: readOrgFlag(args),
228
- personal: args.includes('--personal'),
229
- offline: !!output || dryRun
230
- })
231
- if (org.refused) {
232
- error('Refusing to create this site without naming an owner.')
233
- note(org.reason)
212
+ // ⭐ THE WORKSPACE THIS PUSH WORKS IN — the one chosen with the login, unless this
213
+ // command names another (`workspace.js`). Resolved before any lane runs: a site this
214
+ // push creates is created in it, and a site the backend keeps elsewhere is refused.
215
+ const ws = await resolveWorkspace({ client, args, offline: !!output || dryRun })
216
+ if (ws.refused) {
217
+ error('This push works in one workspace, and none is chosen.')
218
+ note(ws.reason)
234
219
  return { exitCode: 2 }
235
220
  }
236
221
 
@@ -289,16 +274,12 @@ export async function push(args = [], deps = {}) {
289
274
  }
290
275
  if (!fnd.proceed) return { exitCode: 1 }
291
276
 
292
- // ⭐ Name the workspace every site request works in. The user's own choice (`--org`,
293
- // `--personal`, the picker) is explicit, so a `409 wrong_workspace` stops the push; a
294
- // recorded one is adopted afresh from the backend's answer when stale
295
- // (`nameSiteWorkspace`).
296
- nameSiteWorkspace(client, {
297
- siteDir,
298
- workspace: org.workspace,
299
- explicit: EXPLICIT_OWNER.has(org.source),
300
- note
301
- })
277
+ // ⭐ Every site request names the workspace; a site outside it stops the push
278
+ // (`WorkspaceMismatchError`). A site this push creates is created in it — said first.
279
+ client.setWorkspace(ws.workspace, { source: ws.source })
280
+ if (ws.source !== 'offline' && !readBackendState(siteDir, client.origin).site?.uuid) {
281
+ note(`This push creates the site in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source]}).`)
282
+ }
302
283
 
303
284
  // Build BOTH directional packages (the producer side). Each carries its own
304
285
  // `index` — the per-entity source-file map for back-fill, correlated by submission
@@ -25,12 +25,11 @@ import { join } from 'node:path'
25
25
  import yaml from 'js-yaml'
26
26
 
27
27
  import { resolveSiteDir } from './deploy.js'
28
- import { probeUnpushed, nameSiteWorkspace, readSiteWorkspace } from '../backend/site-sync.js'
29
- import { readOrgFlag } from '../utils/args.js'
28
+ import { probeUnpushed } from '../backend/site-sync.js'
29
+ import { resolveWorkspace } from '../backend/workspace.js'
30
30
  import {
31
31
  BackendClient,
32
32
  resolveBackendOrigin,
33
- workspaceHandle,
34
33
  WorkspaceMismatchError
35
34
  } from '../backend/client.js'
36
35
  import { readBackendState } from '@uniweb/build/uwx'
@@ -128,15 +127,11 @@ export async function status(args = []) {
128
127
  args,
129
128
  command: 'Status'
130
129
  })
131
- // The workspace the site's requests name — as pull names it. A stale record is
132
- // re-adopted from the backend's answer; `--json` keeps stdout to the document.
133
- const orgFlag = readOrgFlag(args)
134
- nameSiteWorkspace(client, {
135
- siteDir,
136
- workspace: orgFlag ? workspaceHandle(orgFlag) : readSiteWorkspace(siteDir, client.origin),
137
- explicit: Boolean(orgFlag),
138
- note: jsonMode ? undefined : say.dim
139
- })
130
+ // The workspace this status works in — the login's, unless this command names
131
+ // another (`workspace.js`). None chosen is reported, like a site outside it.
132
+ const ws = await resolveWorkspace({ client, args })
133
+ if (ws.refused) throw Object.assign(new Error(ws.reason), { status: 409 })
134
+ client.setWorkspace(ws.workspace, { source: ws.source })
140
135
  if (uuid) site = await client.siteStatus(uuid)
141
136
  // Foundation freshness: prefer the LOCAL foundation's scoped name (so a
142
137
  // local-foundation site can be checked too); fall back to a scoped
package/src/index.js CHANGED
@@ -1456,15 +1456,10 @@ ${colors.bright}Options:${colors.reset}
1456
1456
  bind to, and the app cannot open such a site.
1457
1457
  --no-save Do not record this deploy in deploy.yml
1458
1458
  --no-validate Skip the content-conformance check (it only warns)
1459
- --org @org Publish under @org (membership-gated). On a site's FIRST
1460
- publish it decides which org owns the site, and whose storage
1461
- its assets are charged to. Recorded in sync.json and named on
1462
- every request after, so it is never re-typed. Without it, you
1463
- are asked once. Later, it must match the workspace the backend
1464
- works on the site from, or publish stops.
1465
- --personal Create the site under your personal account, deliberately.
1466
- Only needed on a first publish, and only to answer the owner
1467
- question without a prompt (CI, agents, scripts).
1459
+ --org @org Work in @org for this publish, instead of the workspace
1460
+ --personal chosen at \`uniweb login\` (or your personal workspace). A
1461
+ site this publish creates is created — owned — there, and a
1462
+ site the backend keeps in another workspace stops it.
1468
1463
  `,
1469
1464
  create: `
1470
1465
  ${colors.cyan}${colors.bright}uniweb create${colors.reset} ${colors.dim}— Create a new project${colors.reset}
@@ -1793,16 +1788,26 @@ does nothing; add --password, --browser, --token-paste or --token to log in agai
1793
1788
  Without --backend: https://uniweb.app (or \$UNIWEB_REGISTER_URL). No command talks
1794
1789
  to a backend you are not logged in to — run one before logging in and it asks first.
1795
1790
 
1791
+ ${colors.bright}The workspace you work in.${colors.reset} A login works in ONE workspace — your
1792
+ personal one, or an organization's — and every push, pull and publish works in it:
1793
+ a site it creates is created there, and a site kept in another workspace is refused.
1794
+ With no organization it is your personal workspace; with organizations you are asked,
1795
+ or name it. Already logged in, \`uniweb login --org @other\` switches without logging
1796
+ in again.
1797
+
1796
1798
  ${colors.bright}Options:${colors.reset}
1797
1799
  --backend <url> The backend to log in to
1800
+ --org @org Work in @org (an organization you belong to)
1801
+ --personal Work in your personal workspace
1798
1802
  --token <bearer> Seed + verify a session from a bearer token (non-interactive)
1799
1803
  --browser Force the browser method
1800
1804
  --password Force the username/password method
1801
1805
  --token-paste Force the paste-a-token prompt
1802
1806
 
1803
- In non-interactive mode (CI / no TTY), pass \`--token <bearer>\`, or set
1804
- \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`, or set \`UNIWEB_TOKEN\` (used per-command,
1805
- not stored). \`uniweb logout\` logs you out.
1807
+ In non-interactive mode (no TTY — an agent, a script), pass \`--token <bearer>\` with
1808
+ \`--org @org\` or \`--personal\`, or set \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`, or set
1809
+ \`UNIWEB_TOKEN\` (per process, not stored) with \`UNIWEB_WORKSPACE=@org\` or \`personal\`.
1810
+ \`uniweb logout\` logs you out.
1806
1811
  `,
1807
1812
  refresh: `
1808
1813
  ${colors.cyan}${colors.bright}uniweb refresh${colors.reset} ${colors.dim}— Catch up with teammates AND app authors${colors.reset}
@@ -2082,8 +2087,8 @@ ${colors.bright}Publish Options:${colors.reset}
2082
2087
  --dry-run Resolve everything; release/sync/POST nothing
2083
2088
  --yes Skip confirmations (CI); never block on a prompt
2084
2089
  --no-release Ship content against the already-released code; release nothing
2085
- --org @org Publish under @org (first publish only; then remembered)
2086
- --personal Own the new site personally, deliberately (first publish only)
2090
+ --org @org Work in @org for this publish, not your login's workspace
2091
+ --personal Work in your personal workspace for this publish
2087
2092
  --no-save Do not record this deploy in deploy.yml
2088
2093
  --no-validate Skip the content-conformance check (it only warns)
2089
2094